feat(cmd/broker): wire config → log → store → httpserver (forgejo-mcp-broker-t37)
Final phase-1 step: the broker now starts. run() parses config, opens the store, builds the httpserver, and blocks on signal.NotifyContext until SIGTERM/SIGINT fires, at which point it drains through httpserver.Run's graceful-shutdown path and closes the store. --version is handled before config.Load so operators can inspect a binary without providing the rest of the config. flag.ErrHelp is passed through so -h exits 0. Config failure exits 2; runtime failure exits 1. Integration tests build the binary once in TestMain and exercise three acceptance scenarios against it: - --version: prints build info, exits 0 - no config: exits nonzero with stderr listing every missing field - full startup: /healthz returns 200 with correct JSON; SIGTERM triggers clean exit within 2s Closes forgejo-mcp-broker-t37. Phase 1 complete. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
36722940eb
commit
09fcdc5af4
3 changed files with 265 additions and 18 deletions
|
|
@ -1,31 +1,96 @@
|
|||
// Command fjmcp-broker is an OAuth 2.1 authorization server and MCP session
|
||||
// broker that fronts forgejo-mcp. See ../../README.md and ../../docs/ for the
|
||||
// design.
|
||||
// broker that fronts forgejo-mcp. See ../../README.md and ../../docs/ for
|
||||
// the design.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/buildinfo"
|
||||
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/config"
|
||||
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/httpserver"
|
||||
brokerlog "kode.naiv.no/olemd/forgejo-mcp-broker/internal/log"
|
||||
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/store"
|
||||
)
|
||||
|
||||
// Exit codes follow the usual convention: 0 success, 2 config/usage, 1 runtime.
|
||||
const (
|
||||
exitSuccess = 0
|
||||
exitRuntime = 1
|
||||
exitConfig = 2
|
||||
)
|
||||
|
||||
func main() {
|
||||
var showVersion bool
|
||||
flag.BoolVar(&showVersion, "version", false, "print build info and exit")
|
||||
flag.Parse()
|
||||
os.Exit(run(os.Args[1:], os.Stderr))
|
||||
}
|
||||
|
||||
if showVersion {
|
||||
fmt.Printf("fjmcp-broker %s (rev %s, built %s)\n",
|
||||
buildinfo.Version, buildinfo.GitRevision, buildinfo.BuildDate)
|
||||
return
|
||||
// run is the testable entry point. Parses config, wires dependencies, and
|
||||
// blocks until the HTTP server exits or a shutdown signal arrives. Returns
|
||||
// an OS exit code.
|
||||
func run(args []string, out io.Writer) int {
|
||||
// --version is handled before config.Load so operators can inspect a
|
||||
// binary without providing the rest of the required config.
|
||||
for _, a := range args {
|
||||
if a == "--version" || a == "-version" {
|
||||
fmt.Fprintf(out, "fjmcp-broker %s (rev %s, built %s)\n",
|
||||
buildinfo.Version, buildinfo.GitRevision, buildinfo.BuildDate)
|
||||
return exitSuccess
|
||||
}
|
||||
}
|
||||
|
||||
// Full startup wiring (config → log → store → httpserver) lands in
|
||||
// forgejo-mcp-broker-t37. Until then this binary only serves --version
|
||||
// so the bootstrap acceptance criteria can be exercised.
|
||||
fmt.Fprintln(os.Stderr, "fjmcp-broker: runtime wiring not yet implemented (phase 1 in progress)")
|
||||
fmt.Fprintln(os.Stderr, "Use --version to print build info.")
|
||||
os.Exit(2)
|
||||
cfg, err := config.Load(args, out)
|
||||
switch {
|
||||
case errors.Is(err, flag.ErrHelp):
|
||||
return exitSuccess
|
||||
case err != nil:
|
||||
fmt.Fprintln(out, "fjmcp-broker: config error:")
|
||||
fmt.Fprintln(out, err.Error())
|
||||
return exitConfig
|
||||
}
|
||||
|
||||
logger := brokerlog.New(out, cfg.Debug)
|
||||
logger.Info("starting broker",
|
||||
"listen", cfg.Listen,
|
||||
"public_url", cfg.PublicURL,
|
||||
"forgejo_url", cfg.ForgejoURL,
|
||||
"store_path", cfg.StorePath,
|
||||
"max_sessions", cfg.MaxSessions,
|
||||
"idle_timeout", cfg.SessionIdleTimeout.String(),
|
||||
)
|
||||
|
||||
// Signal handling is owned here; the HTTP server just responds to ctx
|
||||
// cancellation. This keeps internal/httpserver free of signal coupling
|
||||
// and makes it testable without any OS-level wiring.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
st, err := store.Open(ctx, cfg.StorePath)
|
||||
if err != nil {
|
||||
logger.Error("open store", "err", err.Error())
|
||||
return exitRuntime
|
||||
}
|
||||
defer func() {
|
||||
if err := st.Close(); err != nil {
|
||||
logger.Error("close store", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
srv := &httpserver.Server{
|
||||
Addr: cfg.Listen,
|
||||
Log: logger,
|
||||
Store: st,
|
||||
}
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Error("server exit", "err", err.Error())
|
||||
return exitRuntime
|
||||
}
|
||||
logger.Info("broker stopped")
|
||||
return exitSuccess
|
||||
}
|
||||
|
|
|
|||
182
cmd/broker/main_integration_test.go
Normal file
182
cmd/broker/main_integration_test.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package main_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Binary path shared across tests — built once in TestMain to keep the
|
||||
// integration suite fast.
|
||||
var binPath string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "fjmcp-broker-bin-*")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "integration: mkdir:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
binPath = filepath.Join(dir, "fjmcp-broker")
|
||||
build := exec.Command("go", "build", "-o", binPath, ".")
|
||||
build.Stderr = os.Stderr
|
||||
if err := build.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "integration: build:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestBinary_Version(t *testing.T) {
|
||||
out, err := exec.Command(binPath, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("--version should exit 0: %v (output: %s)", err, out)
|
||||
}
|
||||
if !strings.Contains(string(out), "fjmcp-broker") {
|
||||
t.Errorf("version output missing binary name: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinary_MissingConfig_FailsWithClearError(t *testing.T) {
|
||||
cmd := exec.Command(binPath)
|
||||
// Reset env so the binary sees no ambient config.
|
||||
cmd.Env = []string{"PATH=" + os.Getenv("PATH")}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatal("binary should exit nonzero with no config")
|
||||
}
|
||||
// Every required field should be mentioned, not just the first.
|
||||
for _, want := range []string{
|
||||
"public-url", "forgejo-url",
|
||||
"forgejo-oauth-client-id", "forgejo-oauth-client-secret",
|
||||
} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Errorf("config error should mention %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinary_Health_And_SigtermShutsDownCleanly(t *testing.T) {
|
||||
addr := freePort(t)
|
||||
storePath := filepath.Join(t.TempDir(), "broker.db")
|
||||
|
||||
cmd := exec.Command(binPath,
|
||||
"--public-url", "http://localhost:1234",
|
||||
"--forgejo-url", "https://forgejo.example.com",
|
||||
"--forgejo-oauth-client-id", "test-id",
|
||||
"--forgejo-oauth-client-secret", "test-secret",
|
||||
"--store-path", storePath,
|
||||
"--listen", addr,
|
||||
)
|
||||
cmd.Env = []string{"PATH=" + os.Getenv("PATH")}
|
||||
// Capture stderr so test failures surface the broker's logs.
|
||||
stderrR, stderrW := io.Pipe()
|
||||
cmd.Stderr = stderrW
|
||||
var capturedStderr strings.Builder
|
||||
go func() {
|
||||
_, _ = io.Copy(&capturedStderr, stderrR)
|
||||
}()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start broker: %v", err)
|
||||
}
|
||||
// Ensure we always try to reap the process.
|
||||
defer func() {
|
||||
if cmd.ProcessState == nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}
|
||||
_ = stderrW.Close()
|
||||
}()
|
||||
|
||||
waitListening(t, addr, 5*time.Second)
|
||||
|
||||
resp, err := http.Get("http://" + addr + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /healthz: %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("/healthz status = %d, want 200\nbody: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var h map[string]string
|
||||
if err := json.Unmarshal(body, &h); err != nil {
|
||||
t.Fatalf("/healthz body not JSON: %v\nbody: %s", err, body)
|
||||
}
|
||||
for _, k := range []string{"status", "version", "git_revision", "build_date", "store"} {
|
||||
if h[k] == "" {
|
||||
t.Errorf("/healthz missing field %q: %v", k, h)
|
||||
}
|
||||
}
|
||||
if h["status"] != "ok" {
|
||||
t.Errorf("/healthz status = %q, want ok (body: %s)", h["status"], body)
|
||||
}
|
||||
if h["store"] != "ok" {
|
||||
t.Errorf("/healthz store = %q, want ok (body: %s)", h["store"], body)
|
||||
}
|
||||
|
||||
// Send SIGTERM and verify clean exit.
|
||||
start := time.Now()
|
||||
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("signal SIGTERM: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("broker exit with error: %v\nstderr:\n%s", err, capturedStderr.String())
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Errorf("shutdown took %s, want < 2s", elapsed)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
t.Fatal("broker did not exit within 3s of SIGTERM")
|
||||
}
|
||||
}
|
||||
|
||||
// freePort returns a 127.0.0.1:<port> the kernel assigned. The listener is
|
||||
// closed immediately, so there's a tiny race window before the broker
|
||||
// rebinds — acceptable on loopback.
|
||||
func freePort(t *testing.T) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("pick free port: %v", err)
|
||||
}
|
||||
addr := l.Addr().String()
|
||||
_ = l.Close()
|
||||
return addr
|
||||
}
|
||||
|
||||
// waitListening polls the target until a TCP dial succeeds or the deadline
|
||||
// expires. Serves as a sync barrier for "the binary is up".
|
||||
func waitListening(t *testing.T, addr string, within time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(within)
|
||||
for time.Now().Before(deadline) {
|
||||
c, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("broker did not start listening on %s within %s", addr, within)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue