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 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 mcp: barbacane_compiler::McpConfig::default(),
186 }
187 }
188
189 fn test_state() -> Arc<AdminState> {
190 Arc::new(AdminState {
191 manifest: Arc::new(ArcSwap::new(Arc::new(test_manifest()))),
192 metrics: Arc::new(MetricsRegistry::new()),
193 drift_detected: Arc::new(AtomicBool::new(false)),
194 started_at: Instant::now(),
195 })
196 }
197
198 #[test]
199 fn test_health_returns_200() {
200 let state = test_state();
201 let response = health_response(&state);
202 assert_eq!(response.status(), StatusCode::OK);
203 }
204
205 #[tokio::test]
206 async fn test_provenance_response_contains_hash() {
207 let state = test_state();
208 let response = provenance_response(&state);
209 assert_eq!(response.status(), StatusCode::OK);
210
211 let body = extract_body(response).await;
212 let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
213
214 assert_eq!(json["artifact_hash"], "sha256:combined123");
215 assert_eq!(json["provenance"]["commit"], "abc123def");
216 assert_eq!(json["provenance"]["source"], "ci/github-actions");
217 assert_eq!(json["drift_detected"], false);
218 assert_eq!(json["source_specs"][0]["file"], "petstore.yaml");
219 }
220
221 #[tokio::test]
222 async fn test_provenance_reflects_drift() {
223 let state = test_state();
224 state.drift_detected.store(true, Ordering::Relaxed);
225
226 let response = provenance_response(&state);
227 let body = extract_body(response).await;
228 let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
229
230 assert_eq!(json["drift_detected"], true);
231 }
232
233 #[test]
234 fn test_metrics_returns_200() {
235 let state = test_state();
236 let response = metrics_response(&state);
237 assert_eq!(response.status(), StatusCode::OK);
238 }
239
240 #[tokio::test]
241 async fn test_health_contains_expected_fields() {
242 let state = test_state();
243 let response = health_response(&state);
244 let body = extract_body(response).await;
245 let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
246
247 assert_eq!(json["status"], "healthy");
248 assert_eq!(json["artifact_version"], 2);
249 assert_eq!(json["compiler_version"], "0.2.1");
250 assert_eq!(json["routes_count"], 5);
251 assert!(json["uptime_secs"].is_u64());
252 }
253
254 #[test]
255 fn test_not_found_response() {
256 let response = json_response(StatusCode::NOT_FOUND, r#"{"error":"not found"}"#);
257 assert_eq!(response.status(), StatusCode::NOT_FOUND);
258 }
259
260 #[test]
261 fn test_method_not_allowed_response() {
262 let response = json_response(
263 StatusCode::METHOD_NOT_ALLOWED,
264 r#"{"error":"method not allowed"}"#,
265 );
266 assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
267 }
268
269 #[tokio::test]
270 async fn test_provenance_without_provenance_metadata() {
271 let manifest = Manifest {
272 barbacane_artifact_version: 2,
273 compiled_at: "2026-03-01T00:00:00Z".to_string(),
274 compiler_version: "0.2.1".to_string(),
275 source_specs: vec![],
276 routes_count: 0,
277 checksums: BTreeMap::new(),
278 plugins: vec![],
279 artifact_hash: "sha256:test".to_string(),
280 provenance: barbacane_compiler::Provenance::default(),
281 mcp: barbacane_compiler::McpConfig::default(),
282 };
283 let state = Arc::new(AdminState {
284 manifest: Arc::new(ArcSwap::new(Arc::new(manifest))),
285 metrics: Arc::new(MetricsRegistry::new()),
286 drift_detected: Arc::new(AtomicBool::new(false)),
287 started_at: Instant::now(),
288 });
289
290 let response = provenance_response(&state);
291 let body = extract_body(response).await;
292 let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
293
294 assert!(json["provenance"]["commit"].is_null());
295 assert!(json["provenance"]["source"].is_null());
296 assert_eq!(json["source_specs"].as_array().unwrap().len(), 0);
297 assert_eq!(json["plugins"].as_array().unwrap().len(), 0);
298 }
299
300 #[tokio::test]
301 async fn test_provenance_with_plugins() {
302 let manifest = Manifest {
303 barbacane_artifact_version: 2,
304 compiled_at: "2026-03-01T00:00:00Z".to_string(),
305 compiler_version: "0.2.1".to_string(),
306 source_specs: vec![],
307 routes_count: 0,
308 checksums: BTreeMap::new(),
309 plugins: vec![barbacane_compiler::BundledPlugin {
310 name: "rate-limit".to_string(),
311 version: "1.0.0".to_string(),
312 plugin_type: "middleware".to_string(),
313 wasm_path: "plugins/rate-limit.wasm".to_string(),
314 sha256: "sha256:plugin_hash".to_string(),
315 capabilities: barbacane_compiler::PluginCapabilities::default(),
316 }],
317 artifact_hash: "sha256:test".to_string(),
318 provenance: barbacane_compiler::Provenance::default(),
319 mcp: barbacane_compiler::McpConfig::default(),
320 };
321 let state = Arc::new(AdminState {
322 manifest: Arc::new(ArcSwap::new(Arc::new(manifest))),
323 metrics: Arc::new(MetricsRegistry::new()),
324 drift_detected: Arc::new(AtomicBool::new(false)),
325 started_at: Instant::now(),
326 });
327
328 let response = provenance_response(&state);
329 let body = extract_body(response).await;
330 let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
331
332 let plugins = json["plugins"].as_array().unwrap();
333 assert_eq!(plugins.len(), 1);
334 assert_eq!(plugins[0]["name"], "rate-limit");
335 assert_eq!(plugins[0]["version"], "1.0.0");
336 assert_eq!(plugins[0]["sha256"], "sha256:plugin_hash");
337 }
338
339 #[test]
340 fn test_metrics_content_type_is_prometheus() {
341 let state = test_state();
342 let response = metrics_response(&state);
343 let ct = response
344 .headers()
345 .get("content-type")
346 .and_then(|v| v.to_str().ok())
347 .unwrap_or("");
348 assert!(
349 ct.starts_with("text/plain"),
350 "Metrics content type should be text/plain for Prometheus, got: {}",
351 ct,
352 );
353 }
354}