Harden native Windows agent lifecycle

This commit is contained in:
rcourtman
2026-07-10 19:16:23 +01:00
parent c89918d78e
commit 3bea52b1b5
11 changed files with 369 additions and 17 deletions
@@ -92,6 +92,10 @@ jobs:
if: matrix.unix
run: go test ./scripts/installtests
- name: Test native Windows installer contracts
if: ${{ !matrix.unix }}
run: go test ./scripts/installtests -run '^Test(InstallPS1|WindowsAgentLifecycle)'
- name: Build and execute native Unix agent
if: matrix.unix
shell: bash
@@ -119,6 +123,22 @@ jobs:
exit 1
}
- name: Exercise native Windows service lifecycle
if: ${{ !matrix.unix }}
shell: pwsh
timeout-minutes: 10
run: |
go build -buildvcs=false -trimpath -ldflags="-s -w -X main.Version=6.0.5-ci.1" -o pulse-agent-windows-ci-1.exe ./cmd/pulse-agent
go build -buildvcs=false -trimpath -ldflags="-s -w -X main.Version=6.0.5-ci.2" -o pulse-agent-windows-ci-2.exe ./cmd/pulse-agent
go build -buildvcs=false -trimpath -o windows-lifecycle-server.exe ./scripts/installtests/windowslifecycleserver
& ./scripts/installtests/windows_agent_lifecycle.ps1 `
-Phase Full `
-ServerBinary ./windows-lifecycle-server.exe `
-AgentV1 ./pulse-agent-windows-ci-1.exe `
-AgentV2 ./pulse-agent-windows-ci-2.exe `
-InstallerPath ./scripts/install.ps1 `
-ConfirmLifecycleMutation
freebsd-build-contract:
name: FreeBSD cross-build contract
runs-on: ubuntu-24.04
+41 -5
View File
@@ -27,6 +27,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
"github.com/rcourtman/pulse-go-rewrite/internal/kubernetesagent"
pulselogging "github.com/rcourtman/pulse-go-rewrite/internal/logging"
"github.com/rcourtman/pulse-go-rewrite/internal/remoteconfig"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
@@ -60,6 +61,11 @@ var (
}, []string{"module"})
)
const (
agentLogMaxSizeMB = 25
agentLogMaxAgeDays = 14
)
// Runnable is an interface for agents that can be run
type Runnable interface {
Run(ctx context.Context) error
@@ -131,8 +137,11 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
}
// 2. Setup Logging
zerolog.SetGlobalLevel(cfg.LogLevel)
logger := zerolog.New(os.Stdout).Level(cfg.LogLevel).With().Timestamp().Logger()
logger, closeLogger, err := configureAgentLogger(cfg)
if err != nil {
return fmt.Errorf("failed to configure unified agent logging: %w", err)
}
defer closeLogger()
cfg.Logger = &logger
if cfg.InsecureSkipVerify && cfg.ServerFingerprint == "" {
@@ -520,6 +529,32 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
return nil
}
func configureAgentLogger(cfg Config) (zerolog.Logger, func(), error) {
zerolog.SetGlobalLevel(cfg.LogLevel)
if cfg.LogFile == "" {
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
return logger, func() {}, nil
}
logger, closer, err := pulselogging.NewStandaloneLogger(pulselogging.Config{
Format: "json",
Level: cfg.LogLevel.String(),
Component: "pulse-agent",
FilePath: cfg.LogFile,
MaxSizeMB: agentLogMaxSizeMB,
MaxAgeDays: agentLogMaxAgeDays,
Compress: true,
}, os.Stdout)
if err != nil {
return zerolog.Logger{}, func() {}, fmt.Errorf("initialize log file %q: %w", cfg.LogFile, err)
}
return logger, func() {
if closer != nil {
_ = closer.Close()
}
}, nil
}
// readAgentIDFile reads a persisted agent identifier from the given path.
func readAgentIDFile(path string) (string, error) {
if path == "" {
@@ -723,6 +758,7 @@ type Config struct {
ServerFingerprint string
DeploySSHUser string
LogLevel zerolog.Level
LogFile string
Logger *zerolog.Logger
// Module flags
@@ -784,6 +820,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
envDeploySSHUser := strings.TrimSpace(getenv("PULSE_DEPLOY_SSH_USER"))
envTags := strings.TrimSpace(getenv("PULSE_TAGS"))
envLogLevel := strings.TrimSpace(getenv("LOG_LEVEL"))
envLogFile := strings.TrimSpace(getenv("PULSE_LOG_FILE"))
envEnableHost := strings.TrimSpace(getenv("PULSE_ENABLE_HOST"))
envEnableDocker := strings.TrimSpace(getenv("PULSE_ENABLE_DOCKER"))
envEnableKubernetes := strings.TrimSpace(getenv("PULSE_ENABLE_KUBERNETES"))
@@ -864,6 +901,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
serverFingerprintFlag := fs.String("server-fingerprint", envServerFingerprint, "Expected Pulse server TLS certificate fingerprint (SHA256)")
deploySSHUserFlag := fs.String("deploy-ssh-user", envDeploySSHUser, "SSH user for peer deploy fan-out (default: root; non-root requires passwordless sudo)")
logLevelFlag := fs.String("log-level", defaultLogLevel(envLogLevel), "Log level")
logFileFlag := fs.String("log-file", envLogFile, "Write rotating JSON logs to this file")
enableHostFlag := fs.Bool("enable-host", defaultEnableHost, "Enable Host Agent module")
enableDockerFlag := fs.Bool("enable-docker", defaultEnableDocker, "Enable Docker / Podman Agent module")
@@ -995,6 +1033,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
ServerFingerprint: strings.TrimSpace(*serverFingerprintFlag),
DeploySSHUser: deploySSHUser,
LogLevel: logLevel,
LogFile: strings.TrimSpace(*logFileFlag),
EnableHost: *enableHostFlag,
EnableDocker: *enableDockerFlag,
DockerConfigured: dockerConfigured,
@@ -1348,9 +1387,6 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z
if l, err := zerolog.ParseLevel(s); err == nil {
cfg.LogLevel = l
zerolog.SetGlobalLevel(l)
// Re-create logger with new level
newLogger := zerolog.New(os.Stdout).Level(l).With().Timestamp().Logger()
cfg.Logger = &newLogger
logger.Info().Str("val", s).Msg("Remote config: log_level")
}
}
+54
View File
@@ -67,6 +67,60 @@ func TestDockerRuntimeHelpUsesDockerPodmanCopy(t *testing.T) {
}
}
func TestLoadConfigPreservesAgentLogFile(t *testing.T) {
t.Run("environment", func(t *testing.T) {
cfg, err := loadConfig(nil, func(key string) string {
if key == "PULSE_LOG_FILE" {
return ` C:\ProgramData\Pulse\pulse-agent.log `
}
return ""
})
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.LogFile != `C:\ProgramData\Pulse\pulse-agent.log` {
t.Fatalf("LogFile = %q", cfg.LogFile)
}
})
t.Run("flag overrides environment", func(t *testing.T) {
cfg, err := loadConfig([]string{"--log-file", `D:\Pulse\agent.jsonl`}, func(key string) string {
if key == "PULSE_LOG_FILE" {
return `C:\ProgramData\Pulse\pulse-agent.log`
}
return ""
})
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.LogFile != `D:\Pulse\agent.jsonl` {
t.Fatalf("LogFile = %q", cfg.LogFile)
}
})
}
func TestAgentFileLoggingUsesCanonicalRotatingSink(t *testing.T) {
source, err := os.ReadFile("main.go")
if err != nil {
t.Fatalf("read pulse-agent main.go: %v", err)
}
text := string(source)
for _, want := range []string{
`pulselogging.NewStandaloneLogger(pulselogging.Config{`,
`MaxSizeMB: agentLogMaxSizeMB`,
`MaxAgeDays: agentLogMaxAgeDays`,
`Compress: true`,
`Write rotating JSON logs to this file`,
} {
if !strings.Contains(text, want) {
t.Fatalf("expected canonical rotating agent log contract %q", want)
}
}
if strings.Contains(text, "newLogger := zerolog.New(os.Stdout)") {
t.Fatal("remote log-level updates must not replace the configured file sink")
}
}
func TestGatherTags(t *testing.T) {
tests := []struct {
name string
@@ -34,6 +34,9 @@ that binary, not separate customer-facing agent products.
6. `cmd/pulse-agent/main.go`
7. `scripts/install.sh`
8. `scripts/install.ps1`
8a. `.github/workflows/unified-agent-native.yml`
8b. `scripts/installtests/windows_agent_lifecycle.ps1`
8c. `scripts/installtests/windowslifecycleserver/main.go`
9. `frontend-modern/src/api/agentProfiles.ts`
10. `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`
11. `frontend-modern/src/components/Settings/agentProfileSettings.ts`
@@ -262,7 +265,14 @@ update, profile rollout, command reachability, or fleet-control authority.
also expose the same local health/readiness server as foreground
`pulse-agent` runs so installer "healthy" verification and post-install
smoke checks prove a live agent runtime, not merely a running service
wrapper.
wrapper. The service must pass the installer-owned ProgramData log path to
the agent's canonical rotating file sink, and install success requires both
`/readyz` and a non-empty log file. SCM recovery must be configured as a
required lifecycle contract, including non-crash failures, rather than a
best-effort warning. Native Windows proof must exercise preflight, install,
version replacement, logged readiness, forced-process recovery, service
restart or OS reboot persistence, and complete uninstall cleanup through
the reusable lifecycle harness under `scripts/installtests/`.
25. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
Legacy update recovery is cross-platform lifecycle continuity. Linux may
read procfs or a systemd unit, while FreeBSD and pfSense must recover the
@@ -282,7 +282,12 @@ TLS floor in the dynamic config.
It must expose a non-mutating preflight for the exact Windows agent
architecture before Administrator-only install changes, accept token-file
enrollment input, and avoid interactive download-failure prompts when
launched by generated non-interactive onboarding commands.
launched by generated non-interactive onboarding commands. A completed
install must own a durable rotating ProgramData log, verify that log
together with local `/readyz`, and fail closed if required SCM recovery
actions or non-crash recovery cannot be configured. The Windows native CI
path must run the reusable lifecycle harness rather than stopping at a
parser check or foreground self-test.
8. `scripts/install.sh` shared with `agent-lifecycle`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
Existing-agent update commands copied from the settings UI must use the
installer-owned `--update` mode rather than serializing a fresh enrollment
@@ -2117,7 +2122,13 @@ persisted Windows service ever starts.
Windows installability proof must also verify the installed service's local
readiness endpoint, not just SCM `Running` state: the Windows service runtime
must start the shared Pulse Agent health/readiness server so `/readyz` can prove
the agent modules initialized after install.
the agent modules initialized after install. That proof must also require the
installer-advertised ProgramData log to exist and contain startup evidence,
exercise configured SCM crash recovery, replace one real agent version with a
second, and prove uninstall removes the service, binary, state, token/log
artifacts, and readiness listener. OS-reboot-capable labs use the harness's
split install/update and post-reboot/uninstall phases; hosted CI uses its full
service-lifecycle phase without rebooting the ephemeral runner.
Copied PowerShell uninstall commands must preserve that same
`PULSE_INSECURE_SKIP_VERIFY` setting so the governed deregistration request can
still reach self-signed Pulse deployments during removal.
@@ -1040,6 +1040,7 @@
"internal/hostagent/"
],
"owned_files": [
".github/workflows/unified-agent-native.yml",
"cmd/pulse-agent/main.go",
"frontend-modern/src/api/agentProfiles.ts",
"frontend-modern/src/api/nodes.ts",
@@ -1101,7 +1102,9 @@
"internal/securityutil/httpurl.go",
"pkg/securityutil/httpurl.go",
"scripts/install.ps1",
"scripts/install.sh"
"scripts/install.sh",
"scripts/installtests/windows_agent_lifecycle.ps1",
"scripts/installtests/windowslifecycleserver/main.go"
],
"verification": {
"allow_same_subsystem_tests": false,
@@ -1240,6 +1243,21 @@
"scripts/installtests/install_ps1_test.go"
]
},
{
"id": "windows-agent-native-lifecycle-proof",
"label": "Windows agent native lifecycle harness proof",
"match_prefixes": [],
"match_files": [
".github/workflows/unified-agent-native.yml",
"scripts/installtests/windows_agent_lifecycle.ps1",
"scripts/installtests/windowslifecycleserver/main.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"scripts/installtests/install_ps1_test.go"
]
},
{
"id": "agent-profiles-surface",
"label": "agent profile management proof",
@@ -5590,6 +5608,7 @@
"internal/config/config.go",
"internal/config/watcher.go",
"internal/crypto/crypto.go",
"internal/logging/logging.go",
"internal/securityutil/secure_storage_dir.go",
"internal/telemetry/telemetry.go",
"pkg/audit/async_logger.go",
@@ -5770,6 +5789,19 @@
"internal/securityutil/secure_storage_dir_test.go"
]
},
{
"id": "secure-rotating-log-sink",
"label": "secure rotating log sink proof",
"match_prefixes": [],
"match_files": [
"internal/logging/logging.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/logging/logging_test.go"
]
},
{
"id": "security-api-surface",
"label": "security API surface proof",
@@ -68,6 +68,7 @@ controls as normal product settings.
40. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx`
41. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts`
42. `internal/api/agent_exec_token_binding.go`
43. `internal/logging/logging.go`
## Shared Boundaries
@@ -310,6 +311,11 @@ the `white_label` branding entitlement.
pinned-fingerprint TLS clients keep one fail-closed security floor.
9. Change operator-facing Resource Privacy/Data Handling posture through `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` and `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` together so resource classification, handling-boundary, redaction copy, and the route-backed/hidden-sidebar presentation stay governed as a trust surface.
10. Change inside-guest runtime collection boundaries through `docs/AGENT_SECURITY.md`, `docs/UNIFIED_AGENT.md`, `cmd/pulse-agent/main.go`, `internal/api/router.go`, and `internal/config/config.go` together. Docker / Podman inventory inside a VM or LXC may come from a guest-local `pulse-agent` module or explicitly reported guest data; LXC Docker inventory may also be collected by a Proxmox host agent only through explicit server opt-in, with optional VMID allowlisting and a minimal summary command set that avoids `docker inspect`, environment, mount, file, command, and process collection. Local Unified Agent Docker / Podman disables must not be reversed by remote profile configuration, and self-test/update preflight that needs the live runtime token must pass it through a short-lived token file rather than argv. The `--enable-docker` help line is part of that operator privacy control, so it must remain "Enable Docker / Podman Agent module" instead of exposing internal collection-module wording. The `--enable-commands` help line and installer disclosure must identify Pulse command execution as disabled by default and required for Patrol actions or the explicit Proxmox LXC Docker inventory path, not as implicit guest access.
Agent file logging is local operational state, not a second telemetry path:
`cmd/pulse-agent/main.go` must use the canonical owner-only rotating sink,
retain that sink when remote configuration changes log level, and never
place runtime tokens or enrollment secrets in the service command or log
output.
Global resource timeline reads through `/api/resources/timeline` are
adjacent monitoring-read surfaces, not a privacy bypass. Provider activity
filters may expose backend-authored task/event metadata, but the endpoint
+32
View File
@@ -134,6 +134,38 @@ func newBaseLogger() zerolog.Logger {
return zerolog.New(globalSink).Hook(componentHook{}).With().Timestamp().Logger()
}
// NewStandaloneLogger creates a logger with an independent output chain and
// optional rotating file sink. Unlike Init, it does not replace package-global
// logging state, so long-lived service binaries can retain their own file sink
// even when imported server/config packages reconfigure the global logger.
func NewStandaloneLogger(cfg Config, output io.Writer) (zerolog.Logger, io.Closer, error) {
if output == nil {
output = os.Stderr
}
writer := output
fileWriter, err := newRollingFileWriter(cfg)
if err != nil {
return zerolog.Logger{}, nil, err
}
var closer io.Closer
if fileWriter != nil {
// Put the durable sink first. Windows services may inherit an invalid
// stdout/stderr handle; io.MultiWriter stops on the first writer error,
// so console-first ordering would silently starve the service log.
writer = io.MultiWriter(fileWriter, writer)
if candidate, ok := fileWriter.(io.Closer); ok {
closer = candidate
}
}
context := zerolog.New(writer).With().Timestamp()
if component := strings.TrimSpace(cfg.Component); component != "" {
context = context.Str("component", component)
}
return context.Logger(), closer, nil
}
// Init configures zerolog globals and refreshes the package logger output.
func Init(cfg Config) zerolog.Logger {
mu.Lock()
+72
View File
@@ -345,6 +345,78 @@ func TestRollingFileWriter(t *testing.T) {
}
}
func TestNewStandaloneLoggerWritesIndependentRotatingFile(t *testing.T) {
logFile := filepath.Join(t.TempDir(), "pulse-agent.log")
var console bytes.Buffer
logger, closer, err := NewStandaloneLogger(Config{
Component: "pulse-agent",
FilePath: logFile,
MaxSizeMB: 1,
MaxAgeDays: 1,
Compress: true,
}, &console)
if err != nil {
t.Fatalf("NewStandaloneLogger error: %v", err)
}
if closer == nil {
t.Fatal("expected standalone file closer")
}
logger.Info().Str("version", "test.1").Msg("service started")
if err := closer.Close(); err != nil {
t.Fatalf("close standalone logger: %v", err)
}
fileContent, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("read standalone log: %v", err)
}
for label, content := range map[string]string{
"console": console.String(),
"file": string(fileContent),
} {
if !strings.Contains(content, `"component":"pulse-agent"`) ||
!strings.Contains(content, `"version":"test.1"`) ||
!strings.Contains(content, `"message":"service started"`) {
t.Fatalf("%s output missing structured event: %s", label, content)
}
}
}
type failingLogWriter struct{}
func (failingLogWriter) Write([]byte) (int, error) {
return 0, errors.New("console handle unavailable")
}
func TestNewStandaloneLoggerPrioritizesFileWhenConsoleFails(t *testing.T) {
logFile := filepath.Join(t.TempDir(), "pulse-agent.log")
logger, closer, err := NewStandaloneLogger(Config{
Component: "pulse-agent",
FilePath: logFile,
MaxSizeMB: 1,
MaxAgeDays: 1,
}, failingLogWriter{})
if err != nil {
t.Fatalf("NewStandaloneLogger error: %v", err)
}
if closer == nil {
t.Fatal("expected standalone file closer")
}
logger.Info().Msg("service survives invalid console")
if err := closer.Close(); err != nil {
t.Fatalf("close standalone logger: %v", err)
}
content, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("read standalone log: %v", err)
}
if !strings.Contains(string(content), `"message":"service survives invalid console"`) {
t.Fatalf("durable log did not receive event after console failure: %s", content)
}
}
func TestInitClosesPreviousRollingFileWriter(t *testing.T) {
t.Cleanup(resetLoggingState)
+18 -8
View File
@@ -935,9 +935,17 @@ if (-not [string]::IsNullOrWhiteSpace($CACertPath)) { $ServiceArgs += @("--cacer
if (-not [string]::IsNullOrWhiteSpace($ServerFingerprint)) { $ServiceArgs += @("--server-fingerprint", "`"$ServerFingerprint`"") }
if (-not [string]::IsNullOrWhiteSpace($AgentId)) { $ServiceArgs += @("--agent-id", "`"$AgentId`"") }
if (-not [string]::IsNullOrWhiteSpace($Hostname)) { $ServiceArgs += @("--hostname", "`"$Hostname`"") }
$ServiceArgs += @("--log-file", "`"$LogFile`"")
$BinPath = "`"$DestPath`" $($ServiceArgs -join ' ')"
# Create the state/log directory before SCM starts the service so the agent can
# establish its rotating file sink as part of startup.
$LogDir = Split-Path $LogFile -Parent
if (-not (Test-Path $LogDir)) {
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
}
# Create Service using New-Service (more reliable than sc.exe create)
try {
New-Service -Name $AgentName `
@@ -953,13 +961,14 @@ try {
$scOutput = sc.exe failure $AgentName reset= 86400 actions= restart/5000/restart/5000/restart/5000 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Failed to configure service recovery: $scOutput" -ForegroundColor Yellow
Show-Error "Failed to configure service recovery actions: $scOutput"
Exit 1
}
# Ensure log directory exists
$LogDir = Split-Path $LogFile -Parent
if (-not (Test-Path $LogDir)) {
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
$scOutput = sc.exe failureflag $AgentName 1 2>&1
if ($LASTEXITCODE -ne 0) {
Show-Error "Failed to enable service recovery for non-crash failures: $scOutput"
Exit 1
}
# Start the service
@@ -983,7 +992,8 @@ Start-Sleep -Seconds 2
for ($i = 0; $i -lt $maxIterations; $i++) {
try {
$response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop
if ($response.StatusCode -eq 200) {
$logReady = (Test-Path $LogFile) -and ((Get-Item $LogFile).Length -gt 0)
if ($response.StatusCode -eq 200 -and $logReady) {
$healthy = $true
break
}
@@ -1012,8 +1022,8 @@ Write-Host ""
if ($healthy) {
Write-Host "Installation complete! Agent is running." -ForegroundColor Green
} else {
Write-Host "Installation complete, but the agent may not be running correctly." -ForegroundColor Yellow
Write-Host "Check logs: Get-Content '$LogFile' -Tail 50" -ForegroundColor Yellow
Show-Error "Installation did not reach a healthy, logged runtime. Check logs with: Get-Content '$LogFile' -Tail 50"
Exit 1
}
Write-Host "Service: $AgentName"
Write-Host "Binary: $DestPath"
+69
View File
@@ -27,6 +27,75 @@ func TestInstallPS1ParsesWithPowerShell(t *testing.T) {
}
}
func TestWindowsAgentLifecycleHarnessParsesWithPowerShell(t *testing.T) {
pwsh, err := exec.LookPath("pwsh")
if err != nil {
t.Skip("pwsh not installed")
}
scriptPath := repoFile("scripts", "installtests", "windows_agent_lifecycle.ps1")
cmd := exec.Command(pwsh,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
`$errors = $null; [System.Management.Automation.Language.Parser]::ParseFile($env:PULSE_WINDOWS_LIFECYCLE_PATH, [ref]$null, [ref]$errors) > $null; if ($errors.Count) { $errors | ForEach-Object { Write-Error $_.ToString() }; exit 1 }`,
)
cmd.Env = append(os.Environ(), "PULSE_WINDOWS_LIFECYCLE_PATH="+scriptPath)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Windows lifecycle harness failed PowerShell parser check: %v\n%s", err, output)
}
}
func TestWindowsAgentLifecycleHarnessPinsCompleteServiceProof(t *testing.T) {
content, err := os.ReadFile(repoFile("scripts", "installtests", "windows_agent_lifecycle.ps1"))
if err != nil {
t.Fatalf("read Windows lifecycle harness: %v", err)
}
script := string(content)
required := []string{
`[ValidateSet('Full', 'InstallUpdate', 'PostRebootUninstall')]`,
`Pass -ConfirmLifecycleMutation`,
`-PreflightOnly ` + "`" + `$true`,
`Assert-AgentRuntime -ExpectedVersion $versionV1`,
`Assert-AgentRuntime -ExpectedVersion $versionV2`,
`Assert-CrashRecovery -ExpectedVersion $versionV2`,
`Post-reboot persistence and uninstall proof passed.`,
`PulseAgent service still exists after uninstall.`,
`Pulse Agent state directory still exists after uninstall.`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
t.Fatalf("Windows lifecycle harness missing proof contract: %s", needle)
}
}
}
func TestInstallPS1OwnsWindowsServiceLoggingAndRecovery(t *testing.T) {
content, err := os.ReadFile(repoFile("scripts", "install.ps1"))
if err != nil {
t.Fatalf("read install.ps1: %v", err)
}
script := string(content)
required := []string{
`$ServiceArgs += @("--log-file", "` + "`" + `"$LogFile` + "`" + `"")`,
`$scOutput = sc.exe failure $AgentName reset= 86400 actions= restart/5000/restart/5000/restart/5000`,
`Show-Error "Failed to configure service recovery actions: $scOutput"`,
`$scOutput = sc.exe failureflag $AgentName 1`,
`Show-Error "Failed to enable service recovery for non-crash failures: $scOutput"`,
`$logReady = (Test-Path $LogFile) -and ((Get-Item $LogFile).Length -gt 0)`,
`if ($response.StatusCode -eq 200 -and $logReady)`,
`Installation did not reach a healthy, logged runtime.`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
t.Fatalf("install.ps1 missing Windows service logging/recovery contract: %s", needle)
}
}
}
func TestInstallPS1DockerModeDefaultsHostOff(t *testing.T) {
content, err := os.ReadFile(repoFile("scripts", "install.ps1"))
if err != nil {