package config import ( "fmt" "os" "regexp" "time" "github.com/BurntSushi/toml" ) var idPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`) const ( maxIDLen = 128 defaultHeartbeat = 30 * time.Second defaultBatch = 10 * time.Second defaultMetricsAddr = "127.0.0.1:9465" ) type Config struct { AgentID string `toml:"agent_id"` Hostname string `toml:"hostname"` Mode string `toml:"mode"` StateDir string `toml:"state_dir"` Server ServerConfig `toml:"server"` Metrics MetricsConfig `toml:"metrics"` Checks []CheckConfig `toml:"checks"` } type ServerConfig struct { URL string `toml:"url"` Token string `toml:"token"` HeartbeatInterval Duration `toml:"heartbeat_interval"` BatchInterval Duration `toml:"batch_interval"` } type MetricsConfig struct { Enabled bool `toml:"enabled"` Listen string `toml:"listen"` } type CheckConfig struct { ID string `toml:"id"` Name string `toml:"name"` Command []string `toml:"command"` Interval Duration `toml:"interval"` Timeout Duration `toml:"timeout"` NotificationOwner string `toml:"notification_owner"` DedupeKey string `toml:"dedupe_key"` } type Duration struct{ time.Duration } func (d *Duration) UnmarshalText(b []byte) error { v, err := time.ParseDuration(string(b)) if err != nil { return err } d.Duration = v return nil } func Load(path string) (*Config, error) { var c Config if _, err := toml.DecodeFile(path, &c); err != nil { return nil, fmt.Errorf("read config: %w", err) } c.applyDefaults() if err := c.Validate(); err != nil { return nil, err } return &c, nil } func (c *Config) applyDefaults() { if c.Mode == "" { c.Mode = "hybrid" } if c.Hostname == "" { if h, err := os.Hostname(); err == nil { c.Hostname = h } } if c.Server.HeartbeatInterval.Duration == 0 { c.Server.HeartbeatInterval.Duration = defaultHeartbeat } if c.Server.BatchInterval.Duration == 0 { c.Server.BatchInterval.Duration = defaultBatch } if c.Metrics.Listen == "" { c.Metrics.Listen = defaultMetricsAddr } } func (c *Config) Validate() error { if c.AgentID != "" { if err := ValidateID("agent_id", c.AgentID); err != nil { return err } } switch c.Mode { case "prometheus_only", "push_only", "hybrid": default: return fmt.Errorf("invalid mode %q", c.Mode) } if c.StateDir == "" { return fmt.Errorf("state_dir is required") } if c.PushesToServer() { if c.Server.URL == "" { return fmt.Errorf("server.url is required for mode %q", c.Mode) } if c.Server.Token == "" { return fmt.Errorf("server.token is required for mode %q", c.Mode) } } if len(c.Checks) == 0 { return fmt.Errorf("at least one check is required") } seen := make(map[string]struct{}, len(c.Checks)) for i := range c.Checks { ch := &c.Checks[i] if err := ValidateID("checks[].id", ch.ID); err != nil { return err } if _, dup := seen[ch.ID]; dup { return fmt.Errorf("duplicate check id %q", ch.ID) } seen[ch.ID] = struct{}{} if len(ch.Command) == 0 { return fmt.Errorf("check %q: command is required", ch.ID) } if ch.Interval.Duration <= 0 { return fmt.Errorf("check %q: interval must be > 0", ch.ID) } if ch.Timeout.Duration <= 0 { return fmt.Errorf("check %q: timeout must be > 0", ch.ID) } if ch.Timeout.Duration > ch.Interval.Duration { return fmt.Errorf("check %q: timeout must be <= interval", ch.ID) } switch ch.NotificationOwner { case "", "server", "prometheus", "none": default: return fmt.Errorf("check %q: invalid notification_owner %q", ch.ID, ch.NotificationOwner) } if ch.NotificationOwner == "" { ch.NotificationOwner = "server" } if len(ch.DedupeKey) > 256 { return fmt.Errorf("check %q: dedupe_key too long", ch.ID) } } return nil } // PushesToServer is true when the mode requires heartbeat/events push. func (c *Config) PushesToServer() bool { return c.Mode == "push_only" || c.Mode == "hybrid" } // ExposesMetrics is true when the agent should serve /metrics. // `metrics.enabled` is an independent gate; both must agree. func (c *Config) ExposesMetrics() bool { return c.Metrics.Enabled && (c.Mode == "prometheus_only" || c.Mode == "hybrid") } func ValidateID(field, v string) error { if v == "" { return fmt.Errorf("%s is empty", field) } if len(v) > maxIDLen { return fmt.Errorf("%s exceeds %d chars", field, maxIDLen) } if !idPattern.MatchString(v) { return fmt.Errorf("%s has invalid characters", field) } return nil }