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    // The admin endpoints (/health, /metrics, /provenance) are unauthenticated
46    // by design (metrics scraping). /provenance and /metrics expose build and
47    // operational metadata, so warn when the port is reachable off-host.
48    if !addr.ip().is_loopback() {
49        tracing::warn!(
50            %addr,
51            "admin API (incl. /provenance, /metrics) is bound to a non-loopback \
52             address and is UNAUTHENTICATED; restrict it to loopback or place it \
53             behind a trusted network boundary"
54        );
55    }
56
57    loop {
58        tokio::select! {
59            _ = shutdown_rx.changed() => {
60                if *shutdown_rx.borrow() {
61                    return Ok(());
62                }
63            }
64            result = listener.accept() => {
65                let (stream, _) = result.map_err(|e| format!("admin accept: {}", e))?;
66                let state = state.clone();
67                tokio::spawn(async move {
68                    let service = service_fn(move |req| {
69                        let state = state.clone();
70                        async move { handle_request(req, &state) }
71                    });
72                    let _ = auto::Builder::new(TokioExecutor::new())
73                        .serve_connection(TokioIo::new(stream), service)
74                        .await;
75                });
76            }
77        }
78    }
79}
80
81fn handle_request(
82    req: Request<hyper::body::Incoming>,
83    state: &AdminState,
84) -> Result<Response<Full<Bytes>>, Infallible> {
85    let path = req.uri().path();
86    let method = req.method();
87
88    if method != Method::GET {
89        return Ok(json_response(
90            StatusCode::METHOD_NOT_ALLOWED,
91            r#"{"error":"method not allowed"}"#,
92        ));
93    }
94
95    let response = match path {
96        "/health" => health_response(state),
97        "/metrics" => metrics_response(state),
98        "/provenance" => provenance_response(state),
99        _ => json_response(StatusCode::NOT_FOUND, r#"{"error":"not found"}"#),
100    };
101
102    Ok(response)
103}
104
105fn health_response(state: &AdminState) -> Response<Full<Bytes>> {
106    let manifest = state.manifest.load();
107    let uptime_secs = state.started_at.elapsed().as_secs();
108
109    let body = serde_json::json!({
110        "status": "healthy",
111        "artifact_version": manifest.barbacane_artifact_version,
112        "compiler_version": manifest.compiler_version,
113        "routes_count": manifest.routes_count,
114        "uptime_secs": uptime_secs,
115    });
116
117    json_response(StatusCode::OK, &body.to_string())
118}
119
120fn metrics_response(state: &AdminState) -> Response<Full<Bytes>> {
121    let body = barbacane_telemetry::prometheus::render_metrics(&state.metrics);
122
123    Response::builder()
124        .status(StatusCode::OK)
125        .header("content-type", barbacane_telemetry::PROMETHEUS_CONTENT_TYPE)
126        .body(Full::new(Bytes::from(body)))
127        .expect("valid response")
128}
129
130fn provenance_response(state: &AdminState) -> Response<Full<Bytes>> {
131    let manifest = state.manifest.load();
132
133    let body = serde_json::json!({
134        "artifact_hash": manifest.artifact_hash,
135        "compiled_at": manifest.compiled_at,
136        "compiler_version": manifest.compiler_version,
137        "artifact_version": manifest.barbacane_artifact_version,
138        "provenance": manifest.provenance,
139        "source_specs": manifest.source_specs.iter().map(|s| {
140            serde_json::json!({
141                "file": s.file,
142                "sha256": s.sha256,
143                "type": s.spec_type,
144            })
145        }).collect::<Vec<_>>(),
146        "plugins": manifest.plugins.iter().map(|p| {
147            serde_json::json!({
148                "name": p.name,
149                "version": p.version,
150                "sha256": p.sha256,
151            })
152        }).collect::<Vec<_>>(),
153        "drift_detected": state.drift_detected.load(Ordering::Relaxed),
154    });
155
156    json_response(StatusCode::OK, &body.to_string())
157}
158
159fn json_response(status: StatusCode, body: &str) -> Response<Full<Bytes>> {
160    Response::builder()
161        .status(status)
162        .header("content-type", "application/json")
163        .body(Full::new(Bytes::from(body.to_string())))
164        .expect("valid response")
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use http_body_util::BodyExt;
171    use std::collections::BTreeMap;
172
173    async fn extract_body(response: Response<Full<Bytes>>) -> String {
174        let collected = response.into_body().collect().await.expect("collect body");
175        String::from_utf8(collected.to_bytes().to_vec()).expect("valid utf8")
176    }
177
178    fn test_manifest() -> Manifest {
179        Manifest {
180            barbacane_artifact_version: 2,
181            compiled_at: "2026-03-01T00:00:00Z".to_string(),
182            compiler_version: "0.2.1".to_string(),
183            source_specs: vec![barbacane_compiler::SourceSpec {
184                file: "petstore.yaml".to_string(),
185                sha256: "abc123".to_string(),
186                spec_type: "openapi".to_string(),
187                version: "3.0.3".to_string(),
188            }],
189            routes_count: 5,
190            checksums: BTreeMap::from([("routes.json".to_string(), "sha256:def456".to_string())]),
191            plugins: vec![],
192            artifact_hash: "sha256:combined123".to_string(),
193            provenance: barbacane_compiler::Provenance {
194                commit: Some("abc123def".to_string()),
195                source: Some("ci/github-actions".to_string()),
196            },
197            mcp: barbacane_compiler::McpConfig::default(),
198            signature: None,
199            signing_public_key: None,
200            capabilities_enforced: false,
201        }
202    }
203
204    fn test_state() -> Arc<AdminState> {
205        Arc::new(AdminState {
206            manifest: Arc::new(ArcSwap::new(Arc::new(test_manifest()))),
207            metrics: Arc::new(MetricsRegistry::new()),
208            drift_detected: Arc::new(AtomicBool::new(false)),
209            started_at: Instant::now(),
210        })
211    }
212
213    #[test]
214    fn test_health_returns_200() {
215        let state = test_state();
216        let response = health_response(&state);
217        assert_eq!(response.status(), StatusCode::OK);
218    }
219
220    #[tokio::test]
221    async fn test_provenance_response_contains_hash() {
222        let state = test_state();
223        let response = provenance_response(&state);
224        assert_eq!(response.status(), StatusCode::OK);
225
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["artifact_hash"], "sha256:combined123");
230        assert_eq!(json["provenance"]["commit"], "abc123def");
231        assert_eq!(json["provenance"]["source"], "ci/github-actions");
232        assert_eq!(json["drift_detected"], false);
233        assert_eq!(json["source_specs"][0]["file"], "petstore.yaml");
234    }
235
236    #[tokio::test]
237    async fn test_provenance_reflects_drift() {
238        let state = test_state();
239        state.drift_detected.store(true, Ordering::Relaxed);
240
241        let response = provenance_response(&state);
242        let body = extract_body(response).await;
243        let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
244
245        assert_eq!(json["drift_detected"], true);
246    }
247
248    #[test]
249    fn test_metrics_returns_200() {
250        let state = test_state();
251        let response = metrics_response(&state);
252        assert_eq!(response.status(), StatusCode::OK);
253    }
254
255    #[tokio::test]
256    async fn test_health_contains_expected_fields() {
257        let state = test_state();
258        let response = health_response(&state);
259        let body = extract_body(response).await;
260        let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
261
262        assert_eq!(json["status"], "healthy");
263        assert_eq!(json["artifact_version"], 2);
264        assert_eq!(json["compiler_version"], "0.2.1");
265        assert_eq!(json["routes_count"], 5);
266        assert!(json["uptime_secs"].is_u64());
267    }
268
269    #[test]
270    fn test_not_found_response() {
271        let response = json_response(StatusCode::NOT_FOUND, r#"{"error":"not found"}"#);
272        assert_eq!(response.status(), StatusCode::NOT_FOUND);
273    }
274
275    #[test]
276    fn test_method_not_allowed_response() {
277        let response = json_response(
278            StatusCode::METHOD_NOT_ALLOWED,
279            r#"{"error":"method not allowed"}"#,
280        );
281        assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
282    }
283
284    #[tokio::test]
285    async fn test_provenance_without_provenance_metadata() {
286        let manifest = Manifest {
287            barbacane_artifact_version: 2,
288            compiled_at: "2026-03-01T00:00:00Z".to_string(),
289            compiler_version: "0.2.1".to_string(),
290            source_specs: vec![],
291            routes_count: 0,
292            checksums: BTreeMap::new(),
293            plugins: vec![],
294            artifact_hash: "sha256:test".to_string(),
295            provenance: barbacane_compiler::Provenance::default(),
296            mcp: barbacane_compiler::McpConfig::default(),
297            signature: None,
298            signing_public_key: None,
299            capabilities_enforced: false,
300        };
301        let state = Arc::new(AdminState {
302            manifest: Arc::new(ArcSwap::new(Arc::new(manifest))),
303            metrics: Arc::new(MetricsRegistry::new()),
304            drift_detected: Arc::new(AtomicBool::new(false)),
305            started_at: Instant::now(),
306        });
307
308        let response = provenance_response(&state);
309        let body = extract_body(response).await;
310        let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
311
312        assert!(json["provenance"]["commit"].is_null());
313        assert!(json["provenance"]["source"].is_null());
314        assert_eq!(json["source_specs"].as_array().unwrap().len(), 0);
315        assert_eq!(json["plugins"].as_array().unwrap().len(), 0);
316    }
317
318    #[tokio::test]
319    async fn test_provenance_with_plugins() {
320        let manifest = Manifest {
321            barbacane_artifact_version: 2,
322            compiled_at: "2026-03-01T00:00:00Z".to_string(),
323            compiler_version: "0.2.1".to_string(),
324            source_specs: vec![],
325            routes_count: 0,
326            checksums: BTreeMap::new(),
327            plugins: vec![barbacane_compiler::BundledPlugin {
328                name: "rate-limit".to_string(),
329                version: "1.0.0".to_string(),
330                plugin_type: "middleware".to_string(),
331                wasm_path: "plugins/rate-limit.wasm".to_string(),
332                sha256: "sha256:plugin_hash".to_string(),
333                capabilities: barbacane_compiler::PluginCapabilities::default(),
334            }],
335            artifact_hash: "sha256:test".to_string(),
336            provenance: barbacane_compiler::Provenance::default(),
337            mcp: barbacane_compiler::McpConfig::default(),
338            signature: None,
339            signing_public_key: None,
340            capabilities_enforced: false,
341        };
342        let state = Arc::new(AdminState {
343            manifest: Arc::new(ArcSwap::new(Arc::new(manifest))),
344            metrics: Arc::new(MetricsRegistry::new()),
345            drift_detected: Arc::new(AtomicBool::new(false)),
346            started_at: Instant::now(),
347        });
348
349        let response = provenance_response(&state);
350        let body = extract_body(response).await;
351        let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
352
353        let plugins = json["plugins"].as_array().unwrap();
354        assert_eq!(plugins.len(), 1);
355        assert_eq!(plugins[0]["name"], "rate-limit");
356        assert_eq!(plugins[0]["version"], "1.0.0");
357        assert_eq!(plugins[0]["sha256"], "sha256:plugin_hash");
358    }
359
360    #[test]
361    fn test_metrics_content_type_is_prometheus() {
362        let state = test_state();
363        let response = metrics_response(&state);
364        let ct = response
365            .headers()
366            .get("content-type")
367            .and_then(|v| v.to_str().ok())
368            .unwrap_or("");
369        assert!(
370            ct.starts_with("text/plain"),
371            "Metrics content type should be text/plain for Prometheus, got: {}",
372            ct,
373        );
374    }
375}