Skip to main content

barbacane_lib/
admin.rs

1//! Admin API listener for operational endpoints.
2//!
3//! Serves health, metrics, and provenance on a dedicated port
4//! separate from user traffic (ADR-0022).
5
6use std::convert::Infallible;
7use std::net::SocketAddr;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10use std::time::Instant;
11
12use arc_swap::ArcSwap;
13use bytes::Bytes;
14use http_body_util::Full;
15use hyper::service::service_fn;
16use hyper::{Method, Request, Response, StatusCode};
17use hyper_util::rt::{TokioExecutor, TokioIo};
18use hyper_util::server::conn::auto;
19use tokio::net::TcpListener;
20use tokio::sync::watch;
21
22use barbacane_compiler::Manifest;
23use barbacane_telemetry::MetricsRegistry;
24
25/// Shared state for the admin server.
26pub struct AdminState {
27    pub manifest: Arc<ArcSwap<Manifest>>,
28    pub metrics: Arc<MetricsRegistry>,
29    pub drift_detected: Arc<AtomicBool>,
30    pub started_at: Instant,
31}
32
33/// Start the admin HTTP server.
34///
35/// Serves `/health`, `/metrics`, and `/provenance` on a dedicated port.
36pub async fn start_admin_server(
37    addr: SocketAddr,
38    state: Arc<AdminState>,
39    mut shutdown_rx: watch::Receiver<bool>,
40) -> Result<(), String> {
41    let listener = TcpListener::bind(addr)
42        .await
43        .map_err(|e| format!("admin: failed to bind to {}: {}", addr, e))?;
44
45    loop {
46        tokio::select! {
47            _ = shutdown_rx.changed() => {
48                if *shutdown_rx.borrow() {
49                    return Ok(());
50                }
51            }
52            result = listener.accept() => {
53                let (stream, _) = result.map_err(|e| format!("admin accept: {}", e))?;
54                let state = state.clone();
55                tokio::spawn(async move {
56                    let service = service_fn(move |req| {
57                        let state = state.clone();
58                        async move { handle_request(req, &state) }
59                    });
60                    let _ = auto::Builder::new(TokioExecutor::new())
61                        .serve_connection(TokioIo::new(stream), service)
62                        .await;
63                });
64            }
65        }
66    }
67}
68
69fn handle_request(
70    req: Request<hyper::body::Incoming>,
71    state: &AdminState,
72) -> Result<Response<Full<Bytes>>, Infallible> {
73    let path = req.uri().path();
74    let method = req.method();
75
76    if method != Method::GET {
77        return Ok(json_response(
78            StatusCode::METHOD_NOT_ALLOWED,
79            r#"{"error":"method not allowed"}"#,
80        ));
81    }
82
83    let response = match path {
84        "/health" => health_response(state),
85        "/metrics" => metrics_response(state),
86        "/provenance" => provenance_response(state),
87        _ => json_response(StatusCode::NOT_FOUND, r#"{"error":"not found"}"#),
88    };
89
90    Ok(response)
91}
92
93fn health_response(state: &AdminState) -> Response<Full<Bytes>> {
94    let manifest = state.manifest.load();
95    let uptime_secs = state.started_at.elapsed().as_secs();
96
97    let body = serde_json::json!({
98        "status": "healthy",
99        "artifact_version": manifest.barbacane_artifact_version,
100        "compiler_version": manifest.compiler_version,
101        "routes_count": manifest.routes_count,
102        "uptime_secs": uptime_secs,
103    });
104
105    json_response(StatusCode::OK, &body.to_string())
106}
107
108fn metrics_response(state: &AdminState) -> Response<Full<Bytes>> {
109    let body = barbacane_telemetry::prometheus::render_metrics(&state.metrics);
110
111    Response::builder()
112        .status(StatusCode::OK)
113        .header("content-type", barbacane_telemetry::PROMETHEUS_CONTENT_TYPE)
114        .body(Full::new(Bytes::from(body)))
115        .expect("valid response")
116}
117
118fn provenance_response(state: &AdminState) -> Response<Full<Bytes>> {
119    let manifest = state.manifest.load();
120
121    let body = serde_json::json!({
122        "artifact_hash": manifest.artifact_hash,
123        "compiled_at": manifest.compiled_at,
124        "compiler_version": manifest.compiler_version,
125        "artifact_version": manifest.barbacane_artifact_version,
126        "provenance": manifest.provenance,
127        "source_specs": manifest.source_specs.iter().map(|s| {
128            serde_json::json!({
129                "file": s.file,
130                "sha256": s.sha256,
131                "type": s.spec_type,
132            })
133        }).collect::<Vec<_>>(),
134        "plugins": manifest.plugins.iter().map(|p| {
135            serde_json::json!({
136                "name": p.name,
137                "version": p.version,
138                "sha256": p.sha256,
139            })
140        }).collect::<Vec<_>>(),
141        "drift_detected": state.drift_detected.load(Ordering::Relaxed),
142    });
143
144    json_response(StatusCode::OK, &body.to_string())
145}
146
147fn json_response(status: StatusCode, body: &str) -> Response<Full<Bytes>> {
148    Response::builder()
149        .status(status)
150        .header("content-type", "application/json")
151        .body(Full::new(Bytes::from(body.to_string())))
152        .expect("valid response")
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use http_body_util::BodyExt;
159    use std::collections::BTreeMap;
160
161    async fn extract_body(response: Response<Full<Bytes>>) -> String {
162        let collected = response.into_body().collect().await.expect("collect body");
163        String::from_utf8(collected.to_bytes().to_vec()).expect("valid utf8")
164    }
165
166    fn test_manifest() -> Manifest {
167        Manifest {
168            barbacane_artifact_version: 2,
169            compiled_at: "2026-03-01T00:00:00Z".to_string(),
170            compiler_version: "0.2.1".to_string(),
171            source_specs: vec![barbacane_compiler::SourceSpec {
172                file: "petstore.yaml".to_string(),
173                sha256: "abc123".to_string(),
174                spec_type: "openapi".to_string(),
175                version: "3.0.3".to_string(),
176            }],
177            routes_count: 5,
178            checksums: BTreeMap::from([("routes.json".to_string(), "sha256:def456".to_string())]),
179            plugins: vec![],
180            artifact_hash: "sha256:combined123".to_string(),
181            provenance: barbacane_compiler::Provenance {
182                commit: Some("abc123def".to_string()),
183                source: Some("ci/github-actions".to_string()),
184            },
185        }
186    }
187
188    fn test_state() -> Arc<AdminState> {
189        Arc::new(AdminState {
190            manifest: Arc::new(ArcSwap::new(Arc::new(test_manifest()))),
191            metrics: Arc::new(MetricsRegistry::new()),
192            drift_detected: Arc::new(AtomicBool::new(false)),
193            started_at: Instant::now(),
194        })
195    }
196
197    #[test]
198    fn test_health_returns_200() {
199        let state = test_state();
200        let response = health_response(&state);
201        assert_eq!(response.status(), StatusCode::OK);
202    }
203
204    #[tokio::test]
205    async fn test_provenance_response_contains_hash() {
206        let state = test_state();
207        let response = provenance_response(&state);
208        assert_eq!(response.status(), StatusCode::OK);
209
210        let body = extract_body(response).await;
211        let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
212
213        assert_eq!(json["artifact_hash"], "sha256:combined123");
214        assert_eq!(json["provenance"]["commit"], "abc123def");
215        assert_eq!(json["provenance"]["source"], "ci/github-actions");
216        assert_eq!(json["drift_detected"], false);
217        assert_eq!(json["source_specs"][0]["file"], "petstore.yaml");
218    }
219
220    #[tokio::test]
221    async fn test_provenance_reflects_drift() {
222        let state = test_state();
223        state.drift_detected.store(true, Ordering::Relaxed);
224
225        let response = provenance_response(&state);
226        let body = extract_body(response).await;
227        let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
228
229        assert_eq!(json["drift_detected"], true);
230    }
231
232    #[test]
233    fn test_metrics_returns_200() {
234        let state = test_state();
235        let response = metrics_response(&state);
236        assert_eq!(response.status(), StatusCode::OK);
237    }
238
239    #[tokio::test]
240    async fn test_health_contains_expected_fields() {
241        let state = test_state();
242        let response = health_response(&state);
243        let body = extract_body(response).await;
244        let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
245
246        assert_eq!(json["status"], "healthy");
247        assert_eq!(json["artifact_version"], 2);
248        assert_eq!(json["compiler_version"], "0.2.1");
249        assert_eq!(json["routes_count"], 5);
250        assert!(json["uptime_secs"].is_u64());
251    }
252
253    #[test]
254    fn test_not_found_response() {
255        let response = json_response(StatusCode::NOT_FOUND, r#"{"error":"not found"}"#);
256        assert_eq!(response.status(), StatusCode::NOT_FOUND);
257    }
258
259    #[test]
260    fn test_method_not_allowed_response() {
261        let response = json_response(
262            StatusCode::METHOD_NOT_ALLOWED,
263            r#"{"error":"method not allowed"}"#,
264        );
265        assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
266    }
267
268    #[tokio::test]
269    async fn test_provenance_without_provenance_metadata() {
270        let manifest = Manifest {
271            barbacane_artifact_version: 2,
272            compiled_at: "2026-03-01T00:00:00Z".to_string(),
273            compiler_version: "0.2.1".to_string(),
274            source_specs: vec![],
275            routes_count: 0,
276            checksums: BTreeMap::new(),
277            plugins: vec![],
278            artifact_hash: "sha256:test".to_string(),
279            provenance: barbacane_compiler::Provenance::default(),
280        };
281        let state = Arc::new(AdminState {
282            manifest: Arc::new(ArcSwap::new(Arc::new(manifest))),
283            metrics: Arc::new(MetricsRegistry::new()),
284            drift_detected: Arc::new(AtomicBool::new(false)),
285            started_at: Instant::now(),
286        });
287
288        let response = provenance_response(&state);
289        let body = extract_body(response).await;
290        let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
291
292        assert!(json["provenance"]["commit"].is_null());
293        assert!(json["provenance"]["source"].is_null());
294        assert_eq!(json["source_specs"].as_array().unwrap().len(), 0);
295        assert_eq!(json["plugins"].as_array().unwrap().len(), 0);
296    }
297
298    #[tokio::test]
299    async fn test_provenance_with_plugins() {
300        let manifest = Manifest {
301            barbacane_artifact_version: 2,
302            compiled_at: "2026-03-01T00:00:00Z".to_string(),
303            compiler_version: "0.2.1".to_string(),
304            source_specs: vec![],
305            routes_count: 0,
306            checksums: BTreeMap::new(),
307            plugins: vec![barbacane_compiler::BundledPlugin {
308                name: "rate-limit".to_string(),
309                version: "1.0.0".to_string(),
310                plugin_type: "middleware".to_string(),
311                wasm_path: "plugins/rate-limit.wasm".to_string(),
312                sha256: "sha256:plugin_hash".to_string(),
313                capabilities: barbacane_compiler::PluginCapabilities::default(),
314            }],
315            artifact_hash: "sha256:test".to_string(),
316            provenance: barbacane_compiler::Provenance::default(),
317        };
318        let state = Arc::new(AdminState {
319            manifest: Arc::new(ArcSwap::new(Arc::new(manifest))),
320            metrics: Arc::new(MetricsRegistry::new()),
321            drift_detected: Arc::new(AtomicBool::new(false)),
322            started_at: Instant::now(),
323        });
324
325        let response = provenance_response(&state);
326        let body = extract_body(response).await;
327        let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
328
329        let plugins = json["plugins"].as_array().unwrap();
330        assert_eq!(plugins.len(), 1);
331        assert_eq!(plugins[0]["name"], "rate-limit");
332        assert_eq!(plugins[0]["version"], "1.0.0");
333        assert_eq!(plugins[0]["sha256"], "sha256:plugin_hash");
334    }
335
336    #[test]
337    fn test_metrics_content_type_is_prometheus() {
338        let state = test_state();
339        let response = metrics_response(&state);
340        let ct = response
341            .headers()
342            .get("content-type")
343            .and_then(|v| v.to_str().ok())
344            .unwrap_or("");
345        assert!(
346            ct.starts_with("text/plain"),
347            "Metrics content type should be text/plain for Prometheus, got: {}",
348            ct,
349        );
350    }
351}