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`,