AUD-49: build the Hub skeleton (hear-route-answer)
- Three inbound routes from docs/hub-api.md: POST /ask, POST /approve, GET /health. - Five-step pipeline in src/hub, with packages hear, understand, check, route, answer. - Routing through OmniRoute with the capability list and routing.md. The never-route control check runs before any LLM call. - One real destination: Jervis house TTS, started out of band. - Fake /ask project (src/tests/fakeproject) for the round-trip test. - Append-only, size-capped request log. Bearer auth, source match, rate limit. - Tests: all packages pass, including the fake /ask round trip. Race clean. - Dockerfile and compose updated to mount routing.md and secrets, log on tmpfs. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
429
src/main.go
429
src/main.go
@@ -1,51 +1,294 @@
|
||||
// Command hub is the Central AI Hub service.
|
||||
//
|
||||
// Skeleton (AUD-48): it serves GET /health only. POST /ask and POST /approve
|
||||
// arrive in AUD-49. The wire format is frozen in docs/hub-api.md.
|
||||
// It serves the three frozen inbound routes from docs/hub-api.md:
|
||||
//
|
||||
// POST /ask — hear, understand, check, route, answer
|
||||
// POST /approve — decide one Hub-owned approval
|
||||
// GET /health — liveness only
|
||||
//
|
||||
// The pipeline lives in package hub. This file is the HTTP layer only.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hub/apierr"
|
||||
"hub/check"
|
||||
"hub/config"
|
||||
"hub/destinations"
|
||||
"hub/hear"
|
||||
"hub/hub"
|
||||
"hub/logbook"
|
||||
"hub/route"
|
||||
"hub/understand"
|
||||
)
|
||||
|
||||
const (
|
||||
serviceName = "hub"
|
||||
serviceVersion = "1"
|
||||
defaultPort = "8788"
|
||||
|
||||
maxBodyBytes = 64 * 1024
|
||||
)
|
||||
|
||||
var started = time.Now()
|
||||
|
||||
type healthResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Service string `json:"service"`
|
||||
Version string `json:"version"`
|
||||
UptimeS int64 `json:"uptime_s"`
|
||||
Checks map[string]string `json:"checks"`
|
||||
// server is the HTTP surface.
|
||||
type server struct {
|
||||
svc *hub.Service
|
||||
cfg *config.Config
|
||||
rate *rateLimiter
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Status string `json:"status"`
|
||||
Error errorInfo `json:"error"`
|
||||
}
|
||||
func main() {
|
||||
hc := flag.Bool("healthcheck", false, "Call this process's /health and exit.")
|
||||
flag.Parse()
|
||||
|
||||
type errorInfo struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// port reads HUB_PORT. It falls back to 8788.
|
||||
func port() string {
|
||||
if p := os.Getenv("HUB_PORT"); p != "" {
|
||||
return p
|
||||
cfg := config.Load()
|
||||
if *hc {
|
||||
healthcheck(cfg)
|
||||
return
|
||||
}
|
||||
return defaultPort
|
||||
|
||||
rules, err := understand.LoadRules(cfg.RoutingFile)
|
||||
if err != nil {
|
||||
log.Printf("routing: using built-in rules (%v)", err)
|
||||
}
|
||||
|
||||
lb, err := logbook.New(cfg.LogPath, cfg.LogMaxBytes, cfg.LogKeepDays)
|
||||
if err != nil {
|
||||
log.Fatalf("cannot open log %s: %v", cfg.LogPath, err)
|
||||
}
|
||||
defer lb.Close()
|
||||
|
||||
projects := map[string]route.ProjectQuerier{}
|
||||
for name, p := range cfg.Projects {
|
||||
projects[name] = destinations.NewProjectClient(name, p.URL, p.Token, cfg.ProjectTimeout)
|
||||
}
|
||||
speaker := destinations.NewSpeaker(cfg.JervisURL, cfg.TTSTimeout)
|
||||
paperclip := destinations.NewPaperclip(cfg.PaperclipURL, cfg.PaperclipKey, cfg.PaperclipCompanyID, cfg.ApproveTimeout)
|
||||
approvals := hub.NewApprovals(10 * time.Minute)
|
||||
|
||||
dispatcher := &route.Dispatcher{
|
||||
Rules: rules,
|
||||
Projects: projects,
|
||||
Speaker: speaker,
|
||||
Paperclip: paperclip,
|
||||
Approvals: approvals,
|
||||
}
|
||||
svc := &hub.Service{
|
||||
Config: cfg,
|
||||
Rules: rules,
|
||||
Router: understand.NewOmniRouter(cfg.OmniURL, cfg.OmniKey, cfg.OmniModel, cfg.LLMTimeout),
|
||||
Dispatcher: dispatcher,
|
||||
Approvals: approvals,
|
||||
Log: lb,
|
||||
Speaker: speaker,
|
||||
Paperclip: paperclip,
|
||||
Started: time.Now(),
|
||||
HealthProbe: omniProbe(cfg),
|
||||
}
|
||||
|
||||
s := &server{svc: svc, cfg: cfg, rate: newRateLimiter(cfg.RatePerMin)}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: s.routes(),
|
||||
ReadHeaderTimeout: 1 * time.Second,
|
||||
ReadTimeout: cfg.AskTimeout,
|
||||
// WriteTimeout must be longer than the /ask deadline, or the server can
|
||||
// cut the reply off when the handler finishes at the deadline.
|
||||
WriteTimeout: cfg.AskTimeout + 3*time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("%s %s listening on :%s", serviceName, serviceVersion, cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("%s stopped: %v", serviceName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// routes builds the HTTP multiplexer.
|
||||
func (s *server) routes() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ask", s.handleAsk)
|
||||
mux.HandleFunc("/approve", s.handleApprove)
|
||||
mux.HandleFunc("/health", s.handleHealth)
|
||||
mux.HandleFunc("/", notFound)
|
||||
return mux
|
||||
}
|
||||
|
||||
// handleAsk serves POST /ask.
|
||||
func (s *server) handleAsk(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "", "bad_request", "Use POST for this route.")
|
||||
return
|
||||
}
|
||||
if !requireJSON(w, r) {
|
||||
return
|
||||
}
|
||||
source, ok := s.auth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ok, retry := s.rate.allow(source); !ok {
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%d", retry))
|
||||
writeError(w, http.StatusTooManyRequests, "", "rate_limited", "Too many requests. Try again shortly.")
|
||||
return
|
||||
}
|
||||
|
||||
body, ok := readBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, apiErr := hear.Parse(body)
|
||||
if apiErr != nil {
|
||||
writeAPIError(w, apiErr, "")
|
||||
return
|
||||
}
|
||||
if apiErr := check.Ask(req, source); apiErr != nil {
|
||||
writeAPIError(w, apiErr, req.RequestID)
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.AskTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp := s.svc.Ask(ctx, req)
|
||||
s.svc.LogAsk(req, resp, time.Since(start))
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleApprove serves POST /approve. Only HUB_TOKEN_ADMIN works.
|
||||
func (s *server) handleApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "", "bad_request", "Use POST for this route.")
|
||||
return
|
||||
}
|
||||
if !requireJSON(w, r) {
|
||||
return
|
||||
}
|
||||
source, ok := s.auth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ok, retry := s.rate.allow(source); !ok {
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%d", retry))
|
||||
writeError(w, http.StatusTooManyRequests, "", "rate_limited", "Too many requests. Try again shortly.")
|
||||
return
|
||||
}
|
||||
if apiErr := check.Admin(source); apiErr != nil {
|
||||
writeAPIError(w, apiErr, "")
|
||||
return
|
||||
}
|
||||
|
||||
body, ok := readBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, apiErr := hub.ParseApprove(body)
|
||||
if apiErr != nil {
|
||||
writeAPIError(w, apiErr, "")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.ApproveTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, apiErr := s.svc.Approve(ctx, req)
|
||||
if apiErr != nil {
|
||||
writeAPIError(w, apiErr, "")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleHealth serves GET /health. No authentication.
|
||||
func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "", "bad_request", "Use GET for this route.")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.HealthTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp := s.svc.Health(ctx)
|
||||
code := http.StatusOK
|
||||
if !resp.OK {
|
||||
code = http.StatusServiceUnavailable
|
||||
}
|
||||
writeJSON(w, code, resp)
|
||||
}
|
||||
|
||||
// notFound answers an unknown path with the frozen error envelope.
|
||||
func notFound(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "", "not_found", "Unknown path.")
|
||||
}
|
||||
|
||||
// auth reads the bearer token and returns the client source. It writes the 401
|
||||
// reply itself when the token is missing or unknown.
|
||||
func (s *server) auth(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
token := bearer(r)
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "", "unauthorized", "A bearer token is required.")
|
||||
return "", false
|
||||
}
|
||||
source, ok := s.cfg.SourceForToken(token)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "", "unauthorized", "The bearer token is not valid.")
|
||||
return "", false
|
||||
}
|
||||
return source, true
|
||||
}
|
||||
|
||||
// bearer reads the token from the Authorization header.
|
||||
func bearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[len(prefix):])
|
||||
}
|
||||
|
||||
// requireJSON checks the content type and returns false after writing 415.
|
||||
func requireJSON(w http.ResponseWriter, r *http.Request) bool {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "", "unsupported_media_type", "Send the body as application/json.")
|
||||
return false
|
||||
}
|
||||
mediaType := strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0]))
|
||||
if mediaType != "application/json" {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "", "unsupported_media_type", "Send the body as application/json.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// readBody reads at most 64 KiB. A larger body gets 413.
|
||||
func readBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "", "payload_too_large", "The request body is over 64 KiB.")
|
||||
return nil, false
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "", "bad_request", "The request body is not valid JSON.")
|
||||
return nil, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
@@ -54,42 +297,99 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, errCode, message string) {
|
||||
type errorBody struct {
|
||||
Status string `json:"status"`
|
||||
Error errorInfo `json:"error"`
|
||||
}
|
||||
|
||||
type errorInfo struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, requestID, errCode, message string) {
|
||||
writeJSON(w, code, errorBody{
|
||||
Status: "error",
|
||||
Error: errorInfo{Code: errCode, Message: message},
|
||||
Error: errorInfo{Code: errCode, Message: message, RequestID: requestID},
|
||||
})
|
||||
}
|
||||
|
||||
// handleHealth is GET /health. Liveness only. No authentication.
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "bad_request", "Use GET for this route.")
|
||||
return
|
||||
// writeAPIError writes an *apierr.Error with the frozen envelope.
|
||||
func writeAPIError(w http.ResponseWriter, e *apierr.Error, requestID string) {
|
||||
writeError(w, e.HTTP, requestID, e.Code, e.Message)
|
||||
}
|
||||
|
||||
// rateLimiter is a fixed-window cap per client token (docs/hub-api.md section
|
||||
// 4). It protects the Hub and is not a security control.
|
||||
type rateLimiter struct {
|
||||
limit int
|
||||
mu sync.Mutex
|
||||
win map[string]*rateWindow
|
||||
}
|
||||
|
||||
type rateWindow struct {
|
||||
start time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
func newRateLimiter(limit int) *rateLimiter {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
// OmniRoute and MQTT are wired in AUD-49, so they read "unknown" here.
|
||||
writeJSON(w, http.StatusOK, healthResponse{
|
||||
OK: true,
|
||||
Service: serviceName,
|
||||
Version: serviceVersion,
|
||||
UptimeS: int64(time.Since(started).Seconds()),
|
||||
Checks: map[string]string{
|
||||
"omniroute": "unknown",
|
||||
"mqtt": "unknown",
|
||||
"log": "ok",
|
||||
},
|
||||
})
|
||||
return &rateLimiter{limit: limit, win: map[string]*rateWindow{}}
|
||||
}
|
||||
|
||||
// handleNotFound answers an unknown path with the frozen error envelope.
|
||||
func handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Unknown path.")
|
||||
// allow reports whether the key may proceed. It returns the seconds to wait
|
||||
// when it may not.
|
||||
func (rl *rateLimiter) allow(key string) (bool, int) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
w, ok := rl.win[key]
|
||||
if !ok || now.Sub(w.start) >= time.Minute {
|
||||
rl.win[key] = &rateWindow{start: now, count: 1}
|
||||
return true, 0
|
||||
}
|
||||
if w.count >= rl.limit {
|
||||
retry := int(time.Until(w.start.Add(time.Minute)).Seconds())
|
||||
if retry < 1 {
|
||||
retry = 1
|
||||
}
|
||||
return false, retry
|
||||
}
|
||||
w.count++
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// healthcheck calls this process's own /health. It is the container health probe,
|
||||
// because the runtime image has no shell and no curl.
|
||||
func healthcheck() {
|
||||
url := fmt.Sprintf("http://127.0.0.1:%s/health", port())
|
||||
// omniProbe checks OmniRoute for GET /health. It never blocks over 1 second.
|
||||
func omniProbe(cfg *config.Config) func(ctx context.Context) map[string]string {
|
||||
return func(ctx context.Context) map[string]string {
|
||||
if cfg.OmniKey == "" {
|
||||
return map[string]string{"omniroute": "unknown"}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.OmniURL+"/v1/models", nil)
|
||||
if err != nil {
|
||||
return map[string]string{"omniroute": "down"}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.OmniKey)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return map[string]string{"omniroute": "down"}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return map[string]string{"omniroute": "ok"}
|
||||
}
|
||||
return map[string]string{"omniroute": "down"}
|
||||
}
|
||||
}
|
||||
|
||||
// healthcheck calls this process's own /health. It is the container health
|
||||
// probe, because the runtime image has no shell and no curl.
|
||||
func healthcheck(cfg *config.Config) {
|
||||
url := fmt.Sprintf("http://127.0.0.1:%s/health", cfg.Port)
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
@@ -103,30 +403,3 @@ func healthcheck() {
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func main() {
|
||||
hc := flag.Bool("healthcheck", false, "Call this process's /health and exit.")
|
||||
flag.Parse()
|
||||
if *hc {
|
||||
healthcheck()
|
||||
return
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", handleHealth)
|
||||
mux.HandleFunc("/", handleNotFound)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port(),
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 1 * time.Second,
|
||||
ReadTimeout: 1 * time.Second,
|
||||
WriteTimeout: 1 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("%s %s listening on :%s", serviceName, serviceVersion, port())
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("%s stopped: %v", serviceName, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user