From 3f405af0c658b67b8e89946bdfbee7ea8d3cb959 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 19 Jul 2026 19:41:57 +0100 Subject: [PATCH] Align Windows agent state with the installer Use the installer-owned ProgramData directory consistently for token lookup, enrollment state, agent identity, receipts, and service runtime data. Pass that directory explicitly to the Windows service and retain the existing Linux default elsewhere. Contract-Neutral: Windows agent state-path alignment preserves the existing installer and agent public contract --- cmd/pulse-agent/main.go | 39 +++++++++++++++++------- cmd/pulse-agent/main_test.go | 13 ++++++++ scripts/install.ps1 | 3 +- scripts/installtests/install_ps1_test.go | 1 + 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index 1a5185623..dbaf31a6c 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -14,6 +14,7 @@ import ( "os/signal" "path/filepath" "reflect" + "runtime" "strconv" "strings" "sync" @@ -1027,7 +1028,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { kubeIncludeAllPodsFlag := fs.Bool("kube-include-all-pods", utils.ParseBool(envKubeIncludeAllPods), "Include all non-succeeded pods (may be large)") kubeIncludeAllDeploymentsFlag := fs.Bool("kube-include-all-deployments", utils.ParseBool(envKubeIncludeAllDeployments), "Include all deployments, not just problem ones") kubeMaxPodsFlag := fs.Int("kube-max-pods", defaultInt(envKubeMaxPods, 200), "Max pods included in report") - stateDirFlag := fs.String("state-dir", envStateDir, "Persistent state directory (default: /var/lib/pulse-agent)") + stateDirFlag := fs.String("state-dir", envStateDir, "Persistent state directory (default: platform service state directory)") reportIPFlag := fs.String("report-ip", envReportIP, "IP address to report (for multi-NIC systems)") disableCephFlag := fs.Bool("disable-ceph", utils.ParseBool(envDisableCeph), "Disable local Ceph status polling") showVersion := fs.Bool("version", false, "Print the agent version and exit") @@ -1072,12 +1073,11 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { // enrollment, use it instead of the bootstrap token embedded in the service // config. This ensures the agent survives restarts after enrollment. stateDir := strings.TrimSpace(*stateDirFlag) + if stateDir == "" { + stateDir = defaultAgentStateDir() + } if *enrollFlag { - enrollStateDir := stateDir - if enrollStateDir == "" { - enrollStateDir = "/var/lib/pulse-agent" - } - runtimeTokenPath := filepath.Join(enrollStateDir, "runtime.token") + runtimeTokenPath := filepath.Join(stateDir, "runtime.token") if content, err := os.ReadFile(runtimeTokenPath); err == nil { if t := strings.TrimSpace(string(content)); t != "" { token = t @@ -1086,7 +1086,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { } if token == "" && *enrollFlag && !*selfTest { - return Config{}, fmt.Errorf("Pulse API token is required for enrollment (use --token, --token-file, PULSE_TOKEN env, or /var/lib/pulse-agent/token)") + return Config{}, fmt.Errorf("Pulse API token is required for enrollment (use --token, --token-file, PULSE_TOKEN env, or %s)", defaultTokenFilePath()) } logLevel, err := parseLogLevel(*logLevelFlag) @@ -1172,7 +1172,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { KubeIncludeAllPods: *kubeIncludeAllPodsFlag, KubeIncludeAllDeployments: *kubeIncludeAllDeploymentsFlag, KubeMaxPods: kubeMaxPods, - StateDir: strings.TrimSpace(*stateDirFlag), + StateDir: stateDir, DiskExclude: diskExclude, ReportIP: strings.TrimSpace(*reportIPFlag), DisableCeph: *disableCephFlag, @@ -1294,13 +1294,30 @@ func resolveEnableCommands(enableFlag, disableFlag bool, envEnable, envDisable s // 1. --token flag (direct value) // 2. --token-file flag (read from file) // 3. PULSE_TOKEN environment variable -// 4. Default token file at /var/lib/pulse-agent/token +// 4. Default token file under the platform state directory // // Reading from a file is more secure than CLI args as tokens won't appear in `ps` output. func resolveToken(tokenFlag, tokenFileFlag, envToken string) string { return resolveTokenInternal(tokenFlag, tokenFileFlag, envToken, os.ReadFile) } +// defaultAgentStateDir mirrors where each platform's installer keeps agent +// state: %ProgramData%\Pulse on Windows (see scripts/install.ps1), the +// systemd/launchd convention /var/lib/pulse-agent everywhere else. +func defaultAgentStateDir() string { + if runtime.GOOS == "windows" { + if pd := strings.TrimSpace(os.Getenv("ProgramData")); pd != "" { + return filepath.Join(pd, "Pulse") + } + return `C:\ProgramData\Pulse` + } + return "/var/lib/pulse-agent" +} + +func defaultTokenFilePath() string { + return filepath.Join(defaultAgentStateDir(), "token") +} + func resolveTokenInternal(tokenFlag, tokenFileFlag, envToken string, readFile func(string) ([]byte, error)) string { // 1. Direct token from --token flag if t := strings.TrimSpace(tokenFlag); t != "" { @@ -1321,8 +1338,8 @@ func resolveTokenInternal(tokenFlag, tokenFileFlag, envToken string, readFile fu return t } - // 4. Default token file (most secure method for systemd services) - defaultTokenFile := "/var/lib/pulse-agent/token" + // 4. Default token file (most secure method for service installs) + defaultTokenFile := defaultTokenFilePath() if content, err := readFile(defaultTokenFile); err == nil { if t := strings.TrimSpace(string(content)); t != "" { return t diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index 8fb41eedb..3f186937a 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -921,6 +921,9 @@ func TestLoadConfig(t *testing.T) { if cfg.EnableHost != true { t.Errorf("expected host enabled by default") } + if cfg.StateDir != defaultAgentStateDir() { + t.Errorf("expected platform state directory %q, got %q", defaultAgentStateDir(), cfg.StateDir) + } }) t.Run("env overrides", func(t *testing.T) { @@ -985,6 +988,16 @@ func TestLoadConfig(t *testing.T) { } }) + t.Run("state directory flag overrides platform default", func(t *testing.T) { + cfg, err := loadConfig([]string{"-token", "test-token", "-state-dir", "/custom/pulse-state"}, func(s string) string { return "" }) + if err != nil { + t.Fatal(err) + } + if cfg.StateDir != "/custom/pulse-state" { + t.Errorf("expected explicit state directory, got %q", cfg.StateDir) + } + }) + t.Run("token optional when enrollment disabled", func(t *testing.T) { cfg, err := loadConfig([]string{"-url", "http://token-optional.example.com", "-enable-host"}, func(s string) string { return "" }) if err != nil { diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 3b46f565f..de6d6f6b6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -947,7 +947,8 @@ Save-ConnectionState # Build command line args (properly escaped) $ServiceArgs = @( "--url", "`"$Url`"", - "--interval", "`"$Interval`"" + "--interval", "`"$Interval`"", + "--state-dir", "`"$StateDir`"" ) if (-not [string]::IsNullOrWhiteSpace($Token)) { $ServiceArgs += @("--token-file", "`"$TokenFilePath`"") } if ($EnableHost) { $ServiceArgs += "--enable-host" } else { $ServiceArgs += "--enable-host=false" } diff --git a/scripts/installtests/install_ps1_test.go b/scripts/installtests/install_ps1_test.go index 45051eaee..668b375ba 100644 --- a/scripts/installtests/install_ps1_test.go +++ b/scripts/installtests/install_ps1_test.go @@ -111,6 +111,7 @@ func TestInstallPS1OwnsWindowsServiceLoggingAndRecovery(t *testing.T) { script := string(content) required := []string{ `$ServiceArgs += @("--log-file", "` + "`" + `"$LogFile` + "`" + `"")`, + `"--state-dir", "` + "`" + `"$StateDir` + "`" + `"`, `$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`,