working install and uninstall flow in Vagrant

This commit is contained in:
2026-04-27 22:06:57 -05:00
parent ea006827f5
commit 506dfbd669
16 changed files with 1857 additions and 390 deletions
+105 -3
View File
@@ -65,6 +65,59 @@ function Invoke-Vagrant {
Invoke-Tool -FilePath "vagrant" -Arguments $Arguments
}
function Save-GuestTraceBundle {
param(
[Parameter(Mandatory)][string]$GuestTracePath,
[Parameter(Mandatory)][string]$GuestZipPath,
[Parameter(Mandatory)][string]$LocalTracePath
)
try {
New-Item -ItemType Directory -Force -Path $LocalTracePath | Out-Null
$guestTraceLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestTracePath
$guestZipLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestZipPath
$command = @(
"`$tracePath = $guestTraceLiteral"
"`$zipPath = $guestZipLiteral"
"if (-not (Test-Path -LiteralPath `$tracePath)) { Write-Output '__COVENANT_TRACE_EMPTY__'; exit 0 }"
"New-Item -ItemType File -Force -Path (Join-Path `$tracePath '.keep') | Out-Null"
"Remove-Item -LiteralPath `$zipPath -Force -ErrorAction SilentlyContinue"
"Compress-Archive -Path (Join-Path `$tracePath '*') -DestinationPath `$zipPath -Force"
"Write-Output '__COVENANT_TRACE_B64_START__'"
"[Convert]::ToBase64String([IO.File]::ReadAllBytes(`$zipPath))"
"Write-Output '__COVENANT_TRACE_B64_END__'"
) -join "; "
$output = Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $command)
$lines = @($output | ForEach-Object { $_.ToString().Trim() })
if ($lines -contains "__COVENANT_TRACE_EMPTY__") {
Write-Warning "Guest trace path did not exist: $GuestTracePath"
return
}
$start = [Array]::IndexOf($lines, "__COVENANT_TRACE_B64_START__")
$end = [Array]::IndexOf($lines, "__COVENANT_TRACE_B64_END__")
if ($start -lt 0 -or $end -le $start) {
Write-Warning "Guest trace bundle markers were not found in WinRM output."
return
}
$base64 = (($lines[($start + 1)..($end - 1)]) -join "").Trim()
if (-not $base64) {
Write-Warning "Guest trace bundle was empty."
return
}
$zipPath = Join-Path $LocalTracePath "guest-trace.zip"
[IO.File]::WriteAllBytes($zipPath, [Convert]::FromBase64String($base64))
Expand-Archive -LiteralPath $zipPath -DestinationPath $LocalTracePath -Force
Write-Host "Trace bundle: $LocalTracePath"
}
catch {
Write-Warning "Failed to collect guest trace bundle: $($_.Exception.Message)"
}
}
function Open-HyperVViewer {
param([Parameter(Mandatory)][string]$VmName)
@@ -91,6 +144,38 @@ function Invoke-Process {
}
}
function Assert-PackagedInstallerBundle {
param([Parameter(Mandatory)][string]$InstallerPath)
$magic = [Text.Encoding]::ASCII.GetBytes("COVENANT_SETUP_BUNDLE_V1")
$stream = [IO.File]::Open(
$InstallerPath,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::ReadWrite)
try {
if ($stream.Length -lt $magic.Length) {
throw "Packaged installer is too small to contain the embedded bundle marker: $InstallerPath"
}
$null = $stream.Seek(-1 * $magic.Length, [IO.SeekOrigin]::End)
$actual = [byte[]]::new($magic.Length)
$read = $stream.Read($actual, 0, $actual.Length)
if ($read -ne $magic.Length) {
throw "Could not read embedded bundle marker from packaged installer: $InstallerPath"
}
for ($i = 0; $i -lt $magic.Length; $i++) {
if ($actual[$i] -ne $magic[$i]) {
throw "Packaged installer is missing the embedded bundle marker: $InstallerPath"
}
}
}
finally {
$stream.Dispose()
}
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$releaseExe = Join-Path $repoRoot "target\release\covenant-setup.exe"
$payloadRoot = Join-Path $repoRoot "vm\self-test\payload"
@@ -99,18 +184,23 @@ $manifestPathAbs = Resolve-RepoPath -RepoRoot $repoRoot -Path $ManifestPath
$outputRootAbs = Resolve-RepoPath -RepoRoot $repoRoot -Path $OutputRoot
$installerPath = Join-Path $outputRootAbs "covenant-setup-installer.exe"
$resultPath = Join-Path $outputRootAbs "guest-result.json"
$traceRootAbs = Join-Path $outputRootAbs "trace"
$guestRoot = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke"
$guestInstallerPath = Join-Path $guestRoot "covenant-setup-installer.exe"
$guestResultPath = Join-Path $guestRoot "guest-result.json"
$guestTraceRoot = Join-Path $guestRoot "trace"
$guestTraceZipPath = Join-Path $guestRoot "trace.zip"
$guestScriptRoot = Join-Path $guestRoot "scripts"
$guestCommand = "& '$guestScriptRoot\Start-InteractiveSelfInstall.ps1' -InstallerPath '$guestInstallerPath' -ResultPath '$guestResultPath' -ScriptRoot '$guestScriptRoot'"
$guestCommand = "& '$guestScriptRoot\Start-InteractiveSelfInstall.ps1' -InstallerPath '$guestInstallerPath' -ResultPath '$guestResultPath' -ScriptRoot '$guestScriptRoot' -TracePath '$guestTraceRoot'"
Assert-Command -Name "cargo"
Assert-Command -Name "dotnet"
Assert-Command -Name "vagrant"
New-Item -ItemType Directory -Force -Path $payloadRoot | Out-Null
New-Item -ItemType Directory -Force -Path $outputRootAbs | Out-Null
Remove-Item -LiteralPath $resultPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $traceRootAbs -Recurse -Force -ErrorAction SilentlyContinue
try {
if (-not $SkipBuild) {
@@ -131,6 +221,7 @@ try {
if (-not (Test-Path -LiteralPath $installerPath)) {
throw "Packaged installer not found at $installerPath"
}
Assert-PackagedInstallerBundle -InstallerPath $installerPath
if (-not $SkipVmBoot) {
Invoke-Vagrant -Arguments @("up", "--provider", $Provider)
@@ -157,8 +248,15 @@ try {
Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $waitForShellCommand)
Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", "New-Item -ItemType Directory -Force -Path '$guestRoot' | Out-Null; New-Item -ItemType Directory -Force -Path '$guestScriptRoot' | Out-Null")
$clearGuestTraceCommand = @(
"try {"
"if (Test-Path -LiteralPath '$guestTraceRoot') { Remove-Item -LiteralPath '$guestTraceRoot' -Recurse -Force -ErrorAction SilentlyContinue }"
"if (Test-Path -LiteralPath '$guestTraceZipPath') { Remove-Item -LiteralPath '$guestTraceZipPath' -Force -ErrorAction SilentlyContinue }"
"} catch { Write-Warning `$_.Exception.Message }"
"exit 0"
) -join "; "
Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $clearGuestTraceCommand)
Invoke-Vagrant -Arguments @("upload", $installerPath, $guestInstallerPath)
Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Approve-InstallerDialogs.ps1"), (Join-Path $guestScriptRoot "Approve-InstallerDialogs.ps1"))
Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Invoke-InteractiveInstaller.ps1"), (Join-Path $guestScriptRoot "Invoke-InteractiveInstaller.ps1"))
Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Start-InteractiveSelfInstall.ps1"), (Join-Path $guestScriptRoot "Start-InteractiveSelfInstall.ps1"))
@@ -180,9 +278,13 @@ try {
Write-Host "Smoke test passed."
Write-Host "Installer: $installerPath"
Write-Host "Result JSON: $resultPath"
Write-Host "InstallRoot: $($result.installRoot)"
Write-Host "Trace: $traceRootAbs"
Write-Host "InstallRoot: $($result.installRoot) (installed, then removed)"
Write-Host "Uninstall: exit $($result.uninstallExitCode), verified=$($result.uninstallVerified)"
}
finally {
Save-GuestTraceBundle -GuestTracePath $guestTraceRoot -GuestZipPath $guestTraceZipPath -LocalTracePath $traceRootAbs
if ($DestroyAfter) {
try {
Invoke-Vagrant -Arguments @("destroy", "-f")
@@ -0,0 +1,115 @@
param(
[string]$TracePath = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\trace",
[string]$ZipPath = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\trace-abort.zip",
[string]$TaskNamePrefix = "CovenantSetupSelfInstall"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Continue"
function Write-TraceEvent {
param(
[Parameter(Mandatory)][string]$Phase,
[object]$Detail = $null
)
try {
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
$event = [ordered]@{
time = (Get-Date).ToUniversalTime().ToString("o")
pid = $PID
script = Split-Path -Leaf $PSCommandPath
phase = $Phase
detail = $Detail
}
$event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8
}
catch {
Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)"
}
}
function Write-DiagnosticFile {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][scriptblock]$Capture
)
$path = Join-Path $TracePath $Name
Write-TraceEvent -Phase "abort_diagnostic_file_start" -Detail @{ name = $Name }
try {
$value = & $Capture
$value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8
Write-TraceEvent -Phase "abort_diagnostic_file_finish" -Detail @{ name = $Name }
}
catch {
[ordered]@{
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8
Write-TraceEvent -Phase "abort_diagnostic_file_error" -Detail @{
name = $Name
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
}
}
}
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
Write-TraceEvent -Phase "abort_requested"
Write-DiagnosticFile -Name "abort-processes.json" -Capture {
Get-CimInstance Win32_Process |
Where-Object {
$_.Name -match "covenant|Covenant|powershell|pwsh" -or
$_.CommandLine -match "CovenantSetup|Invoke-InteractiveInstaller|Start-InteractiveSelfInstall"
} |
Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate
}
Write-DiagnosticFile -Name "abort-windows.json" -Capture {
Get-Process |
Where-Object { $_.MainWindowHandle -ne 0 -or $_.ProcessName -match "covenant|Covenant|powershell|pwsh" } |
Select-Object Id, ProcessName, MainWindowTitle, MainWindowHandle, StartTime
}
Write-DiagnosticFile -Name "abort-scheduled-tasks.json" -Capture {
Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue |
Select-Object TaskName, State, TaskPath, Actions, Triggers
}
Write-DiagnosticFile -Name "abort-scheduled-task-info.json" -Capture {
Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue |
ForEach-Object { Get-ScheduledTaskInfo -TaskName $_.TaskName -ErrorAction SilentlyContinue } |
Select-Object TaskName, LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns
}
Write-DiagnosticFile -Name "abort-application-events.json" -Capture {
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Write-DiagnosticFile -Name "abort-system-events.json" -Capture {
Get-WinEvent -FilterHashtable @{ LogName = "System"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Get-Process -Name "covenant-setup-installer", "covenant-setup", "covenant-setup-uninstall", "Covenant.Setup.Ui" -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue |
Stop-ScheduledTask -ErrorAction SilentlyContinue
Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue |
Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue
$scriptProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
$_.ProcessId -ne $PID -and
$_.CommandLine -match "Invoke-InteractiveInstaller|Start-InteractiveSelfInstall"
}
foreach ($process in $scriptProcesses) {
Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue
}
Write-TraceEvent -Phase "abort_cleanup_complete"
Remove-Item -LiteralPath $ZipPath -Force -ErrorAction SilentlyContinue
Compress-Archive -Path (Join-Path $TracePath "*") -DestinationPath $ZipPath -Force
Write-Output "__COVENANT_TRACE_B64_START__"
[Convert]::ToBase64String([IO.File]::ReadAllBytes($ZipPath))
Write-Output "__COVENANT_TRACE_B64_END__"
@@ -1,26 +0,0 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][int]$InstallerProcessId,
[string]$WindowTitle = "covenant-setup",
[int]$PollMilliseconds = 500
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName Microsoft.VisualBasic
Add-Type -AssemblyName System.Windows.Forms
while ($true) {
$process = Get-Process -Id $InstallerProcessId -ErrorAction SilentlyContinue
if (-not $process) {
break
}
if ([Microsoft.VisualBasic.Interaction]::AppActivate($WindowTitle)) {
Start-Sleep -Milliseconds 200
[System.Windows.Forms.SendKeys]::SendWait("{ENTER}")
}
Start-Sleep -Milliseconds $PollMilliseconds
}
@@ -2,16 +2,120 @@
param(
[Parameter(Mandatory)][string]$InstallerPath,
[Parameter(Mandatory)][string]$ResultPath,
[int]$TimeoutSeconds = 600
[int]$TimeoutSeconds = 600,
[string]$TracePath = $(Join-Path (Split-Path -Parent $ResultPath) "trace"),
[string]$OperationName = "installer",
[string]$InstallerArgumentsBase64 = "",
[string[]]$InstallerArguments = @("--headed", "--automation")
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Write-TraceEvent {
param(
[Parameter(Mandatory)][string]$Phase,
[object]$Detail = $null
)
try {
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
$event = [ordered]@{
time = (Get-Date).ToUniversalTime().ToString("o")
pid = $PID
script = Split-Path -Leaf $PSCommandPath
phase = $Phase
detail = $Detail
}
$event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8
}
catch {
Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)"
}
}
function Write-DiagnosticFile {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][scriptblock]$Capture
)
$path = Join-Path $TracePath $Name
try {
$value = & $Capture
$value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8
}
catch {
[ordered]@{
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8
}
}
function Export-InstallerDiagnostics {
param(
[Parameter(Mandatory)][string]$Reason,
[System.Diagnostics.Process]$InstallerProcess = $null
)
Write-TraceEvent -Phase "installer_diagnostics_start" -Detail @{ reason = $Reason; installerPid = $(if ($InstallerProcess) { $InstallerProcess.Id } else { $null }) }
Write-DiagnosticFile -Name "interactive-context.json" -Capture {
[ordered]@{
reason = $Reason
computerName = $env:COMPUTERNAME
userName = $env:USERNAME
sessionName = $env:SESSIONNAME
installerPath = $InstallerPath
installerArgs = $InstallerArguments
operationName = $OperationName
resultPath = $ResultPath
tracePath = $TracePath
timeoutSeconds = $TimeoutSeconds
installerPid = $(if ($InstallerProcess) { $InstallerProcess.Id } else { $null })
installerExited = $(if ($InstallerProcess) { $InstallerProcess.HasExited } else { $null })
}
}
Write-DiagnosticFile -Name "interactive-processes.json" -Capture {
Get-CimInstance Win32_Process |
Where-Object { $_.Name -match 'covenant|Covenant|powershell|pwsh|dotnet' } |
Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate
}
Write-DiagnosticFile -Name "interactive-windows.json" -Capture {
Get-Process |
Where-Object { $_.MainWindowHandle -ne 0 -or $_.ProcessName -match 'covenant|Covenant|powershell|pwsh' } |
Select-Object Id, ProcessName, MainWindowTitle, MainWindowHandle, StartTime
}
Write-DiagnosticFile -Name "interactive-application-events.json" -Capture {
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Write-TraceEvent -Phase "installer_diagnostics_finish" -Detail @{ reason = $Reason }
}
$installer = $null
$startedAt = Get-Date
try {
if (-not [string]::IsNullOrWhiteSpace($InstallerArgumentsBase64)) {
$argumentsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($InstallerArgumentsBase64))
$decodedArguments = ConvertFrom-Json -InputObject $argumentsJson
$InstallerArguments = @()
foreach ($argument in $decodedArguments) {
$InstallerArguments += [string]$argument
}
}
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
Write-TraceEvent -Phase "interactive_installer_start" -Detail @{
installerPath = $InstallerPath
installerArgs = $InstallerArguments
operationName = $OperationName
resultPath = $ResultPath
tracePath = $TracePath
timeoutSeconds = $TimeoutSeconds
}
if (-not (Test-Path -LiteralPath $InstallerPath)) {
throw "Installer not found: $InstallerPath"
}
@@ -22,17 +126,47 @@ try {
}
Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue
$installer = Start-Process -FilePath $InstallerPath -ArgumentList @("--headed", "--automation") -PassThru
$env:COVENANT_SETUP_TRACE_DIR = $TracePath
Write-TraceEvent -Phase "trace_environment_set" -Detail @{ name = "COVENANT_SETUP_TRACE_DIR"; value = $TracePath }
if (-not $installer.WaitForExit($TimeoutSeconds * 1000)) {
Stop-Process -Id $installer.Id -Force -ErrorAction SilentlyContinue
throw "Installer timed out after $TimeoutSeconds seconds."
$installer = Start-Process -FilePath $InstallerPath -ArgumentList $InstallerArguments -PassThru
Write-TraceEvent -Phase "installer_process_started" -Detail @{ pid = $installer.Id; operationName = $OperationName }
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$lastPoll = Get-Date "2000-01-01"
while (-not $installer.HasExited) {
if ((Get-Date) -ge $deadline) {
Export-InstallerDiagnostics -Reason "installer_timeout" -InstallerProcess $installer
Stop-Process -Id $installer.Id -Force -ErrorAction SilentlyContinue
throw "Installer timed out after $TimeoutSeconds seconds."
}
if (((Get-Date) - $lastPoll).TotalSeconds -ge 10) {
$lastPoll = Get-Date
$installer.Refresh()
Write-TraceEvent -Phase "installer_still_running" -Detail @{
pid = $installer.Id
operationName = $OperationName
elapsedSeconds = [int]((Get-Date) - $startedAt).TotalSeconds
responding = $installer.Responding
mainWindow = $installer.MainWindowTitle
}
}
Start-Sleep -Seconds 2
$installer.Refresh()
}
Write-TraceEvent -Phase "installer_process_exited" -Detail @{ pid = $installer.Id; exitCode = $installer.ExitCode; operationName = $OperationName }
Export-InstallerDiagnostics -Reason "installer_exit" -InstallerProcess $installer
$result = [ordered]@{
success = ($installer.ExitCode -eq 0)
exitCode = [int]$installer.ExitCode
installerPath = $InstallerPath
installerArgs = $InstallerArguments
operationName = $OperationName
tracePath = $TracePath
startedAt = $startedAt.ToString("o")
finishedAt = (Get-Date).ToString("o")
}
@@ -43,9 +177,15 @@ try {
}
}
catch {
Write-TraceEvent -Phase "interactive_installer_error" -Detail @{
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
}
Export-InstallerDiagnostics -Reason "interactive_installer_error" -InstallerProcess $installer
$failure = [ordered]@{
success = $false
error = $_.Exception.Message
tracePath = $TracePath
startedAt = $startedAt.ToString("o")
finishedAt = (Get-Date).ToString("o")
}
@@ -4,7 +4,8 @@ param(
[Parameter(Mandatory)][string]$ResultPath,
[string]$ScriptRoot = $PSScriptRoot,
[int]$TimeoutSeconds = 600,
[string]$TaskNamePrefix = "CovenantSetupSelfInstall"
[string]$TaskNamePrefix = "CovenantSetupSelfInstall",
[string]$TracePath = $(Join-Path (Split-Path -Parent $ResultPath) "trace")
)
Set-StrictMode -Version Latest
@@ -16,16 +17,310 @@ function Quote-TaskArgument {
return '"' + $Value.Replace('"', '""') + '"'
}
function Write-TraceEvent {
param(
[Parameter(Mandatory)][string]$Phase,
[object]$Detail = $null
)
try {
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
$event = [ordered]@{
time = (Get-Date).ToUniversalTime().ToString("o")
pid = $PID
script = Split-Path -Leaf $PSCommandPath
phase = $Phase
detail = $Detail
}
$event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8
}
catch {
Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)"
}
}
function Write-DiagnosticFile {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][scriptblock]$Capture
)
$path = Join-Path $TracePath $Name
Write-TraceEvent -Phase "diagnostic_file_start" -Detail @{ name = $Name }
try {
$value = & $Capture
$value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8
Write-TraceEvent -Phase "diagnostic_file_finish" -Detail @{ name = $Name }
}
catch {
[ordered]@{
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8
Write-TraceEvent -Phase "diagnostic_file_error" -Detail @{
name = $Name
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
}
}
}
function Convert-DateTimeForTrace {
param([object]$Value)
if ($null -eq $Value) {
return $null
}
if ($Value -is [DateTime] -and $Value -eq [DateTime]::MinValue) {
return $null
}
try {
return ([DateTime]$Value).ToString("o")
}
catch {
return [string]$Value
}
}
function Export-SmokeDiagnostics {
param([Parameter(Mandatory)][string]$Reason)
Write-TraceEvent -Phase "diagnostics_start" -Detail @{ reason = $Reason }
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
Write-DiagnosticFile -Name "guest-context.json" -Capture {
[ordered]@{
reason = $Reason
computerName = $env:COMPUTERNAME
userName = $env:USERNAME
installerPath = $InstallerPath
resultPath = $ResultPath
tracePath = $TracePath
taskName = $taskName
installRoot = $installRoot
}
}
Write-DiagnosticFile -Name "processes.json" -Capture {
Get-CimInstance Win32_Process |
Where-Object { $_.Name -match 'covenant|Covenant|powershell|pwsh|dotnet' } |
Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate
}
Write-DiagnosticFile -Name "scheduled-task.json" -Capture {
[ordered]@{
task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
}
}
Write-DiagnosticFile -Name "scheduled-task-events.json" -Capture {
Get-WinEvent -LogName "Microsoft-Windows-TaskScheduler/Operational" -MaxEvents 300 -ErrorAction SilentlyContinue |
Where-Object { $_.Message -like "*$taskName*" } |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Write-DiagnosticFile -Name "application-events.json" -Capture {
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Write-DiagnosticFile -Name "system-events.json" -Capture {
Get-WinEvent -FilterHashtable @{ LogName = "System"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Write-DiagnosticFile -Name "install-root-files.json" -Capture {
if (Test-Path -LiteralPath $installRoot) {
Get-ChildItem -LiteralPath $installRoot -Force -Recurse |
Select-Object FullName, Length, LastWriteTimeUtc, Attributes
}
else {
[ordered]@{ exists = $false; path = $installRoot }
}
}
Write-DiagnosticFile -Name "registry-state.json" -Capture {
if (Test-Path -LiteralPath $registryPath) {
Get-ItemProperty -LiteralPath $registryPath
}
else {
[ordered]@{ exists = $false; path = $registryPath }
}
}
Write-DiagnosticFile -Name "result-file.json" -Capture {
if (Test-Path -LiteralPath $ResultPath) {
Get-Content -LiteralPath $ResultPath -Raw
}
else {
[ordered]@{ exists = $false; path = $ResultPath }
}
}
Write-TraceEvent -Phase "diagnostics_finish" -Detail @{ reason = $Reason }
}
function Invoke-InteractiveOperation {
param(
[Parameter(Mandatory)][string]$TaskName,
[Parameter(Mandatory)][string]$ExecutablePath,
[Parameter(Mandatory)][string]$RunResultPath,
[Parameter(Mandatory)][string]$OperationName,
[string[]]$Arguments = @()
)
$script:taskName = $TaskName
Remove-Item -LiteralPath $RunResultPath -Force -ErrorAction SilentlyContinue
# The task is started manually below. Keep the trigger far enough out that it
# cannot fire a second copy while the smoke harness is collecting diagnostics.
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddDays(1)
$actionArgumentItems = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", (Quote-TaskArgument -Value $interactiveScript),
"-InstallerPath", (Quote-TaskArgument -Value $ExecutablePath),
"-ResultPath", (Quote-TaskArgument -Value $RunResultPath),
"-TimeoutSeconds", $TimeoutSeconds,
"-TracePath", (Quote-TaskArgument -Value $TracePath),
"-OperationName", (Quote-TaskArgument -Value $OperationName)
)
if ($Arguments.Count -gt 0) {
$argumentsJson = ConvertTo-Json -InputObject $Arguments -Compress
$argumentsBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($argumentsJson))
$actionArgumentItems += "-InstallerArgumentsBase64"
$actionArgumentItems += $argumentsBase64
}
$actionArguments = $actionArgumentItems -join " "
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArguments
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
try {
Register-ScheduledTask `
-TaskName $TaskName `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Principal $principal `
-Force | Out-Null
Write-TraceEvent -Phase "scheduled_task_registered" -Detail @{
taskName = $TaskName
operationName = $OperationName
executablePath = $ExecutablePath
executableArgs = $Arguments
actionArguments = $actionArguments
runResultPath = $RunResultPath
}
Start-ScheduledTask -TaskName $TaskName
Write-TraceEvent -Phase "scheduled_task_started" -Detail @{ taskName = $TaskName; operationName = $OperationName }
$deadline = (Get-Date).AddSeconds($TimeoutSeconds + 120)
$lastPoll = Get-Date "2000-01-01"
while ((Get-Date) -lt $deadline) {
if (Test-Path -LiteralPath $RunResultPath) {
Write-TraceEvent -Phase "result_file_observed" -Detail @{
taskName = $TaskName
operationName = $OperationName
runResultPath = $RunResultPath
}
break
}
if (((Get-Date) - $lastPoll).TotalSeconds -ge 10) {
$lastPoll = Get-Date
$taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Write-TraceEvent -Phase "waiting_for_interactive_task" -Detail @{
taskName = $TaskName
operationName = $OperationName
state = $(if ($task) { $task.State.ToString() } else { $null })
lastRunTime = $(if ($taskInfo) { Convert-DateTimeForTrace -Value $taskInfo.LastRunTime } else { $null })
lastTaskResult = $(if ($taskInfo) { $taskInfo.LastTaskResult } else { $null })
nextRunTime = $(if ($taskInfo) { Convert-DateTimeForTrace -Value $taskInfo.NextRunTime } else { $null })
}
}
Start-Sleep -Seconds 2
}
if (-not (Test-Path -LiteralPath $RunResultPath)) {
Export-SmokeDiagnostics -Reason "${OperationName}_task_timeout"
$taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName
throw "Timed out waiting for $OperationName interactive task. LastTaskResult=$($taskInfo.LastTaskResult)"
}
$runResult = Get-Content -LiteralPath $RunResultPath -Raw | ConvertFrom-Json
Write-TraceEvent -Phase "interactive_task_result_read" -Detail @{
taskName = $TaskName
operationName = $OperationName
result = $runResult
}
if (-not $runResult.success) {
Export-SmokeDiagnostics -Reason "${OperationName}_task_failed"
throw "Interactive $OperationName task failed: $($runResult.error)"
}
return $runResult
}
finally {
Write-TraceEvent -Phase "scheduled_task_unregister" -Detail @{ taskName = $TaskName; operationName = $OperationName }
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
}
}
function Wait-ForUninstallCleanup {
param([int]$TimeoutSeconds = 60)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$lastPoll = Get-Date "2000-01-01"
while ((Get-Date) -lt $deadline) {
$remaining = [ordered]@{
installRoot = Test-Path -LiteralPath $installRoot
installedExe = Test-Path -LiteralPath $installedExe
journalPath = Test-Path -LiteralPath $journalPath
uninstallExe = Test-Path -LiteralPath $uninstallExe
shortcutPath = Test-Path -LiteralPath $shortcutPath
registryPath = Test-Path -LiteralPath $registryPath
uninstallRegistryPath = Test-Path -LiteralPath $uninstallRegistryPath
}
if (-not ($remaining.installRoot -or $remaining.installedExe -or $remaining.journalPath -or $remaining.uninstallExe -or $remaining.shortcutPath -or $remaining.registryPath -or $remaining.uninstallRegistryPath)) {
Write-TraceEvent -Phase "uninstall_cleanup_observed" -Detail $remaining
return
}
if (((Get-Date) - $lastPoll).TotalSeconds -ge 5) {
$lastPoll = Get-Date
Write-TraceEvent -Phase "waiting_for_uninstall_cleanup" -Detail $remaining
}
Start-Sleep -Seconds 1
}
throw "Uninstall cleanup did not complete within $TimeoutSeconds seconds."
}
$installRoot = Join-Path $env:LOCALAPPDATA "CovenantSetupSelfTest"
$shortcutPath = Join-Path ([Environment]::GetFolderPath("Desktop")) "Covenant Setup Self Test.lnk"
$registryPath = "HKCU:\Software\CovenantSetupSelfTest"
$journalPath = Join-Path $installRoot "journal.json"
$installedExe = Join-Path $installRoot "bin\covenant-setup.exe"
$uninstallExe = Join-Path $installRoot "covenant-setup-uninstall.exe"
$taskName = "{0}-{1}" -f $TaskNamePrefix, ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())
$uninstallRegistryPath = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Covenant_Setup_Self_Test"
$taskStamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$installTaskName = "{0}-Install-{1}" -f $TaskNamePrefix, $taskStamp
$uninstallTaskName = "{0}-Uninstall-{1}" -f $TaskNamePrefix, $taskStamp
$taskName = $installTaskName
$interactiveScript = Join-Path $ScriptRoot "Invoke-InteractiveInstaller.ps1"
$installRunResultPath = $null
$uninstallRunResultPath = $null
try {
Remove-Item -LiteralPath $TracePath -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
Write-TraceEvent -Phase "self_install_start" -Detail @{
installerPath = $InstallerPath
resultPath = $ResultPath
tracePath = $TracePath
timeoutSeconds = $TimeoutSeconds
}
if (-not (Test-Path -LiteralPath $InstallerPath)) {
throw "Installer not found in guest: $InstallerPath"
}
@@ -39,51 +334,24 @@ try {
New-Item -ItemType Directory -Force -Path $resultDir | Out-Null
}
Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue
$installRunResultPath = Join-Path $resultDir "install-run-result.json"
$uninstallRunResultPath = Join-Path $resultDir "uninstall-run-result.json"
Remove-Item -LiteralPath $installRunResultPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $uninstallRunResultPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $shortcutPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $uninstallRegistryPath -Recurse -Force -ErrorAction SilentlyContinue
Write-TraceEvent -Phase "self_install_cleaned_previous_state"
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1)
$actionArguments = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", (Quote-TaskArgument -Value $interactiveScript),
"-InstallerPath", (Quote-TaskArgument -Value $InstallerPath),
"-ResultPath", (Quote-TaskArgument -Value $ResultPath),
"-TimeoutSeconds", $TimeoutSeconds
) -join " "
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArguments
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Principal $principal `
-Force | Out-Null
Start-ScheduledTask -TaskName $taskName
$deadline = (Get-Date).AddSeconds($TimeoutSeconds + 120)
while ((Get-Date) -lt $deadline) {
if (Test-Path -LiteralPath $ResultPath) {
break
}
Start-Sleep -Seconds 2
}
if (-not (Test-Path -LiteralPath $ResultPath)) {
$taskInfo = Get-ScheduledTaskInfo -TaskName $taskName
throw "Timed out waiting for interactive installer task. LastTaskResult=$($taskInfo.LastTaskResult)"
}
$runResult = Get-Content -LiteralPath $ResultPath -Raw | ConvertFrom-Json
if (-not $runResult.success) {
throw "Interactive installer task failed: $($runResult.error)"
}
$installRunResult = Invoke-InteractiveOperation `
-TaskName $installTaskName `
-ExecutablePath $InstallerPath `
-RunResultPath $installRunResultPath `
-OperationName "install" `
-Arguments @("--headed", "--automation")
Write-TraceEvent -Phase "verification_start"
if (-not (Test-Path -LiteralPath $installedExe)) {
throw "Installed executable missing: $installedExe"
}
@@ -114,34 +382,90 @@ try {
throw "Journal did not record the packaged payload copy."
}
$uninstallRunResult = Invoke-InteractiveOperation `
-TaskName $uninstallTaskName `
-ExecutablePath $uninstallExe `
-RunResultPath $uninstallRunResultPath `
-OperationName "uninstall" `
-Arguments @("--headed", "--automation", "uninstall", $journalPath)
Write-TraceEvent -Phase "uninstall_verification_start"
Wait-ForUninstallCleanup -TimeoutSeconds 60
if (Test-Path -LiteralPath $installedExe) {
throw "Installed executable still exists after uninstall: $installedExe"
}
if (Test-Path -LiteralPath $journalPath) {
throw "Journal still exists after uninstall: $journalPath"
}
if (Test-Path -LiteralPath $uninstallExe) {
throw "Installed uninstaller still exists after uninstall: $uninstallExe"
}
if (Test-Path -LiteralPath $shortcutPath) {
throw "Desktop shortcut still exists after uninstall: $shortcutPath"
}
if (Test-Path -LiteralPath $registryPath) {
throw "Registry key still exists after uninstall: $registryPath"
}
if (Test-Path -LiteralPath $uninstallRegistryPath) {
throw "Installed Apps registry key still exists after uninstall: $uninstallRegistryPath"
}
if (Test-Path -LiteralPath $installRoot) {
throw "Install root still exists after uninstall: $installRoot"
}
$verification = [ordered]@{
success = $true
exitCode = [int]$runResult.exitCode
installerPath = $InstallerPath
installRoot = $installRoot
installedExe = $installedExe
journalPath = $journalPath
uninstallExe = $uninstallExe
shortcutPath = $shortcutPath
registryPath = $registryPath
success = $true
exitCode = 0
installExitCode = [int]$installRunResult.exitCode
uninstallExitCode = [int]$uninstallRunResult.exitCode
installerPath = $InstallerPath
installRoot = $installRoot
installedExe = $installedExe
journalPath = $journalPath
uninstallExe = $uninstallExe
shortcutPath = $shortcutPath
registryPath = $registryPath
uninstallRegistryPath = $uninstallRegistryPath
tracePath = $TracePath
installRunResultPath = $installRunResultPath
uninstallRunResultPath = $uninstallRunResultPath
journalActionTypes = $journalActionTypes
taskName = $taskName
startedAt = $runResult.startedAt
finishedAt = $runResult.finishedAt
installTaskName = $installTaskName
uninstallTaskName = $uninstallTaskName
installStartedAt = $installRunResult.startedAt
installFinishedAt = $installRunResult.finishedAt
uninstallStartedAt = $uninstallRunResult.startedAt
uninstallFinishedAt = $uninstallRunResult.finishedAt
uninstallVerified = $true
}
$verification | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8
Write-TraceEvent -Phase "self_install_success" -Detail $verification
}
catch {
Write-TraceEvent -Phase "self_install_error" -Detail @{
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
}
Export-SmokeDiagnostics -Reason "self_install_error"
$failure = [ordered]@{
success = $false
error = $_.Exception.Message
taskName = $taskName
installRoot = $installRoot
resultPath = $ResultPath
success = $false
error = $_.Exception.Message
taskName = $taskName
installTaskName = $installTaskName
uninstallTaskName = $uninstallTaskName
installRoot = $installRoot
resultPath = $ResultPath
installRunResultPath = $installRunResultPath
uninstallRunResultPath = $uninstallRunResultPath
tracePath = $TracePath
}
$failure | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8
exit 1
}
finally {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
foreach ($taskToRemove in @($installTaskName, $uninstallTaskName)) {
if ($taskToRemove) {
Unregister-ScheduledTask -TaskName $taskToRemove -Confirm:$false -ErrorAction SilentlyContinue
}
}
}