first commit
This commit is contained in:
135
go-api/cmd/api/main.go
Normal file
135
go-api/cmd/api/main.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
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", "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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The sweeper's context is cancelled by the same signal that stops the
|
||||
// server, so the ticker goes away with the process rather than outliving
|
||||
// the pool it queries.
|
||||
go sweepSessions(ctx, server.Sessions(), 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}))
|
||||
}
|
||||
Reference in New Issue
Block a user