mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Extract the transport-independent action lifecycle service
Planning, approval decisions, and execution for typed resource actions move out of the HTTP handlers in internal/api/actions.go into a new internal/actionlifecycle.Service owned by api-contracts. The REST handlers become thin decode/actor/error-mapping adapters over the one shared service, and ResourceHandlers.ActionLifecycle() exposes the same service for in-process consumers, so a future Patrol action broker inherits identical resource lookup, availability checks, plan hashing, audit persistence, remediation locks, plan-drift revalidation, execution, and terminal publication instead of loopback HTTP or a parallel lifecycle. Behavior is preserved: same status codes, error codes, and audit/ lifecycle persistence ordering, backed by the existing api contract tests plus new fail-closed proofs for the service itself (unknown resource/capability, availability refusal, unapproved execution, remediation lock, plan drift, missing executor, missing store). Contract text in api-contracts, agent-lifecycle, and storage-recovery now names the service alongside actions.go and planner.go; the subsystem registry owns internal/actionlifecycle/ under api-contracts with a dedicated path policy; the code-standards and contract source pins follow the moved invariants; and the subsystem_lookup line-number pin shifts with the api-contracts canonical-files list insertion. This is the first slice of making the typed action lifecycle the only autonomous execution route for Patrol, Assistant, and MCP.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
[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 ([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 ($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 ([regex]::Matches($recovery, 'RESTART').Count -lt 3) {
|
||||
throw "Service recovery actions are incomplete: $recovery"
|
||||
}
|
||||
$failureFlag = (& sc.exe qfailureflag $serviceName 2>&1 | Out-String)
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", "127.0.0.1:17655", "HTTP listen address")
|
||||
agentBinary := flag.String("agent-binary", "", "Windows agent binary to serve")
|
||||
version := flag.String("version", "", "Version returned by /api/version")
|
||||
flag.Parse()
|
||||
|
||||
if strings.TrimSpace(*agentBinary) == "" || strings.TrimSpace(*version) == "" {
|
||||
log.Fatal("--agent-binary and --version are required")
|
||||
}
|
||||
|
||||
binaryPath, err := filepath.Abs(*agentBinary)
|
||||
if err != nil {
|
||||
log.Fatalf("resolve agent binary: %v", err)
|
||||
}
|
||||
binary, err := os.ReadFile(binaryPath)
|
||||
if err != nil {
|
||||
log.Fatalf("read agent binary: %v", err)
|
||||
}
|
||||
digest := sha256.Sum256(binary)
|
||||
checksum := hex.EncodeToString(digest[:])
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/version", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"version": *version,
|
||||
"agentUpdateTargetVersion": *version,
|
||||
"channel": "stable",
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/download/pulse-agent", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("arch") != "windows-amd64" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(binary)))
|
||||
w.Header().Set("X-Checksum-Sha256", checksum)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(binary)
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
})
|
||||
|
||||
server := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
}
|
||||
log.Printf("Windows lifecycle server listening on %s with %s (%s)", *listen, filepath.Base(binaryPath), *version)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -2917,8 +2917,8 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 1196,
|
||||
"heading_line": 140,
|
||||
"line": 1197,
|
||||
"heading_line": 141,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user