Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 }}
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down Expand Up @@ -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 }}

Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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 . .
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 18 additions & 12 deletions bin/memayu/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions crates/memayu-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions crates/memayu-api/src/modules/health/dto.rs
Original file line number Diff line number Diff line change
@@ -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,
}
2 changes: 2 additions & 0 deletions crates/memayu-api/src/modules/health/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod dto;
pub mod service;
100 changes: 100 additions & 0 deletions crates/memayu-api/src/modules/health/service.rs
Original file line number Diff line number Diff line change
@@ -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<ProviderRow> {
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);
}
}
1 change: 1 addition & 0 deletions crates/memayu-api/src/modules/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod api_keys;
pub mod auth;
pub mod health;
pub mod memory;
pub mod providers;
pub mod request_logs;
15 changes: 15 additions & 0 deletions crates/memayu-api/src/transport/handlers/health.rs
Original file line number Diff line number Diff line change
@@ -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<ApiState>) -> Json<HealthResponse> {
Json(service::status(&state.db).await)
}
1 change: 1 addition & 0 deletions crates/memayu-api/src/transport/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod api_keys;
pub mod auth;
pub mod health;
pub mod memory;
pub mod providers;
pub mod request_logs;
6 changes: 6 additions & 0 deletions crates/memayu-api/src/transport/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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))
Expand Down
9 changes: 9 additions & 0 deletions crates/memayu-api/src/web_services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading