Files
pulse/scripts/installtests/windows_agent_lifecycle.ps1
T
pulse-triage[bot] 3dfef8cffa Reject failed observations inside Windows lifecycle proof
Close the connected exit-propagation gap in the CI service harness: a version or sc.exe query that prints plausible text but exits nonzero must not become accepted evidence. Check each captured native exit before inspecting the text, retaining the existing checked installer invocation.

The workflow and harness regressions now cover twelve native boundaries. Controlled PowerShell execution remains required by native Windows CI and explicitly unavailable in the offline Linux guest; production installation, service operations and customer acceptance are not inferred.

Contract-Neutral: CI proof verdict propagation only; production agent, installer, service lifecycle and permission contracts are unchanged.
Change-source: pulse-maintainer
2026-10-06 08:16:10 +01:00

245 lines
11 KiB
PowerShell

[CmdletBinding()]
param (
[ValidateSet('Full', 'InstallUpdate', 'PostRebootUninstall')]
[string]$Phase = 'Full',
[Parameter(Mandatory = $true)]
[string]$ServerBinary,
[string]$AgentV1,
[Parameter(Mandatory = $true)]
[string]$AgentV2,
[Parameter(Mandatory = $true)]
[string]$InstallerPath,
[int]$Port = 17655,
[switch]$ConfirmLifecycleMutation
)
$ErrorActionPreference = 'Stop'
$serviceName = 'PulseAgent'
$stateDir = Join-Path $env:ProgramData 'Pulse'
$logFile = Join-Path $stateDir 'pulse-agent.log'
$proofStatePath = Join-Path $stateDir 'windows-lifecycle-proof.json'
$baseUrl = "http://127.0.0.1:$Port"
$serverProcess = $null
$previousDisableAutoUpdate = $null
$restoreAutoUpdateAtExit = $true
function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]$identity
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Windows lifecycle proof must run from an elevated PowerShell session.'
}
}
function Resolve-RequiredPath {
param([string]$Path, [string]$Label)
if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path $Path -PathType Leaf)) {
throw "$Label does not exist: $Path"
}
return (Resolve-Path $Path).Path
}
function Stop-LifecycleServer {
if ($null -ne $script:serverProcess -and -not $script:serverProcess.HasExited) {
Stop-Process -Id $script:serverProcess.Id -Force -ErrorAction SilentlyContinue
$script:serverProcess.WaitForExit(5000) | Out-Null
}
$script:serverProcess = $null
}
function Start-LifecycleServer {
param([string]$AgentPath)
Stop-LifecycleServer
$version = ((& $AgentPath --version 2>$null) | Select-Object -First 1).Trim()
if ($LASTEXITCODE -ne 0) { throw "Lifecycle input version check failed (exit $LASTEXITCODE)." }
if ([string]::IsNullOrWhiteSpace($version)) {
throw "Could not read agent version from $AgentPath"
}
$arguments = @(
'--listen', "127.0.0.1:$Port",
'--agent-binary', ('"{0}"' -f $AgentPath),
'--version', $version
)
$script:serverProcess = Start-Process -FilePath $script:resolvedServerBinary -ArgumentList $arguments -PassThru -NoNewWindow
for ($i = 0; $i -lt 30; $i++) {
if ($script:serverProcess.HasExited) {
throw "Lifecycle server exited with code $($script:serverProcess.ExitCode)."
}
try {
$response = Invoke-RestMethod -Uri "$baseUrl/api/version" -TimeoutSec 2
if ($response.version -eq $version) {
return $version
}
} catch {
}
Start-Sleep -Milliseconds 500
}
throw "Lifecycle server did not become ready at $baseUrl."
}
function Invoke-Installer {
param([string]$Arguments, [string]$Label)
$escapedInstaller = $script:resolvedInstallerPath.Replace("'", "''")
$command = "& '$escapedInstaller' $Arguments"
& $script:powerShellPath -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command $command
if ($LASTEXITCODE -ne 0) {
throw "$Label failed with exit code $LASTEXITCODE."
}
}
function Wait-AgentReady {
param([int]$TimeoutSeconds = 45)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
do {
try {
$service = Get-Service -Name $serviceName -ErrorAction Stop
$ready = Invoke-RestMethod -Uri 'http://127.0.0.1:9191/readyz' -TimeoutSec 2
if ($service.Status -eq 'Running' -and $ready.ready -eq $true) {
return
}
} catch {
}
Start-Sleep -Seconds 1
} while ((Get-Date) -lt $deadline)
throw 'PulseAgent did not reach local readiness before the timeout.'
}
function Assert-AgentRuntime {
param([string]$ExpectedVersion)
Wait-AgentReady
$service = Get-CimInstance Win32_Service | Where-Object Name -eq $serviceName
if ($null -eq $service -or $service.State -ne 'Running' -or $service.StartMode -ne 'Auto') {
throw "Unexpected service state: $($service | ConvertTo-Json -Compress)"
}
$installedVersion = ((& "$env:ProgramFiles\Pulse\pulse-agent.exe" --version 2>$null) | Select-Object -First 1).Trim()
if ($LASTEXITCODE -ne 0) { throw "Installed agent version check failed (exit $LASTEXITCODE)." }
if ($installedVersion -ne $ExpectedVersion) {
throw "Installed version is $installedVersion; expected $ExpectedVersion."
}
if ($service.PathName -notlike '*--log-file*' -or $service.PathName -notlike '*pulse-agent.log*') {
throw "Service command does not carry the canonical log-file argument: $($service.PathName)"
}
if (-not (Test-Path $logFile) -or (Get-Item $logFile).Length -le 0) {
throw "Agent log file is missing or empty: $logFile"
}
$logText = Get-Content $logFile -Raw
$hasStartupEvent = $logText -like '*Starting Pulse Unified Agent*' -or $logText -like '*Pulse Agent service is running*'
if (-not $hasStartupEvent -or $logText -notlike "*$ExpectedVersion*") {
throw "Agent log does not contain startup evidence for $ExpectedVersion."
}
$recovery = (& sc.exe qfailure $serviceName 2>&1 | Out-String)
if ($LASTEXITCODE -ne 0) { throw "Service recovery query failed (exit $LASTEXITCODE)." }
if ([regex]::Matches($recovery, 'RESTART').Count -lt 3) {
throw "Service recovery actions are incomplete: $recovery"
}
$failureFlag = (& sc.exe qfailureflag $serviceName 2>&1 | Out-String)
if ($LASTEXITCODE -ne 0) { throw "Service failure-flag query failed (exit $LASTEXITCODE)." }
if ($failureFlag -notmatch 'TRUE|1') {
throw "Service non-crash recovery flag is not enabled: $failureFlag"
}
return [uint32]$service.ProcessId
}
function Assert-CrashRecovery {
param([string]$ExpectedVersion)
$previousPid = Assert-AgentRuntime -ExpectedVersion $ExpectedVersion
Stop-Process -Id $previousPid -Force
$deadline = (Get-Date).AddSeconds(45)
do {
Start-Sleep -Seconds 1
$service = Get-CimInstance Win32_Service | Where-Object Name -eq $serviceName
if ($null -ne $service -and $service.State -eq 'Running' -and [uint32]$service.ProcessId -ne $previousPid) {
try {
Wait-AgentReady -TimeoutSeconds 5
return
} catch {
}
}
} while ((Get-Date) -lt $deadline)
throw 'PulseAgent did not recover after its process was terminated.'
}
function Invoke-UninstallAndAssertClean {
Invoke-Installer -Label 'uninstall' -Arguments '-Uninstall $true -NonInteractive $true'
Start-Sleep -Seconds 2
if ($null -ne (Get-Service $serviceName -ErrorAction SilentlyContinue)) {
throw 'PulseAgent service still exists after uninstall.'
}
if (Test-Path "$env:ProgramFiles\Pulse\pulse-agent.exe") {
throw 'Pulse Agent binary still exists after uninstall.'
}
if (Test-Path $stateDir) {
throw 'Pulse Agent state directory still exists after uninstall.'
}
if (Get-NetTCPConnection -LocalPort 9191 -State Listen -ErrorAction SilentlyContinue) {
throw 'Pulse Agent readiness listener still exists after uninstall.'
}
}
Assert-Administrator
if (-not $ConfirmLifecycleMutation) {
throw 'Pass -ConfirmLifecycleMutation to acknowledge service installation, process termination, and uninstall on this dedicated Windows runner.'
}
$resolvedServerBinary = Resolve-RequiredPath $ServerBinary 'Lifecycle server binary'
$resolvedAgentV2 = Resolve-RequiredPath $AgentV2 'Version-two agent binary'
$resolvedInstallerPath = Resolve-RequiredPath $InstallerPath 'Windows installer'
$resolvedAgentV1 = $null
if ($Phase -ne 'PostRebootUninstall') {
$resolvedAgentV1 = Resolve-RequiredPath $AgentV1 'Version-one agent binary'
}
$powerShellPath = (Get-Process -Id $PID).Path
try {
if ($Phase -eq 'PostRebootUninstall') {
$proofState = Get-Content $proofStatePath -Raw | ConvertFrom-Json
$previousDisableAutoUpdate = $proofState.previousDisableAutoUpdate
$restoreAutoUpdateAtExit = $true
$versionV2 = Start-LifecycleServer -AgentPath $resolvedAgentV2
Assert-AgentRuntime -ExpectedVersion $versionV2 | Out-Null
Invoke-UninstallAndAssertClean
Write-Host 'Post-reboot persistence and uninstall proof passed.' -ForegroundColor Green
return
}
if (Get-Service $serviceName -ErrorAction SilentlyContinue) {
throw 'PulseAgent is already installed. Use a disposable runner or uninstall it before starting this proof.'
}
if (Test-Path $stateDir) {
throw "Pulse state already exists at $stateDir. Use a clean disposable runner."
}
$previousDisableAutoUpdate = [Environment]::GetEnvironmentVariable('PULSE_DISABLE_AUTO_UPDATE', 'Machine')
[Environment]::SetEnvironmentVariable('PULSE_DISABLE_AUTO_UPDATE', 'true', 'Machine')
$versionV1 = Start-LifecycleServer -AgentPath $resolvedAgentV1
Invoke-Installer -Label 'preflight' -Arguments "-Url '$baseUrl' -PreflightOnly `$true -NonInteractive `$true"
Invoke-Installer -Label 'install' -Arguments "-Url '$baseUrl' -AgentId 'windows-lifecycle-agent' -Hostname 'windows-lifecycle-agent' -EnableHost `$true -EnableDocker `$false -EnableKubernetes `$false -EnableProxmox `$false -EnableCommands `$false -NonInteractive `$true"
Assert-AgentRuntime -ExpectedVersion $versionV1 | Out-Null
$versionV2 = Start-LifecycleServer -AgentPath $resolvedAgentV2
Invoke-Installer -Label 'update' -Arguments "-Url '$baseUrl' -AgentId 'windows-lifecycle-agent' -Hostname 'windows-lifecycle-agent' -EnableHost `$true -EnableDocker `$false -EnableKubernetes `$false -EnableProxmox `$false -EnableCommands `$false -NonInteractive `$true"
Assert-AgentRuntime -ExpectedVersion $versionV2 | Out-Null
Assert-CrashRecovery -ExpectedVersion $versionV2
if ($Phase -eq 'InstallUpdate') {
[ordered]@{
expectedVersion = $versionV2
previousDisableAutoUpdate = $previousDisableAutoUpdate
} | ConvertTo-Json | Set-Content $proofStatePath -Encoding ascii
$restoreAutoUpdateAtExit = $false
Write-Host 'Install, update, logging, and crash-recovery proof passed; reboot the VM and run PostRebootUninstall.' -ForegroundColor Green
return
}
Restart-Service $serviceName -Force
Assert-AgentRuntime -ExpectedVersion $versionV2 | Out-Null
Invoke-UninstallAndAssertClean
Write-Host 'Full Windows service lifecycle proof passed.' -ForegroundColor Green
} finally {
Stop-LifecycleServer
if ($restoreAutoUpdateAtExit) {
[Environment]::SetEnvironmentVariable('PULSE_DISABLE_AUTO_UPDATE', $previousDisableAutoUpdate, 'Machine')
}
}