mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
fix health checks on startup
This commit is contained in:
@@ -71,6 +71,21 @@ ATCR_UI_ENABLED=true
|
||||
# Log formatter: text, json (default: text)
|
||||
# ATCR_LOG_FORMATTER=text
|
||||
|
||||
# ==============================================================================
|
||||
# Hold Health Check Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# How often to check health of hold endpoints in the background (default: 15m)
|
||||
# Queries database for unique hold endpoints and checks if they're reachable
|
||||
# Examples: 5m, 15m, 30m, 1h
|
||||
# ATCR_HEALTH_CHECK_INTERVAL=15m
|
||||
|
||||
# How long to cache health check results (default: 15m)
|
||||
# Cached results avoid redundant health checks on page renders
|
||||
# Should be >= ATCR_HEALTH_CHECK_INTERVAL for efficiency
|
||||
# Examples: 15m, 30m, 1h
|
||||
# ATCR_HEALTH_CACHE_TTL=15m
|
||||
|
||||
# ==============================================================================
|
||||
# Jetstream Configuration (ATProto event streaming)
|
||||
# ==============================================================================
|
||||
|
||||
+24
-4
@@ -75,19 +75,39 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Initialize hold health checker
|
||||
fmt.Println("Initializing hold health checker...")
|
||||
cacheTTL := 15 * time.Minute // Cache TTL from user requirements
|
||||
|
||||
// Parse health check cache TTL from environment (default: 15m)
|
||||
cacheTTL := 15 * time.Minute
|
||||
if cacheTTLStr := os.Getenv("ATCR_HEALTH_CACHE_TTL"); cacheTTLStr != "" {
|
||||
if parsed, err := time.ParseDuration(cacheTTLStr); err == nil {
|
||||
cacheTTL = parsed
|
||||
} else {
|
||||
fmt.Printf("Warning: Invalid ATCR_HEALTH_CACHE_TTL '%s', using default 15m\n", cacheTTLStr)
|
||||
}
|
||||
}
|
||||
|
||||
healthChecker := holdhealth.NewChecker(cacheTTL)
|
||||
|
||||
// Start background health check worker
|
||||
refreshInterval := 5 * time.Minute // Refresh every 5 minutes
|
||||
// Parse refresh interval from environment (default: 15m)
|
||||
refreshInterval := 15 * time.Minute
|
||||
if refreshIntervalStr := os.Getenv("ATCR_HEALTH_CHECK_INTERVAL"); refreshIntervalStr != "" {
|
||||
if parsed, err := time.ParseDuration(refreshIntervalStr); err == nil {
|
||||
refreshInterval = parsed
|
||||
} else {
|
||||
fmt.Printf("Warning: Invalid ATCR_HEALTH_CHECK_INTERVAL '%s', using default 15m\n", refreshIntervalStr)
|
||||
}
|
||||
}
|
||||
|
||||
startupDelay := 5 * time.Second // Wait for hold services to start (Docker compose)
|
||||
dbAdapter := holdhealth.NewDBAdapter(uiDatabase)
|
||||
healthWorker := holdhealth.NewWorker(healthChecker, dbAdapter, refreshInterval)
|
||||
healthWorker := holdhealth.NewWorkerWithStartupDelay(healthChecker, dbAdapter, refreshInterval, startupDelay)
|
||||
|
||||
// Create context for worker lifecycle management
|
||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||
defer workerCancel() // Ensure context is cancelled on all exit paths
|
||||
healthWorker.Start(workerCtx)
|
||||
fmt.Println("Hold health worker started (5min refresh interval, 15min cache TTL)")
|
||||
fmt.Printf("Hold health worker started (5s startup delay, %s refresh interval, %s cache TTL)\n", refreshInterval, cacheTTL)
|
||||
|
||||
// Initialize OAuth components
|
||||
fmt.Println("Initializing OAuth components...")
|
||||
|
||||
+827
-603
File diff suppressed because it is too large
Load Diff
@@ -309,6 +309,49 @@ func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) {
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
// GetRepositoryMetadata retrieves metadata for a repository from its most recent manifest
|
||||
func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL string, err error) {
|
||||
var titleNull, descriptionNull, sourceURLNull, documentationURLNull, licensesNull, iconURLNull sql.NullString
|
||||
|
||||
err = db.QueryRow(`
|
||||
SELECT title, description, source_url, documentation_url, licenses, icon_url
|
||||
FROM manifests
|
||||
WHERE did = ? AND repository = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// No manifests found - return empty strings
|
||||
return "", "", "", "", "", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", "", "", "", "", err
|
||||
}
|
||||
|
||||
// Convert NullString to string
|
||||
if titleNull.Valid {
|
||||
title = titleNull.String
|
||||
}
|
||||
if descriptionNull.Valid {
|
||||
description = descriptionNull.String
|
||||
}
|
||||
if sourceURLNull.Valid {
|
||||
sourceURL = sourceURLNull.String
|
||||
}
|
||||
if documentationURLNull.Valid {
|
||||
documentationURL = documentationURLNull.String
|
||||
}
|
||||
if licensesNull.Valid {
|
||||
licenses = licensesNull.String
|
||||
}
|
||||
if iconURLNull.Valid {
|
||||
iconURL = iconURLNull.String
|
||||
}
|
||||
|
||||
return title, description, sourceURL, documentationURL, licenses, iconURL, nil
|
||||
}
|
||||
|
||||
// GetUserByDID retrieves a user by DID
|
||||
func GetUserByDID(db *sql.DB, did string) (*User, error) {
|
||||
var user User
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetRepositoryMetadata(t *testing.T) {
|
||||
// Create in-memory test database
|
||||
db, err := InitDB(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Insert test user
|
||||
testUser := &User{
|
||||
DID: "did:plc:test123",
|
||||
Handle: "testuser.bsky.social",
|
||||
PDSEndpoint: "https://test.pds.example.com",
|
||||
Avatar: "",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
if err := UpsertUser(db, testUser); err != nil {
|
||||
t.Fatalf("Failed to insert user: %v", err)
|
||||
}
|
||||
|
||||
// Test 1: No manifests - should return empty strings
|
||||
title, description, sourceURL, documentationURL, licenses, iconURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error for nonexistent repo, got: %v", err)
|
||||
}
|
||||
if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" {
|
||||
t.Error("Expected all empty strings for nonexistent repository")
|
||||
}
|
||||
|
||||
// Test 2: Insert manifest with metadata
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at,
|
||||
title, description, source_url, documentation_url, licenses, icon_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json",
|
||||
time.Now().Add(-2*time.Hour),
|
||||
"My App", "A cool application", "https://github.com/user/myapp", "https://docs.example.com", "MIT", "https://example.com/icon.png")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert manifest: %v", err)
|
||||
}
|
||||
|
||||
// Test 3: Retrieve metadata
|
||||
title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get repository metadata: %v", err)
|
||||
}
|
||||
|
||||
if title != "My App" {
|
||||
t.Errorf("Expected title 'My App', got '%s'", title)
|
||||
}
|
||||
if description != "A cool application" {
|
||||
t.Errorf("Expected description 'A cool application', got '%s'", description)
|
||||
}
|
||||
if sourceURL != "https://github.com/user/myapp" {
|
||||
t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", sourceURL)
|
||||
}
|
||||
if documentationURL != "https://docs.example.com" {
|
||||
t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", documentationURL)
|
||||
}
|
||||
if licenses != "MIT" {
|
||||
t.Errorf("Expected licenses 'MIT', got '%s'", licenses)
|
||||
}
|
||||
if iconURL != "https://example.com/icon.png" {
|
||||
t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", iconURL)
|
||||
}
|
||||
|
||||
// Test 4: Insert newer manifest with different metadata
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at,
|
||||
title, description, source_url, documentation_url, licenses, icon_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, testUser.DID, "myapp", "sha256:def456", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json",
|
||||
time.Now(), // Most recent
|
||||
"My App v2", "An even cooler application", "https://github.com/user/myapp-v2", "https://v2.docs.example.com", "Apache-2.0", "https://example.com/icon-v2.png")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert newer manifest: %v", err)
|
||||
}
|
||||
|
||||
// Test 5: Should return metadata from most recent manifest
|
||||
title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get repository metadata: %v", err)
|
||||
}
|
||||
|
||||
if title != "My App v2" {
|
||||
t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", title)
|
||||
}
|
||||
if description != "An even cooler application" {
|
||||
t.Errorf("Expected description from newest manifest, got '%s'", description)
|
||||
}
|
||||
if licenses != "Apache-2.0" {
|
||||
t.Errorf("Expected licenses 'Apache-2.0', got '%s'", licenses)
|
||||
}
|
||||
|
||||
// Test 6: Manifest with NULL metadata fields
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, testUser.DID, "minimal-app", "sha256:minimal", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert minimal manifest: %v", err)
|
||||
}
|
||||
|
||||
// Test 7: Should handle NULL fields gracefully
|
||||
title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get repository metadata for minimal app: %v", err)
|
||||
}
|
||||
|
||||
if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" {
|
||||
t.Error("Expected all empty strings for manifest with NULL metadata fields")
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,20 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
ManifestCount: len(manifests),
|
||||
}
|
||||
|
||||
// Fetch repository metadata from most recent manifest
|
||||
title, description, sourceURL, documentationURL, licenses, iconURL, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch repository metadata: %v", err)
|
||||
// Continue without metadata on error
|
||||
} else {
|
||||
repo.Title = title
|
||||
repo.Description = description
|
||||
repo.SourceURL = sourceURL
|
||||
repo.DocumentationURL = documentationURL
|
||||
repo.Licenses = licenses
|
||||
repo.IconURL = iconURL
|
||||
}
|
||||
|
||||
// Fetch star count
|
||||
stats, err := db.GetRepositoryStats(h.DB, owner.DID, repository)
|
||||
if err != nil {
|
||||
|
||||
@@ -251,3 +251,20 @@ func TestGetCacheStats(t *testing.T) {
|
||||
t.Errorf("Expected unreachable=1, got %v", stats["unreachable"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWorkerWithStartupDelay(t *testing.T) {
|
||||
checker := NewChecker(15 * time.Minute)
|
||||
|
||||
// Test NewWorker (no delay)
|
||||
worker := NewWorker(checker, nil, 5*time.Minute)
|
||||
if worker.startupDelay != 0 {
|
||||
t.Errorf("Expected startupDelay=0 for NewWorker, got %v", worker.startupDelay)
|
||||
}
|
||||
|
||||
// Test NewWorkerWithStartupDelay
|
||||
startupDelay := 5 * time.Second
|
||||
workerWithDelay := NewWorkerWithStartupDelay(checker, nil, 5*time.Minute, startupDelay)
|
||||
if workerWithDelay.startupDelay != startupDelay {
|
||||
t.Errorf("Expected startupDelay=%v, got %v", startupDelay, workerWithDelay.startupDelay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type Worker struct {
|
||||
cleanupTicker *time.Ticker
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
startupDelay time.Duration
|
||||
}
|
||||
|
||||
// NewWorker creates a new background worker
|
||||
@@ -32,6 +33,19 @@ func NewWorker(checker *Checker, db DBQuerier, refreshInterval time.Duration) *W
|
||||
refreshTicker: time.NewTicker(refreshInterval),
|
||||
cleanupTicker: time.NewTicker(30 * time.Minute), // Cleanup every 30 minutes
|
||||
stopChan: make(chan struct{}),
|
||||
startupDelay: 0, // No delay by default for backward compatibility
|
||||
}
|
||||
}
|
||||
|
||||
// NewWorkerWithStartupDelay creates a new background worker with a startup delay
|
||||
func NewWorkerWithStartupDelay(checker *Checker, db DBQuerier, refreshInterval, startupDelay time.Duration) *Worker {
|
||||
return &Worker{
|
||||
checker: checker,
|
||||
db: db,
|
||||
refreshTicker: time.NewTicker(refreshInterval),
|
||||
cleanupTicker: time.NewTicker(30 * time.Minute), // Cleanup every 30 minutes
|
||||
stopChan: make(chan struct{}),
|
||||
startupDelay: startupDelay,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +57,19 @@ func (w *Worker) Start(ctx context.Context) {
|
||||
|
||||
log.Println("Hold health worker: Starting background health checks")
|
||||
|
||||
// Perform initial check immediately
|
||||
// Wait for services to be ready (Docker startup race condition)
|
||||
if w.startupDelay > 0 {
|
||||
log.Printf("Hold health worker: Waiting %s for services to be ready...", w.startupDelay)
|
||||
select {
|
||||
case <-time.After(w.startupDelay):
|
||||
// Continue with initial check
|
||||
case <-ctx.Done():
|
||||
log.Println("Hold health worker: Context cancelled during startup delay")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Perform initial check
|
||||
w.refreshAllHolds(ctx)
|
||||
|
||||
for {
|
||||
|
||||
Reference in New Issue
Block a user