From a72ac43f1d21ffc2039adf38108631c6150f74f7 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 23 Feb 2026 20:34:20 +0100 Subject: [PATCH] feat(api): add /health endpoint with Docker healthchecks Allow Docker to distinguish a running container from one actually serving traffic by adding a /health endpoint that pings the database. Returns 200 when healthy, 503 when the DB connection is lost. Add HEALTHCHECK to Dockerfile and healthcheck config to the server service in docker-compose.yml. Also add start_period to the existing db healthcheck for consistency. --- Dockerfile | 3 +++ docker/docker-compose.yml | 7 +++++++ server/api/api_server.go | 3 +++ server/api/endpoints.go | 28 ++++++++++++++++++++++++++ server/api/endpoints_test.go | 38 ++++++++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+) diff --git a/Dockerfile b/Dockerfile index 6aa0185c4..59424bc2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,4 +25,7 @@ COPY --from=builder /build/schemas/ ./schemas/ USER erupe +HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \ + CMD wget -qO- http://localhost:8080/health || exit 1 + ENTRYPOINT ["./erupe-ce"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 97e1d5ec7..96578f738 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -20,6 +20,7 @@ services: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s + start_period: 5s retries: 5 pgadmin: image: dpage/pgadmin4 @@ -59,3 +60,9 @@ services: - "54006:54006" - "54007:54007" - "54008:54008" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] + interval: 10s + timeout: 3s + start_period: 15s + retries: 3 diff --git a/server/api/api_server.go b/server/api/api_server.go index ea048a13c..424516efc 100644 --- a/server/api/api_server.go +++ b/server/api/api_server.go @@ -26,6 +26,7 @@ type Config struct { type APIServer struct { sync.Mutex logger *zap.Logger + db *sqlx.DB erupeConfig *cfg.Config userRepo APIUserRepo charRepo APICharacterRepo @@ -38,6 +39,7 @@ type APIServer struct { func NewAPIServer(config *Config) *APIServer { s := &APIServer{ logger: config.Logger, + db: config.DB, erupeConfig: config.ErupeConfig, httpServer: &http.Server{}, } @@ -61,6 +63,7 @@ func (s *APIServer) Start() error { r.HandleFunc("/character/export", s.ExportSave) r.HandleFunc("/api/ss/bbs/upload.php", s.ScreenShot) r.HandleFunc("/api/ss/bbs/{id}", s.ScreenShotGet) + r.HandleFunc("/health", s.Health) r.HandleFunc("/version", s.Version) handler := handlers.CORS(handlers.AllowedHeaders([]string{"Content-Type"}))(r) s.httpServer.Handler = handlers.LoggingHandler(os.Stdout, handler) diff --git a/server/api/endpoints.go b/server/api/endpoints.go index 25dfb68e9..598241d1a 100644 --- a/server/api/endpoints.go +++ b/server/api/endpoints.go @@ -1,6 +1,7 @@ package api import ( + "context" "database/sql" "encoding/json" "encoding/xml" @@ -443,3 +444,30 @@ func (s *APIServer) ScreenShot(w http.ResponseWriter, r *http.Request) { writeResult("200") } + +// Health handles GET /health, returning the server's health status. +// It pings the database to verify connectivity. +func (s *APIServer) Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if s.db == nil { + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "unhealthy", + "error": "database not configured", + }) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + if err := s.db.PingContext(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "unhealthy", + "error": err.Error(), + }) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + }) +} diff --git a/server/api/endpoints_test.go b/server/api/endpoints_test.go index 1e172faab..80f77f508 100644 --- a/server/api/endpoints_test.go +++ b/server/api/endpoints_test.go @@ -572,6 +572,44 @@ func TestNewAuthDataTimestamps(t *testing.T) { } } +// TestHealthEndpointNoDB tests the /health endpoint when no database is configured. +func TestHealthEndpointNoDB(t *testing.T) { + logger := NewTestLogger(t) + defer func() { _ = logger.Sync() }() + + server := &APIServer{ + logger: logger, + erupeConfig: NewTestConfig(), + db: nil, + } + + req := httptest.NewRequest("GET", "/health", nil) + recorder := httptest.NewRecorder() + + server.Health(recorder, req) + + if recorder.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status %d, got %d", http.StatusServiceUnavailable, recorder.Code) + } + + if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json" { + t.Errorf("Content-Type = %v, want application/json", contentType) + } + + var resp map[string]string + if err := json.NewDecoder(recorder.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if resp["status"] != "unhealthy" { + t.Errorf("status = %q, want %q", resp["status"], "unhealthy") + } + + if resp["error"] != "database not configured" { + t.Errorf("error = %q, want %q", resp["error"], "database not configured") + } +} + // BenchmarkLauncherEndpoint benchmarks the launcher endpoint func BenchmarkLauncherEndpoint(b *testing.B) { logger, _ := zap.NewDevelopment()