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:
bob-boat 2026-05-06 16:22:03 -04:00
parent ba6db7c82f
commit 2183850c03
5 changed files with 502 additions and 1 deletions

179
internal/config/config.go Normal file
View 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
}

View file

@ -0,0 +1,168 @@
package config
import (
"strings"
"testing"
)
func TestParse_FullValid(t *testing.T) {
body := []byte(`
agent: foreman
inbox_dir: /tmp/inbox
sources:
webhook:
listen: 127.0.0.1:18790
routes:
/forgejo/push:
recipient: bob
type: INFO
payload_template: "push to {{ .repo }}"
/alert:
recipient: bob
type: NEEDS-RESPONSE
priority: urgent
payload_template: "alert"
drop_folder:
path: /tmp/incoming
poll_fallback_seconds: 60
`)
c, err := parse(body)
if err != nil {
t.Fatal(err)
}
if c.Agent != "foreman" {
t.Errorf("agent = %q", c.Agent)
}
if c.InboxDir != "/tmp/inbox" {
t.Errorf("inbox = %q", c.InboxDir)
}
if c.Sources.Webhook == nil || len(c.Sources.Webhook.Routes) != 2 {
t.Errorf("webhook routes: %+v", c.Sources.Webhook)
}
if c.Sources.DropFolder == nil || c.Sources.DropFolder.Path != "/tmp/incoming" {
t.Errorf("dropfolder: %+v", c.Sources.DropFolder)
}
}
func TestParse_AppliesDefaults(t *testing.T) {
body := []byte(`
agent: foreman
sources:
drop_folder:
path: /tmp/x
`)
c, err := parse(body)
if err != nil {
t.Fatal(err)
}
if c.InboxDir == "" {
t.Error("inbox_dir default not applied")
}
if c.Sources.DropFolder.PollFallbackSeconds != DefaultPollFallbackSeconds {
t.Errorf("poll default = %d", c.Sources.DropFolder.PollFallbackSeconds)
}
}
func TestParse_HomeExpansion(t *testing.T) {
body := []byte(`
agent: foreman
sources:
drop_folder:
path: ~/foo/bar
`)
c, err := parse(body)
if err != nil {
t.Fatal(err)
}
if strings.HasPrefix(c.Sources.DropFolder.Path, "~") {
t.Errorf("~ not expanded: %q", c.Sources.DropFolder.Path)
}
if !strings.HasSuffix(c.Sources.DropFolder.Path, "/foo/bar") {
t.Errorf("expansion lost suffix: %q", c.Sources.DropFolder.Path)
}
}
func TestParse_RejectsInvalid(t *testing.T) {
cases := map[string]string{
"missing agent": `sources:\n webhook:\n routes: {}`,
"no sources": `agent: x`,
"empty webhook routes": `
agent: x
sources:
webhook:
listen: 127.0.0.1:18790
routes: {}
`,
"route missing recipient": `
agent: x
sources:
webhook:
routes:
/x:
type: INFO
payload_template: y
`,
"route missing type": `
agent: x
sources:
webhook:
routes:
/x:
recipient: r
payload_template: y
`,
"route missing template": `
agent: x
sources:
webhook:
routes:
/x:
recipient: r
type: INFO
`,
"route path no slash": `
agent: x
sources:
webhook:
routes:
bad:
recipient: r
type: INFO
payload_template: y
`,
"unknown top-level field": `
agent: x
typo: oops
sources:
drop_folder:
path: /x
`,
"poll negative": `
agent: x
sources:
drop_folder:
path: /x
poll_fallback_seconds: -1
`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
if _, err := parse([]byte(body)); err == nil {
t.Errorf("expected error for: %s", name)
}
})
}
}
func TestToWebhookRouteMap(t *testing.T) {
w := &WebhookConfig{
Routes: map[string]WebhookRoute{
"/x": {Recipient: "r", Type: "INFO", Priority: "urgent", PayloadTemplate: "p"},
},
}
got := w.ToWebhookRouteMap()
r := got["/x"]
if r.Recipient != "r" || r.Type != "INFO" || r.Priority != "urgent" || r.PayloadTemplate != "p" {
t.Errorf("conversion lost data: %+v", r)
}
}