mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
556 lines
18 KiB
Go
556 lines
18 KiB
Go
package hold
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/atproto/did"
|
|
"atcr.io/pkg/hold/admin"
|
|
holddb "atcr.io/pkg/hold/db"
|
|
"atcr.io/pkg/hold/gc"
|
|
holdlabeler "atcr.io/pkg/hold/labeler"
|
|
"atcr.io/pkg/hold/oci"
|
|
"atcr.io/pkg/hold/pds"
|
|
"atcr.io/pkg/hold/quota"
|
|
"atcr.io/pkg/logging"
|
|
"atcr.io/pkg/s3"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
// purgerAdapter bridges *pds.HoldPDS to the holdlabeler.Purger interface, which
|
|
// uses its own outcome type to avoid an import cycle with pkg/hold/pds.
|
|
type purgerAdapter struct {
|
|
pds *pds.HoldPDS
|
|
}
|
|
|
|
func (a purgerAdapter) PurgeManifestRecords(ctx context.Context, manifestURI string) (holdlabeler.PurgeOutcome, error) {
|
|
r, err := a.pds.PurgeManifestRecords(ctx, manifestURI)
|
|
if err != nil || r == nil {
|
|
return holdlabeler.PurgeOutcome{}, err
|
|
}
|
|
return holdlabeler.PurgeOutcome{
|
|
LayersDeleted: r.LayersDeleted,
|
|
ScanDeleted: r.ScanDeleted,
|
|
ImageConfigDeleted: r.ImageConfigDeleted,
|
|
}, nil
|
|
}
|
|
|
|
func (a purgerAdapter) PurgeUserManifests(ctx context.Context, userDID string) (holdlabeler.PurgeOutcome, error) {
|
|
r, err := a.pds.PurgeUserManifests(ctx, userDID)
|
|
if err != nil || r == nil {
|
|
return holdlabeler.PurgeOutcome{}, err
|
|
}
|
|
return holdlabeler.PurgeOutcome{
|
|
LayersDeleted: r.LayersDeleted,
|
|
ScanDeleted: r.ScanDeleted,
|
|
ImageConfigDeleted: r.ImageConfigDeleted,
|
|
}, nil
|
|
}
|
|
|
|
// HoldServer is the hold service with an exposed router for extensibility.
|
|
// Consumers can add routes to Router before calling Serve().
|
|
type HoldServer struct {
|
|
// Router is the chi router. Add routes before calling Serve().
|
|
Router chi.Router
|
|
|
|
// PDS is the embedded ATProto PDS. Nil if database path is not configured.
|
|
PDS *pds.HoldPDS
|
|
|
|
// QuotaManager manages storage quotas per tier.
|
|
QuotaManager *quota.Manager
|
|
|
|
// Config is the hold service configuration.
|
|
Config *Config
|
|
|
|
// internal fields for shutdown
|
|
httpServer *http.Server
|
|
broadcaster *pds.EventBroadcaster
|
|
scanBroadcaster *pds.ScanBroadcaster
|
|
garbageCollector *gc.GarbageCollector
|
|
adminUI *admin.AdminUI
|
|
holdDB *holddb.HoldDB // shared database connection (nil for :memory:)
|
|
labelerSubscriber *holdlabeler.Subscriber
|
|
labelerCache *holdlabeler.Cache
|
|
}
|
|
|
|
// NewHoldServer initializes PDS, storage, quota, XRPC handlers, and returns
|
|
// before starting. Consumer can add routes to Router before calling Serve().
|
|
func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
|
// Initialize structured logging with optional remote shipping
|
|
logging.InitLoggerWithShipper(cfg.LogLevel, logging.ShipperConfig{
|
|
Backend: cfg.LogShipper.Backend,
|
|
URL: cfg.LogShipper.URL,
|
|
BatchSize: cfg.LogShipper.BatchSize,
|
|
FlushInterval: cfg.LogShipper.FlushInterval,
|
|
Service: "hold",
|
|
Username: cfg.LogShipper.Username,
|
|
Password: cfg.LogShipper.Password,
|
|
})
|
|
|
|
s := &HoldServer{
|
|
Config: cfg,
|
|
}
|
|
|
|
if cfg.Server.TestMode {
|
|
atproto.SetTestMode(true)
|
|
}
|
|
|
|
// Initialize embedded PDS if database path is configured
|
|
var xrpcHandler *pds.XRPCHandler
|
|
var s3Service *s3.S3Service
|
|
if cfg.Database.Path != "" {
|
|
ctx := context.Background()
|
|
|
|
holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve hold DID: %w", err)
|
|
}
|
|
slog.Info("Initializing embedded PDS", "did", holdDID)
|
|
|
|
if cfg.Database.Path != ":memory:" {
|
|
// File mode: open centralized shared DB (supports embedded replica sync)
|
|
dbFilePath := cfg.Database.Path + "/db.sqlite3"
|
|
libsqlCfg := holddb.LibsqlConfig{
|
|
SyncURL: cfg.Database.LibsqlSyncURL,
|
|
AuthToken: cfg.Database.LibsqlAuthToken,
|
|
SyncInterval: cfg.Database.LibsqlSyncInterval,
|
|
}
|
|
s.holdDB, err = holddb.OpenHoldDB(dbFilePath, libsqlCfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open hold database: %w", err)
|
|
}
|
|
|
|
// Use shared DB for all subsystems
|
|
s.PDS, err = pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL(), cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts, s.holdDB.DB)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize embedded PDS: %w", err)
|
|
}
|
|
|
|
s.broadcaster = pds.NewEventBroadcasterWithDB(holdDID, 100, s.holdDB.DB)
|
|
} else {
|
|
// In-memory mode (tests): each subsystem opens its own connection
|
|
s.PDS, err = pds.NewHoldPDS(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL(), cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize embedded PDS: %w", err)
|
|
}
|
|
|
|
s.broadcaster = pds.NewEventBroadcaster(holdDID, 100, ":memory:")
|
|
}
|
|
|
|
// Create S3 service (used for bootstrap, handlers, GC, etc.)
|
|
s3Service, err = s3.NewS3Service(cfg.Storage.S3Params())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create S3 service: %w", err)
|
|
}
|
|
|
|
// Bootstrap events from existing repo records (one-time migration).
|
|
// Must run BEFORE the live event handler is wired, so it captures
|
|
// the full historical state without interference from new writes.
|
|
if err := s.broadcaster.BootstrapFromRepo(s.PDS); err != nil {
|
|
slog.Warn("Failed to bootstrap events from repo", "error", err)
|
|
}
|
|
|
|
// Backfill records index from existing MST data (one-time on startup)
|
|
if err := s.PDS.BackfillRecordsIndex(ctx); err != nil {
|
|
slog.Warn("Failed to backfill records index", "error", err)
|
|
}
|
|
|
|
// Wire up repo event handler with records indexing + broadcaster.
|
|
// Must be BEFORE Bootstrap so that record creates/updates during
|
|
// bootstrap (captain, crew, profile) emit to the firehose.
|
|
indexingHandler := s.PDS.CreateRecordsIndexEventHandler(s.broadcaster.SetRepoEventHandler())
|
|
s.PDS.RepomgrRef().SetEventHandler(indexingHandler, true)
|
|
|
|
// Bootstrap PDS with captain record, hold owner as first crew member, and profile.
|
|
// Now that the event handler is wired, any changes here emit to the firehose.
|
|
if err := s.PDS.Bootstrap(ctx, s3Service, pds.BootstrapConfig{
|
|
OwnerDID: cfg.Registration.OwnerDID,
|
|
Public: cfg.Server.Public,
|
|
AllowAllCrew: cfg.Registration.AllowAllCrew,
|
|
ProfileAvatarURL: cfg.Registration.ProfileAvatarURL,
|
|
ProfileDisplayName: cfg.Registration.ProfileDisplayName,
|
|
ProfileDescription: cfg.Registration.ProfileDescription,
|
|
Region: cfg.Registration.Region,
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to bootstrap PDS: %w", err)
|
|
}
|
|
|
|
// Sync successor from config (if set) — separate from Bootstrap to avoid changing its signature
|
|
if cfg.Server.Successor != "" {
|
|
if _, captain, err := s.PDS.GetCaptainRecord(ctx); err == nil && captain.Successor != cfg.Server.Successor {
|
|
captain.Successor = cfg.Server.Successor
|
|
if _, err := s.PDS.UpdateCaptainRecord(ctx, captain); err != nil {
|
|
slog.Warn("Failed to sync successor from config", "error", err)
|
|
} else {
|
|
slog.Info("Synced successor from config", "successor", cfg.Server.Successor)
|
|
}
|
|
}
|
|
}
|
|
|
|
slog.Info("Embedded PDS initialized successfully with firehose and records index enabled")
|
|
} else {
|
|
return nil, fmt.Errorf("database path is required for embedded PDS authorization")
|
|
}
|
|
|
|
// Initialize quota manager from config
|
|
var err error
|
|
s.QuotaManager, err = quota.NewManagerFromConfig(&cfg.Quota)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load quota config: %w", err)
|
|
}
|
|
if s.QuotaManager.IsEnabled() {
|
|
slog.Info("Quota enforcement enabled", "tiers", s.QuotaManager.TierCount(), "defaultTier", s.QuotaManager.GetDefaultTier())
|
|
} else {
|
|
slog.Info("Quota enforcement disabled (no quota tiers configured)")
|
|
}
|
|
|
|
// Create XRPC handlers
|
|
var ociHandler *oci.XRPCHandler
|
|
if s.PDS != nil {
|
|
xrpcHandler = pds.NewXRPCHandler(s.PDS, *s3Service, s.broadcaster, nil, s.QuotaManager)
|
|
if cfg.Server.AppviewDID != "" {
|
|
xrpcHandler.SetAppviewDID(cfg.Server.AppviewDID)
|
|
}
|
|
ociHandler = oci.NewXRPCHandler(s.PDS, *s3Service, cfg.Registration.EnableBlueskyPosts, nil, s.QuotaManager)
|
|
|
|
// Initialize scan broadcaster if scanner secret is configured
|
|
if cfg.Scanner.Secret != "" {
|
|
holdDID := s.PDS.DID()
|
|
rescanInterval := cfg.Scanner.RescanInterval
|
|
var sb *pds.ScanBroadcaster
|
|
if s.holdDB != nil {
|
|
sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, cfg.Server.RelayEndpoints, s.holdDB.DB, s3Service, s.PDS, rescanInterval)
|
|
} else {
|
|
scanDBPath := cfg.Database.Path + "/db.sqlite3"
|
|
sb, err = pds.NewScanBroadcaster(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, cfg.Server.RelayEndpoints, scanDBPath, s3Service, s.PDS, rescanInterval)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize scan broadcaster: %w", err)
|
|
}
|
|
s.scanBroadcaster = sb
|
|
xrpcHandler.SetScanBroadcaster(sb)
|
|
ociHandler.SetScanBroadcaster(sb)
|
|
slog.Info("Scan broadcaster initialized (scanner WebSocket enabled)",
|
|
"rescanInterval", rescanInterval)
|
|
}
|
|
|
|
// Initialize labeler cache + subscriber if a labeler is configured.
|
|
// The cache is created either way so the GC can take a non-nil
|
|
// pointer; without a configured DID it just stays empty and exerts
|
|
// no effect.
|
|
if s.holdDB != nil {
|
|
cache, cacheErr := holdlabeler.NewCache(s.holdDB.DB)
|
|
if cacheErr != nil {
|
|
return nil, fmt.Errorf("failed to initialize labeler cache: %w", cacheErr)
|
|
}
|
|
s.labelerCache = cache
|
|
if cfg.Labeler.DID != "" {
|
|
s.labelerSubscriber = holdlabeler.NewSubscriber(
|
|
cfg.Labeler.DID,
|
|
s.labelerCache,
|
|
purgerAdapter{pds: s.PDS},
|
|
)
|
|
slog.Info("Hold labeler subscriber initialized",
|
|
"labeler", cfg.Labeler.DID,
|
|
"grace_window", cfg.Labeler.GraceWindow)
|
|
}
|
|
}
|
|
|
|
// Initialize garbage collector
|
|
s.garbageCollector = gc.NewGarbageCollector(s.PDS, s3Service, cfg.GC,
|
|
gc.WithTakedownCache(s.labelerCache, cfg.Labeler.GraceWindow))
|
|
slog.Info("Garbage collector initialized",
|
|
"enabled", cfg.GC.Enabled)
|
|
}
|
|
|
|
// Setup HTTP routes with chi router
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.RealIP)
|
|
r.Use(middleware.Maybe(middleware.Logger, func(r *http.Request) bool {
|
|
return r.URL.Path != "/xrpc/_health"
|
|
}))
|
|
|
|
if xrpcHandler != nil {
|
|
r.Use(xrpcHandler.CORSMiddleware())
|
|
}
|
|
|
|
// Root page
|
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
fmt.Fprintf(w, "This is a hold server. More info at https://atcr.io")
|
|
})
|
|
|
|
// Robots.txt - disallow crawling of all endpoints except root
|
|
r.Get("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
fmt.Fprint(w, "User-agent: *\nAllow: /\nDisallow: /xrpc/\nDisallow: /admin/\n")
|
|
})
|
|
|
|
// Register XRPC/ATProto PDS endpoints
|
|
if xrpcHandler != nil {
|
|
slog.Info("Registering ATProto PDS endpoints")
|
|
xrpcHandler.RegisterHandlers(r)
|
|
}
|
|
|
|
// Register OCI multipart upload endpoints
|
|
if ociHandler != nil {
|
|
slog.Info("Registering OCI multipart upload endpoints")
|
|
ociHandler.RegisterHandlers(r)
|
|
}
|
|
|
|
// Initialize and register admin panel if enabled
|
|
if cfg.Admin.Enabled && s.PDS != nil {
|
|
adminCfg := admin.AdminConfig{
|
|
Enabled: true,
|
|
PublicURL: cfg.Server.PublicURL,
|
|
ConfigPath: cfg.ConfigPath(),
|
|
}
|
|
|
|
s.adminUI, err = admin.NewAdminUI(context.Background(), s.PDS, s.QuotaManager, s.garbageCollector, adminCfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize admin panel: %w", err)
|
|
}
|
|
|
|
if s.adminUI != nil {
|
|
slog.Info("Registering admin panel routes")
|
|
s.adminUI.RegisterRoutes(r)
|
|
}
|
|
}
|
|
|
|
s.Router = r
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Serve starts the HTTP server on the configured address and blocks until
|
|
// shutdown signal.
|
|
func (s *HoldServer) Serve() error {
|
|
listener, err := net.Listen("tcp", s.Config.Server.Addr)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create listener: %w", err)
|
|
}
|
|
return s.ServeWithListener(listener)
|
|
}
|
|
|
|
// ServeWithListener starts the HTTP server on the provided listener and
|
|
// blocks until a shutdown signal arrives or Shutdown() is called. Tests use
|
|
// this to bind a 127.0.0.1:0 listener and learn the assigned port before
|
|
// driving requests.
|
|
func (s *HoldServer) ServeWithListener(listener net.Listener) error {
|
|
s.httpServer = &http.Server{
|
|
Handler: s.Router,
|
|
ReadTimeout: s.Config.Server.ReadTimeout,
|
|
WriteTimeout: s.Config.Server.WriteTimeout,
|
|
}
|
|
|
|
// Set up signal handling for graceful shutdown
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
defer signal.Stop(sigChan)
|
|
|
|
// Start server in goroutine
|
|
serverErr := make(chan error, 1)
|
|
go func() {
|
|
slog.Info("Starting hold service", "addr", listener.Addr().String())
|
|
err := s.httpServer.Serve(listener)
|
|
if err != nil && err != http.ErrServerClosed {
|
|
serverErr <- err
|
|
return
|
|
}
|
|
serverErr <- nil
|
|
}()
|
|
|
|
// Update status post to "online" after server starts
|
|
if s.PDS != nil {
|
|
ctx := context.Background()
|
|
if err := s.PDS.SetStatus(ctx, "online"); err != nil {
|
|
slog.Warn("Failed to set status post to online", "error", err)
|
|
} else {
|
|
slog.Info("Status post set to online")
|
|
}
|
|
}
|
|
|
|
// Fetch appview metadata for branding (Bluesky posts)
|
|
if s.Config.Server.AppviewURL() != "" {
|
|
meta, err := atproto.FetchAppviewMetadata(context.Background(), s.Config.Server.AppviewURL())
|
|
if err != nil {
|
|
slog.Warn("Failed to fetch appview metadata, using defaults", "appview_url", s.Config.Server.AppviewURL(), "error", err)
|
|
} else {
|
|
s.PDS.SetAppviewMeta(meta)
|
|
slog.Info("Fetched appview metadata", "clientName", meta.ClientName, "clientShortName", meta.ClientShortName)
|
|
}
|
|
}
|
|
|
|
// Request crawl from every known relay (plus any custom endpoint) so the
|
|
// embedded PDS becomes discoverable. Without this, did:web holds are
|
|
// invisible to relays — and to any appview that backfills via them.
|
|
// Skipped in test_mode: local dev holds aren't reachable by public relays.
|
|
if !s.Config.Server.TestMode {
|
|
go s.requestCrawls()
|
|
} else {
|
|
slog.Info("Skipping relay crawl requests (test_mode enabled)")
|
|
}
|
|
|
|
// Start garbage collector (runs on startup + nightly)
|
|
if s.garbageCollector != nil {
|
|
s.garbageCollector.Start(context.Background())
|
|
}
|
|
|
|
// Start labeler subscriber if configured.
|
|
if s.labelerSubscriber != nil {
|
|
s.labelerSubscriber.Start()
|
|
slog.Info("Hold labeler subscriber started", "labeler_did", s.labelerSubscriber.LabelerDID())
|
|
}
|
|
|
|
// Wait for signal or server error
|
|
select {
|
|
case err := <-serverErr:
|
|
if err != nil {
|
|
slog.Error("Server failed", "error", err)
|
|
logging.Shutdown()
|
|
return err
|
|
}
|
|
// Clean exit (e.g. tests called Shutdown()). Tear down side workers.
|
|
s.shutdown()
|
|
case sig := <-sigChan:
|
|
slog.Info("Received signal, shutting down gracefully", "signal", sig)
|
|
s.shutdown()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Shutdown gracefully stops the HTTP server. Safe to call from tests instead
|
|
// of sending SIGTERM. Production code goes through the signal handler inside
|
|
// ServeWithListener.
|
|
func (s *HoldServer) Shutdown(ctx context.Context) error {
|
|
if s.httpServer == nil {
|
|
return nil
|
|
}
|
|
return s.httpServer.Shutdown(ctx)
|
|
}
|
|
|
|
// requestCrawls fans out com.atproto.sync.requestCrawl to every known relay so
|
|
// the embedded PDS becomes discoverable on the relay network. Configured
|
|
// RelayEndpoints (if any aren't already in KnownRelays) are included as well.
|
|
// Best-effort: per-relay failures are logged but never block startup.
|
|
func (s *HoldServer) requestCrawls() {
|
|
publicURL := s.Config.Server.PublicURL
|
|
if publicURL == "" {
|
|
return
|
|
}
|
|
|
|
seen := make(map[string]bool)
|
|
targets := make([]string, 0, len(atproto.KnownRelays)+len(s.Config.Server.RelayEndpoints))
|
|
for _, r := range atproto.KnownRelays {
|
|
if !seen[r.URL] {
|
|
seen[r.URL] = true
|
|
targets = append(targets, r.URL)
|
|
}
|
|
}
|
|
for _, custom := range s.Config.Server.RelayEndpoints {
|
|
if custom != "" && !seen[custom] {
|
|
seen[custom] = true
|
|
targets = append(targets, custom)
|
|
}
|
|
}
|
|
|
|
slog.Info("Requesting crawl from relays", "count", len(targets))
|
|
var wg sync.WaitGroup
|
|
for _, relay := range targets {
|
|
wg.Add(1)
|
|
go func(relay string) {
|
|
defer wg.Done()
|
|
if err := atproto.RequestCrawl(relay, publicURL); err != nil {
|
|
slog.Warn("Failed to request crawl from relay", "relay", relay, "error", err)
|
|
return
|
|
}
|
|
slog.Info("Crawl requested from relay", "relay", relay)
|
|
}(relay)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func (s *HoldServer) shutdown() {
|
|
// Update status post to "offline" before shutdown
|
|
if s.PDS != nil {
|
|
ctx := context.Background()
|
|
if err := s.PDS.SetStatus(ctx, "offline"); err != nil {
|
|
slog.Warn("Failed to set status post to offline", "error", err)
|
|
} else {
|
|
slog.Info("Status post set to offline")
|
|
}
|
|
}
|
|
|
|
// Stop garbage collector
|
|
if s.garbageCollector != nil {
|
|
s.garbageCollector.Stop()
|
|
slog.Info("Garbage collector stopped")
|
|
}
|
|
|
|
// Stop labeler subscriber
|
|
if s.labelerSubscriber != nil {
|
|
s.labelerSubscriber.Stop()
|
|
slog.Info("Labeler subscriber stopped")
|
|
}
|
|
|
|
// Close scan broadcaster database connection
|
|
if s.scanBroadcaster != nil {
|
|
if err := s.scanBroadcaster.Close(); err != nil {
|
|
slog.Warn("Failed to close scan broadcaster database", "error", err)
|
|
} else {
|
|
slog.Info("Scan broadcaster database closed")
|
|
}
|
|
}
|
|
|
|
// Close broadcaster database connection
|
|
if s.broadcaster != nil {
|
|
if err := s.broadcaster.Close(); err != nil {
|
|
slog.Warn("Failed to close broadcaster database", "error", err)
|
|
} else {
|
|
slog.Info("Broadcaster database closed")
|
|
}
|
|
}
|
|
|
|
// Close admin panel
|
|
if s.adminUI != nil {
|
|
if err := s.adminUI.Close(); err != nil {
|
|
slog.Warn("Failed to close admin panel", "error", err)
|
|
} else {
|
|
slog.Info("Admin panel closed")
|
|
}
|
|
}
|
|
|
|
// Close shared database connection and connector (after all subsystems)
|
|
if s.holdDB != nil {
|
|
if err := s.holdDB.Close(); err != nil {
|
|
slog.Warn("Failed to close hold database", "error", err)
|
|
} else {
|
|
slog.Info("Hold database closed")
|
|
}
|
|
}
|
|
|
|
// Graceful shutdown with 10 second timeout
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
|
|
slog.Error("Server shutdown error", "error", err)
|
|
} else {
|
|
slog.Info("Server shutdown complete")
|
|
}
|
|
|
|
logging.Shutdown()
|
|
}
|