cellos-server 0.5.1

HTTP control plane for CellOS — admission, projection over JetStream, WebSocket fan-out of CloudEvents. Pure event-sourced architecture.
Documentation
//! `/v1/cells` — read-only projection over JetStream cell events.
//!
//! ARCH-001: until this projector landed, the server saw cell events on
//! `cellos.events.>` (`events_seen=14, events_applied=5` in the wave-1
//! e2e report — the missed nine were every supervisor-emitted cell
//! event) but never updated `state.cells`, so `cellctl get cells` always
//! returned `[]` regardless of supervisor activity. The projector lives
//! in `state::AppState::apply_cell_event`; these handlers are the read
//! surface clients hit.
//!
//! Filters
//! -------
//! - `?state=<running|destroyed|pending>` — exact-match on `CellState`
//!   (case-insensitive). Unknown values produce a 400.
//! - `?formation=<id-or-name>` — reserved for when the supervisor emits a
//!   `formationId` on `Correlation`; today the supervisor's CloudEvents
//!   do not carry that field, so this filter only matches cells whose
//!   `formation_id` happens to be populated (currently: none). We accept
//!   the param without 400 so the cellctl wire shape can stabilise now.
//! - `?limit=<n>` — clamp returned-row count. Default unlimited, max 1000.

use axum::extract::{Path, Query, State};
use axum::http::HeaderMap;
use axum::Json;
use serde::Deserialize;

use crate::auth::require_bearer;
use crate::error::{AppError, AppErrorKind};
use crate::state::{AppState, CellRecord, CellState};

/// Query string parameters accepted by `GET /v1/cells`. All optional.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListCellsParams {
    /// `running`, `destroyed`, or `pending` (case-insensitive).
    pub state: Option<String>,
    /// Formation id or name. Matched against `CellRecord::formation_id`
    /// when populated. Reserved — see module docs.
    pub formation: Option<String>,
    /// Max rows to return. Hard-capped at 1000 to keep the response size
    /// bounded.
    pub limit: Option<usize>,
}

/// Upper bound on `?limit`. Picked at 4x the highest cell count we've
/// observed in an e2e formation (~250) so operator-side `cellctl get
/// cells` never paginates implicitly in practice, but a misbehaving
/// caller can't OOM the server by asking for `usize::MAX` rows.
const MAX_LIMIT: usize = 1000;

pub async fn list_cells(
    State(state): State<AppState>,
    headers: HeaderMap,
    Query(params): Query<ListCellsParams>,
) -> Result<Json<Vec<CellRecord>>, AppError> {
    require_bearer(&headers, &state.api_token)?;

    let state_filter = match params.state.as_deref() {
        None => None,
        Some(raw) => Some(parse_state_filter(raw)?),
    };
    let limit = params.limit.map(|n| n.min(MAX_LIMIT));

    let map = state.cells.read().await;
    let mut out: Vec<CellRecord> = map
        .values()
        .filter(|c| match state_filter {
            Some(want) => c.state == want,
            None => true,
        })
        .filter(|c| match params.formation.as_deref() {
            None => true,
            Some(want) => c
                .formation_id
                .as_ref()
                .map(|fid| fid.to_string() == want)
                .unwrap_or(false),
        })
        .cloned()
        .collect();

    if let Some(n) = limit {
        out.truncate(n);
    }
    Ok(Json(out))
}

pub async fn get_cell(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<String>,
) -> Result<Json<CellRecord>, AppError> {
    require_bearer(&headers, &state.api_token)?;
    let map = state.cells.read().await;
    map.get(&id)
        .cloned()
        .map(Json)
        .ok_or_else(|| AppError::not_found(format!("cell {id} not found")))
}

/// Parse `?state=` into a `CellState`. Returns RFC 9457 400 for unknown
/// values so the operator sees a precise complaint rather than a silent
/// "no cells matched".
fn parse_state_filter(raw: &str) -> Result<CellState, AppError> {
    match raw.to_ascii_lowercase().as_str() {
        "pending" => Ok(CellState::Pending),
        "running" => Ok(CellState::Running),
        "destroyed" => Ok(CellState::Destroyed),
        other => Err(AppError::new(
            AppErrorKind::BadRequest,
            format!("unknown ?state value: {other} (want pending|running|destroyed)"),
        )),
    }
}