Collector milestone 3: HTTP webhook source + /health
internal/source/webhook routes inbound POSTs to inbox events via a
configured table. Each route specifies recipient, type, priority, and
a Go text/template payload renderer that consumes the request body
decoded as JSON.
v1 binds loopback only — New() rejects non-loopback addresses at
construction. Caddy + bearer-token reverse-proxy is the v2 upgrade
path per spec §4.
Behavior:
- POST + matched route + valid JSON body → render template, emit, 202
- Missing route → 404
- Wrong method → 405
- Bad JSON → 400
- Template render failure → 500
- Emit failure → 500 (caller responsible for retry; HTTP source has no
durable staging)
- Empty body → empty data map for template (lets fixed-string templates
work without sending {})
- 1 MiB request body cap
GET /health returns JSON Stats{received, emitted, errors, uptime_sec}
on the same listener for journalctl correlation per spec §3.5.
10 tests passing — non-loopback rejection, bad type/template
rejection, route+template happy path, priority defaulting, empty body,
404/400/405/500, health endpoint counters.
31 tests across the three internal packages, all passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f9d81471c4
commit
ba6db7c82f
2 changed files with 571 additions and 0 deletions
248
internal/source/webhook/webhook.go
Normal file
248
internal/source/webhook/webhook.go
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
// Package webhook implements the HTTP webhook Source.
|
||||
//
|
||||
// Listens on a loopback address and routes incoming POSTs to inbox events
|
||||
// according to a routing table. Each route specifies recipient, type,
|
||||
// priority, and a Go text/template that renders the payload from the request
|
||||
// body decoded as JSON.
|
||||
//
|
||||
// v1 is loopback-only (127.0.0.1) — no authentication. The spec calls for
|
||||
// Caddy + bearer-token reverse-proxy as the v2 upgrade path. Do NOT bind to
|
||||
// 0.0.0.0 in v1.
|
||||
//
|
||||
// Per spec §3.5, the same listener also exposes /health with per-source
|
||||
// counters.
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"git.botbought.ai/foreman/agent-watcher/internal/inbox"
|
||||
"git.botbought.ai/foreman/agent-watcher/internal/source"
|
||||
)
|
||||
|
||||
// Route maps a URL path to an emitted event.
|
||||
type Route struct {
|
||||
// Recipient is the target inbox name.
|
||||
Recipient string
|
||||
|
||||
// Type is one of INFO, NEEDS-RESPONSE, ACK-REQUEST.
|
||||
Type string
|
||||
|
||||
// Priority is "normal" or "urgent". Empty defaults to "normal".
|
||||
Priority string
|
||||
|
||||
// PayloadTemplate is a Go text/template rendered with the request body
|
||||
// (decoded as JSON into a map[string]any) as the dot. Plain strings
|
||||
// without {{}} render to themselves.
|
||||
PayloadTemplate string
|
||||
}
|
||||
|
||||
// Config configures a webhook Source.
|
||||
type Config struct {
|
||||
// Listen is the bind address, e.g. "127.0.0.1:18790".
|
||||
Listen string
|
||||
|
||||
// Routes maps URL path → Route. Path includes the leading slash.
|
||||
Routes map[string]Route
|
||||
}
|
||||
|
||||
// Source serves HTTP and emits an inbox event for each matched POST.
|
||||
type Source struct {
|
||||
cfg Config
|
||||
logger *slog.Logger
|
||||
|
||||
tmpls map[string]*template.Template
|
||||
|
||||
// counters
|
||||
received atomic.Uint64
|
||||
emitted atomic.Uint64
|
||||
errors atomic.Uint64
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
// New parses the route templates and returns a configured Source.
|
||||
// Returns an error if any template fails to parse.
|
||||
func New(cfg Config, logger *slog.Logger) (*Source, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if cfg.Listen == "" {
|
||||
return nil, errors.New("webhook: listen address required")
|
||||
}
|
||||
if !isLoopback(cfg.Listen) {
|
||||
return nil, fmt.Errorf("webhook: listen %q is not a loopback address; v1 forbids non-loopback binds", cfg.Listen)
|
||||
}
|
||||
|
||||
tmpls := make(map[string]*template.Template, len(cfg.Routes))
|
||||
for path, r := range cfg.Routes {
|
||||
if !validTypes[r.Type] {
|
||||
return nil, fmt.Errorf("webhook: route %s: invalid type %q", path, r.Type)
|
||||
}
|
||||
if r.Priority != "" && r.Priority != "normal" && r.Priority != "urgent" {
|
||||
return nil, fmt.Errorf("webhook: route %s: invalid priority %q", path, r.Priority)
|
||||
}
|
||||
t, err := template.New(path).Option("missingkey=zero").Parse(r.PayloadTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webhook: route %s: parse template: %w", path, err)
|
||||
}
|
||||
tmpls[path] = t
|
||||
}
|
||||
return &Source{
|
||||
cfg: cfg,
|
||||
logger: logger.With("source", "webhook", "listen", cfg.Listen),
|
||||
tmpls: tmpls,
|
||||
startedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Source) Name() string { return "webhook" }
|
||||
|
||||
// Run blocks until ctx is canceled.
|
||||
func (s *Source) Run(ctx context.Context, emit source.Emit) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", s.health)
|
||||
mux.HandleFunc("/", s.handler(emit))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: s.cfg.Listen,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
listenErr := make(chan error, 1)
|
||||
go func() {
|
||||
s.logger.Info("listening")
|
||||
listenErr <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
return ctx.Err()
|
||||
case err := <-listenErr:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("webhook: serve: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) handler(emit source.Emit) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s.received.Add(1)
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
route, ok := s.cfg.Routes[r.URL.Path]
|
||||
if !ok {
|
||||
http.Error(w, "no route", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap
|
||||
if err != nil {
|
||||
s.errors.Add(1)
|
||||
s.logger.Warn("read body", "path", r.URL.Path, "err", err)
|
||||
http.Error(w, "read failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var data map[string]any
|
||||
if len(bytes.TrimSpace(body)) > 0 {
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
s.errors.Add(1)
|
||||
s.logger.Warn("body not JSON", "path", r.URL.Path, "err", err)
|
||||
http.Error(w, "body must be JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
data = map[string]any{}
|
||||
}
|
||||
|
||||
var rendered bytes.Buffer
|
||||
if err := s.tmpls[r.URL.Path].Execute(&rendered, data); err != nil {
|
||||
s.errors.Add(1)
|
||||
s.logger.Warn("template render", "path", r.URL.Path, "err", err)
|
||||
http.Error(w, "render failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
priority := route.Priority
|
||||
if priority == "" {
|
||||
priority = "normal"
|
||||
}
|
||||
ev := &inbox.Event{
|
||||
From: "collector",
|
||||
Type: route.Type,
|
||||
Priority: priority,
|
||||
Payload: rendered.String(),
|
||||
Source: "webhook:" + r.URL.Path,
|
||||
}
|
||||
if err := emit(route.Recipient, ev); err != nil {
|
||||
s.errors.Add(1)
|
||||
s.logger.Error("emit", "path", r.URL.Path, "err", err)
|
||||
http.Error(w, "emit failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.emitted.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Stats returns the current counter snapshot. Safe to call from any goroutine.
|
||||
type Stats struct {
|
||||
Received uint64 `json:"received"`
|
||||
Emitted uint64 `json:"emitted"`
|
||||
Errors uint64 `json:"errors"`
|
||||
UptimeSec int64 `json:"uptime_sec"`
|
||||
}
|
||||
|
||||
func (s *Source) Stats() Stats {
|
||||
return Stats{
|
||||
Received: s.received.Load(),
|
||||
Emitted: s.emitted.Load(),
|
||||
Errors: s.errors.Load(),
|
||||
UptimeSec: int64(time.Since(s.startedAt).Seconds()),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) health(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(s.Stats())
|
||||
}
|
||||
|
||||
// validTypes mirrors agent-ping. Kept here (not imported from dropfolder) so
|
||||
// the webhook source has no cross-source dependency.
|
||||
var validTypes = map[string]bool{
|
||||
"INFO": true,
|
||||
"NEEDS-RESPONSE": true,
|
||||
"ACK-REQUEST": true,
|
||||
}
|
||||
|
||||
// isLoopback returns true if addr binds only to a loopback interface.
|
||||
// Accepts "127.0.0.1:port", "[::1]:port", "localhost:port".
|
||||
func isLoopback(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue