feat(httpserver,log): /healthz, graceful shutdown, slog constructor

Implements internal/httpserver and internal/log.

httpserver (forgejo-mcp-broker-8ei):
- Server struct owns the HTTP lifecycle; Run(ctx) blocks, Handler() returns
  the composed handler for unit tests
- GET /healthz returns JSON with status, version, git_revision, build_date,
  and store probe result. Returns 503 when the store reports unhealthy
- Signal handling delegated to the caller via ctx cancellation — main wires
  signal.NotifyContext, httpserver just responds to Done()
- Graceful shutdown with a configurable deadline (default 10s). When the
  deadline expires, falls back to http.Server.Close() so lingering
  connections are forcibly terminated — http.Server.Shutdown alone never
  interrupts active connections
- ExtraHandler extension point for the OAuth + MCP routes that land in
  phase 2 and phase 5, so the server doesn't need to be re-plumbed later

log:
- Small slog wrapper: New(w, debug) returns a JSON logger that stamps every
  record with service/version/git_rev for correlation across deployments
- Discard() helper for tests

Tests: 97.9% coverage on httpserver (all health states, wrong-method,
ExtraHandler dispatch, ctx-cancel shutdown, shutdown-deadline force-close
of hanging requests, missing-field errors), 100% on log.

Closes forgejo-mcp-broker-8ei.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-24 17:26:32 +02:00
commit 36722940eb
7 changed files with 506 additions and 14 deletions

55
internal/log/log_test.go Normal file
View file

@ -0,0 +1,55 @@
package log_test
import (
"bytes"
"encoding/json"
"strings"
"testing"
brokerlog "kode.naiv.no/olemd/forgejo-mcp-broker/internal/log"
)
func TestNew_WritesJSON(t *testing.T) {
var buf bytes.Buffer
l := brokerlog.New(&buf, false)
l.Info("hello", "key", "value")
var rec map[string]any
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
t.Fatalf("output is not valid JSON: %v\ngot: %s", err, buf.String())
}
if rec["msg"] != "hello" {
t.Errorf("msg = %v, want hello", rec["msg"])
}
if rec["service"] != "fjmcp-broker" {
t.Errorf("service = %v, want fjmcp-broker", rec["service"])
}
if rec["key"] != "value" {
t.Errorf("key = %v, want value", rec["key"])
}
}
func TestNew_DebugSuppressedByDefault(t *testing.T) {
var buf bytes.Buffer
l := brokerlog.New(&buf, false)
l.Debug("debug-only")
if buf.Len() > 0 {
t.Errorf("debug record should be suppressed at info level, got: %s", buf.String())
}
}
func TestNew_DebugIncludedWhenEnabled(t *testing.T) {
var buf bytes.Buffer
l := brokerlog.New(&buf, true)
l.Debug("debug-enabled")
if !strings.Contains(buf.String(), "debug-enabled") {
t.Errorf("debug record missing with debug=true, got: %s", buf.String())
}
}
func TestDiscard_NoOutput(t *testing.T) {
l := brokerlog.Discard()
l.Info("ignored")
l.Error("also ignored")
// Nothing to assert except that these calls don't panic.
}