package runtimeconfig import ( "fmt" "os" "path" "time" "github.com/itworx/pulse/internal/config" ) const ( defaultAgentID = "pulse-agent" defaultCollectInterval = 10 * time.Second minimumCollectInterval = time.Second maximumCollectInterval = 5 * time.Minute defaultProcRoot = "/proc" defaultSysRoot = "/sys" maximumAgentIDLength = 120 maximumHostNameLength = 255 maximumConfigurableRowCount = 5000 ) // AgentConfig is everything pulse-agent needs to run. It is deliberately separate from // internal/config: the agent container receives only PULSE_DATABASE_URL and its own // settings, and must not require the public URL, OIDC or Prometheus configuration the // API validates. type AgentConfig struct { Service ServiceConfig // AgentID identifies this agent in every snapshot and in the protocol hello. AgentID string // DatabaseURL is the only credential the agent holds; it is never logged. DatabaseURL string // CollectInterval is how often a full collection pass runs. The scheduling loop // itself ticks faster when this is large, so the heartbeat stays inside the // 10 second window the healthcheck contract requires. CollectInterval time.Duration // ProcRoot and SysRoot are the read-only mounts the collector reads. ProcRoot string SysRoot string // FilesystemRoot prefixes mount points before statfs. Empty disables filesystem // capacity collection, which is the safe default in a container where the host's // mount points do not resolve. FilesystemRoot string // HostName overrides the name read from the reader's UTS namespace, which in a // container is the container's name rather than the host's. HostName string // MaxProcesses bounds the process inventory; zero uses the domain default. MaxProcesses int // UnraidURL and UnraidAPIToken are an optional, read-only source for bounded // container snapshots. They must be configured together; the token is never logged. UnraidURL string UnraidAPIToken string // UnraidCAFile optionally adds one private CA/leaf certificate while retaining // normal TLS hostname and chain verification. UnraidCAFile string } // LoadAgent reads and validates the agent's environment. func LoadAgent() (AgentConfig, error) { service, err := Load("agent") if err != nil { return AgentConfig{}, err } agent := AgentConfig{ Service: service, AgentID: valueOrDefault("PULSE_AGENT_ID", defaultAgentID), DatabaseURL: os.Getenv("PULSE_DATABASE_URL"), ProcRoot: valueOrDefault("PULSE_AGENT_PROC_ROOT", defaultProcRoot), SysRoot: valueOrDefault("PULSE_AGENT_SYS_ROOT", defaultSysRoot), FilesystemRoot: os.Getenv("PULSE_AGENT_FS_ROOT"), HostName: os.Getenv("PULSE_AGENT_HOST_NAME"), UnraidURL: os.Getenv("PULSE_UNRAID_URL"), UnraidAPIToken: os.Getenv("PULSE_UNRAID_API_TOKEN"), UnraidCAFile: os.Getenv("PULSE_UNRAID_CA_FILE"), } if len(agent.AgentID) > maximumAgentIDLength { return AgentConfig{}, fmt.Errorf("PULSE_AGENT_ID must be at most %d characters", maximumAgentIDLength) } if agent.DatabaseURL == "" { return AgentConfig{}, fmt.Errorf("PULSE_DATABASE_URL is required: the agent publishes snapshots through the database") } if err := config.ValidateDatabaseURL(agent.DatabaseURL); err != nil { return AgentConfig{}, err } interval, err := durationOrDefault("PULSE_AGENT_COLLECT_INTERVAL", defaultCollectInterval, minimumCollectInterval, maximumCollectInterval) if err != nil { return AgentConfig{}, err } agent.CollectInterval = interval for name, value := range map[string]string{ "PULSE_AGENT_PROC_ROOT": agent.ProcRoot, "PULSE_AGENT_SYS_ROOT": agent.SysRoot, } { if !path.IsAbs(value) { return AgentConfig{}, fmt.Errorf("%s must be an absolute path", name) } } if agent.FilesystemRoot != "" && !path.IsAbs(agent.FilesystemRoot) { return AgentConfig{}, fmt.Errorf("PULSE_AGENT_FS_ROOT must be an absolute path when set") } if len(agent.HostName) > maximumHostNameLength { return AgentConfig{}, fmt.Errorf("PULSE_AGENT_HOST_NAME must be at most %d characters", maximumHostNameLength) } if (agent.UnraidURL == "") != (agent.UnraidAPIToken == "") { return AgentConfig{}, fmt.Errorf("PULSE_UNRAID_URL and PULSE_UNRAID_API_TOKEN must be configured together") } if agent.UnraidURL != "" { if err := config.ValidateURL(agent.UnraidURL, true); err != nil { return AgentConfig{}, fmt.Errorf("PULSE_UNRAID_URL: %w", err) } } if agent.UnraidCAFile != "" { if agent.UnraidURL == "" || !path.IsAbs(agent.UnraidCAFile) { return AgentConfig{}, fmt.Errorf("PULSE_UNRAID_CA_FILE requires a configured Unraid source and an absolute path") } } rows, err := intOrDefault("PULSE_AGENT_MAX_PROCESSES", 0, 1, maximumConfigurableRowCount) if err != nil { return AgentConfig{}, err } agent.MaxProcesses = rows return agent, nil } func durationOrDefault(key string, fallback, minimum, maximum time.Duration) (time.Duration, error) { raw := os.Getenv(key) if raw == "" { return fallback, nil } value, err := time.ParseDuration(raw) if err != nil || value < minimum || value > maximum { return 0, fmt.Errorf("%s must be a duration between %s and %s", key, minimum, maximum) } return value, nil } func intOrDefault(key string, fallback, minimum, maximum int) (int, error) { raw := os.Getenv(key) if raw == "" { return fallback, nil } value, err := parseInt(raw) if err != nil || value < minimum || value > maximum { return 0, fmt.Errorf("%s must be an integer between %d and %d", key, minimum, maximum) } return value, nil }