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/// Ready means the route table is loaded, the ACME runtime exists when
38/// `system.yaml` asks for managed certificates, **and** no ACME-managed
39/// certificate is still serving a placeholder — renewal failures never affect
40/// readiness, only a cert that has never successfully issued does. Returns `200 OK` with
41/// the compiled route count (and the empty `acme.placeholder` list) once
42/// both hold, or `503 Service Unavailable` while either does not. Every
43/// branch carries the same `acme.placeholder` shape, even "no routes loaded"
44/// (where the acme runtime hasn't necessarily been consulted), so callers
45/// can rely on one JSON shape regardless of why readiness failed.
46async fn readyz(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
47    let routes = state.routes.read().await;
48    if routes.is_empty() {
49        return (
50            StatusCode::SERVICE_UNAVAILABLE,
51            Json(serde_json::json!({
52                "status": "not_ready",
53                "reason": "no routes loaded",
54                "acme": {"placeholder": Vec::<String>::new()},
55            })),
56        );
57    }
58    // ACME is configured but `server::start_server` has not seeded the runtime
59    // yet: the admin listener comes up first, so without this the probe would
60    // answer 200 in the window before any managed certificate — placeholder or
61    // real — exists.
62    if state.acme_expected && state.acme.load().is_none() {
63        return (
64            StatusCode::SERVICE_UNAVAILABLE,
65            Json(serde_json::json!({
66                "status": "not_ready",
67                "reason": "acme starting",
68                "acme": {"placeholder": Vec::<String>::new()},
69            })),
70        );
71    }
72    let placeholders = state
73        .acme
74        .load()
75        .as_ref()
76        .map(|rt| rt.placeholder_ids())
77        .unwrap_or_default();
78    if !placeholders.is_empty() {
79        return (
80            StatusCode::SERVICE_UNAVAILABLE,
81            Json(serde_json::json!({
82                "status": "not_ready",
83                "reason": "acme placeholder certs",
84                "acme": {"placeholder": placeholders},
85            })),
86        );
87    }
88    (
89        StatusCode::OK,
90        Json(serde_json::json!({
91            "status": "ready",
92            "routes": routes.len(),
93            "acme": {"placeholder": placeholders},
94        })),
95    )
96}
97
98/// `GET /api/status` — gateway version plus route and policy counts.
99///
100/// ```json
101/// { "version": "0.1.0", "routes": 3, "policies": 2 }
102/// ```
103async fn status(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
104    let routes = state.routes.read().await;
105    let gw = state.gateway.read().await;
106    Json(serde_json::json!({
107        "version": env!("CARGO_PKG_VERSION"),
108        "routes": routes.len(),
109        "policies": gw.policies.len(),
110    }))
111}
112
113/// `GET /api/config/export` — renders the live in-memory gateway
114/// configuration (routes + policies, exactly what the data plane is running)
115/// as YAML, served as `text/yaml; charset=utf-8`. This is the `gateway.yaml`
116/// equivalent of whatever has been built through the UI / Admin API.
117///
118/// Values keep their `${ENV_VAR}` templates: env interpolation happens when a
119/// policy is compiled, not in the stored config, so the export mirrors the
120/// source you would write by hand rather than the resolved secrets.
121///
122/// Errors: `500 Internal Server Error` if the config cannot be serialized.
123async fn export_config(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
124    let gw = state.gateway.read().await;
125    match serde_yaml::to_string(&*gw) {
126        Ok(yaml) => (
127            StatusCode::OK,
128            [("content-type", "text/yaml; charset=utf-8")],
129            yaml,
130        )
131            .into_response(),
132        Err(e) => (
133            StatusCode::INTERNAL_SERVER_ERROR,
134            Json(serde_json::json!({"error": format!("failed to serialize config: {}", e)})),
135        )
136            .into_response(),
137    }
138}
139
140/// `GET /metrics` — renders the shared gateway registry (per-route and
141/// per-node counters/histograms recorded by the data plane) in Prometheus
142/// text exposition format, served as `text/plain; charset=utf-8`.
143async fn metrics(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
144    (
145        StatusCode::OK,
146        [("content-type", "text/plain; charset=utf-8")],
147        state.metrics.render(),
148    )
149}
150
151/// `POST /api/config/reload` — re-reads `gateway.yaml` from disk (with env
152/// interpolation), recompiles all route graphs, and swaps them in. Returns
153/// `{"status": "reloaded"}` on success.
154///
155/// Errors: `500 Internal Server Error` if no config path is set or the file
156/// fails to parse/validate/compile; the running config is left unchanged.
157async fn reload_config(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
158    match state.reload_from_disk().await {
159        Ok(_) => (
160            StatusCode::OK,
161            Json(serde_json::json!({"status": "reloaded"})),
162        )
163            .into_response(),
164        Err(e) => (
165            StatusCode::INTERNAL_SERVER_ERROR,
166            Json(serde_json::json!({"error": e})),
167        )
168            .into_response(),
169    }
170}
171
172#[cfg(test)]
173mod acme_readyz_tests {
174    use super::*;
175    use crate::config::{GatewayConfig, SystemConfig};
176    use crate::config_store::FileConfigStore;
177    use axum::body::Body;
178    use axum::http::Request;
179    use tower::ServiceExt;
180
181    fn state() -> Arc<SharedState> {
182        state_with("{}")
183    }
184
185    fn state_with(system_yaml: &str) -> Arc<SharedState> {
186        let system: SystemConfig = serde_yaml::from_str(system_yaml).unwrap();
187        let gateway: GatewayConfig = serde_yaml::from_str(
188            "routes:\n  - name: r\n    match:\n      path: /x\n    policy: p\npolicies:\n  - name: p\n    nodes:\n      - id: in\n        type: listener\n      - id: out\n        type: client\n    edges:\n      - { from: in.out, to: out.in }\n",
189        )
190        .unwrap();
191        Arc::new(
192            SharedState::new(
193                system,
194                gateway,
195                None,
196                Arc::new(FileConfigStore::new("g.yaml".into())),
197            )
198            .unwrap(),
199        )
200    }
201
202    async fn readyz_status(state: Arc<SharedState>) -> (StatusCode, serde_json::Value) {
203        let resp = router()
204            .with_state(state)
205            .oneshot(
206                Request::builder()
207                    .uri("/readyz")
208                    .body(Body::empty())
209                    .unwrap(),
210            )
211            .await
212            .unwrap();
213        let status = resp.status();
214        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
215            .await
216            .unwrap();
217        (status, serde_json::from_slice(&bytes).unwrap())
218    }
219
220    #[tokio::test]
221    async fn readyz_is_503_while_a_managed_cert_is_a_placeholder() {
222        let s = state();
223        let (status, _) = readyz_status(s.clone()).await;
224        assert_eq!(status, StatusCode::OK, "no acme ⇒ ready");
225
226        s.acme
227            .store(Some(crate::acme::testing::placeholder_runtime(&[
228                "p.example.com",
229            ])));
230        let (status, body) = readyz_status(s.clone()).await;
231        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
232        assert_eq!(body["acme"]["placeholder"][0], "p.example.com");
233
234        s.acme.store(Some(crate::acme::testing::issued_runtime(&[
235            "p.example.com",
236        ])));
237        let (status, body) = readyz_status(s).await;
238        assert_eq!(status, StatusCode::OK);
239        assert_eq!(body["acme"]["placeholder"].as_array().unwrap().len(), 0);
240    }
241
242    /// The admin listener starts before `server::start_server` seeds
243    /// `state.acme`, so a config that asks for managed certificates must read
244    /// as not-ready until the runtime exists — otherwise the probe reports
245    /// ready while nothing has been issued and no listener has bound.
246    #[tokio::test]
247    async fn readyz_is_503_before_the_acme_runtime_is_seeded() {
248        let s = state_with(
249            "acme:
250  terms_of_service_agreed: true
251  directory_url: https://127.0.0.1:1/directory
252tls:
253  acme:
254    domains: [p.example.com]
255",
256        );
257        assert!(s.acme_expected);
258        let (status, body) = readyz_status(s.clone()).await;
259        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
260        assert_eq!(body["reason"], "acme starting");
261        assert_eq!(body["acme"]["placeholder"].as_array().unwrap().len(), 0);
262
263        s.acme.store(Some(crate::acme::testing::issued_runtime(&[
264            "p.example.com",
265        ])));
266        let (status, _) = readyz_status(s).await;
267        assert_eq!(status, StatusCode::OK);
268
269        // A file-only TLS config never expects ACME.
270        let file_tls = state_with(
271            "tls:
272  cert_path: /tmp/c.pem
273  key_path: /tmp/k.pem
274",
275        );
276        assert!(!file_tls.acme_expected);
277        assert_eq!(readyz_status(file_tls).await.0, StatusCode::OK);
278    }
279}