Files
krow_backend/go-api/cmd/api/main.go
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

150 lines
4.8 KiB
Go

// Command api is the Krow HTTP API.
//
// Configuration, a verified PostgreSQL pool, /health, the sign-in endpoints,
// and the entity and current-user endpoints described in docs/api-contract.md.
//
// Every request outside the public allowlist carries a session cookie that this
// process resolves to a real user. The development organization that used to be
// injected into every request is gone: identity now comes from the sessions
// table, and a database with no users is a database nobody can sign in to,
// which is the correct behaviour rather than a gap.
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/krow/krow-backend/go-api/internal/auth"
"github.com/krow/krow-backend/go-api/internal/config"
"github.com/krow/krow-backend/go-api/internal/db"
"github.com/krow/krow-backend/go-api/internal/httpserver"
)
// version is stamped at link time:
//
// go build -ldflags="-X main.version=$(git rev-parse --short HEAD)"
//
// The Dockerfile passes its VERSION build arg through to this. "dev" is what an
// unstamped local build reports, which is honest — it says the binary was not
// built by the release path rather than inventing a number.
var version = "dev"
func main() {
if err := run(); err != nil {
slog.Error("fatal", "error", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
log := newLogger(cfg.Log.Level)
log.Info("starting krow-api", "version", version,
"env", cfg.AppEnv, "database", cfg.DB.Redacted())
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
database, err := db.Open(ctx, cfg.DB)
if err != nil {
return err
}
defer database.Close()
log.Info("database connected", "schema", cfg.DB.Schema)
server, err := httpserver.New(cfg, database, log, httpserver.WithBuildVersion(version))
if err != nil {
return err
}
// The sweepers' context is cancelled by the same signal that stops the
// server, so the tickers go away with the process rather than outliving
// the pool they query.
go sweepSessions(ctx, server.Sessions(), log)
// OAuth codes and tokens, and the rate-limit counters. Returns immediately
// when the deployment does not serve MCP, so this line costs an unconfigured
// deployment one nil check at startup and nothing after.
go httpserver.SweepMaintenance(ctx, server.Maintenance(), log)
errCh := make(chan error, 1)
go func() { errCh <- server.Start() }()
log.Info("listening", "addr", server.Addr(), "endpoints", server.Endpoints(),
"health", "http://"+server.Addr()+"/health",
"cors_origins", cfg.HTTP.CORSOrigins)
select {
case err := <-errCh:
return err
case <-ctx.Done():
log.Info("shutdown signal received, draining")
// context.Background: ctx is already cancelled, and Shutdown needs a
// live deadline of its own to drain in-flight requests.
return server.Shutdown(context.Background())
}
}
// sweepInterval is how often dead sessions are collected.
//
// Sweeping is housekeeping, not correctness: Manager.Authenticate already
// refuses an expired session and deletes the row as it finds it, so a session
// is never usable between its expiry and the next sweep. This only collects the
// rows nobody comes back for. Fifteen minutes keeps the table from growing
// without putting a DELETE on any hot path.
const sweepInterval = 15 * time.Minute
// sweepSessions deletes expired sessions until the context is cancelled.
//
// It runs once immediately so a process that has been down for a while does not
// carry a backlog for a further fifteen minutes, then on the ticker. A failed
// sweep is logged and retried at the next tick: the table being briefly larger
// than it should be is not worth stopping the API for.
func sweepSessions(ctx context.Context, sessions *auth.Manager, log *slog.Logger) {
ticker := time.NewTicker(sweepInterval)
defer ticker.Stop()
sweep := func() {
// A deadline of its own, so a slow or wedged DELETE cannot leave this
// goroutine blocked past shutdown.
sweepCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
n, err := sessions.Sweep(sweepCtx)
switch {
case err != nil && ctx.Err() != nil:
// Shutting down; the cancellation is expected, not a failure.
case err != nil:
log.Warn("session sweep failed", "error", err)
case n > 0:
log.Info("swept expired sessions", "deleted", n)
default:
log.Debug("session sweep found nothing to delete")
}
}
sweep()
for {
select {
case <-ctx.Done():
log.Debug("session sweeper stopped")
return
case <-ticker.C:
sweep()
}
}
}
func newLogger(level string) *slog.Logger {
var lvl slog.Level
if err := lvl.UnmarshalText([]byte(level)); err != nil {
lvl = slog.LevelInfo
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
}