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 }
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 }],
314 artifact_hash: "sha256:test".to_string(),
315 provenance: barbacane_compiler::Provenance::default(),
316 };
317 let state = Arc::new(AdminState {
318 manifest: Arc::new(ArcSwap::new(Arc::new(manifest))),
319 metrics: Arc::new(MetricsRegistry::new()),
320 drift_detected: Arc::new(AtomicBool::new(false)),
321 started_at: Instant::now(),
322 });
323
324 let response = provenance_response(&state);
325 let body = extract_body(response).await;
326 let json: serde_json::Value = serde_json::from_str(&body).expect("valid json");
327
328 let plugins = json["plugins"].as_array().unwrap();
329 assert_eq!(plugins.len(), 1);
330 assert_eq!(plugins[0]["name"], "rate-limit");
331 assert_eq!(plugins[0]["version"], "1.0.0");
332 assert_eq!(plugins[0]["sha256"], "sha256:plugin_hash");
333 }
334
335 #[test]
336 fn test_metrics_content_type_is_prometheus() {
337 let state = test_state();
338 let response = metrics_response(&state);
339 let ct = response
340 .headers()
341 .get("content-type")
342 .and_then(|v| v.to_str().ok())
343 .unwrap_or("");
344 assert!(
345 ct.starts_with("text/plain"),
346 "Metrics content type should be text/plain for Prometheus, got: {}",
347 ct,
348 );
349 }
350}