This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func setAgentEnvironment(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("PULSE_DATABASE_URL", "postgres://pulse:secret@postgres:5432/pulse?sslmode=disable")
|
||||
t.Setenv("PULSE_AGENT_ID", "")
|
||||
t.Setenv("PULSE_AGENT_COLLECT_INTERVAL", "")
|
||||
t.Setenv("PULSE_AGENT_PROC_ROOT", "")
|
||||
t.Setenv("PULSE_AGENT_SYS_ROOT", "")
|
||||
t.Setenv("PULSE_AGENT_FS_ROOT", "")
|
||||
t.Setenv("PULSE_AGENT_HOST_NAME", "")
|
||||
t.Setenv("PULSE_AGENT_MAX_PROCESSES", "")
|
||||
t.Setenv("PULSE_UNRAID_URL", "")
|
||||
t.Setenv("PULSE_UNRAID_API_TOKEN", "")
|
||||
t.Setenv("PULSE_UNRAID_CA_FILE", "")
|
||||
t.Setenv("PULSE_HEARTBEAT_FILE", "")
|
||||
}
|
||||
|
||||
func TestAgentPrivateCARequiresConfiguredHTTPSUnraidSource(t *testing.T) {
|
||||
setAgentEnvironment(t)
|
||||
t.Setenv("PULSE_UNRAID_CA_FILE", "/run/pulse/unraid-ca.pem")
|
||||
if _, err := LoadAgent(); err == nil {
|
||||
t.Fatal("expected CA without Unraid source rejection")
|
||||
}
|
||||
t.Setenv("PULSE_UNRAID_URL", "https://unraid.example.test:5001/graphql")
|
||||
t.Setenv("PULSE_UNRAID_API_TOKEN", "viewer-key")
|
||||
config, err := LoadAgent()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.UnraidCAFile != "/run/pulse/unraid-ca.pem" {
|
||||
t.Fatalf("CA file = %q", config.UnraidCAFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentRequiresCompleteOptionalUnraidSource(t *testing.T) {
|
||||
setAgentEnvironment(t)
|
||||
t.Setenv("PULSE_UNRAID_URL", "https://tower.example.invalid/graphql")
|
||||
if _, err := LoadAgent(); err == nil {
|
||||
t.Fatal("expected incomplete Unraid configuration to fail")
|
||||
}
|
||||
t.Setenv("PULSE_UNRAID_API_TOKEN", "test-key")
|
||||
if _, err := LoadAgent(); err != nil {
|
||||
t.Fatalf("complete HTTPS Unraid configuration failed: %v", err)
|
||||
}
|
||||
t.Setenv("PULSE_UNRAID_URL", "http://tower.example.invalid/graphql")
|
||||
if _, err := LoadAgent(); err == nil {
|
||||
t.Fatal("expected non-HTTPS Unraid URL to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentUsesSafeDefaults(t *testing.T) {
|
||||
setAgentEnvironment(t)
|
||||
config, err := LoadAgent()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgent returned error: %v", err)
|
||||
}
|
||||
if config.AgentID != defaultAgentID {
|
||||
t.Fatalf("agent id = %q", config.AgentID)
|
||||
}
|
||||
if config.CollectInterval != defaultCollectInterval {
|
||||
t.Fatalf("interval = %s", config.CollectInterval)
|
||||
}
|
||||
if config.ProcRoot != "/proc" || config.SysRoot != "/sys" {
|
||||
t.Fatalf("unexpected roots: %+v", config)
|
||||
}
|
||||
if config.FilesystemRoot != "" {
|
||||
t.Fatal("filesystem collection must stay opt-in")
|
||||
}
|
||||
if config.Service.HeartbeatFile != defaultHeartbeatFile {
|
||||
t.Fatalf("heartbeat file = %q", config.Service.HeartbeatFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentRequiresADatabaseURL(t *testing.T) {
|
||||
setAgentEnvironment(t)
|
||||
t.Setenv("PULSE_DATABASE_URL", "")
|
||||
if _, err := LoadAgent(); err == nil {
|
||||
t.Fatal("expected a missing database URL to fail")
|
||||
}
|
||||
t.Setenv("PULSE_DATABASE_URL", "mysql://nope")
|
||||
if _, err := LoadAgent(); err == nil {
|
||||
t.Fatal("expected a non-PostgreSQL URL to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentRejectsOutOfRangeValues(t *testing.T) {
|
||||
cases := map[string]map[string]string{
|
||||
"interval too small": {"PULSE_AGENT_COLLECT_INTERVAL": "10ms"},
|
||||
"interval too large": {"PULSE_AGENT_COLLECT_INTERVAL": "10m"},
|
||||
"interval malformed": {"PULSE_AGENT_COLLECT_INTERVAL": "soon"},
|
||||
"relative proc root": {"PULSE_AGENT_PROC_ROOT": "proc"},
|
||||
"relative sys root": {"PULSE_AGENT_SYS_ROOT": "sys"},
|
||||
"relative fs root": {"PULSE_AGENT_FS_ROOT": "host"},
|
||||
"row count too large": {"PULSE_AGENT_MAX_PROCESSES": "99999"},
|
||||
"row count malformed": {"PULSE_AGENT_MAX_PROCESSES": "many"},
|
||||
"relative heartbeat": {"PULSE_HEARTBEAT_FILE": "healthy"},
|
||||
}
|
||||
for name, environment := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
setAgentEnvironment(t)
|
||||
for key, value := range environment {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
if _, err := LoadAgent(); err == nil {
|
||||
t.Fatal("expected a validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentAcceptsExplicitSettings(t *testing.T) {
|
||||
setAgentEnvironment(t)
|
||||
t.Setenv("PULSE_AGENT_ID", "tower-agent")
|
||||
t.Setenv("PULSE_AGENT_COLLECT_INTERVAL", "30s")
|
||||
t.Setenv("PULSE_AGENT_PROC_ROOT", "/host/proc")
|
||||
t.Setenv("PULSE_AGENT_SYS_ROOT", "/host/sys")
|
||||
t.Setenv("PULSE_AGENT_FS_ROOT", "/host/root")
|
||||
t.Setenv("PULSE_AGENT_HOST_NAME", "tower")
|
||||
t.Setenv("PULSE_AGENT_MAX_PROCESSES", "250")
|
||||
t.Setenv("PULSE_HEARTBEAT_FILE", "/tmp/agent-healthy")
|
||||
|
||||
config, err := LoadAgent()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgent returned error: %v", err)
|
||||
}
|
||||
if config.AgentID != "tower-agent" || config.CollectInterval != 30*time.Second {
|
||||
t.Fatalf("unexpected config: %+v", config)
|
||||
}
|
||||
if config.ProcRoot != "/host/proc" || config.SysRoot != "/host/sys" || config.FilesystemRoot != "/host/root" {
|
||||
t.Fatalf("unexpected roots: %+v", config)
|
||||
}
|
||||
if config.HostName != "tower" || config.MaxProcesses != 250 {
|
||||
t.Fatalf("unexpected identity or limits: %+v", config)
|
||||
}
|
||||
if config.Service.HeartbeatFile != "/tmp/agent-healthy" {
|
||||
t.Fatalf("heartbeat file = %q", config.Service.HeartbeatFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAPIAddress = "127.0.0.1:8081"
|
||||
defaultShutdown = 10 * time.Second
|
||||
// defaultHeartbeatFile matches deploy/healthcheck-heartbeat.sh. See
|
||||
// docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md.
|
||||
defaultHeartbeatFile = "/tmp/healthy"
|
||||
)
|
||||
|
||||
// ServiceConfig contains only foundation runtime settings. Infrastructure credentials and
|
||||
// datasource settings are deliberately not accepted by the service skeletons yet.
|
||||
type ServiceConfig struct {
|
||||
ServiceName string
|
||||
ListenAddress string
|
||||
ShutdownAfter time.Duration
|
||||
// HeartbeatFile is the liveness file a loop-driven service updates after
|
||||
// every completed iteration, read by the container healthcheck.
|
||||
HeartbeatFile string
|
||||
}
|
||||
|
||||
func Load(serviceName string) (ServiceConfig, error) {
|
||||
if serviceName == "" {
|
||||
return ServiceConfig{}, fmt.Errorf("service name is required")
|
||||
}
|
||||
|
||||
config := ServiceConfig{ServiceName: serviceName, ShutdownAfter: defaultShutdown}
|
||||
envPrefix := "PULSE_" + strings.ToUpper(serviceName)
|
||||
if serviceName == "api" {
|
||||
config.ListenAddress = valueOrDefault("PULSE_API_ADDR", defaultAPIAddress)
|
||||
} else {
|
||||
config.ListenAddress = valueOrDefault(envPrefix+"_ADDR", "")
|
||||
}
|
||||
if raw := os.Getenv(envPrefix + "_SHUTDOWN_TIMEOUT"); raw != "" {
|
||||
duration, err := time.ParseDuration(raw)
|
||||
if err != nil || duration <= 0 || duration > time.Minute {
|
||||
return ServiceConfig{}, fmt.Errorf("%s_SHUTDOWN_TIMEOUT must be a duration between 1ns and 1m", envPrefix)
|
||||
}
|
||||
config.ShutdownAfter = duration
|
||||
}
|
||||
if config.ListenAddress != "" {
|
||||
if err := validateListenAddress(config.ListenAddress); err != nil {
|
||||
return ServiceConfig{}, fmt.Errorf("%s_ADDR: %w", envPrefix, err)
|
||||
}
|
||||
}
|
||||
config.HeartbeatFile = valueOrDefault("PULSE_HEARTBEAT_FILE", defaultHeartbeatFile)
|
||||
if err := validateHeartbeatFile(config.HeartbeatFile); err != nil {
|
||||
return ServiceConfig{}, fmt.Errorf("PULSE_HEARTBEAT_FILE: %w", err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// validateHeartbeatFile keeps the heartbeat path an ordinary absolute file path
|
||||
// so a misconfigured value fails at startup instead of silently never being
|
||||
// written, which the healthcheck would eventually punish as a hang.
|
||||
func validateHeartbeatFile(path string) error {
|
||||
if !strings.HasPrefix(path, "/") || len(path) > 255 || strings.ContainsAny(path, "\x00\r\n") || strings.HasSuffix(path, "/") {
|
||||
return fmt.Errorf("must be an absolute file path of at most 255 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func valueOrDefault(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func validateListenAddress(address string) error {
|
||||
host, portText, err := net.SplitHostPort(address)
|
||||
if err != nil || host == "" {
|
||||
return fmt.Errorf("must be host:port")
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return fmt.Errorf("port must be an integer between 1 and 65535")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseInt(value string) (int, error) { return strconv.Atoi(value) }
|
||||
@@ -0,0 +1,28 @@
|
||||
package runtimeconfig
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadRejectsInvalidAPIAddress(t *testing.T) {
|
||||
t.Setenv("PULSE_API_ADDR", "not-an-address")
|
||||
if _, err := Load("api"); err == nil {
|
||||
t.Fatal("expected invalid address error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidShutdownTimeout(t *testing.T) {
|
||||
t.Setenv("PULSE_WORKER_SHUTDOWN_TIMEOUT", "2h")
|
||||
if _, err := Load("worker"); err == nil {
|
||||
t.Fatal("expected invalid timeout error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesSafeDefaults(t *testing.T) {
|
||||
t.Setenv("PULSE_API_ADDR", "")
|
||||
config, err := Load("api")
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if config.ListenAddress != defaultAPIAddress || config.ShutdownAfter != defaultShutdown {
|
||||
t.Fatalf("unexpected defaults: %#v", config)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user