textmachine/platform/cmd/tmplatformd/main.go

190 lines
5.6 KiB
Go

// Command tmplatformd is the TextMachine control plane: HTTP for the frontend, Postgres for the
// read model, and (from P-1 onward) a worker that supervises tmctl processes. It contains no
// translation logic: the engine is spawned, never linked (D39.81).
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"textmachine/platform/internal/auth"
"textmachine/platform/internal/config"
"textmachine/platform/internal/httpapi"
"textmachine/platform/internal/login"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/reqid"
)
// sessionSweep is how often expired sessions are deleted. The table is small and the work is a
// single DELETE, so the interval is about not accumulating rows, not about load.
const sessionSweep = time.Hour
func main() {
// Structured logs on stderr, like the engine's: stdout stays free for anything machine-read.
// The handler is wrapped so every *Context call carries its request id without saying so.
log := slog.New(reqid.WithContext(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
if err := run(log); err != nil {
log.Error("fatal", "err", err)
os.Exit(1)
}
}
func run(log *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
var db *pgstore.Store
if cfg.DSN == "" {
// Deliberate: the process still serves liveness so a supervisor can start it before the
// database exists. /readyz is the honest signal, and it says no.
log.Warn("no TM_PLATFORM_DSN: starting without a database, /readyz will report not ready")
} else {
if cfg.Migrate {
if err := pgstore.Migrate(ctx, cfg.DSN); err != nil {
return err
}
log.Info("migrations applied")
}
if db, err = pgstore.Open(ctx, cfg.DSN); err != nil {
return err
}
defer db.Close()
}
cookies := auth.Cookies{Insecure: cfg.InsecureCookies}
if cfg.InsecureCookies {
log.Warn("TM_PLATFORM_INSECURE_COOKIES: serving the session cookie without Secure, under a dev name — never in production")
}
authn := &auth.Authenticator{
IdleTTL: cfg.SessionIdleTTL,
Cookies: cookies,
Log: log,
Deny: httpapi.ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"),
}
deps := httpapi.Deps{Log: log, Auth: authn, TrustedOrigins: cfg.TrustedOrigins, HSTS: !cfg.InsecureCookies}
if db != nil {
deps.DB = db
authn.Sessions = db
go sweepSessions(ctx, db, log)
}
switch {
case !cfg.LoginEnabled():
log.Warn("no TM_PLATFORM_OIDC_ISSUER: sign-in is not mounted")
case db == nil:
// A login writes rows. Mounting it without a database would answer every attempt with a 503
// from deep inside the flow instead of saying so once, here.
return errors.New("sign-in is configured but TM_PLATFORM_DSN is not: a login needs the database")
default:
lg, err := login.New(login.Config{
Provider: cfg.OIDCProvider,
Issuer: cfg.OIDCIssuer,
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
RedirectURL: cfg.OIDCRedirectURL,
AfterLogin: cfg.AfterLogin,
SessionIdleTTL: cfg.SessionIdleTTL,
SessionMaxAge: cfg.SessionMaxAge,
SignupGrantMicroUSD: cfg.SignupGrantMicroUSD,
}, db, cookies, log)
if err != nil {
return err
}
lg.SetFail(httpapi.WriteProblem)
deps.Login = lg
go sweepLogins(ctx, db, log)
}
if db != nil {
stopRunner, err := startRunner(ctx, cfg, db, log, &deps)
if err != nil {
return err
}
defer stopRunner()
}
handler, err := httpapi.New(deps)
if err != nil {
return err
}
// A second signal must kill rather than wait: once the drain starts, the handler is
// unregistered and the next SIGTERM goes back to being fatal.
go func() {
<-ctx.Done()
stop()
}()
srv := httpapi.NewServer(cfg.Addr, handler, log)
ln, err := srv.Listen(ctx)
if err != nil {
return err
}
log.Info("listening", "addr", ln.Addr().String())
return srv.Run(ctx, ln)
}
// sweepSessions deletes rows past their absolute expiry (PD-7). A failed sweep is logged and
// retried on the next tick: it is housekeeping, and it must never take the service down.
func sweepSessions(ctx context.Context, db *pgstore.Store, log *slog.Logger) {
t := time.NewTicker(sessionSweep)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
c, cancel := context.WithTimeout(ctx, 30*time.Second)
n, err := db.SweepSessions(c, time.Now())
cancel()
switch {
case err != nil:
log.Error("session sweep failed", "err", err)
case n > 0:
log.Info("session sweep", "deleted", n)
}
}
}
}
// loginJournalRetention is how long a sign-in stays in the journal. Long enough to answer "was
// that me last month", short enough that an unauthenticated endpoint cannot grow the table without
// end.
const loginJournalRetention = 180 * 24 * time.Hour
// sweepLogins deletes abandoned authorization requests and journal entries past retention.
func sweepLogins(ctx context.Context, db *pgstore.Store, log *slog.Logger) {
t := time.NewTicker(15 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
c, cancel := context.WithTimeout(ctx, time.Minute)
states, err := db.DeleteExpiredLoginStates(c, time.Now())
if err != nil {
log.Error("login state sweep failed", "err", err)
}
events, err := db.DeleteOldLoginEvents(c, time.Now().Add(-loginJournalRetention))
cancel()
if err != nil {
log.Error("login journal sweep failed", "err", err)
}
if states > 0 || events > 0 {
log.Info("login sweep", "states", states, "events", events)
}
}
}
}