From 03aac93db36166b72ca957c2dd5b87b9d7f34249 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 11 May 2026 00:22:03 +0200 Subject: [PATCH] chore: add /status /ready best practices for Docker & K8S --- Dockerfile | 4 ++-- docker-compose.yml | 6 ++++++ src/interfaces/api/mod.rs | 1 + src/interfaces/api/routes.rs | 42 ++++++++++++++++++++++++++++++++++-- src/interfaces/mod.rs | 1 + src/main.rs | 9 +++++++- 6 files changed, 58 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 495c9b38..8caf1479 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,10 +67,10 @@ WORKDIR /app # Expose application port EXPOSE 8086 -# Basic health check — verifies the HTTP server responds on the main port. +# Liveness probe — verifies the HTTP server is up (no DB check, fast). # Docker / Compose / Swarm will mark the container unhealthy after 3 failures. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -qO- http://localhost:8086/api/version || exit 1 + CMD wget -qO- http://localhost:8086/health || exit 1 # Entrypoint fixes volume permissions then drops to oxicloud user. # The container starts as root so it can chown mounted volumes, diff --git a/docker-compose.yml b/docker-compose.yml index 0fe491d9..4a1d3353 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,12 @@ services: - .env volumes: - storage_data:/app/storage + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8086/ready || exit 1"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 3 networks: oxicloud: diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 3f0b07c9..c3dc610e 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -4,6 +4,7 @@ pub mod handlers; pub mod routes; pub use routes::create_api_routes; +pub use routes::create_health_routes; pub use routes::create_public_api_routes; use utoipa::OpenApi; diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index b070bf88..17de42e3 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -2,8 +2,9 @@ use crate::application::services::batch_operations::BatchOperationService; use crate::common::di::AppState; use axum::{ Router, - extract::DefaultBodyLimit, - response::Json as AxumJson, + extract::{DefaultBodyLimit, State}, + http::StatusCode, + response::{IntoResponse, Json as AxumJson}, routing::{delete, get, post, put}, }; use serde_json::json; @@ -11,6 +12,31 @@ use std::sync::Arc; use tower_http::{compression::CompressionLayer, trace::TraceLayer}; use utoipa::OpenApi; +/// Liveness probe — returns 200 if the process is running, no DB check. +async fn health() -> impl IntoResponse { + (StatusCode::OK, AxumJson(json!({"status": "ok"}))) +} + +/// Readiness probe — returns 200 if the DB pool can serve queries, 503 otherwise. +async fn ready(State(state): State>) -> impl IntoResponse { + match &state.db_pool { + Some(pool) => match sqlx::query("SELECT 1").execute(pool.as_ref()).await { + Ok(_) => ( + StatusCode::OK, + AxumJson(json!({"status": "ok", "db": "ok"})), + ), + Err(_) => ( + StatusCode::SERVICE_UNAVAILABLE, + AxumJson(json!({"status": "error", "db": "error"})), + ), + }, + None => ( + StatusCode::SERVICE_UNAVAILABLE, + AxumJson(json!({"status": "error", "db": "not configured"})), + ), + } +} + /// Returns the application version from Cargo.toml (compile-time constant) async fn get_version() -> AxumJson { AxumJson(json!({ @@ -45,6 +71,18 @@ use crate::interfaces::api::handlers::search_handler::{ }; use crate::interfaces::api::handlers::trash_handler; +/// Creates root-level health check routes — mounted directly at `/`, not under `/api/`. +/// (follow docker/kubernetes best practices) +/// +/// - `GET /health` — liveness probe, no DB check, always 200 if process is up. +/// - `GET /ready` — readiness probe, pings DB pool, returns 503 if unreachable. +pub fn create_health_routes(app_state: &Arc) -> Router> { + Router::new() + .route("/health", get(health)) + .route("/ready", get(ready)) + .with_state(app_state.clone()) +} + /// Creates public API routes that should NOT require authentication. pub fn create_public_api_routes(app_state: &Arc) -> Router> { let share_service = app_state.share_service.clone(); diff --git a/src/interfaces/mod.rs b/src/interfaces/mod.rs index 5fa958f5..1b7e6d78 100644 --- a/src/interfaces/mod.rs +++ b/src/interfaces/mod.rs @@ -5,4 +5,5 @@ pub mod nextcloud; pub mod web; pub use api::create_api_routes; +pub use api::create_health_routes; pub use api::create_public_api_routes; diff --git a/src/main.rs b/src/main.rs index e0ffeb4a..bfef6ae8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,7 +50,9 @@ use oxicloud::interfaces; use common::di::AppServiceFactory; use infrastructure::db::create_database_pools; -use interfaces::{create_api_routes, create_public_api_routes, web::create_web_routes}; +use interfaces::{ + create_api_routes, create_health_routes, create_public_api_routes, web::create_web_routes, +}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -115,6 +117,7 @@ async fn main() -> Result<(), Box> { // Build application router let api_routes = create_api_routes(&app_state); let public_api_routes = create_public_api_routes(&app_state); + let health_routes = create_health_routes(&app_state); let web_routes = create_web_routes(); let mut app; @@ -319,6 +322,8 @@ async fn main() -> Result<(), Box> { )); app = Router::new() + // Health / readiness probes — no auth, mounted at root + .merge(health_routes) // Rate-limited auth endpoints (login, register, refresh) .nest("/api/auth", auth_login) .nest("/api/auth", auth_register) @@ -375,6 +380,8 @@ async fn main() -> Result<(), Box> { // Auth disabled — no middleware applied tracing::warn!("Authentication is DISABLED — all API routes are publicly accessible"); app = Router::new() + // Health / readiness probes — no auth, mounted at root + .merge(health_routes) .nest("/api", public_api_routes) .nest("/api", api_routes) // RFC 6764 well-known discovery (just redirects)