feat: wire server entrypoint; add go-oidc/v3 and uuid dependencies
main.go connects all packages: config → pool → migrate → cipher → store → providermgr → catalog → optional OIDC → rate limiter → API server. WriteTimeout is zero to support SSE streaming; no middleware.Timeout. Config validation now allows OIDC to be optional (static-token fallback).
This commit is contained in:
@@ -24,3 +24,9 @@ GATEWAY_OIDC_ADMIN_GROUP=llm-admins
|
||||
# --- Security ---
|
||||
# 64 hex chars (32 bytes) for AES-256-GCM. Generate with: openssl rand -hex 32
|
||||
GATEWAY_SECURITY_ENCRYPTION_KEY=
|
||||
# Static bearer token for admin API. Generate with: openssl rand -hex 32
|
||||
GATEWAY_SECURITY_ADMIN_TOKEN=
|
||||
|
||||
# --- Catalog ---
|
||||
# How often to sync model lists from providers. Default: 30m
|
||||
GATEWAY_CATALOG_SYNC_INTERVAL=30m
|
||||
|
||||
@@ -13,11 +13,20 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/api"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/catalog"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/config"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/crypto"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/db"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/oidcauth"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/providermgr"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/ratelimit"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/store"
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/web"
|
||||
|
||||
// Self-register all provider implementations.
|
||||
_ "git.govekar.net/LovroG05/localmodelroutex/internal/providers/all"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -27,9 +36,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// run wires together config, the database, and the HTTP server, then blocks
|
||||
// until a shutdown signal is received. Returning an error (instead of calling
|
||||
// os.Exit everywhere) keeps deferred cleanup running.
|
||||
func run() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -38,7 +44,6 @@ func run() error {
|
||||
|
||||
slog.SetDefault(newLogger(cfg))
|
||||
|
||||
// Root context cancelled on SIGINT/SIGTERM, driving graceful shutdown.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -54,16 +59,76 @@ func run() error {
|
||||
}
|
||||
slog.Info("database schema up to date")
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
Handler: newRouter(pool),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
cipher, err := crypto.NewCipher(cfg.Security.EncryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init cipher: %w", err)
|
||||
}
|
||||
|
||||
st := store.New(pool)
|
||||
mgr := providermgr.New(st, cipher)
|
||||
|
||||
if err := mgr.Reload(ctx); err != nil {
|
||||
slog.Warn("initial provider load failed", "err", err)
|
||||
}
|
||||
|
||||
cache := catalog.NewCache(st)
|
||||
if err := cache.Reload(ctx); err != nil {
|
||||
slog.Warn("initial catalog load failed", "err", err)
|
||||
}
|
||||
|
||||
syncer := catalog.NewSyncer(st, cache)
|
||||
|
||||
// OIDC is optional; only initialised when the issuer is configured.
|
||||
var oidcV *oidcauth.Verifier
|
||||
if cfg.OIDC.Issuer != "" {
|
||||
v, err := oidcauth.New(ctx, cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.AdminGroup)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init oidc: %w", err)
|
||||
}
|
||||
oidcV = v
|
||||
slog.Info("OIDC admin auth enabled", "issuer", cfg.OIDC.Issuer)
|
||||
}
|
||||
|
||||
limiter := ratelimit.New()
|
||||
|
||||
// syncNow is used by admin endpoints to force an immediate refresh.
|
||||
syncNow := func(ctx context.Context) error {
|
||||
if err := mgr.Reload(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return syncer.SyncAll(ctx, toProviderRefs(mgr.List()))
|
||||
}
|
||||
|
||||
// refresh is called each syncer tick: reload providers then return refs.
|
||||
refresh := func(ctx context.Context) ([]catalog.ProviderRef, error) {
|
||||
if err := mgr.Reload(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toProviderRefs(mgr.List()), nil
|
||||
}
|
||||
|
||||
go syncer.Run(ctx, cfg.Catalog.SyncInterval, refresh)
|
||||
|
||||
a := api.New(api.Deps{
|
||||
Store: st,
|
||||
Cache: cache,
|
||||
Manager: mgr,
|
||||
Cipher: cipher,
|
||||
AdminToken: cfg.Security.AdminToken,
|
||||
OIDCVerifier: oidcV,
|
||||
Limiter: limiter,
|
||||
SyncNow: syncNow,
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
Handler: newRouter(pool, a),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
// WriteTimeout is intentionally zero: SSE streams can run indefinitely.
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// Run the listener in the background so we can also wait on ctx.Done.
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
slog.Info("http server listening", "addr", srv.Addr, "env", cfg.Server.Env)
|
||||
@@ -88,8 +153,6 @@ func run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// newLogger returns a slog logger: human-readable text at debug level in
|
||||
// development, structured JSON at info level otherwise.
|
||||
func newLogger(cfg *config.Config) *slog.Logger {
|
||||
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
|
||||
if cfg.IsDev() {
|
||||
@@ -99,21 +162,16 @@ func newLogger(cfg *config.Config) *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||||
}
|
||||
|
||||
// newRouter builds the HTTP handler with baseline middleware and health
|
||||
// probes. Feature routes hang off this router as the api package grows.
|
||||
func newRouter(pool *pgxpool.Pool) http.Handler {
|
||||
func newRouter(pool interface{ Ping(context.Context) error }, a *api.API) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(60 * time.Second))
|
||||
// No Timeout middleware: SSE streams must not be cut off by a global deadline.
|
||||
|
||||
// Liveness: the process is running.
|
||||
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// Readiness: dependencies are reachable.
|
||||
r.Get("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
@@ -125,8 +183,19 @@ func newRouter(pool *pgxpool.Pool) http.Handler {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// Dashboard SPA — catch-all, registered last so API routes take precedence.
|
||||
a.Mount(r)
|
||||
|
||||
// Dashboard SPA — registered last so API routes take precedence.
|
||||
r.Handle("/*", web.Handler())
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// toProviderRefs converts manager instances to catalog provider references.
|
||||
func toProviderRefs(insts []providermgr.Instance) []catalog.ProviderRef {
|
||||
refs := make([]catalog.ProviderRef, len(insts))
|
||||
for i, inst := range insts {
|
||||
refs[i] = catalog.ProviderRef{ID: inst.ID, Provider: inst.Provider}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ module git.govekar.net/LovroG05/localmodelroutex
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.19.0
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -24,6 +27,7 @@ require (
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
|
||||
@@ -6,6 +6,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -27,6 +29,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -39,6 +43,8 @@ github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7g
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -105,6 +111,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
|
||||
Reference in New Issue
Block a user