// Package config loads the Collector's per-host YAML configuration. // // Config layout (spec §3.2): // // agent: foreman # this host's identity // inbox_dir: ~/Nyx/workspace/pings # optional; default shown // sources: // webhook: // listen: 127.0.0.1:18790 // routes: // /forgejo/push: // recipient: bob // type: INFO // priority: normal # optional // payload_template: "..." // drop_folder: // path: ~/Nyx/workspace/incoming/ // poll_fallback_seconds: 30 // // Either source may be omitted; the Collector starts with whatever is // configured. Validation is strict — an unknown top-level field or a // malformed route returns an error. package config import ( "errors" "fmt" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "git.botbought.ai/foreman/agent-watcher/internal/source/webhook" ) // Config is the loaded, validated configuration. type Config struct { Agent string `yaml:"agent"` InboxDir string `yaml:"inbox_dir,omitempty"` Sources Sources `yaml:"sources"` } // Sources groups per-source-type configuration. type Sources struct { Webhook *WebhookConfig `yaml:"webhook,omitempty"` DropFolder *DropFolderConfig `yaml:"drop_folder,omitempty"` } // WebhookConfig configures the HTTP webhook source. type WebhookConfig struct { Listen string `yaml:"listen"` Routes map[string]WebhookRoute `yaml:"routes"` } // WebhookRoute is one entry in the webhook routing table. type WebhookRoute struct { Recipient string `yaml:"recipient"` Type string `yaml:"type"` Priority string `yaml:"priority,omitempty"` PayloadTemplate string `yaml:"payload_template"` } // DropFolderConfig configures the drop-folder source. type DropFolderConfig struct { Path string `yaml:"path"` PollFallbackSeconds int `yaml:"poll_fallback_seconds,omitempty"` } // Default values applied when fields are unset. const ( DefaultInboxDir = "~/Nyx/workspace/pings" DefaultDropPath = "~/Nyx/workspace/incoming" DefaultWebhookListen = "127.0.0.1:18790" DefaultPollFallbackSeconds = 30 ) // Load parses the YAML file at path, applies defaults, and validates. func Load(path string) (*Config, error) { body, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("config: read %s: %w", path, err) } return parse(body) } func parse(body []byte) (*Config, error) { var c Config dec := yaml.NewDecoder(strings.NewReader(string(body))) dec.KnownFields(true) if err := dec.Decode(&c); err != nil { return nil, fmt.Errorf("config: parse: %w", err) } if err := c.applyDefaultsAndValidate(); err != nil { return nil, err } return &c, nil } func (c *Config) applyDefaultsAndValidate() error { if c.Agent == "" { return errors.New("config: agent is required") } if c.InboxDir == "" { c.InboxDir = DefaultInboxDir } c.InboxDir = expand(c.InboxDir) if c.Sources.Webhook == nil && c.Sources.DropFolder == nil { return errors.New("config: at least one source (webhook or drop_folder) must be configured") } if w := c.Sources.Webhook; w != nil { if w.Listen == "" { w.Listen = DefaultWebhookListen } if len(w.Routes) == 0 { return errors.New("config: webhook configured but no routes defined") } for path, r := range w.Routes { if !strings.HasPrefix(path, "/") { return fmt.Errorf("config: webhook route %q must start with /", path) } if r.Recipient == "" { return fmt.Errorf("config: webhook route %s: recipient required", path) } if r.Type == "" { return fmt.Errorf("config: webhook route %s: type required", path) } if r.PayloadTemplate == "" { return fmt.Errorf("config: webhook route %s: payload_template required", path) } } } if d := c.Sources.DropFolder; d != nil { if d.Path == "" { d.Path = DefaultDropPath } d.Path = expand(d.Path) if d.PollFallbackSeconds == 0 { d.PollFallbackSeconds = DefaultPollFallbackSeconds } if d.PollFallbackSeconds < 0 { return errors.New("config: drop_folder.poll_fallback_seconds must be >= 0") } } return nil } // expand expands a leading ~/ to $HOME. func expand(p string) string { if !strings.HasPrefix(p, "~/") && p != "~" { return p } home, err := os.UserHomeDir() if err != nil || home == "" { return p } if p == "~" { return home } return filepath.Join(home, p[2:]) } // ToWebhookRouteMap converts validated routes to the source/webhook.Route // shape so main.go can hand them straight to webhook.New. func (w *WebhookConfig) ToWebhookRouteMap() map[string]webhook.Route { out := make(map[string]webhook.Route, len(w.Routes)) for path, r := range w.Routes { out[path] = webhook.Route{ Recipient: r.Recipient, Type: r.Type, Priority: r.Priority, PayloadTemplate: r.PayloadTemplate, } } return out }