1use 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
25pub 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
33pub 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 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}