diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..006243b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,166 @@ +// Package config loads and validates the targets.yaml file. +package config + +import ( + "fmt" + "os" + "time" + + "gopkg.in/yaml.v3" +) + +// Duration wraps time.Duration so YAML values like "30s" parse naturally. +type Duration struct{ time.Duration } + +func (d *Duration) UnmarshalYAML(value *yaml.Node) error { + var s string + if err := value.Decode(&s); err != nil { + return err + } + dur, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", s, err) + } + d.Duration = dur + return nil +} + +// CheckType enumerates the supported probe kinds. +type CheckType string + +const ( + CheckHTTP CheckType = "http" + CheckTCP CheckType = "tcp" + CheckTLS CheckType = "tls" + CheckICMP CheckType = "icmp" + CheckDocker CheckType = "docker" +) + +// Target is one monitored endpoint. +type Target struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Type CheckType `yaml:"type"` + + // Type-specific endpoints. + URL string `yaml:"url"` // http + Address string `yaml:"address"` // tcp, tls (host:port) + Host string `yaml:"host"` // icmp + Container string `yaml:"container"` // docker + Socket string `yaml:"socket"` // docker (default /var/run/docker.sock) + + Interval Duration `yaml:"interval"` + Timeout Duration `yaml:"timeout"` + + // Flap detection: N consecutive failures before "down", + // M consecutive successes before "up" again. + FailThreshold int `yaml:"fail_threshold"` + RecoverThreshold int `yaml:"recover_threshold"` + + ExpectStatus int `yaml:"expect_status"` // http: exact status (0 = any < 400) + WarnDays int `yaml:"warn_days"` // tls: degraded when cert expires sooner + DegradedLatencyMS float64 `yaml:"degraded_latency_ms"` // any: degraded above this latency +} + +// Settings holds server-level options. +type Settings struct { + Listen string `yaml:"listen"` + DB string `yaml:"db"` + HistoryDays int `yaml:"history_days"` +} + +// Config is the full parsed targets.yaml. +type Config struct { + Settings Settings `yaml:"settings"` + Targets []Target `yaml:"targets"` +} + +// Load reads, parses, validates and applies defaults to a config file. +func Load(path string) (*Config, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + if err := cfg.validate(); err != nil { + return nil, err + } + cfg.applyDefaults() + return &cfg, nil +} + +func (c *Config) validate() error { + if len(c.Targets) == 0 { + return fmt.Errorf("no targets defined") + } + seen := map[string]bool{} + for i, t := range c.Targets { + if t.ID == "" { + return fmt.Errorf("target %d: missing id", i) + } + if seen[t.ID] { + return fmt.Errorf("duplicate target id %q", t.ID) + } + seen[t.ID] = true + switch t.Type { + case CheckHTTP: + if t.URL == "" { + return fmt.Errorf("target %q: http check requires url", t.ID) + } + case CheckTCP, CheckTLS: + if t.Address == "" { + return fmt.Errorf("target %q: %s check requires address (host:port)", t.ID, t.Type) + } + case CheckICMP: + if t.Host == "" { + return fmt.Errorf("target %q: icmp check requires host", t.ID) + } + case CheckDocker: + if t.Container == "" { + return fmt.Errorf("target %q: docker check requires container", t.ID) + } + default: + return fmt.Errorf("target %q: unknown check type %q", t.ID, t.Type) + } + } + return nil +} + +func (c *Config) applyDefaults() { + if c.Settings.Listen == "" { + c.Settings.Listen = ":8080" + } + if c.Settings.DB == "" { + c.Settings.DB = "dash.db" + } + if c.Settings.HistoryDays <= 0 { + c.Settings.HistoryDays = 30 + } + for i := range c.Targets { + t := &c.Targets[i] + if t.Name == "" { + t.Name = t.ID + } + if t.Interval.Duration <= 0 { + t.Interval.Duration = 30 * time.Second + } + if t.Timeout.Duration <= 0 { + t.Timeout.Duration = 5 * time.Second + } + if t.FailThreshold <= 0 { + t.FailThreshold = 3 + } + if t.RecoverThreshold <= 0 { + t.RecoverThreshold = 2 + } + if t.Type == CheckTLS && t.WarnDays <= 0 { + t.WarnDays = 14 + } + if t.Type == CheckDocker && t.Socket == "" { + t.Socket = "/var/run/docker.sock" + } + } +}