feat: Go backend production cutover — website layer, ingest forwarding, alerts, live fixes
Completes the Go backend so it can fully replace Python in production: tracker-go website layer (serves the unchanged frontend): - static file serving + SPA fallback + /icons (website.go) - login/logout with itsdangerous cookie ISSUING (bcrypt, Python-interop) and the /me handler (auth.go issueSessionCookie + website.go) - admin user CRUD (website_admin.go) and the issue-board write side (website_issues.go) - request-scoped user context + requireAdmin (auth.go) cutover ingest (gated off during the parallel run, required for a clean cutover): - inventory forwarding: full_inventory -> /process-inventory, inventory_delta -> item POST/DELETE, per-character serialized, fire-and-forget (inventory_forward.go) - death/idle Discord alerts via DISCORD_ACLOG_WEBHOOK (aclog.go) - SKIP_SCHEMA_INIT so write mode against the prod DBs runs no DDL (tracker-go + inventory-go) two bugs found live and fixed: - coerceNum: the plugin sends kills_per_hour/deaths/total_deaths/prismatic_taper_count as STRINGS; pydantic coerced them, Go's number helpers wrote null/0 (reads.go/ingest.go) - telemetry is broadcast TYPELESS so the browser ignores it and uses the /live poll; broadcasting it typed flapped the per-player counters 0<->value (ingest.go stripType) docker-compose.cutover.yml: reversible override flipping the Go services to write mode against the production DBs and repointing the Discord bot at the Go /ws/live. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
776076b981
commit
5ade47dc64
13 changed files with 1074 additions and 66 deletions
|
|
@ -44,6 +44,7 @@ type Server struct {
|
|||
ingestor *Ingestor // non-nil only in ingest/shadow mode
|
||||
hub *Hub // browser /ws/live fan-out
|
||||
plugins *pluginRegistry
|
||||
loginLimiter *loginLimiter
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +56,13 @@ func main() {
|
|||
runCombatMergeCLI()
|
||||
return
|
||||
}
|
||||
// `tracker-go issue-cookie <username> <is_admin> <secret_key>` prints a
|
||||
// session token — a hook to cross-check itsdangerous cookie interop with the
|
||||
// Python service.
|
||||
if len(os.Args) > 1 && os.Args[1] == "issue-cookie" {
|
||||
runIssueCookieCLI()
|
||||
return
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
|
|
@ -68,6 +76,7 @@ func main() {
|
|||
srv := &Server{
|
||||
cache: newLiveCache(),
|
||||
totals: newTotalsCache(),
|
||||
loginLimiter: newLoginLimiter(),
|
||||
staticDir: cfg.StaticDir,
|
||||
secretKey: cfg.SecretKey,
|
||||
sharedSecret: cfg.SharedSecret,
|
||||
|
|
@ -103,22 +112,38 @@ func main() {
|
|||
defer pool.Close()
|
||||
srv.pool = pool
|
||||
|
||||
// Ingest/shadow mode owns its own DB: create the schema on first run.
|
||||
// Write mode (shadow OR cutover) owns the ingest path; read-only mode
|
||||
// (parallel read API) skips all of this.
|
||||
if !cfg.ReadOnly {
|
||||
schemaCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
initSchema(schemaCtx, pool, logger)
|
||||
cancel()
|
||||
}
|
||||
|
||||
// Shadow ingest: replay the Python /ws/live firehose into our handlers.
|
||||
if cfg.IngestWS != "" {
|
||||
if cfg.ReadOnly {
|
||||
logger.Error("SHADOW_INGEST_WS set but READ_ONLY=true; refusing to ingest into the production DB")
|
||||
os.Exit(1)
|
||||
// Schema init only when we own a fresh DB. In cutover (reusing the
|
||||
// production DB) SKIP_SCHEMA_INIT keeps us from running ANY DDL.
|
||||
if !cfg.SkipSchemaInit {
|
||||
schemaCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
initSchema(schemaCtx, pool, logger)
|
||||
cancel()
|
||||
}
|
||||
|
||||
srv.ingestor = newIngestor(pool, logger, srv.hub.broadcast, srv.plugins)
|
||||
go srv.runShadowConsumer(ctx, cfg.IngestWS)
|
||||
logger.Info("shadow ingest enabled", "source", cfg.IngestWS)
|
||||
|
||||
if cfg.IngestWS != "" {
|
||||
// Shadow: replay the Python /ws/live firehose. Inventory forwarding
|
||||
// + Discord alerts stay OFF (would double production writes/alerts;
|
||||
// inventory isn't in the firehose anyway).
|
||||
go srv.runShadowConsumer(ctx, cfg.IngestWS)
|
||||
logger.Info("shadow ingest enabled", "source", cfg.IngestWS)
|
||||
} else {
|
||||
// Cutover: the real plugin connects to /ws/position. Forward
|
||||
// inventory to the inventory service and post death/idle alerts.
|
||||
srv.ingestor.invFwd = newInvForwarder(cfg.InventoryURL, logger, srv.hub.broadcast)
|
||||
if cfg.DiscordACLog != "" {
|
||||
srv.ingestor.aclog = newACLogPoster(cfg.DiscordACLog, logger)
|
||||
go srv.ingestor.aclog.runIdleLoop(ctx, pool)
|
||||
}
|
||||
logger.Info("cutover ingest enabled", "inventory_url", cfg.InventoryURL, "aclog", cfg.DiscordACLog != "")
|
||||
}
|
||||
} else if cfg.IngestWS != "" {
|
||||
logger.Error("SHADOW_INGEST_WS set but READ_ONLY=true; refusing to ingest into the production DB")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
go srv.runCacheLoop(ctx)
|
||||
|
|
@ -166,6 +191,8 @@ type config struct {
|
|||
SharedSecret string // plugin /ws/position auth
|
||||
SharedSecretLegacy string // plugin auth rotation fallback
|
||||
IngestWS string // optional: a /ws/live URL to shadow-ingest from (Python tracker)
|
||||
SkipSchemaInit bool // cutover: trust the existing prod schema, run no DDL
|
||||
DiscordACLog string // #aclog webhook for death/idle alerts (cutover only)
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
|
|
@ -179,6 +206,8 @@ func loadConfig() config {
|
|||
SharedSecret: os.Getenv("SHARED_SECRET"),
|
||||
SharedSecretLegacy: os.Getenv("SHARED_SECRET_LEGACY"),
|
||||
IngestWS: os.Getenv("SHADOW_INGEST_WS"),
|
||||
SkipSchemaInit: envOr("SKIP_SCHEMA_INIT", "false") == "true",
|
||||
DiscordACLog: os.Getenv("DISCORD_ACLOG_WEBHOOK"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -230,6 +259,28 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
|
|||
|
||||
// Inventory-service reverse proxies.
|
||||
s.registerProxyRoutes(mux)
|
||||
|
||||
// Website layer: login/logout + icons + static frontend (cutover).
|
||||
mux.HandleFunc("GET /login", s.handleLoginGet)
|
||||
mux.HandleFunc("POST /login", s.handleLoginPost)
|
||||
mux.HandleFunc("GET /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /icons/{filename}", s.handleIcon)
|
||||
|
||||
// Admin user management.
|
||||
mux.HandleFunc("GET /admin/users", s.handleAdminPage)
|
||||
mux.HandleFunc("GET /api-admin/users", s.handleListUsers)
|
||||
mux.HandleFunc("POST /api-admin/users", s.handleCreateUser)
|
||||
mux.HandleFunc("PATCH /api-admin/users/{user_id}", s.handleUpdateUser)
|
||||
mux.HandleFunc("DELETE /api-admin/users/{user_id}", s.handleDeleteUser)
|
||||
|
||||
// Issue board write side (GET /issues is registered above).
|
||||
mux.HandleFunc("POST /issues", s.handleAddIssue)
|
||||
mux.HandleFunc("PATCH /issues/{issue_id}", s.handleUpdateIssue)
|
||||
mux.HandleFunc("POST /issues/{issue_id}/comments", s.handleAddComment)
|
||||
mux.HandleFunc("DELETE /issues/{issue_id}", s.handleDeleteIssue)
|
||||
// Catch-all: serve the static frontend (SPA). Registered last; every
|
||||
// specific route above is more specific, so this only handles the rest.
|
||||
mux.HandleFunc("GET /", s.handleStatic)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue