Files
at-container-registry/pkg/hold/server.go
T
Evan JarrettandClaude Fable 5.1 0080957a21 remove the runtime test_mode switch; the testmode build tag is the only one
server.test_mode survived the build-tag refactor only to feed five
behavioral branches: the registry's fall-back to the default hold when
the user's hold is unreachable, backfill warning suppression for
external holds, the appview listener close on shutdown, the hold's
relay-crawl skip, and the hold's appview-issuer tolerance. Every one of
them is a "this is a local development build" decision, which is what
the tag already says, and local development has to build with the tag
or nothing resolves. So they read atproto.TestModeBuild now, and the
flag, SetTestMode, IsTestMode, the middleware option, the backfill
constructor parameter, the never-read field on RemoteHoldAuthorizer,
the example and template YAML lines, and the docker-compose env vars
are gone. The registry keeps the fallback as a field seeded from the
constant so the production-path tests can pin it off under the tag.

The 24 SetTestMode calls in tests were dead already: stripping them and
running the affected packages tagged changed nothing.

Tests that resolve a loopback did:web used to t.Fatal naming the tag,
which left a bare `go test ./...` permanently red in five packages.
They now live under `//go:build testmode`: whole-file constraints where
every test needs it, and sibling *_testmode_test.go files holding the
moved tests plus their fixtures where a file mixed. The harness carries
the constraint too, with its package doc in an untagged doc.go so the
package still exists without it. An untagged run compiles those tests
out and passes; make test keeps the tag and runs everything.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
2026-09-11 11:09:44 -05:00

569 lines
19 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"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
// 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,
}
// 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 {
// h2c so the load balancer can reach this origin over HTTP/2 without TLS.
// TLS terminates at the LB, so the backend leg is cleartext and ALPN never
// runs; without this wrapper net/http can only ever answer HTTP/1.1 here.
//
// The wrapper is opt-in per connection: it upgrades only for a client that
// sends the h2c preface or an "Upgrade: h2c" header, and hands everything
// else to the router untouched. An HTTP/1.1 WebSocket upgrade is therefore
// unaffected, which matters because this server carries subscribeRepos.
//
// NB: that is also why the hold's LB *backend* leaves http2_enabled off
// (see ensureLBHTTP2 in deploy/upcloud). WebSocket over HTTP/2 needs the
// RFC 8441 Extended CONNECT that Go's http2 server does not implement for
// Upgrade:, so routing this backend over h2 would break the firehose and
// the scanner's connection. This wrapper makes the capability available;
// enabling it for this backend is a separate, deliberate decision.
s.httpServer = &http.Server{
Handler: h2c.NewHandler(s.Router, &http2.Server{}),
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 a testmode build: local dev holds aren't reachable by public relays.
if !atproto.TestModeBuild {
go s.requestCrawls()
} else {
slog.Info("Skipping relay crawl requests (testmode build)")
}
// 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()
}