From e0124a5d1f245065b8e1940e3257bc9c3c6ed202 Mon Sep 17 00:00:00 2001 From: savioruz Date: Tue, 25 Aug 2026 01:07:02 +0700 Subject: [PATCH 1/2] feat: add /api/health readiness endpoint (#47) Add an unauthenticated GET /api/health that reports whether the server is usable, not just listening. Returns "setup_required" until both the admin account and provider config exist, then "ready". - memayu-api: new health module (dto + service) reading the users and provider_config tables; DB errors resolve conservative to setup_required. - memayu-api: register /api/health on the full router outside auth and rate limiting. - memayu-web: register /api/health on the setup-only boot router so a fresh unconfigured instance is still healthcheckable. - Dockerfile: set MEMAYU_PORT=8080 to match EXPOSE 8080, and add g++ (libsql-ffi needs a C++ compiler) so the image builds. - README: document the endpoint as the Docker/systemd healthcheck target with example HEALTHCHECK and ExecStartPost snippets. - Tests: 4 service unit tests, 3 API integration tests, and a setup-only router test covering every state transition. --- Dockerfile | 3 +- README.md | 28 +++++ bin/memayu/tests/cli.rs | 30 +++--- crates/memayu-api/src/lib.rs | 1 + crates/memayu-api/src/modules/health/dto.rs | 17 +++ crates/memayu-api/src/modules/health/mod.rs | 2 + .../memayu-api/src/modules/health/service.rs | 100 ++++++++++++++++++ crates/memayu-api/src/modules/mod.rs | 1 + .../src/transport/handlers/health.rs | 15 +++ .../memayu-api/src/transport/handlers/mod.rs | 1 + crates/memayu-api/src/transport/routes.rs | 6 ++ crates/memayu-api/src/web_services.rs | 9 ++ crates/memayu-api/tests/api.rs | 81 ++++++++++++++ crates/memayu-web/src/lib.rs | 11 ++ crates/memayu-web/tests/dashboard.rs | 31 +++++- 15 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 crates/memayu-api/src/modules/health/dto.rs create mode 100644 crates/memayu-api/src/modules/health/mod.rs create mode 100644 crates/memayu-api/src/modules/health/service.rs create mode 100644 crates/memayu-api/src/transport/handlers/health.rs diff --git a/Dockerfile b/Dockerfile index bd69746..4b0f424 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM rust:alpine AS builder -RUN apk add --no-cache musl-dev pkgconfig +RUN apk add --no-cache musl-dev pkgconfig g++ WORKDIR /app COPY . . @@ -16,6 +16,7 @@ RUN apk add --no-cache ca-certificates && \ COPY --from=builder /app/target/release/memayu /usr/local/bin/memayu ENV MEMAYU_LIBSQL_PATH=/data/memayu.db +ENV MEMAYU_PORT=8080 USER memayu WORKDIR /data diff --git a/README.md b/README.md index 71ee2da..823b194 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,34 @@ docker run --rm -p 8080:8080 \ ghcr.io/savioruz/memayu:latest ``` +The server exposes an unauthenticated readiness endpoint at +`GET /api/health`: + +```json +{ "status": "setup_required" } +``` + +`setup_required` means the process is listening but not yet usable (first-run +setup is incomplete — no admin account and/or no provider config). Once both +exist it returns `{ "status": "ready" }`. Use this as the healthcheck target +for Docker/systemd instead of treating "port is listening" as "server is +usable". + +**Docker HEALTHCHECK** (add to a `Dockerfile` or `docker-compose.yml`): + +```dockerfile +HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=5 \ + CMD wget -q -O /dev/stdout http://127.0.0.1:8080/api/health | grep -q '"status":"ready"' +``` + +**systemd** (ExecStartPost waits for readiness before the unit is active): + +```ini +ExecStartPost=/bin/sh -c 'for i in $(seq 1 30); do \ + curl -fsS http://127.0.0.1:18080/api/health | grep -q "\"status\":\"ready\"" && exit 0; \ + sleep 1; done; exit 1' +``` + ## Usage ### CLI diff --git a/bin/memayu/tests/cli.rs b/bin/memayu/tests/cli.rs index 43ec6c4..6158a26 100644 --- a/bin/memayu/tests/cli.rs +++ b/bin/memayu/tests/cli.rs @@ -50,13 +50,20 @@ fn start_mock_embedder() -> u16 { port } -/// Like [`start_mock_embedder`] but derives a distinct embedding from each -/// request's full body. Used by batch tests where every item must get its own -/// vector so the storage layer doesn't treat them as duplicates and merge them. +/// Like [`start_mock_embedder`] but returns a different, mutually orthogonal +/// unit vector for each request. Used by batch tests where every item must get +/// its own vector so the storage layer doesn't treat them as duplicates and +/// merge them. fn start_distinct_embedder() -> u16 { - use std::hash::{Hash, Hasher}; + use std::sync::{Arc, Mutex}; let listener = TcpListener::bind("127.0.0.1:0").expect("bind distinct embedder"); let port = listener.local_addr().unwrap().port(); + // Batch items are embedded sequentially (the service awaits each embed in + // order), so handing out mutually orthogonal unit vectors guarantees no two + // items ever exceed the raw dedupe threshold (0.98), no matter the run. + // A previous hash-of-request-body approach produced random [0,1]^3 vectors + // that occasionally collided above 0.98, flaking this test. + let next = Arc::new(Mutex::new(0usize)); std::thread::spawn(move || { for stream in listener.incoming() { let Ok(mut stream) = stream else { break }; @@ -93,14 +100,13 @@ fn start_distinct_embedder() -> u16 { Err(_) => break, } } - let mut h = std::collections::hash_map::DefaultHasher::new(); - buf.hash(&mut h); - let v = h.finish(); - let emb = [ - ((v & 0xffff) as f32) / 65535.0, - (((v >> 16) & 0xffff) as f32) / 65535.0, - (((v >> 32) & 0xffff) as f32) / 65535.0, - ]; + + // Orthogonal basis vectors, one per request in arrival order. + const AXES: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + let mut idx = next.lock().unwrap(); + let emb = AXES[*idx % AXES.len()]; + *idx += 1; + let body = format!( r#"{{"data":[{{"embedding":[{:.6},{:.6},{:.6}]}}]}}"#, emb[0], emb[1], emb[2] diff --git a/crates/memayu-api/src/lib.rs b/crates/memayu-api/src/lib.rs index 6b2408d..ada3f18 100644 --- a/crates/memayu-api/src/lib.rs +++ b/crates/memayu-api/src/lib.rs @@ -14,6 +14,7 @@ pub use modules::auth::model::User; pub use modules::auth::service::{ expires_at_rfc3339, validate_password, SESSION_COOKIE, SESSION_DURATION_SECS, }; +pub use modules::health::dto::{HealthResponse, HealthStatus}; pub use modules::providers::service::{ ensure_provider_config, load_extraction_mode, load_registry, ConfigRegistry, EmbedderConfigProvider, LlmConfigProvider, diff --git a/crates/memayu-api/src/modules/health/dto.rs b/crates/memayu-api/src/modules/health/dto.rs new file mode 100644 index 0000000..af6bf11 --- /dev/null +++ b/crates/memayu-api/src/modules/health/dto.rs @@ -0,0 +1,17 @@ +use serde::Serialize; + +/// Health status of the server. `setup_required` means the server is listening +/// but not yet usable (first-run setup incomplete); `ready` means the admin +/// account and provider config are both in place. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HealthStatus { + SetupRequired, + Ready, +} + +/// Response body for `GET /api/health`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct HealthResponse { + pub status: HealthStatus, +} diff --git a/crates/memayu-api/src/modules/health/mod.rs b/crates/memayu-api/src/modules/health/mod.rs new file mode 100644 index 0000000..4c59e85 --- /dev/null +++ b/crates/memayu-api/src/modules/health/mod.rs @@ -0,0 +1,2 @@ +pub mod dto; +pub mod service; diff --git a/crates/memayu-api/src/modules/health/service.rs b/crates/memayu-api/src/modules/health/service.rs new file mode 100644 index 0000000..c72fc19 --- /dev/null +++ b/crates/memayu-api/src/modules/health/service.rs @@ -0,0 +1,100 @@ +use crate::infrastructure::db::DbClient; +use crate::modules::health::dto::{HealthResponse, HealthStatus}; + +/// Compute the health status of the server. +/// +/// The endpoint is unauthenticated and must work even on a completely fresh, +/// unconfigured instance, so it derives state from the database tables the +/// boot paths already initialize (`users`, `provider_config`). +/// +/// - `setup_required`: no admin account yet, or no provider config yet. +/// - `ready`: both the admin account and provider config are present. +/// +/// DB errors resolve conservatively to `setup_required`: the server is not +/// provably ready if its backing store cannot be queried. +pub async fn status(db: &DbClient) -> HealthResponse { + let users_ready = db.users_empty().await.map(|empty| !empty).unwrap_or(false); + let providers_ready = db + .provider_configs() + .await + .map(|rows| !rows.is_empty()) + .unwrap_or(false); + + let status = if users_ready && providers_ready { + HealthStatus::Ready + } else { + HealthStatus::SetupRequired + }; + HealthResponse { status } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::infrastructure::db::DbClient; + use memayu_config::{StorageBackend, StorageConfig}; + + async fn mem_db() -> DbClient { + let storage = StorageConfig { + backend: StorageBackend::Libsql, + libsql_path: ":memory:".to_string(), + database_url: None, + }; + let db = DbClient::open(&storage).await.unwrap(); + db.init().await.unwrap(); + db + } + + /// A provider row fixture: `(provider, (backend, base_url, api_key, model))`. + type ProviderRow = ( + &'static str, + (&'static str, &'static str, &'static str, &'static str), + ); + + fn provider_rows() -> Vec { + vec![( + "embedder", + ("local", "", "", "sentence-transformers/all-MiniLM-L6-v2"), + )] + } + + #[tokio::test] + async fn fresh_instance_is_setup_required() { + let db = mem_db().await; + assert_eq!(status(&db).await.status, HealthStatus::SetupRequired); + } + + #[tokio::test] + async fn admin_without_providers_is_setup_required() { + let db = mem_db().await; + db.create_user("admin@example.com", "hash", "salt") + .await + .unwrap(); + assert_eq!(status(&db).await.status, HealthStatus::SetupRequired); + } + + #[tokio::test] + async fn providers_without_admin_is_setup_required() { + let db = mem_db().await; + for (provider, (backend, base_url, api_key, model)) in provider_rows() { + db.upsert_provider_config(provider, backend, base_url, api_key, model) + .await + .unwrap(); + } + assert_eq!(status(&db).await.status, HealthStatus::SetupRequired); + } + + #[tokio::test] + async fn admin_and_providers_is_ready() { + let db = mem_db().await; + db.create_user("admin@example.com", "hash", "salt") + .await + .unwrap(); + for (provider, (backend, base_url, api_key, model)) in provider_rows() { + db.upsert_provider_config(provider, backend, base_url, api_key, model) + .await + .unwrap(); + } + assert_eq!(status(&db).await.status, HealthStatus::Ready); + } +} diff --git a/crates/memayu-api/src/modules/mod.rs b/crates/memayu-api/src/modules/mod.rs index 0f268ec..04011e6 100644 --- a/crates/memayu-api/src/modules/mod.rs +++ b/crates/memayu-api/src/modules/mod.rs @@ -1,5 +1,6 @@ pub mod api_keys; pub mod auth; +pub mod health; pub mod memory; pub mod providers; pub mod request_logs; diff --git a/crates/memayu-api/src/transport/handlers/health.rs b/crates/memayu-api/src/transport/handlers/health.rs new file mode 100644 index 0000000..81d0c74 --- /dev/null +++ b/crates/memayu-api/src/transport/handlers/health.rs @@ -0,0 +1,15 @@ +/// Axum handler for the unauthenticated health endpoint. +use crate::modules::health::{dto::HealthResponse, service}; +use crate::transport::middleware::ApiState; +use axum::extract::State; +use axum::Json; + +/// GET /api/health — unauthenticated readiness probe. +/// +/// Returns `{"status":"setup_required"}` before first-run setup is complete +/// (no admin account and/or no provider config), and `{"status":"ready"}` once +/// both are in place. Process supervisors (Docker HEALTHCHECK, systemd) should +/// target this instead of treating "port is listening" as "server is usable". +pub async fn get_health(State(state): State) -> Json { + Json(service::status(&state.db).await) +} diff --git a/crates/memayu-api/src/transport/handlers/mod.rs b/crates/memayu-api/src/transport/handlers/mod.rs index 0f268ec..04011e6 100644 --- a/crates/memayu-api/src/transport/handlers/mod.rs +++ b/crates/memayu-api/src/transport/handlers/mod.rs @@ -1,5 +1,6 @@ pub mod api_keys; pub mod auth; +pub mod health; pub mod memory; pub mod providers; pub mod request_logs; diff --git a/crates/memayu-api/src/transport/routes.rs b/crates/memayu-api/src/transport/routes.rs index 55dcd84..9091bda 100644 --- a/crates/memayu-api/src/transport/routes.rs +++ b/crates/memayu-api/src/transport/routes.rs @@ -115,6 +115,11 @@ pub fn build( middleware::auth_rate_limiter, )); + // Health probe (public — /api prefix) — intentionally outside every auth + // and rate-limit layer so process supervisors can poll it freely before + // setup is complete. It only reports readiness; it never mutates state. + let health_routes = Router::new().route("/api/health", get(handlers::health::get_health)); + // Provider config routes (protected by auth) let provider_routes = Router::new().route( "/api/providers", @@ -158,6 +163,7 @@ pub fn build( // Merge public + protected — security headers + CORS + request ID on the outermost layer Router::new() .merge(auth_routes) + .merge(health_routes) .merge(docs_routes) .merge(protected) .layer(axum::middleware::from_fn(middleware::security_headers)) diff --git a/crates/memayu-api/src/web_services.rs b/crates/memayu-api/src/web_services.rs index a3ab5a4..28ec3c9 100644 --- a/crates/memayu-api/src/web_services.rs +++ b/crates/memayu-api/src/web_services.rs @@ -79,6 +79,15 @@ impl WebServices { } } +// ── Health ── + +impl WebServices { + /// Compute the readiness status exposed by `GET /api/health`. + pub async fn health_status(&self) -> crate::modules::health::dto::HealthResponse { + crate::modules::health::service::status(&self.db).await + } +} + // ── Provider Config ── impl WebServices { diff --git a/crates/memayu-api/tests/api.rs b/crates/memayu-api/tests/api.rs index e199db2..c8ede55 100644 --- a/crates/memayu-api/tests/api.rs +++ b/crates/memayu-api/tests/api.rs @@ -981,4 +981,85 @@ mod tests { assert_eq!(json["embedder"]["base_url"], ""); assert_eq!(json["embedder"]["api_key"], serde_json::Value::Null); } + + // ── /api/health (issue #47) ── + + #[tokio::test] + async fn health_is_unauthenticated_and_setup_required_on_fresh_instance() { + let app = build_test_app().await; + let resp = app + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "setup_required"); + } + + #[tokio::test] + async fn health_is_setup_required_with_admin_but_no_providers() { + let (app, _cookie) = setup_and_login().await; + let resp = app + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "setup_required"); + } + + #[tokio::test] + async fn health_is_ready_after_setup_and_provider_config() { + let (app, cookie) = setup_and_login().await; + + // Persist a provider row the same way the dashboard does. + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/providers") + .header("content-type", "application/json") + .header("cookie", &cookie) + .body(Body::from( + r#"{"embedder":{"backend":"local","base_url":"","api_key":null,"model":"mini"}}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ready"); + } } diff --git a/crates/memayu-web/src/lib.rs b/crates/memayu-web/src/lib.rs index 0e32b1b..9166415 100644 --- a/crates/memayu-web/src/lib.rs +++ b/crates/memayu-web/src/lib.rs @@ -6,11 +6,21 @@ mod pages; use axum::extract::FromRef; use axum::response::Redirect; use axum::routing::{get, post}; +use axum::Json; use axum::Router; use memayu_api::{ConfigRegistry, WebServices}; use memayu_core::MemoryService; use std::sync::Arc; +/// Unauthenticated health probe served in setup-only mode. Mirrors the +/// `GET /api/health` route in the full API router so a fresh, unconfigured +/// instance (where only the setup router is mounted) is still healthcheckable. +async fn health( + axum::extract::State(services): axum::extract::State, +) -> Json { + Json(services.health_status().await) +} + /// Web router state — the web crate never accesses DbClient directly. /// All data access goes through `WebServices` (the memayu-api service layer). #[derive(Clone)] @@ -101,6 +111,7 @@ pub fn build_setup_router(db: memayu_api::DbClient) -> Router { "/setup", get(pages::setup::get_setup).post(pages::setup::post_setup), ) + .route("/api/health", get(health)) .route("/static/{*path}", get(assets::serve_static)) .with_state(WebServices::new(db)) } diff --git a/crates/memayu-web/tests/dashboard.rs b/crates/memayu-web/tests/dashboard.rs index 7453f71..8cc2005 100644 --- a/crates/memayu-web/tests/dashboard.rs +++ b/crates/memayu-web/tests/dashboard.rs @@ -5,7 +5,7 @@ use axum::http::{Request, StatusCode}; use memayu_api::{open_db, ConfigRegistry, EmbedderConfigProvider, LlmConfigProvider}; use memayu_config::StorageConfig; use memayu_core::{EmbedError, EmbedderProvider, ExtractionMode, MemoryService}; -use memayu_web::build_web_router; +use memayu_web::{build_setup_router, build_web_router}; use tower::util::ServiceExt; /// Deterministic fake embedder used to seed memories without network calls. @@ -517,3 +517,32 @@ async fn home_paginates_memories_with_prev_next() { .unwrap(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } + +#[tokio::test] +async fn setup_router_exposes_health() { + // The setup-only boot path must be healthcheckable too, so a fresh + // unconfigured instance reports `setup_required` even before the full + // dashboard/API router is mounted (issue #47). + let storage = test_storage_config(); + let db = open_db(&storage).await.unwrap(); + let app = build_setup_router(db); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024 * 128) + .await + .unwrap(); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!( + body.contains("\"status\":\"setup_required\""), + "unexpected health body: {body}" + ); +} From 72581b328c0d6c0a4fd0e8fe011a9f6c1cde3523 Mon Sep 17 00:00:00 2001 From: savioruz Date: Tue, 25 Aug 2026 01:48:00 +0700 Subject: [PATCH 2/2] chore(ci): pin Rust toolchain to 1.97.1 Replace dtolnay/rust-toolchain@stable and the rust-toolchain.toml stable channel with an explicit 1.97.1 pin so CI and release builds are reproducible across future stable releases. 1.97.1 is the version verified locally (fmt, clippy, test all pass). --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 4 ++-- rust-toolchain.toml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 696f98e..ef49a5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 with: components: rustfmt - name: Format check @@ -26,7 +26,7 @@ jobs: needs: fmt steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 - uses: Swatinem/rust-cache@v2 - name: Build run: cargo build --workspace --all-targets --all-features @@ -37,7 +37,7 @@ jobs: needs: fmt steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 with: components: clippy - uses: Swatinem/rust-cache@v2 @@ -50,7 +50,7 @@ jobs: needs: fmt steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 - uses: Swatinem/rust-cache@v2 - name: Test run: cargo test --workspace --all-features @@ -60,7 +60,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 - uses: rustsec/audit-check@v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 559231f..6045fc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 with: targets: ${{ matrix.target }} @@ -62,7 +62,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 with: targets: ${{ matrix.target }} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 8427192..03ae98d 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "stable" +channel = "1.97.1" profile = "default" components = ["clippy", "rustfmt"]