mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-27 10:03:06 +01:00
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.
This commit is contained in:
@@ -25,4 +25,7 @@ COPY --from=builder /build/schemas/ ./schemas/
|
|||||||
|
|
||||||
USER erupe
|
USER erupe
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["./erupe-ce"]
|
ENTRYPOINT ["./erupe-ce"]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ services:
|
|||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
|
start_period: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
pgadmin:
|
pgadmin:
|
||||||
image: dpage/pgadmin4
|
image: dpage/pgadmin4
|
||||||
@@ -59,3 +60,9 @@ services:
|
|||||||
- "54006:54006"
|
- "54006:54006"
|
||||||
- "54007:54007"
|
- "54007:54007"
|
||||||
- "54008:54008"
|
- "54008:54008"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 15s
|
||||||
|
retries: 3
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Config struct {
|
|||||||
type APIServer struct {
|
type APIServer struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
db *sqlx.DB
|
||||||
erupeConfig *cfg.Config
|
erupeConfig *cfg.Config
|
||||||
userRepo APIUserRepo
|
userRepo APIUserRepo
|
||||||
charRepo APICharacterRepo
|
charRepo APICharacterRepo
|
||||||
@@ -38,6 +39,7 @@ type APIServer struct {
|
|||||||
func NewAPIServer(config *Config) *APIServer {
|
func NewAPIServer(config *Config) *APIServer {
|
||||||
s := &APIServer{
|
s := &APIServer{
|
||||||
logger: config.Logger,
|
logger: config.Logger,
|
||||||
|
db: config.DB,
|
||||||
erupeConfig: config.ErupeConfig,
|
erupeConfig: config.ErupeConfig,
|
||||||
httpServer: &http.Server{},
|
httpServer: &http.Server{},
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,7 @@ func (s *APIServer) Start() error {
|
|||||||
r.HandleFunc("/character/export", s.ExportSave)
|
r.HandleFunc("/character/export", s.ExportSave)
|
||||||
r.HandleFunc("/api/ss/bbs/upload.php", s.ScreenShot)
|
r.HandleFunc("/api/ss/bbs/upload.php", s.ScreenShot)
|
||||||
r.HandleFunc("/api/ss/bbs/{id}", s.ScreenShotGet)
|
r.HandleFunc("/api/ss/bbs/{id}", s.ScreenShotGet)
|
||||||
|
r.HandleFunc("/health", s.Health)
|
||||||
r.HandleFunc("/version", s.Version)
|
r.HandleFunc("/version", s.Version)
|
||||||
handler := handlers.CORS(handlers.AllowedHeaders([]string{"Content-Type"}))(r)
|
handler := handlers.CORS(handlers.AllowedHeaders([]string{"Content-Type"}))(r)
|
||||||
s.httpServer.Handler = handlers.LoggingHandler(os.Stdout, handler)
|
s.httpServer.Handler = handlers.LoggingHandler(os.Stdout, handler)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
@@ -443,3 +444,30 @@ func (s *APIServer) ScreenShot(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeResult("200")
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// BenchmarkLauncherEndpoint benchmarks the launcher endpoint
|
||||||
func BenchmarkLauncherEndpoint(b *testing.B) {
|
func BenchmarkLauncherEndpoint(b *testing.B) {
|
||||||
logger, _ := zap.NewDevelopment()
|
logger, _ := zap.NewDevelopment()
|
||||||
|
|||||||
Reference in New Issue
Block a user