Skip to main content

featherbit/admin/
status.rs

1//! Operational endpoints for the admin API: liveness/readiness probes,
2//! a status summary, Prometheus metrics, and manual config reload.
3
4use std::sync::Arc;
5
6use axum::extract::State;
7use axum::http::StatusCode;
8use axum::response::IntoResponse;
9use axum::routing::{get, post};
10use axum::{Json, Router};
11
12use crate::state::SharedState;
13
14/// Builds the router for `/healthz`, `/readyz`, `/api/status`,
15/// `/api/config/export`, `/api/config/reload`, and `/metrics`.
16pub fn router() -> Router<Arc<SharedState>> {
17    Router::new()
18        .route("/healthz", get(healthz))
19        .route("/readyz", get(readyz))
20        .route("/api/status", get(status))
21        .route("/api/config/export", get(export_config))
22        .route("/api/config/reload", post(reload_config))
23        .route("/metrics", get(metrics))
24}
25
26/// `GET /healthz` — liveness probe. Always `200 OK` with
27/// `{"status": "healthy"}` while the process is running. Exempt from auth.
28async fn healthz() -> impl IntoResponse {
29    (
30        StatusCode::OK,
31        Json(serde_json::json!({"status": "healthy"})),
32    )
33}
34
35/// `GET /readyz` — readiness probe. Exempt from auth.
36///
37/// Returns `200 OK` with the compiled route count once at least one route is
38/// loaded, or `503 Service Unavailable` while the route table is empty.
39async fn readyz(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
40    let routes = state.routes.read().await;
41    if routes.is_empty() {
42        (
43            StatusCode::SERVICE_UNAVAILABLE,
44            Json(serde_json::json!({"status": "not_ready", "reason": "no routes loaded"})),
45        )
46    } else {
47        (
48            StatusCode::OK,
49            Json(serde_json::json!({"status": "ready", "routes": routes.len()})),
50        )
51    }
52}
53
54/// `GET /api/status` — gateway version plus route and policy counts.
55///
56/// ```json
57/// { "version": "0.1.0", "routes": 3, "policies": 2 }
58/// ```
59async fn status(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
60    let routes = state.routes.read().await;
61    let gw = state.gateway.read().await;
62    Json(serde_json::json!({
63        "version": env!("CARGO_PKG_VERSION"),
64        "routes": routes.len(),
65        "policies": gw.policies.len(),
66    }))
67}
68
69/// `GET /api/config/export` — renders the live in-memory gateway
70/// configuration (routes + policies, exactly what the data plane is running)
71/// as YAML, served as `text/yaml; charset=utf-8`. This is the `gateway.yaml`
72/// equivalent of whatever has been built through the UI / Admin API.
73///
74/// Values keep their `${ENV_VAR}` templates: env interpolation happens when a
75/// policy is compiled, not in the stored config, so the export mirrors the
76/// source you would write by hand rather than the resolved secrets.
77///
78/// Errors: `500 Internal Server Error` if the config cannot be serialized.
79async fn export_config(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
80    let gw = state.gateway.read().await;
81    match serde_yaml::to_string(&*gw) {
82        Ok(yaml) => (
83            StatusCode::OK,
84            [("content-type", "text/yaml; charset=utf-8")],
85            yaml,
86        )
87            .into_response(),
88        Err(e) => (
89            StatusCode::INTERNAL_SERVER_ERROR,
90            Json(serde_json::json!({"error": format!("failed to serialize config: {}", e)})),
91        )
92            .into_response(),
93    }
94}
95
96/// `GET /metrics` — renders the shared gateway registry (per-route and
97/// per-node counters/histograms recorded by the data plane) in Prometheus
98/// text exposition format, served as `text/plain; charset=utf-8`.
99async fn metrics(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
100    (
101        StatusCode::OK,
102        [("content-type", "text/plain; charset=utf-8")],
103        state.metrics.render(),
104    )
105}
106
107/// `POST /api/config/reload` — re-reads `gateway.yaml` from disk (with env
108/// interpolation), recompiles all route graphs, and swaps them in. Returns
109/// `{"status": "reloaded"}` on success.
110///
111/// Errors: `500 Internal Server Error` if no config path is set or the file
112/// fails to parse/validate/compile; the running config is left unchanged.
113async fn reload_config(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
114    match state.reload_from_disk().await {
115        Ok(_) => (
116            StatusCode::OK,
117            Json(serde_json::json!({"status": "reloaded"})),
118        )
119            .into_response(),
120        Err(e) => (
121            StatusCode::INTERNAL_SERVER_ERROR,
122            Json(serde_json::json!({"error": e})),
123        )
124            .into_response(),
125    }
126}