Collector milestone 4: config loader + main wiring (binary builds)
internal/config loads ~/.config/agent-watcher/collector.yaml with strict validation (KnownFields=true, so typos fail loud), applies sensible defaults, and expands ~/ in path fields. Either source can be omitted but at least one must be configured. cmd/agent-watcher is the entry point: load config, build inbox.Writer, build configured sources, run them concurrently with a shared Emit closure, wait for SIGINT/SIGTERM, shut down. Logs to stderr — text by default, JSON via --json-log for journald structured fields per spec §3.4. SIGHUP reload is a v2 item; for now restart the systemd unit to pick up config changes. 10 config tests passing — full happy path, defaults applied, ~/ expansion, and a table of 9 invalid configs that must all reject (missing agent, no sources, empty webhook routes, route missing recipient/type/template, route path without leading /, unknown top-level field, negative poll seconds). Binary builds clean: 10.8M single static binary on Linux/amd64. go.mod stays at Go 1.22 to match VPS toolchain. Total: 41 tests across 4 packages, all passing. Build clean. go vet clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ba6db7c82f
commit
2183850c03
5 changed files with 502 additions and 1 deletions
179
internal/config/config.go
Normal file
179
internal/config/config.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue