1#[cfg(feature = "anomaly-detection")]
7use crate::anomaly::AnomalyDetector;
8use crate::config::{NodeConfig, NodeRole, ProxyConfig};
9#[cfg(feature = "edge-proxy")]
10use crate::edge::{EdgeCache, EdgeRegistry, InvalidationEvent};
11#[cfg(feature = "wasm-plugins")]
12use crate::plugins::PluginManager;
13#[cfg(feature = "ha-tr")]
14use crate::replay::{ReplayEngine, TimeTravelRequest};
15use crate::server::{NodeHealth, ServerMetricsSnapshot};
16use crate::{ProxyError, Result};
17#[cfg(feature = "ha-tr")]
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use std::net::SocketAddr;
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::sync::Arc;
24use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
25use tokio::net::TcpStream;
26use tokio::sync::{broadcast, RwLock};
27
28const ADMIN_UI_HTML: &str = include_str!("admin_ui.html");
32
33fn constant_time_eq_str(a: &str, b: &str) -> bool {
36 let (a, b) = (a.as_bytes(), b.as_bytes());
37 if a.len() != b.len() {
38 return false;
39 }
40 let mut diff = 0u8;
41 for i in 0..a.len() {
42 diff |= a[i] ^ b[i];
43 }
44 diff == 0
45}
46
47pub struct AdminServer {
48 listen_address: String,
50 state: Arc<AdminState>,
52 shutdown_tx: broadcast::Sender<()>,
54}
55
56pub struct AdminState {
58 pub node_health: RwLock<HashMap<String, NodeHealth>>,
60 pub metrics: RwLock<ServerMetricsSnapshot>,
62 pub active_sessions: RwLock<u64>,
64 pub config_snapshot: RwLock<ConfigSnapshot>,
66 pub proxy_config: RwLock<Option<ProxyConfig>>,
68 read_lb_counter: AtomicUsize,
70 commands: RwLock<HashMap<String, CommandHandler>>,
72 #[cfg(feature = "pool-modes")]
76 pub pool_manager: RwLock<Option<Arc<crate::pool::ConnectionPoolManager>>>,
77 #[cfg(feature = "circuit-breaker")]
80 pub circuit_breaker: RwLock<Option<Arc<crate::circuit_breaker::CircuitBreakerManager>>>,
81 #[cfg(feature = "ha-tr")]
85 pub replay_engine: RwLock<Option<Arc<ReplayEngine>>>,
86 #[cfg(feature = "wasm-plugins")]
91 pub plugin_manager: RwLock<Option<Arc<PluginManager>>>,
92 pub chaos_overrides: RwLock<HashMap<String, ChaosOverride>>,
97 #[cfg(feature = "anomaly-detection")]
101 pub anomaly_detector: RwLock<Option<Arc<AnomalyDetector>>>,
102 #[cfg(feature = "query-analytics")]
105 pub analytics: RwLock<Option<Arc<crate::analytics::QueryAnalytics>>>,
106 #[cfg(feature = "edge-proxy")]
109 pub edge_cache: RwLock<Option<Arc<EdgeCache>>>,
110 #[cfg(feature = "edge-proxy")]
111 pub edge_registry: RwLock<Option<Arc<EdgeRegistry>>>,
112 pub auth_token: RwLock<Option<String>>,
115 pub migration: RwLock<Option<MigrationInfo>>,
118 pub branch: RwLock<Option<crate::config::BranchConfig>>,
121}
122
123#[derive(Clone)]
126pub struct MigrationInfo {
127 pub target: String,
128 pub writes_only: bool,
129 pub metrics: Arc<crate::mirror::MirrorMetrics>,
130 pub config: crate::config::MirrorConfig,
132 pub cutover: Arc<arc_swap::ArcSwap<Option<Arc<crate::mirror::CutoverTarget>>>>,
134 pub cutover_target: crate::mirror::CutoverTarget,
135}
136
137#[derive(Debug, Clone, Serialize)]
142pub struct ChaosOverride {
143 pub since: String,
145 pub kind: String,
147 pub note: String,
149}
150
151type CommandHandler = Arc<dyn Fn(&[&str]) -> Result<String> + Send + Sync>;
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ConfigSnapshot {
157 pub listen_address: String,
158 pub admin_address: String,
159 pub tr_enabled: bool,
160 pub tr_mode: String,
161 pub pool_min_connections: usize,
162 pub pool_max_connections: usize,
163 pub nodes: Vec<NodeSnapshot>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct NodeSnapshot {
169 pub address: String,
170 pub role: String,
171 pub weight: u32,
172 pub enabled: bool,
173}
174
175impl AdminServer {
176 pub fn new(listen_address: String, state: Arc<AdminState>) -> Self {
178 let (shutdown_tx, _) = broadcast::channel(1);
179
180 Self {
181 listen_address,
182 state,
183 shutdown_tx,
184 }
185 }
186
187 pub async fn run(&self) -> Result<()> {
189 let listener = crate::server::bind_reuseport(&self.listen_address)?;
192
193 tracing::info!(
194 "Admin API listening on {} (SO_REUSEPORT)",
195 self.listen_address
196 );
197
198 let mut shutdown_rx = self.shutdown_tx.subscribe();
199 let conn_limit = std::sync::Arc::new(tokio::sync::Semaphore::new(Self::MAX_ADMIN_CONNS));
203
204 loop {
205 tokio::select! {
206 accept_result = listener.accept() => {
207 match accept_result {
208 Ok((stream, addr)) => {
209 let permit = match conn_limit.clone().try_acquire_owned() {
210 Ok(p) => p,
211 Err(_) => {
212 tracing::warn!(%addr, "admin connection limit reached; dropping");
213 drop(stream);
214 continue;
215 }
216 };
217 let state = self.state.clone();
218 tokio::spawn(async move {
219 let _permit = permit; if let Err(e) = Self::handle_connection(stream, addr, state).await {
221 tracing::error!("Admin connection error: {}", e);
222 }
223 });
224 }
225 Err(e) => {
226 tracing::error!("Admin accept error: {}", e);
227 }
228 }
229 }
230 _ = shutdown_rx.recv() => {
231 tracing::info!("Admin server shutting down");
232 break;
233 }
234 }
235 }
236
237 Ok(())
238 }
239
240 const MAX_ADMIN_CONNS: usize = 256;
244 const ADMIN_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
245 #[cfg(feature = "edge-proxy")]
251 const ADMIN_SSE_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
252 const MAX_ADMIN_HEADERS: usize = 100;
254 const MAX_ADMIN_HEADER_BYTES: usize = 64 * 1024;
256 const MAX_ADMIN_BODY_BYTES: usize = 8 * 1024 * 1024;
259
260 async fn handle_connection(
262 mut stream: TcpStream,
263 addr: SocketAddr,
264 state: Arc<AdminState>,
265 ) -> Result<()> {
266 tracing::debug!("Admin connection from {}", addr);
267
268 let (reader, mut writer) = stream.split();
269 let mut reader = BufReader::new(reader);
270 let mut line = String::new();
271
272 let mut headers = Vec::new();
274 let mut content_length: usize = 0;
275 let mut header_bytes: usize = 0;
276
277 loop {
278 line.clear();
279 let bytes_read =
283 match tokio::time::timeout(Self::ADMIN_READ_TIMEOUT, reader.read_line(&mut line))
284 .await
285 {
286 Ok(r) => r.map_err(|e| ProxyError::Network(format!("Read error: {}", e)))?,
287 Err(_) => return Ok(()), };
289
290 if bytes_read == 0 || line == "\r\n" {
291 break;
292 }
293
294 header_bytes += bytes_read;
297 if headers.len() >= Self::MAX_ADMIN_HEADERS
298 || header_bytes > Self::MAX_ADMIN_HEADER_BYTES
299 {
300 Self::send_response(
301 &mut writer,
302 431,
303 "Request Header Fields Too Large",
304 "header section too large",
305 )
306 .await?;
307 return Ok(());
308 }
309
310 let trimmed = line.trim();
312 if trimmed.to_lowercase().starts_with("content-length:") {
313 if let Some(len_str) = trimmed.split(':').nth(1) {
314 content_length = len_str.trim().parse().unwrap_or(0);
315 }
316 }
317 headers.push(trimmed.to_string());
318 }
319
320 if headers.is_empty() {
321 return Ok(());
322 }
323
324 if content_length > Self::MAX_ADMIN_BODY_BYTES {
329 Self::send_response(
330 &mut writer,
331 413,
332 "Payload Too Large",
333 "request body exceeds admin size limit",
334 )
335 .await?;
336 return Ok(());
337 }
338
339 let request_line = &headers[0];
341 let parts: Vec<&str> = request_line.split_whitespace().collect();
342
343 if parts.len() < 2 {
344 Self::send_response(&mut writer, 400, "Bad Request", "Invalid request line").await?;
345 return Ok(());
346 }
347
348 let method = parts[0];
349 let path = parts[1];
350
351 {
355 let required = state.auth_token.read().await.clone();
356 if let Some(token) = required {
357 let path_only = path.split('?').next().unwrap_or(path);
358 let is_liveness = method == "GET"
359 && matches!(path_only, "/health" | "/healthz" | "/livez" | "/readyz");
360 if !is_liveness && !Self::admin_authorized(&headers, &token) {
361 Self::send_response(
362 &mut writer,
363 401,
364 "Unauthorized",
365 "{\"error\":\"missing or invalid admin bearer token\"}",
366 )
367 .await?;
368 return Ok(());
369 }
370 }
371 }
372
373 #[cfg(feature = "edge-proxy")]
386 if method == "GET" && path.split('?').next().unwrap_or(path) == "/api/edge/subscribe" {
387 let params = parse_query_params(path);
388 let edge_id = params.get("edge_id").map(String::as_str).unwrap_or("");
389 if edge_id.is_empty() {
390 Self::send_json_response(
391 &mut writer,
392 400,
393 &serde_json::json!({ "error": "edge_id query parameter is required" }),
394 )
395 .await?;
396 return Ok(());
397 }
398 let region = params.get("region").map(String::as_str).unwrap_or("");
399 let base_url = params.get("base_url").map(String::as_str).unwrap_or("");
400 return Self::handle_edge_subscribe(&mut writer, &state, edge_id, region, base_url)
401 .await;
402 }
403
404 let body = if content_length > 0 && (method == "POST" || method == "PUT") {
406 let mut body_buf = vec![0u8; content_length];
407 match tokio::time::timeout(Self::ADMIN_READ_TIMEOUT, reader.read_exact(&mut body_buf))
408 .await
409 {
410 Ok(r) => {
411 r.map_err(|e| ProxyError::Network(format!("Body read error: {}", e)))?;
412 }
413 Err(_) => return Ok(()), }
415 Some(String::from_utf8_lossy(&body_buf).to_string())
416 } else {
417 None
418 };
419
420 if method == "GET" && (path == "/" || path == "/ui" || path == "/ui/") {
423 Self::send_html_response(&mut writer, 200, ADMIN_UI_HTML).await?;
424 return Ok(());
425 }
426
427 let response = Self::route_request(method, path, body.as_deref(), &state).await;
429
430 match response {
431 Ok((status, body)) => {
432 Self::send_json_response(&mut writer, status, &body).await?;
433 }
434 Err(e) => {
435 let error = ErrorResponse {
436 error: e.to_string(),
437 };
438 Self::send_json_response(&mut writer, 500, &error).await?;
439 }
440 }
441
442 Ok(())
443 }
444
445 fn admin_authorized(headers: &[String], token: &str) -> bool {
448 let expected = format!("Bearer {}", token);
449 for h in headers {
450 let mut sp = h.splitn(2, ':');
451 let name = sp.next().unwrap_or("").trim();
452 if name.eq_ignore_ascii_case("authorization") {
453 let value = sp.next().unwrap_or("").trim();
454 return constant_time_eq_str(value, &expected);
455 }
456 }
457 false
458 }
459
460 async fn send_html_response(
462 writer: &mut tokio::net::tcp::WriteHalf<'_>,
463 status: u16,
464 html: &str,
465 ) -> Result<()> {
466 let status_text = match status {
467 200 => "OK",
468 404 => "Not Found",
469 _ => "Unknown",
470 };
471 let response = format!(
472 "HTTP/1.1 {} {}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
473 status,
474 status_text,
475 html.len(),
476 html
477 );
478 writer
479 .write_all(response.as_bytes())
480 .await
481 .map_err(|e| ProxyError::Network(format!("Write error: {}", e)))?;
482 Ok(())
483 }
484
485 async fn route_request(
487 method: &str,
488 path: &str,
489 body: Option<&str>,
490 state: &Arc<AdminState>,
491 ) -> Result<(u16, serde_json::Value)> {
492 match (method, path) {
493 ("POST", "/api/sql") => Self::handle_sql_request(body, state).await,
495
496 ("GET", "/health") => {
498 let health = HealthResponse { status: "ok" };
499 Ok((200, serde_json::to_value(health)?))
500 }
501 ("GET", "/health/ready") => {
502 let ready = Self::check_readiness(state).await;
503 let response = ReadinessResponse {
504 ready,
505 message: if ready {
506 "Proxy is ready"
507 } else {
508 "Proxy is not ready"
509 },
510 };
511 let status = if ready { 200 } else { 503 };
512 Ok((status, serde_json::to_value(response)?))
513 }
514 ("GET", "/health/live") => {
515 let response = LivenessResponse { alive: true };
516 Ok((200, serde_json::to_value(response)?))
517 }
518
519 ("GET", "/metrics") => {
521 let metrics = state.metrics.read().await.clone();
522 Ok((200, serde_json::to_value(MetricsResponse::from(metrics))?))
523 }
524 ("GET", "/metrics/prometheus") => {
525 let metrics = state.metrics.read().await.clone();
526 let prometheus = Self::format_prometheus_metrics(&metrics);
527 Ok((200, serde_json::json!({ "text": prometheus })))
528 }
529
530 ("GET", "/nodes") => {
532 let health = state.node_health.read().await;
533 let nodes: Vec<NodeHealthResponse> = health
534 .values()
535 .map(|h| NodeHealthResponse::from(h.clone()))
536 .collect();
537 Ok((200, serde_json::to_value(nodes)?))
538 }
539 ("GET", path) if path.starts_with("/nodes/") => {
540 let node_addr = path.trim_start_matches("/nodes/");
541 let health = state.node_health.read().await;
542 match health.get(node_addr) {
543 Some(h) => Ok((
544 200,
545 serde_json::to_value(NodeHealthResponse::from(h.clone()))?,
546 )),
547 None => Ok((404, serde_json::json!({ "error": "Node not found" }))),
548 }
549 }
550 ("POST", path) if path.starts_with("/nodes/") && path.ends_with("/enable") => {
551 let node_addr = path
552 .trim_start_matches("/nodes/")
553 .trim_end_matches("/enable");
554 Self::set_node_enabled(state, node_addr, true).await?;
555 Ok((200, serde_json::json!({ "status": "enabled" })))
556 }
557 ("POST", path) if path.starts_with("/nodes/") && path.ends_with("/disable") => {
558 let node_addr = path
559 .trim_start_matches("/nodes/")
560 .trim_end_matches("/disable");
561 Self::set_node_enabled(state, node_addr, false).await?;
562 Ok((200, serde_json::json!({ "status": "disabled" })))
563 }
564
565 ("GET", "/topology") => {
571 let topo = Self::compute_topology(state).await;
572 Ok((200, serde_json::to_value(topo)?))
573 }
574
575 #[cfg(feature = "ha-tr")]
579 ("POST", "/api/replay") => Self::handle_replay_request(body, state).await,
580 #[cfg(not(feature = "ha-tr"))]
581 ("POST", "/api/replay") => Ok((
582 503,
583 serde_json::json!({ "error": "ha-tr feature not compiled in" }),
584 )),
585
586 #[cfg(feature = "ha-tr")]
592 ("POST", "/api/shadow") => Self::handle_shadow_request(body).await,
593 #[cfg(not(feature = "ha-tr"))]
594 ("POST", "/api/shadow") => Ok((
595 503,
596 serde_json::json!({ "error": "ha-tr feature not compiled in" }),
597 )),
598
599 ("GET", "/plugins") => Self::handle_plugins_list(state).await,
604
605 #[cfg(feature = "anomaly-detection")]
609 ("GET", p) if p == "/anomalies" || p.starts_with("/anomalies?") => {
610 Self::handle_anomalies_list(p, state).await
611 }
612 #[cfg(not(feature = "anomaly-detection"))]
613 ("GET", p) if p == "/anomalies" || p.starts_with("/anomalies?") => Ok((
614 503,
615 serde_json::json!({ "error": "anomaly-detection feature not compiled in" }),
616 )),
617
618 #[cfg(feature = "query-analytics")]
620 ("GET", p)
621 if p == "/api/analytics"
622 || p == "/analytics"
623 || p.starts_with("/api/analytics?")
624 || p.starts_with("/analytics?") =>
625 {
626 Self::handle_analytics(p, state).await
627 }
628 #[cfg(not(feature = "query-analytics"))]
629 ("GET", p)
630 if p == "/api/analytics"
631 || p == "/analytics"
632 || p.starts_with("/api/analytics?")
633 || p.starts_with("/analytics?") =>
634 {
635 Ok((
636 503,
637 serde_json::json!({ "error": "query-analytics feature not compiled in" }),
638 ))
639 }
640
641 #[cfg(feature = "edge-proxy")]
649 ("GET", "/api/edge") => Self::handle_edge_status(state).await,
650 #[cfg(feature = "edge-proxy")]
651 ("POST", "/api/edge/register") => Self::handle_edge_register(body, state).await,
652 #[cfg(feature = "edge-proxy")]
653 ("POST", "/api/edge/invalidate") => Self::handle_edge_invalidate(body, state).await,
654 #[cfg(not(feature = "edge-proxy"))]
655 ("GET", "/api/edge")
656 | ("POST", "/api/edge/register")
657 | ("POST", "/api/edge/invalidate") => Ok((
658 503,
659 serde_json::json!({ "error": "edge-proxy feature not compiled in" }),
660 )),
661 #[cfg(not(feature = "edge-proxy"))]
665 ("GET", p) if p == "/api/edge/subscribe" || p.starts_with("/api/edge/subscribe?") => {
666 Ok((
667 503,
668 serde_json::json!({ "error": "edge-proxy feature not compiled in" }),
669 ))
670 }
671
672 ("POST", "/api/chaos") => Self::handle_chaos_request(body, state).await,
676 ("GET", "/api/chaos") => {
679 let overrides = state.chaos_overrides.read().await.clone();
680 Ok((200, serde_json::to_value(overrides)?))
681 }
682
683 #[cfg(feature = "circuit-breaker")]
686 ("GET", "/api/circuit") => Self::handle_circuit_status(state).await,
687 #[cfg(not(feature = "circuit-breaker"))]
688 ("GET", "/api/circuit") => Ok((
689 503,
690 serde_json::json!({ "error": "circuit-breaker feature not enabled" }),
691 )),
692
693 ("GET", "/api/migration/status") | ("GET", "/migration/status") => {
695 match state.migration.read().await.as_ref() {
696 Some(info) => {
697 let st =
698 crate::mirror::status(&info.target, info.writes_only, &info.metrics);
699 let mut v = serde_json::to_value(st)?;
700 let cut = info.cutover.load_full().is_some();
701 v["cutover_active"] = serde_json::json!(cut);
702 Ok((200, v))
703 }
704 None => Ok((
705 503,
706 serde_json::json!({ "error": "traffic mirroring not enabled" }),
707 )),
708 }
709 }
710
711 ("POST", "/api/migration/cutover") | ("POST", "/migration/cutover") => {
713 let info = state.migration.read().await.clone();
714 let Some(info) = info else {
715 return Ok((
716 503,
717 serde_json::json!({ "error": "traffic mirroring not enabled" }),
718 ));
719 };
720 let force = path.contains("force=true")
721 || body.map(|b| b.contains("\"force\":true")).unwrap_or(false);
722 let st = crate::mirror::status(&info.target, info.writes_only, &info.metrics);
723 if !st.migration_ready && !force {
724 return Ok((
725 409,
726 serde_json::json!({
727 "ok": false,
728 "error": "not migration_ready (backlog/drops present); pass force=true to override",
729 "status": st,
730 }),
731 ));
732 }
733 info.cutover
734 .store(Arc::new(Some(Arc::new(info.cutover_target.clone()))));
735 tracing::warn!(target = %info.cutover_target.addr, "migration cutover: new connections now route to the promoted target");
736 Ok((
737 200,
738 serde_json::json!({ "ok": true, "promoted_to": info.cutover_target.addr }),
739 ))
740 }
741
742 ("POST", "/api/migration/cutover/rollback")
744 | ("POST", "/migration/cutover/rollback") => {
745 let info = state.migration.read().await.clone();
746 let Some(info) = info else {
747 return Ok((
748 503,
749 serde_json::json!({ "error": "traffic mirroring not enabled" }),
750 ));
751 };
752 info.cutover.store(Arc::new(None));
753 Ok((200, serde_json::json!({ "ok": true, "rolled_back": true })))
754 }
755
756 ("POST", "/api/migration/snapshot") | ("POST", "/migration/snapshot") => {
758 let info = state.migration.read().await.clone();
759 let Some(info) = info else {
760 return Ok((
761 503,
762 serde_json::json!({ "error": "traffic mirroring not enabled" }),
763 ));
764 };
765 let body = body.unwrap_or("{}");
766 let req: serde_json::Value = serde_json::from_str(body)
767 .map_err(|e| ProxyError::Internal(format!("invalid JSON: {}", e)))?;
768 let tables: Vec<String> = req
769 .get("tables")
770 .and_then(|t| t.as_array())
771 .map(|a| {
772 a.iter()
773 .filter_map(|v| v.as_str().map(String::from))
774 .collect()
775 })
776 .unwrap_or_default();
777 if tables.is_empty() {
778 return Ok((
779 400,
780 serde_json::json!({ "error": "provide a non-empty 'tables' array" }),
781 ));
782 }
783 match crate::mirror::snapshot_tables(&info.config, &tables).await {
784 Ok(rep) => {
785 let total: u64 = rep.iter().map(|t| t.copied).sum();
786 Ok((
787 200,
788 serde_json::json!({ "ok": true, "tables": rep, "rows_copied": total }),
789 ))
790 }
791 Err(e) => Ok((500, serde_json::json!({ "ok": false, "error": e }))),
792 }
793 }
794
795 ("GET", p) if p == "/api/branch" || p == "/branch" || p.starts_with("/api/branch?") => {
797 let cfg = state.branch.read().await.clone();
798 let Some(cfg) = cfg else {
799 return Ok((
800 503,
801 serde_json::json!({ "error": "branch databases not enabled" }),
802 ));
803 };
804 match crate::branch::list(&cfg).await {
805 Ok(branches) => Ok((200, serde_json::json!({ "branches": branches }))),
806 Err(e) => Ok((500, serde_json::json!({ "error": e }))),
807 }
808 }
809 ("POST", p) if p == "/api/branch" || p == "/branch" => {
810 let cfg = state.branch.read().await.clone();
811 let Some(cfg) = cfg else {
812 return Ok((
813 503,
814 serde_json::json!({ "error": "branch databases not enabled" }),
815 ));
816 };
817 let req: serde_json::Value = serde_json::from_str(body.unwrap_or("{}"))
818 .map_err(|e| ProxyError::Internal(format!("invalid JSON: {}", e)))?;
819 let name = req.get("name").and_then(|v| v.as_str()).unwrap_or("");
820 if name.is_empty() {
821 return Ok((400, serde_json::json!({ "error": "provide 'name'" })));
822 }
823 let base = req.get("base").and_then(|v| v.as_str());
824 match crate::branch::create(&cfg, name, base).await {
825 Ok(()) => Ok((
826 200,
827 serde_json::json!({ "ok": true, "branch": name,
828 "base": base.unwrap_or(&cfg.base_database) }),
829 )),
830 Err(e) => Ok((500, serde_json::json!({ "ok": false, "error": e }))),
831 }
832 }
833 ("DELETE", p) if p.starts_with("/api/branch") || p.starts_with("/branch") => {
834 let cfg = state.branch.read().await.clone();
835 let Some(cfg) = cfg else {
836 return Ok((
837 503,
838 serde_json::json!({ "error": "branch databases not enabled" }),
839 ));
840 };
841 let name = p.find("name=").map(|i| &p[i + 5..]).unwrap_or("");
842 if name.is_empty() {
843 return Ok((
844 400,
845 serde_json::json!({ "error": "provide ?name=<branch>" }),
846 ));
847 }
848 match crate::branch::drop(&cfg, name).await {
849 Ok(()) => Ok((200, serde_json::json!({ "ok": true, "dropped": name }))),
850 Err(e) => Ok((500, serde_json::json!({ "ok": false, "error": e }))),
851 }
852 }
853
854 ("GET", "/config") => {
856 let config = state.config_snapshot.read().await.clone();
857 Ok((200, serde_json::to_value(config)?))
858 }
859
860 ("GET", "/sessions") => {
862 let count = *state.active_sessions.read().await;
863 let response = SessionsResponse {
864 active_sessions: count,
865 };
866 Ok((200, serde_json::to_value(response)?))
867 }
868
869 ("GET", "/pools") => {
871 let pools = Self::get_pool_stats(state).await;
872 Ok((200, serde_json::to_value(pools)?))
873 }
874
875 ("GET", "/version") => {
877 let response = VersionResponse {
878 version: crate::VERSION.to_string(),
879 build_time: env!("CARGO_PKG_VERSION").to_string(),
880 };
881 Ok((200, serde_json::to_value(response)?))
882 }
883
884 _ => Ok((404, serde_json::json!({ "error": "Not found" }))),
886 }
887 }
888
889 async fn handle_sql_request(
891 body: Option<&str>,
892 state: &Arc<AdminState>,
893 ) -> Result<(u16, serde_json::Value)> {
894 let body = body.ok_or_else(|| ProxyError::Internal("Missing request body".to_string()))?;
896 let request: SqlRequest = serde_json::from_str(body)
897 .map_err(|e| ProxyError::Internal(format!("Invalid JSON: {}", e)))?;
898
899 let sql = request.query.trim();
900 if sql.is_empty() {
901 return Ok((400, serde_json::json!({ "error": "Empty query" })));
902 }
903
904 let is_write = Self::is_write_query(sql);
906 let query_type = if is_write { "write" } else { "read" };
907
908 let proxy_config = state.proxy_config.read().await;
910 let config = proxy_config
911 .as_ref()
912 .ok_or_else(|| ProxyError::Internal("Proxy config not initialized".to_string()))?;
913
914 let health = state.node_health.read().await;
916
917 let target_node = if is_write {
919 Self::select_primary_node(config, &health)?
921 } else {
922 Self::select_read_node(config, &health, state)?
924 };
925
926 let target_address = format!("{}:{}", target_node.host, target_node.port);
927 let http_port = target_node.http_port;
929 let http_url = format!("http://{}:{}/api/sql", target_node.host, http_port);
930
931 tracing::debug!(
932 "Routing {} query to {} ({})",
933 query_type,
934 target_address,
935 match target_node.role {
936 NodeRole::Primary => "primary",
937 NodeRole::Standby => "standby",
938 NodeRole::ReadReplica => "replica",
939 }
940 );
941
942 let result = Self::forward_sql_request(&http_url, sql).await?;
944
945 let response = SqlResponse {
947 query_type: query_type.to_string(),
948 routed_to: target_address,
949 node_role: format!("{:?}", target_node.role).to_lowercase(),
950 result,
951 };
952
953 Ok((200, serde_json::to_value(response)?))
954 }
955
956 fn is_write_query(sql: &str) -> bool {
958 let upper = sql.trim().to_uppercase();
959
960 if upper.starts_with("INSERT")
962 || upper.starts_with("UPDATE")
963 || upper.starts_with("DELETE")
964 || upper.starts_with("CREATE")
965 || upper.starts_with("ALTER")
966 || upper.starts_with("DROP")
967 || upper.starts_with("TRUNCATE")
968 || upper.starts_with("GRANT")
969 || upper.starts_with("REVOKE")
970 || upper.starts_with("VACUUM")
971 || upper.starts_with("REINDEX")
972 || upper.starts_with("MERGE")
973 || upper.starts_with("UPSERT")
974 {
975 return true;
976 }
977
978 if upper.starts_with("BEGIN")
980 || upper.starts_with("COMMIT")
981 || upper.starts_with("ROLLBACK")
982 || upper.starts_with("SAVEPOINT")
983 {
984 return true;
986 }
987
988 false
990 }
991
992 fn select_primary_node<'a>(
994 config: &'a ProxyConfig,
995 health: &HashMap<String, NodeHealth>,
996 ) -> Result<&'a NodeConfig> {
997 config
998 .nodes
999 .iter()
1000 .find(|n| {
1001 n.role == NodeRole::Primary
1002 && n.enabled
1003 && health.get(&n.address()).map(|h| h.healthy).unwrap_or(false)
1004 })
1005 .ok_or_else(|| ProxyError::Internal("No healthy primary node available".to_string()))
1006 }
1007
1008 fn select_read_node<'a>(
1010 config: &'a ProxyConfig,
1011 health: &HashMap<String, NodeHealth>,
1012 state: &AdminState,
1013 ) -> Result<&'a NodeConfig> {
1014 let healthy_nodes: Vec<&NodeConfig> = config
1016 .nodes
1017 .iter()
1018 .filter(|n| n.enabled && health.get(&n.address()).map(|h| h.healthy).unwrap_or(false))
1019 .collect();
1020
1021 if healthy_nodes.is_empty() {
1022 return Err(ProxyError::Internal(
1023 "No healthy nodes available".to_string(),
1024 ));
1025 }
1026
1027 if config.load_balancer.read_write_split {
1029 let read_nodes: Vec<&NodeConfig> = healthy_nodes
1030 .iter()
1031 .filter(|n| n.role == NodeRole::Standby || n.role == NodeRole::ReadReplica)
1032 .copied()
1033 .collect();
1034
1035 if !read_nodes.is_empty() {
1036 let counter = state.read_lb_counter.fetch_add(1, Ordering::Relaxed);
1038 let index = counter % read_nodes.len();
1039 return Ok(read_nodes[index]);
1040 }
1041 }
1042
1043 let counter = state.read_lb_counter.fetch_add(1, Ordering::Relaxed);
1045 let index = counter % healthy_nodes.len();
1046 Ok(healthy_nodes[index])
1047 }
1048
1049 async fn forward_sql_request(url: &str, sql: &str) -> Result<serde_json::Value> {
1051 let request_body = serde_json::json!({ "query": sql });
1053 let body_bytes = serde_json::to_vec(&request_body)
1054 .map_err(|e| ProxyError::Internal(format!("JSON serialization error: {}", e)))?;
1055
1056 let url_parts: Vec<&str> = url.trim_start_matches("http://").splitn(2, '/').collect();
1058 if url_parts.is_empty() {
1059 return Err(ProxyError::Internal("Invalid URL".to_string()));
1060 }
1061
1062 let host_port = url_parts[0];
1063 let path = if url_parts.len() > 1 {
1064 format!("/{}", url_parts[1])
1065 } else {
1066 "/".to_string()
1067 };
1068
1069 let stream = TcpStream::connect(host_port).await.map_err(|e| {
1071 ProxyError::Network(format!("Failed to connect to {}: {}", host_port, e))
1072 })?;
1073
1074 let (reader, mut writer) = stream.into_split();
1075 let mut reader = BufReader::new(reader);
1076
1077 let request = format!(
1079 "POST {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1080 path,
1081 host_port,
1082 body_bytes.len()
1083 );
1084
1085 writer
1086 .write_all(request.as_bytes())
1087 .await
1088 .map_err(|e| ProxyError::Network(format!("Write error: {}", e)))?;
1089 writer
1090 .write_all(&body_bytes)
1091 .await
1092 .map_err(|e| ProxyError::Network(format!("Write body error: {}", e)))?;
1093
1094 let mut response_headers = Vec::new();
1096 let mut line = String::new();
1097 let mut content_length: usize = 0;
1098
1099 loop {
1100 line.clear();
1101 let bytes_read = reader
1102 .read_line(&mut line)
1103 .await
1104 .map_err(|e| ProxyError::Network(format!("Response read error: {}", e)))?;
1105
1106 if bytes_read == 0 || line == "\r\n" {
1107 break;
1108 }
1109
1110 let trimmed = line.trim();
1111 if trimmed.to_lowercase().starts_with("content-length:") {
1112 if let Some(len_str) = trimmed.split(':').nth(1) {
1113 content_length = len_str.trim().parse().unwrap_or(0);
1114 }
1115 }
1116 response_headers.push(trimmed.to_string());
1117 }
1118
1119 let mut body_buf = vec![0u8; content_length];
1121 if content_length > 0 {
1122 reader
1123 .read_exact(&mut body_buf)
1124 .await
1125 .map_err(|e| ProxyError::Network(format!("Response body read error: {}", e)))?;
1126 }
1127
1128 let response_body = String::from_utf8_lossy(&body_buf);
1129
1130 serde_json::from_str(&response_body).map_err(|e| {
1132 ProxyError::Internal(format!(
1133 "Invalid JSON response: {} - body: {}",
1134 e, response_body
1135 ))
1136 })
1137 }
1138
1139 async fn check_readiness(state: &Arc<AdminState>) -> bool {
1141 let health = state.node_health.read().await;
1142
1143 health.values().any(|h| h.healthy)
1145 }
1146
1147 async fn set_node_enabled(
1149 state: &Arc<AdminState>,
1150 node_addr: &str,
1151 enabled: bool,
1152 ) -> Result<()> {
1153 let mut health = state.node_health.write().await;
1154
1155 if let Some(node_health) = health.get_mut(node_addr) {
1156 node_health.healthy = enabled;
1157 Ok(())
1158 } else {
1159 Err(ProxyError::Config(format!("Node not found: {}", node_addr)))
1160 }
1161 }
1162
1163 async fn get_pool_stats(_state: &Arc<AdminState>) -> Vec<PoolStatsResponse> {
1165 #[cfg(feature = "pool-modes")]
1168 if let Some(mgr) = _state.pool_manager.read().await.clone() {
1169 let stats = mgr.get_stats().await;
1170 return stats
1171 .node_stats
1172 .into_iter()
1173 .map(|ns| PoolStatsResponse {
1174 node: ns.node_id.0.to_string(),
1175 active_connections: ns.active as u64,
1176 idle_connections: ns.idle as u64,
1177 pending_requests: 0,
1180 total_connections_created: ns.total as u64,
1181 total_connections_closed: 0,
1182 })
1183 .collect();
1184 }
1185 Vec::new()
1186 }
1187
1188 #[cfg(feature = "ha-tr")]
1192 async fn handle_replay_request(
1193 body: Option<&str>,
1194 state: &Arc<AdminState>,
1195 ) -> Result<(u16, serde_json::Value)> {
1196 let raw =
1197 body.ok_or_else(|| ProxyError::Internal("replay: empty request body".to_string()))?;
1198 let req: ReplayRequestBody = match serde_json::from_str(raw) {
1199 Ok(r) => r,
1200 Err(e) => {
1201 return Ok((
1202 400,
1203 serde_json::json!({ "error": format!("invalid body: {}", e) }),
1204 ));
1205 }
1206 };
1207 let engine = match state.replay_engine.read().await.clone() {
1208 Some(e) => e,
1209 None => {
1210 return Ok((
1211 503,
1212 serde_json::json!({ "error": "replay engine not attached" }),
1213 ));
1214 }
1215 };
1216 let tt = TimeTravelRequest {
1217 from: req.from,
1218 to: req.to,
1219 target_host: req.target_host,
1220 target_port: req.target_port,
1221 target_user: req.target_user,
1222 target_password: req.target_password,
1223 target_database: req.target_database,
1224 };
1225 match engine.replay_window(&tt).await {
1226 Ok(summary) => Ok((200, serde_json::to_value(summary)?)),
1227 Err(e) => Ok((
1228 500,
1229 serde_json::json!({ "error": format!("replay failed: {}", e) }),
1230 )),
1231 }
1232 }
1233
1234 #[cfg(feature = "edge-proxy")]
1237 async fn handle_edge_status(state: &Arc<AdminState>) -> Result<(u16, serde_json::Value)> {
1238 let cache_stats = state.edge_cache.read().await.clone().map(|c| c.stats());
1239 let edges = match state.edge_registry.read().await.clone() {
1240 Some(r) => r.list(),
1241 None => Vec::new(),
1242 };
1243 Ok((
1244 200,
1245 serde_json::json!({
1246 "cache": cache_stats,
1247 "registered": edges,
1248 "edge_count": edges.len(),
1249 }),
1250 ))
1251 }
1252
1253 #[cfg(feature = "edge-proxy")]
1260 async fn handle_edge_register(
1261 body: Option<&str>,
1262 state: &Arc<AdminState>,
1263 ) -> Result<(u16, serde_json::Value)> {
1264 let raw =
1265 body.ok_or_else(|| ProxyError::Internal("edge register: empty body".to_string()))?;
1266 let req: EdgeRegisterBody = match serde_json::from_str(raw) {
1267 Ok(r) => r,
1268 Err(e) => {
1269 return Ok((
1270 400,
1271 serde_json::json!({ "error": format!("invalid body: {}", e) }),
1272 ));
1273 }
1274 };
1275 let registry = match state.edge_registry.read().await.clone() {
1276 Some(r) => r,
1277 None => {
1278 return Ok((
1279 503,
1280 serde_json::json!({ "error": "edge registry not attached" }),
1281 ));
1282 }
1283 };
1284 let now = chrono::Utc::now().to_rfc3339();
1285 match registry.register(&req.edge_id, &req.region, &req.base_url, &now) {
1286 Ok(_rx) => {
1287 Ok((
1294 201,
1295 serde_json::json!({
1296 "edge_id": req.edge_id,
1297 "region": req.region,
1298 "base_url": req.base_url,
1299 "registered_at": now,
1300 }),
1301 ))
1302 }
1303 Err(e) => Ok((503, serde_json::json!({ "error": e.to_string() }))),
1304 }
1305 }
1306
1307 #[cfg(feature = "edge-proxy")]
1321 async fn handle_edge_subscribe(
1322 writer: &mut tokio::net::tcp::WriteHalf<'_>,
1323 state: &Arc<AdminState>,
1324 edge_id: &str,
1325 region: &str,
1326 base_url: &str,
1327 ) -> Result<()> {
1328 let registry = match state.edge_registry.read().await.clone() {
1329 Some(r) => r,
1330 None => {
1331 Self::send_json_response(
1332 writer,
1333 503,
1334 &serde_json::json!({ "error": "edge registry not attached" }),
1335 )
1336 .await?;
1337 return Ok(());
1338 }
1339 };
1340 let now = chrono::Utc::now().to_rfc3339();
1341 let mut rx = match registry.register(edge_id, region, base_url, &now) {
1342 Ok(rx) => rx,
1343 Err(e) => {
1345 Self::send_json_response(
1346 writer,
1347 503,
1348 &serde_json::json!({ "error": e.to_string() }),
1349 )
1350 .await?;
1351 return Ok(());
1352 }
1353 };
1354
1355 if !Self::write_sse(
1363 writer,
1364 b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\n\r\n",
1365 )
1366 .await
1367 {
1368 return Ok(());
1369 }
1370
1371 if let Some(cache) = state.edge_cache.read().await.clone() {
1378 let hello = InvalidationEvent {
1379 up_to_version: cache.current_version(),
1380 tables: Vec::new(),
1381 committed_at: now.clone(),
1382 epoch: cache.epoch(),
1383 };
1384 let json = serde_json::to_string(&hello)
1385 .map_err(|e| ProxyError::Internal(format!("JSON error: {}", e)))?;
1386 let frame = format!("event: invalidate\ndata: {}\n\n", json);
1387 if !Self::write_sse(writer, frame.as_bytes()).await {
1388 return Ok(());
1389 }
1390 }
1391
1392 tracing::info!(edge_id, region, "edge subscribed to invalidation stream");
1393
1394 let mut heartbeat = tokio::time::interval(std::time::Duration::from_secs(15));
1395 heartbeat.tick().await;
1398
1399 loop {
1400 tokio::select! {
1401 ev = rx.recv() => {
1402 match ev {
1403 Some(ev) => {
1404 let json = serde_json::to_string(&ev)
1407 .map_err(|e| ProxyError::Internal(format!("JSON error: {}", e)))?;
1408 let frame = format!("event: invalidate\ndata: {}\n\n", json);
1409 if !Self::write_sse(writer, frame.as_bytes()).await {
1410 tracing::debug!(edge_id, "edge SSE write failed; closing stream");
1414 return Ok(());
1415 }
1416 }
1417 None => {
1421 tracing::debug!(edge_id, "edge subscription evicted by registry");
1422 return Ok(());
1423 }
1424 }
1425 }
1426 _ = heartbeat.tick() => {
1427 if !Self::write_sse(writer, b": keepalive\n\n").await {
1431 tracing::debug!(edge_id, "edge SSE heartbeat failed; closing stream");
1432 return Ok(());
1433 }
1434 registry.touch(edge_id, &chrono::Utc::now().to_rfc3339());
1439 }
1440 }
1441 }
1442 }
1443
1444 #[cfg(feature = "edge-proxy")]
1447 async fn write_sse(writer: &mut tokio::net::tcp::WriteHalf<'_>, bytes: &[u8]) -> bool {
1448 let write = async {
1449 writer.write_all(bytes).await?;
1450 writer.flush().await
1451 };
1452 matches!(
1453 tokio::time::timeout(Self::ADMIN_SSE_WRITE_TIMEOUT, write).await,
1454 Ok(Ok(()))
1455 )
1456 }
1457
1458 #[cfg(feature = "edge-proxy")]
1465 async fn handle_edge_invalidate(
1466 body: Option<&str>,
1467 state: &Arc<AdminState>,
1468 ) -> Result<(u16, serde_json::Value)> {
1469 let raw =
1470 body.ok_or_else(|| ProxyError::Internal("edge invalidate: empty body".to_string()))?;
1471 let req: EdgeInvalidateBody = match serde_json::from_str(raw) {
1472 Ok(r) => r,
1473 Err(e) => {
1474 return Ok((
1475 400,
1476 serde_json::json!({ "error": format!("invalid body: {}", e) }),
1477 ));
1478 }
1479 };
1480 let cache = match state.edge_cache.read().await.clone() {
1481 Some(c) => c,
1482 None => {
1483 return Ok((
1484 503,
1485 serde_json::json!({ "error": "edge cache not attached" }),
1486 ));
1487 }
1488 };
1489 let registry = match state.edge_registry.read().await.clone() {
1490 Some(r) => r,
1491 None => {
1492 return Ok((
1493 503,
1494 serde_json::json!({ "error": "edge registry not attached" }),
1495 ));
1496 }
1497 };
1498 let version = req
1508 .up_to_version
1509 .map(|v| v.min(cache.current_version()))
1510 .unwrap_or_else(|| cache.current_version());
1511 let dropped_local = cache.invalidate(version, &req.tables);
1513 let ev = InvalidationEvent {
1515 up_to_version: version,
1516 tables: req.tables.clone(),
1517 committed_at: chrono::Utc::now().to_rfc3339(),
1518 epoch: cache.epoch(),
1519 };
1520 let (sent, pruned) = registry.broadcast(ev).await;
1521 Ok((
1522 200,
1523 serde_json::json!({
1524 "version": version,
1525 "tables": req.tables,
1526 "dropped_local": dropped_local,
1527 "edges_notified": sent,
1528 "edges_pruned": pruned,
1529 }),
1530 ))
1531 }
1532
1533 #[cfg(feature = "anomaly-detection")]
1538 async fn handle_anomalies_list(
1539 path: &str,
1540 state: &Arc<AdminState>,
1541 ) -> Result<(u16, serde_json::Value)> {
1542 let limit = parse_limit_query(path, 100, 1024);
1543 let det = match state.anomaly_detector.read().await.clone() {
1544 Some(d) => d,
1545 None => {
1546 return Ok((
1547 503,
1548 serde_json::json!({ "error": "anomaly detector not attached" }),
1549 ));
1550 }
1551 };
1552 let events = det.recent_events(limit);
1553 Ok((
1554 200,
1555 serde_json::json!({
1556 "count": events.len(),
1557 "limit": limit,
1558 "events": events,
1559 "buffer_total": det.event_count(),
1560 }),
1561 ))
1562 }
1563
1564 #[cfg(feature = "query-analytics")]
1567 async fn handle_analytics(
1568 path: &str,
1569 state: &Arc<AdminState>,
1570 ) -> Result<(u16, serde_json::Value)> {
1571 use crate::analytics::OrderBy;
1572 let limit = parse_limit_query(path, 50, 1024);
1573 let a = match state.analytics.read().await.clone() {
1574 Some(a) => a,
1575 None => {
1576 return Ok((503, serde_json::json!({ "error": "analytics not enabled" })));
1577 }
1578 };
1579 let top: Vec<serde_json::Value> = a
1580 .top_queries(OrderBy::Calls, limit)
1581 .into_iter()
1582 .map(|s| {
1583 serde_json::json!({
1584 "fingerprint": s.fingerprint_hash,
1585 "normalized": s.normalized,
1586 "calls": s.calls,
1587 "avg_ms": s.avg_time.as_secs_f64() * 1000.0,
1588 "p99_ms": s.p99.as_secs_f64() * 1000.0,
1589 "rows": s.rows,
1590 "errors": s.errors,
1591 })
1592 })
1593 .collect();
1594 let slow_count = a.slow_queries(limit).len();
1595 Ok((
1596 200,
1597 serde_json::json!({
1598 "limit": limit,
1599 "top_queries": top,
1600 "slow_query_count": slow_count,
1601 }),
1602 ))
1603 }
1604
1605 #[cfg(feature = "ha-tr")]
1615 async fn handle_shadow_request(body: Option<&str>) -> Result<(u16, serde_json::Value)> {
1616 use crate::backend::{
1617 tls::default_client_config, BackendClient, BackendConfig, ParamValue, TlsMode,
1618 };
1619 use crate::shadow_execute::shadow_execute;
1620
1621 let raw =
1622 body.ok_or_else(|| ProxyError::Internal("shadow: empty request body".to_string()))?;
1623 let req: ShadowRequestBody = match serde_json::from_str(raw) {
1624 Ok(r) => r,
1625 Err(e) => {
1626 return Ok((
1627 400,
1628 serde_json::json!({ "error": format!("invalid body: {}", e) }),
1629 ));
1630 }
1631 };
1632
1633 let mk_cfg = |host: String,
1636 port: u16,
1637 user: Option<String>,
1638 password: Option<String>,
1639 database: Option<String>| BackendConfig {
1640 host,
1641 port,
1642 user: user.unwrap_or_else(|| "postgres".into()),
1643 password,
1644 database,
1645 application_name: Some("heliosdb-proxy-shadow".into()),
1646 tls_mode: TlsMode::Disable,
1647 connect_timeout: std::time::Duration::from_secs(5),
1648 query_timeout: std::time::Duration::from_secs(30),
1649 tls_config: default_client_config(),
1650 };
1651 let source_cfg = mk_cfg(
1652 req.source_host,
1653 req.source_port,
1654 req.source_user,
1655 req.source_password,
1656 req.source_database,
1657 );
1658 let shadow_cfg = mk_cfg(
1659 req.shadow_host,
1660 req.shadow_port,
1661 req.shadow_user,
1662 req.shadow_password,
1663 req.shadow_database,
1664 );
1665
1666 let mut source = match BackendClient::connect(&source_cfg).await {
1670 Ok(c) => c,
1671 Err(e) => {
1672 return Ok((
1673 500,
1674 serde_json::json!({ "error": format!("source connect: {}", e) }),
1675 ));
1676 }
1677 };
1678
1679 let params: Vec<ParamValue> = req
1680 .params
1681 .unwrap_or_default()
1682 .into_iter()
1683 .map(ParamValue::Text)
1684 .collect();
1685
1686 let outcome = shadow_execute(&mut source, &shadow_cfg, &req.sql, ¶ms).await;
1687 source.close().await;
1688
1689 match outcome {
1690 Ok((_qr, report)) => Ok((
1691 200,
1692 serde_json::json!({
1693 "sql": report.sql,
1694 "both_succeeded": report.both_succeeded,
1695 "row_count_match": report.row_count_match,
1696 "row_hash_match": report.row_hash_match,
1697 "primary_elapsed_us": report.primary_elapsed_us,
1698 "shadow_elapsed_us": report.shadow_elapsed_us,
1699 "primary_error": report.primary_error,
1700 "shadow_error": report.shadow_error,
1701 "is_clean": report.is_clean(),
1702 }),
1703 )),
1704 Err(e) => Ok((
1705 500,
1706 serde_json::json!({ "error": format!("shadow execute: {}", e) }),
1707 )),
1708 }
1709 }
1710
1711 #[cfg(feature = "circuit-breaker")]
1730 async fn handle_circuit_status(state: &Arc<AdminState>) -> Result<(u16, serde_json::Value)> {
1731 let mgr = match state.circuit_breaker.read().await.clone() {
1732 Some(m) => m,
1733 None => {
1734 return Ok((
1735 503,
1736 serde_json::json!({ "error": "circuit breaker not attached" }),
1737 ))
1738 }
1739 };
1740 let nodes = state.config_snapshot.read().await.nodes.clone();
1741 let circuits: Vec<serde_json::Value> = nodes
1742 .iter()
1743 .map(|n| {
1744 let st = mgr.get_breaker(&n.address).get_state();
1745 serde_json::json!({
1746 "node": n.address,
1747 "state": format!("{:?}", st).to_lowercase(),
1748 })
1749 })
1750 .collect();
1751 Ok((200, serde_json::json!({ "circuits": circuits })))
1752 }
1753
1754 async fn handle_chaos_request(
1755 body: Option<&str>,
1756 state: &Arc<AdminState>,
1757 ) -> Result<(u16, serde_json::Value)> {
1758 let raw =
1759 body.ok_or_else(|| ProxyError::Internal("chaos: empty request body".to_string()))?;
1760 let action: ChaosAction = match serde_json::from_str(raw) {
1761 Ok(a) => a,
1762 Err(e) => {
1763 return Ok((
1764 400,
1765 serde_json::json!({ "error": format!("invalid body: {}", e) }),
1766 ));
1767 }
1768 };
1769 match action {
1770 ChaosAction::ForceUnhealthy { target_node } => {
1771 if let Err(e) = Self::set_node_enabled(state, &target_node, false).await {
1772 return Ok((404, serde_json::json!({ "error": e.to_string() })));
1773 }
1774 state.chaos_overrides.write().await.insert(
1775 target_node.clone(),
1776 ChaosOverride {
1777 since: chrono::Utc::now().to_rfc3339(),
1778 kind: "force_unhealthy".to_string(),
1779 note: "forced unhealthy via chaos endpoint".to_string(),
1780 },
1781 );
1782 Ok((
1783 200,
1784 serde_json::json!({
1785 "applied": "force_unhealthy",
1786 "target_node": target_node,
1787 }),
1788 ))
1789 }
1790 ChaosAction::Restore { target_node } => {
1791 if let Err(e) = Self::set_node_enabled(state, &target_node, true).await {
1792 return Ok((404, serde_json::json!({ "error": e.to_string() })));
1793 }
1794 state.chaos_overrides.write().await.remove(&target_node);
1795 Ok((
1796 200,
1797 serde_json::json!({
1798 "restored": target_node,
1799 }),
1800 ))
1801 }
1802 ChaosAction::Reset => {
1803 let overrides: Vec<String> =
1804 state.chaos_overrides.read().await.keys().cloned().collect();
1805 let mut restored = Vec::with_capacity(overrides.len());
1806 for addr in overrides {
1807 let _ = Self::set_node_enabled(state, &addr, true).await;
1808 restored.push(addr);
1809 }
1810 state.chaos_overrides.write().await.clear();
1811 Ok((
1812 200,
1813 serde_json::json!({
1814 "reset": true,
1815 "restored": restored,
1816 }),
1817 ))
1818 }
1819 }
1820 }
1821
1822 #[cfg(feature = "wasm-plugins")]
1828 async fn handle_plugins_list(state: &Arc<AdminState>) -> Result<(u16, serde_json::Value)> {
1829 let pm = match state.plugin_manager.read().await.clone() {
1830 Some(p) => p,
1831 None => {
1832 return Ok((
1833 503,
1834 serde_json::json!({ "error": "plugin manager not attached" }),
1835 ));
1836 }
1837 };
1838 let plugins: Vec<PluginListEntry> = pm
1839 .list_plugins()
1840 .into_iter()
1841 .map(|info| PluginListEntry {
1842 name: info.name,
1843 version: info.version,
1844 description: info.description,
1845 hooks: info
1846 .hooks
1847 .iter()
1848 .map(|h| h.export_name().to_string())
1849 .collect(),
1850 state: format!("{:?}", info.state),
1851 invocations: info.stats.total_calls,
1852 errors: info.stats.error_count,
1853 })
1854 .collect();
1855 Ok((200, serde_json::to_value(plugins)?))
1856 }
1857
1858 #[cfg(not(feature = "wasm-plugins"))]
1859 async fn handle_plugins_list(_state: &Arc<AdminState>) -> Result<(u16, serde_json::Value)> {
1860 Ok((
1861 503,
1862 serde_json::json!({ "error": "wasm-plugins feature not compiled in" }),
1863 ))
1864 }
1865
1866 async fn compute_topology(state: &Arc<AdminState>) -> TopologyResponse {
1872 let health = state.node_health.read().await;
1873 let cfg = state.config_snapshot.read().await;
1874
1875 let mut current_primary: Option<String> = None;
1876 for n in &cfg.nodes {
1877 if n.role.eq_ignore_ascii_case("primary") {
1878 let healthy = health.get(&n.address).map(|h| h.healthy).unwrap_or(false);
1879 if healthy {
1880 current_primary = Some(n.address.clone());
1881 break;
1882 }
1883 }
1884 }
1885
1886 let healthy_nodes = health.values().filter(|h| h.healthy).count() as u32;
1887 let unhealthy_nodes = health.values().filter(|h| !h.healthy).count() as u32;
1888 let total_nodes = cfg.nodes.len() as u32;
1889
1890 TopologyResponse {
1891 current_primary,
1892 healthy_nodes,
1893 unhealthy_nodes,
1894 total_nodes,
1895 last_failover_at: None,
1896 }
1897 }
1898
1899 fn format_prometheus_metrics(metrics: &ServerMetricsSnapshot) -> String {
1901 let mut output = String::new();
1902
1903 output.push_str("# HELP heliosdb_proxy_connections_total Total connections accepted\n");
1904 output.push_str("# TYPE heliosdb_proxy_connections_total counter\n");
1905 output.push_str(&format!(
1906 "heliosdb_proxy_connections_total {}\n",
1907 metrics.connections_accepted
1908 ));
1909
1910 output.push_str("# HELP heliosdb_proxy_connections_closed Total connections closed\n");
1911 output.push_str("# TYPE heliosdb_proxy_connections_closed counter\n");
1912 output.push_str(&format!(
1913 "heliosdb_proxy_connections_closed {}\n",
1914 metrics.connections_closed
1915 ));
1916
1917 output.push_str("# HELP heliosdb_proxy_queries_total Total queries processed\n");
1918 output.push_str("# TYPE heliosdb_proxy_queries_total counter\n");
1919 output.push_str(&format!(
1920 "heliosdb_proxy_queries_total {}\n",
1921 metrics.queries_processed
1922 ));
1923
1924 output.push_str("# HELP heliosdb_proxy_bytes_received_total Total bytes received\n");
1925 output.push_str("# TYPE heliosdb_proxy_bytes_received_total counter\n");
1926 output.push_str(&format!(
1927 "heliosdb_proxy_bytes_received_total {}\n",
1928 metrics.bytes_received
1929 ));
1930
1931 output.push_str("# HELP heliosdb_proxy_bytes_sent_total Total bytes sent\n");
1932 output.push_str("# TYPE heliosdb_proxy_bytes_sent_total counter\n");
1933 output.push_str(&format!(
1934 "heliosdb_proxy_bytes_sent_total {}\n",
1935 metrics.bytes_sent
1936 ));
1937
1938 output.push_str("# HELP heliosdb_proxy_failovers_total Total failovers\n");
1939 output.push_str("# TYPE heliosdb_proxy_failovers_total counter\n");
1940 output.push_str(&format!(
1941 "heliosdb_proxy_failovers_total {}\n",
1942 metrics.failovers
1943 ));
1944
1945 output
1946 }
1947
1948 async fn send_response(
1950 writer: &mut tokio::net::tcp::WriteHalf<'_>,
1951 status: u16,
1952 status_text: &str,
1953 body: &str,
1954 ) -> Result<()> {
1955 let response = format!(
1956 "HTTP/1.1 {} {}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1957 status,
1958 status_text,
1959 body.len(),
1960 body
1961 );
1962
1963 writer
1964 .write_all(response.as_bytes())
1965 .await
1966 .map_err(|e| ProxyError::Network(format!("Write error: {}", e)))?;
1967
1968 Ok(())
1969 }
1970
1971 async fn send_json_response<T: Serialize>(
1973 writer: &mut tokio::net::tcp::WriteHalf<'_>,
1974 status: u16,
1975 body: &T,
1976 ) -> Result<()> {
1977 let json = serde_json::to_string(body)
1978 .map_err(|e| ProxyError::Internal(format!("JSON error: {}", e)))?;
1979
1980 let status_text = match status {
1981 200 => "OK",
1982 400 => "Bad Request",
1983 404 => "Not Found",
1984 500 => "Internal Server Error",
1985 503 => "Service Unavailable",
1986 _ => "Unknown",
1987 };
1988
1989 let response = format!(
1990 "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1991 status,
1992 status_text,
1993 json.len(),
1994 json
1995 );
1996
1997 writer
1998 .write_all(response.as_bytes())
1999 .await
2000 .map_err(|e| ProxyError::Network(format!("Write error: {}", e)))?;
2001
2002 Ok(())
2003 }
2004
2005 pub fn shutdown(&self) {
2007 let _ = self.shutdown_tx.send(());
2008 }
2009}
2010
2011impl AdminState {
2012 pub fn new() -> Self {
2014 Self {
2015 node_health: RwLock::new(HashMap::new()),
2016 metrics: RwLock::new(ServerMetricsSnapshot {
2017 connections_accepted: 0,
2018 connections_closed: 0,
2019 queries_processed: 0,
2020 bytes_received: 0,
2021 bytes_sent: 0,
2022 failovers: 0,
2023 }),
2024 active_sessions: RwLock::new(0),
2025 config_snapshot: RwLock::new(ConfigSnapshot {
2026 listen_address: String::new(),
2027 admin_address: String::new(),
2028 tr_enabled: false,
2029 tr_mode: String::new(),
2030 pool_min_connections: 0,
2031 pool_max_connections: 0,
2032 nodes: Vec::new(),
2033 }),
2034 proxy_config: RwLock::new(None),
2035 read_lb_counter: AtomicUsize::new(0),
2036 commands: RwLock::new(HashMap::new()),
2037 #[cfg(feature = "pool-modes")]
2038 pool_manager: RwLock::new(None),
2039 #[cfg(feature = "circuit-breaker")]
2040 circuit_breaker: RwLock::new(None),
2041 #[cfg(feature = "ha-tr")]
2042 replay_engine: RwLock::new(None),
2043 #[cfg(feature = "wasm-plugins")]
2044 plugin_manager: RwLock::new(None),
2045 chaos_overrides: RwLock::new(HashMap::new()),
2046 #[cfg(feature = "anomaly-detection")]
2047 anomaly_detector: RwLock::new(None),
2048 #[cfg(feature = "query-analytics")]
2049 analytics: RwLock::new(None),
2050 #[cfg(feature = "edge-proxy")]
2051 edge_cache: RwLock::new(None),
2052 #[cfg(feature = "edge-proxy")]
2053 edge_registry: RwLock::new(None),
2054 auth_token: RwLock::new(None),
2055 migration: RwLock::new(None),
2056 branch: RwLock::new(None),
2057 }
2058 }
2059
2060 pub async fn with_auth_token(&self, token: Option<String>) {
2062 *self.auth_token.write().await = token;
2063 }
2064
2065 pub async fn with_migration(&self, info: MigrationInfo) {
2067 *self.migration.write().await = Some(info);
2068 }
2069
2070 pub async fn with_branch(&self, cfg: crate::config::BranchConfig) {
2072 *self.branch.write().await = Some(cfg);
2073 }
2074
2075 #[cfg(feature = "pool-modes")]
2078 pub async fn with_pool_manager(&self, manager: Arc<crate::pool::ConnectionPoolManager>) {
2079 *self.pool_manager.write().await = Some(manager);
2080 }
2081
2082 #[cfg(feature = "circuit-breaker")]
2085 pub async fn with_circuit_breaker(
2086 &self,
2087 manager: Arc<crate::circuit_breaker::CircuitBreakerManager>,
2088 ) {
2089 *self.circuit_breaker.write().await = Some(manager);
2090 }
2091
2092 #[cfg(feature = "anomaly-detection")]
2095 pub async fn with_anomaly_detector(&self, detector: Arc<AnomalyDetector>) {
2096 *self.anomaly_detector.write().await = Some(detector);
2097 }
2098
2099 #[cfg(feature = "query-analytics")]
2101 pub async fn with_analytics(&self, analytics: Arc<crate::analytics::QueryAnalytics>) {
2102 *self.analytics.write().await = Some(analytics);
2103 }
2104
2105 #[cfg(feature = "edge-proxy")]
2108 pub async fn with_edge(&self, cache: Arc<EdgeCache>, registry: Arc<EdgeRegistry>) {
2109 *self.edge_cache.write().await = Some(cache);
2110 *self.edge_registry.write().await = Some(registry);
2111 }
2112
2113 #[cfg(feature = "ha-tr")]
2118 pub async fn with_replay_engine(&self, engine: Arc<ReplayEngine>) {
2119 *self.replay_engine.write().await = Some(engine);
2120 }
2121
2122 #[cfg(feature = "wasm-plugins")]
2126 pub async fn with_plugin_manager(&self, manager: Arc<PluginManager>) {
2127 *self.plugin_manager.write().await = Some(manager);
2128 }
2129
2130 pub async fn set_proxy_config(&self, config: ProxyConfig) {
2132 let mut proxy_config = self.proxy_config.write().await;
2133 *proxy_config = Some(config);
2134 }
2135
2136 pub async fn register_command<F>(&self, name: &str, handler: F)
2138 where
2139 F: Fn(&[&str]) -> Result<String> + Send + Sync + 'static,
2140 {
2141 let mut commands = self.commands.write().await;
2142 commands.insert(name.to_string(), Arc::new(handler));
2143 }
2144
2145 pub async fn execute_command(&self, name: &str, args: &[&str]) -> Result<String> {
2147 let commands = self.commands.read().await;
2148 match commands.get(name) {
2149 Some(handler) => handler(args),
2150 None => Err(ProxyError::Internal(format!("Unknown command: {}", name))),
2151 }
2152 }
2153}
2154
2155impl Default for AdminState {
2156 fn default() -> Self {
2157 Self::new()
2158 }
2159}
2160
2161#[derive(Debug, Deserialize)]
2165struct SqlRequest {
2166 query: String,
2168}
2169
2170#[derive(Debug, Serialize)]
2172struct SqlResponse {
2173 query_type: String,
2175 routed_to: String,
2177 node_role: String,
2179 result: serde_json::Value,
2181}
2182
2183#[derive(Serialize)]
2184struct HealthResponse {
2185 status: &'static str,
2186}
2187
2188#[derive(Serialize)]
2189struct ReadinessResponse {
2190 ready: bool,
2191 message: &'static str,
2192}
2193
2194#[derive(Serialize)]
2195struct LivenessResponse {
2196 alive: bool,
2197}
2198
2199#[derive(Serialize)]
2200struct ErrorResponse {
2201 error: String,
2202}
2203
2204#[derive(Serialize)]
2205struct MetricsResponse {
2206 connections_accepted: u64,
2207 connections_closed: u64,
2208 connections_active: u64,
2209 queries_processed: u64,
2210 bytes_received: u64,
2211 bytes_sent: u64,
2212 failovers: u64,
2213}
2214
2215impl From<ServerMetricsSnapshot> for MetricsResponse {
2216 fn from(m: ServerMetricsSnapshot) -> Self {
2217 Self {
2218 connections_accepted: m.connections_accepted,
2219 connections_closed: m.connections_closed,
2220 connections_active: m.connections_accepted.saturating_sub(m.connections_closed),
2221 queries_processed: m.queries_processed,
2222 bytes_received: m.bytes_received,
2223 bytes_sent: m.bytes_sent,
2224 failovers: m.failovers,
2225 }
2226 }
2227}
2228
2229#[derive(Serialize)]
2230struct NodeHealthResponse {
2231 address: String,
2232 healthy: bool,
2233 last_check: String,
2234 failure_count: u32,
2235 last_error: Option<String>,
2236 latency_ms: f64,
2237 replication_lag_bytes: Option<u64>,
2238}
2239
2240impl From<NodeHealth> for NodeHealthResponse {
2241 fn from(h: NodeHealth) -> Self {
2242 Self {
2243 address: h.address,
2244 healthy: h.healthy,
2245 last_check: h.last_check.to_rfc3339(),
2246 failure_count: h.failure_count,
2247 last_error: h.last_error,
2248 latency_ms: h.latency_ms,
2249 replication_lag_bytes: h.replication_lag_bytes,
2250 }
2251 }
2252}
2253
2254#[derive(Serialize)]
2255struct SessionsResponse {
2256 active_sessions: u64,
2257}
2258
2259#[cfg(feature = "edge-proxy")]
2261#[derive(Debug, Deserialize)]
2262struct EdgeRegisterBody {
2263 edge_id: String,
2264 region: String,
2265 base_url: String,
2266}
2267
2268#[cfg(feature = "edge-proxy")]
2272#[derive(Debug, Deserialize)]
2273struct EdgeInvalidateBody {
2274 #[serde(default)]
2275 tables: Vec<String>,
2276 #[serde(default)]
2277 up_to_version: Option<u64>,
2278}
2279
2280#[cfg(any(feature = "anomaly-detection", feature = "query-analytics"))]
2287fn parse_limit_query(path: &str, default: usize, max: usize) -> usize {
2288 let q = match path.find('?') {
2289 Some(i) => &path[i + 1..],
2290 None => return default,
2291 };
2292 for kv in q.split('&') {
2293 let mut it = kv.splitn(2, '=');
2294 if let (Some(k), Some(v)) = (it.next(), it.next()) {
2295 if k == "limit" {
2296 if let Ok(n) = v.parse::<usize>() {
2297 return n.min(max);
2298 }
2299 }
2300 }
2301 }
2302 default
2303}
2304
2305#[cfg(feature = "edge-proxy")]
2311fn percent_decode(s: &str) -> String {
2312 let bytes = s.as_bytes();
2313 let mut out = Vec::with_capacity(bytes.len());
2314 let mut i = 0;
2315 while i < bytes.len() {
2316 if bytes[i] == b'%' && i + 2 < bytes.len() {
2317 let hi = (bytes[i + 1] as char).to_digit(16);
2318 let lo = (bytes[i + 2] as char).to_digit(16);
2319 if let (Some(hi), Some(lo)) = (hi, lo) {
2320 out.push(((hi as u8) << 4) | lo as u8);
2321 i += 3;
2322 continue;
2323 }
2324 }
2325 out.push(bytes[i]);
2326 i += 1;
2327 }
2328 String::from_utf8_lossy(&out).into_owned()
2329}
2330
2331#[cfg(feature = "edge-proxy")]
2336fn parse_query_params(path: &str) -> HashMap<String, String> {
2337 let mut out = HashMap::new();
2338 let q = match path.find('?') {
2339 Some(i) => &path[i + 1..],
2340 None => return out,
2341 };
2342 for kv in q.split('&') {
2343 let mut it = kv.splitn(2, '=');
2344 if let (Some(k), Some(v)) = (it.next(), it.next()) {
2345 if !k.is_empty() {
2346 out.insert(k.to_string(), percent_decode(v));
2347 }
2348 }
2349 }
2350 out
2351}
2352
2353#[cfg(feature = "ha-tr")]
2355#[derive(Debug, Deserialize)]
2356struct ShadowRequestBody {
2357 sql: String,
2359 #[serde(default)]
2362 params: Option<Vec<String>>,
2363
2364 source_host: String,
2367 source_port: u16,
2368 #[serde(default)]
2369 source_user: Option<String>,
2370 #[serde(default)]
2371 source_password: Option<String>,
2372 #[serde(default)]
2373 source_database: Option<String>,
2374
2375 shadow_host: String,
2378 shadow_port: u16,
2379 #[serde(default)]
2380 shadow_user: Option<String>,
2381 #[serde(default)]
2382 shadow_password: Option<String>,
2383 #[serde(default)]
2384 shadow_database: Option<String>,
2385}
2386
2387#[derive(Debug, Deserialize)]
2392#[serde(tag = "action", rename_all = "snake_case")]
2393enum ChaosAction {
2394 ForceUnhealthy { target_node: String },
2398 Restore { target_node: String },
2400 Reset,
2402}
2403
2404#[cfg(feature = "wasm-plugins")]
2407#[derive(Serialize)]
2408struct PluginListEntry {
2409 name: String,
2410 version: String,
2411 description: String,
2412 hooks: Vec<String>,
2414 state: String,
2416 invocations: u64,
2417 errors: u64,
2418}
2419
2420#[cfg(feature = "ha-tr")]
2422#[derive(Debug, Deserialize)]
2423struct ReplayRequestBody {
2424 from: DateTime<Utc>,
2426 to: DateTime<Utc>,
2428 target_host: String,
2430 target_port: u16,
2432 #[serde(default)]
2438 target_user: Option<String>,
2439 #[serde(default)]
2440 target_password: Option<String>,
2441 #[serde(default)]
2442 target_database: Option<String>,
2443}
2444
2445#[derive(Serialize)]
2449struct TopologyResponse {
2450 #[serde(rename = "currentPrimary")]
2451 current_primary: Option<String>,
2452 #[serde(rename = "healthyNodes")]
2453 healthy_nodes: u32,
2454 #[serde(rename = "unhealthyNodes")]
2455 unhealthy_nodes: u32,
2456 #[serde(rename = "totalNodes")]
2457 total_nodes: u32,
2458 #[serde(rename = "lastFailoverAt")]
2461 last_failover_at: Option<String>,
2462}
2463
2464#[derive(Serialize)]
2465struct PoolStatsResponse {
2466 node: String,
2467 active_connections: u64,
2468 idle_connections: u64,
2469 pending_requests: u64,
2470 total_connections_created: u64,
2471 total_connections_closed: u64,
2472}
2473
2474#[derive(Serialize)]
2475struct VersionResponse {
2476 version: String,
2477 build_time: String,
2478}
2479
2480#[cfg(test)]
2481mod tests {
2482 use super::*;
2483
2484 #[tokio::test]
2485 async fn test_admin_state_creation() {
2486 let state = AdminState::new();
2487 let sessions = state.active_sessions.read().await;
2488 assert_eq!(*sessions, 0);
2489 }
2490
2491 #[test]
2492 fn test_admin_authorized() {
2493 let h = |s: &str| vec!["GET /topology HTTP/1.1".to_string(), s.to_string()];
2494 assert!(AdminServer::admin_authorized(
2495 &h("Authorization: Bearer s3cret"),
2496 "s3cret"
2497 ));
2498 assert!(AdminServer::admin_authorized(
2500 &h("authorization: Bearer s3cret"),
2501 "s3cret"
2502 ));
2503 assert!(!AdminServer::admin_authorized(
2505 &h("Authorization: Bearer nope"),
2506 "s3cret"
2507 ));
2508 assert!(!AdminServer::admin_authorized(
2509 &h("Authorization: Basic s3cret"),
2510 "s3cret"
2511 ));
2512 assert!(!AdminServer::admin_authorized(
2513 &["GET / HTTP/1.1".to_string()],
2514 "s3cret"
2515 ));
2516 }
2517
2518 #[test]
2519 fn test_constant_time_eq_str() {
2520 assert!(constant_time_eq_str("abc", "abc"));
2521 assert!(!constant_time_eq_str("abc", "abd"));
2522 assert!(!constant_time_eq_str("abc", "abcd"));
2523 }
2524
2525 #[tokio::test]
2526 async fn test_readiness_check_no_nodes() {
2527 let state = Arc::new(AdminState::new());
2528 let ready = AdminServer::check_readiness(&state).await;
2529 assert!(!ready);
2530 }
2531
2532 #[tokio::test]
2533 async fn test_readiness_check_with_healthy_node() {
2534 let state = Arc::new(AdminState::new());
2535
2536 {
2537 let mut health = state.node_health.write().await;
2538 health.insert(
2539 "localhost:5432".to_string(),
2540 NodeHealth {
2541 address: "localhost:5432".to_string(),
2542 healthy: true,
2543 last_check: chrono::Utc::now(),
2544 failure_count: 0,
2545 last_error: None,
2546 latency_ms: 1.0,
2547 replication_lag_bytes: None,
2548 },
2549 );
2550 }
2551
2552 let ready = AdminServer::check_readiness(&state).await;
2553 assert!(ready);
2554 }
2555
2556 #[tokio::test]
2557 async fn test_command_registration() {
2558 let state = AdminState::new();
2559
2560 state
2561 .register_command("test", |args| {
2562 Ok(format!("Test command with {} args", args.len()))
2563 })
2564 .await;
2565
2566 let result = state.execute_command("test", &["arg1", "arg2"]).await;
2567 assert!(result.is_ok());
2568 assert_eq!(result.unwrap(), "Test command with 2 args");
2569 }
2570
2571 #[tokio::test]
2572 async fn test_unknown_command() {
2573 let state = AdminState::new();
2574 let result = state.execute_command("unknown", &[]).await;
2575 assert!(result.is_err());
2576 }
2577
2578 #[test]
2579 fn test_prometheus_metrics_format() {
2580 let metrics = ServerMetricsSnapshot {
2581 connections_accepted: 100,
2582 connections_closed: 50,
2583 queries_processed: 1000,
2584 bytes_received: 50000,
2585 bytes_sent: 100000,
2586 failovers: 2,
2587 };
2588
2589 let output = AdminServer::format_prometheus_metrics(&metrics);
2590 assert!(output.contains("heliosdb_proxy_connections_total 100"));
2591 assert!(output.contains("heliosdb_proxy_queries_total 1000"));
2592 assert!(output.contains("heliosdb_proxy_failovers_total 2"));
2593 }
2594
2595 #[test]
2596 fn test_metrics_response_active_connections() {
2597 let snapshot = ServerMetricsSnapshot {
2598 connections_accepted: 100,
2599 connections_closed: 30,
2600 queries_processed: 500,
2601 bytes_received: 10000,
2602 bytes_sent: 20000,
2603 failovers: 1,
2604 };
2605
2606 let response = MetricsResponse::from(snapshot);
2607 assert_eq!(response.connections_active, 70);
2608 }
2609
2610 async fn topology_state(nodes: &[(&str, &str, bool)]) -> Arc<AdminState> {
2613 let state = Arc::new(AdminState::new());
2614 {
2615 let mut cfg = state.config_snapshot.write().await;
2616 cfg.nodes = nodes
2617 .iter()
2618 .map(|(addr, role, _)| NodeSnapshot {
2619 address: (*addr).to_string(),
2620 role: (*role).to_string(),
2621 weight: 100,
2622 enabled: true,
2623 })
2624 .collect();
2625 }
2626 {
2627 let mut health = state.node_health.write().await;
2628 for (addr, _, healthy) in nodes {
2629 health.insert(
2630 (*addr).to_string(),
2631 NodeHealth {
2632 address: (*addr).to_string(),
2633 healthy: *healthy,
2634 last_check: chrono::Utc::now(),
2635 failure_count: 0,
2636 last_error: None,
2637 latency_ms: 1.0,
2638 replication_lag_bytes: None,
2639 },
2640 );
2641 }
2642 }
2643 state
2644 }
2645
2646 #[tokio::test]
2647 async fn test_topology_returns_healthy_primary() {
2648 let state = topology_state(&[
2649 ("primary.svc:5432", "primary", true),
2650 ("standby-a.svc:5432", "standby", true),
2651 ("standby-b.svc:5432", "standby", false),
2652 ])
2653 .await;
2654
2655 let topo = AdminServer::compute_topology(&state).await;
2656 assert_eq!(topo.current_primary.as_deref(), Some("primary.svc:5432"));
2657 assert_eq!(topo.healthy_nodes, 2);
2658 assert_eq!(topo.unhealthy_nodes, 1);
2659 assert_eq!(topo.total_nodes, 3);
2660 }
2661
2662 #[tokio::test]
2663 async fn test_topology_no_primary_when_primary_unhealthy() {
2664 let state = topology_state(&[
2667 ("primary.svc:5432", "primary", false),
2668 ("standby.svc:5432", "standby", true),
2669 ])
2670 .await;
2671
2672 let topo = AdminServer::compute_topology(&state).await;
2673 assert_eq!(topo.current_primary, None);
2674 assert_eq!(topo.healthy_nodes, 1);
2675 assert_eq!(topo.unhealthy_nodes, 1);
2676 }
2677
2678 #[tokio::test]
2679 async fn test_topology_handles_empty_cluster() {
2680 let state = Arc::new(AdminState::new());
2681 let topo = AdminServer::compute_topology(&state).await;
2682 assert_eq!(topo.current_primary, None);
2683 assert_eq!(topo.healthy_nodes, 0);
2684 assert_eq!(topo.unhealthy_nodes, 0);
2685 assert_eq!(topo.total_nodes, 0);
2686 }
2687
2688 #[tokio::test]
2689 async fn test_topology_role_match_is_case_insensitive() {
2690 let state = topology_state(&[("primary.svc:5432", "PRIMARY", true)]).await;
2691 let topo = AdminServer::compute_topology(&state).await;
2692 assert_eq!(topo.current_primary.as_deref(), Some("primary.svc:5432"));
2693 }
2694
2695 #[cfg(feature = "ha-tr")]
2696 #[tokio::test]
2697 async fn test_replay_returns_503_when_engine_unattached() {
2698 let state = Arc::new(AdminState::new());
2699 let body = r#"{
2700 "from": "2026-04-25T10:00:00Z",
2701 "to": "2026-04-25T11:00:00Z",
2702 "target_host": "127.0.0.1",
2703 "target_port": 5432
2704 }"#;
2705 let (status, value) = AdminServer::handle_replay_request(Some(body), &state)
2706 .await
2707 .expect("handler returns Ok with status code");
2708 assert_eq!(status, 503);
2709 assert_eq!(value["error"], "replay engine not attached");
2710 }
2711
2712 #[cfg(feature = "ha-tr")]
2713 #[tokio::test]
2714 async fn test_replay_400_on_malformed_body() {
2715 let state = Arc::new(AdminState::new());
2716 let (status, _) = AdminServer::handle_replay_request(Some("not json"), &state)
2717 .await
2718 .expect("handler returns Ok with status code");
2719 assert_eq!(status, 400);
2720 }
2721
2722 #[cfg(feature = "ha-tr")]
2723 #[tokio::test]
2724 async fn test_replay_errors_on_empty_body() {
2725 let state = Arc::new(AdminState::new());
2726 let err = AdminServer::handle_replay_request(None, &state).await;
2727 assert!(err.is_err(), "empty body must surface as Err");
2728 }
2729
2730 #[cfg(feature = "wasm-plugins")]
2731 #[tokio::test]
2732 async fn test_plugins_list_returns_503_when_manager_unattached() {
2733 let state = Arc::new(AdminState::new());
2734 let (status, value) = AdminServer::handle_plugins_list(&state)
2735 .await
2736 .expect("handler returns Ok with status code");
2737 assert_eq!(status, 503);
2738 assert_eq!(value["error"], "plugin manager not attached");
2739 }
2740
2741 #[cfg(not(feature = "wasm-plugins"))]
2742 #[tokio::test]
2743 async fn test_plugins_list_503_without_feature() {
2744 let state = Arc::new(AdminState::new());
2745 let (status, _) = AdminServer::handle_plugins_list(&state)
2746 .await
2747 .expect("handler returns Ok");
2748 assert_eq!(status, 503);
2749 }
2750
2751 async fn chaos_state_with_node(addr: &str) -> Arc<AdminState> {
2753 let state = Arc::new(AdminState::new());
2754 state.node_health.write().await.insert(
2755 addr.to_string(),
2756 NodeHealth {
2757 address: addr.to_string(),
2758 healthy: true,
2759 last_check: chrono::Utc::now(),
2760 failure_count: 0,
2761 last_error: None,
2762 latency_ms: 1.0,
2763 replication_lag_bytes: None,
2764 },
2765 );
2766 state
2767 }
2768
2769 #[tokio::test]
2770 async fn test_chaos_force_unhealthy_flips_node_and_records_override() {
2771 let state = chaos_state_with_node("primary.svc:5432").await;
2772 let body = r#"{"action":"force_unhealthy","target_node":"primary.svc:5432"}"#;
2773 let (status, value) = AdminServer::handle_chaos_request(Some(body), &state)
2774 .await
2775 .expect("handler returns Ok");
2776 assert_eq!(status, 200);
2777 assert_eq!(value["applied"], "force_unhealthy");
2778 assert!(!state.node_health.read().await["primary.svc:5432"].healthy);
2780 assert!(state
2782 .chaos_overrides
2783 .read()
2784 .await
2785 .contains_key("primary.svc:5432"));
2786 }
2787
2788 #[tokio::test]
2789 async fn test_chaos_restore_clears_override_and_flips_back() {
2790 let state = chaos_state_with_node("primary.svc:5432").await;
2791 let _ = AdminServer::handle_chaos_request(
2792 Some(r#"{"action":"force_unhealthy","target_node":"primary.svc:5432"}"#),
2793 &state,
2794 )
2795 .await
2796 .unwrap();
2797 let (status, _) = AdminServer::handle_chaos_request(
2798 Some(r#"{"action":"restore","target_node":"primary.svc:5432"}"#),
2799 &state,
2800 )
2801 .await
2802 .unwrap();
2803 assert_eq!(status, 200);
2804 assert!(state.node_health.read().await["primary.svc:5432"].healthy);
2805 assert!(state.chaos_overrides.read().await.is_empty());
2806 }
2807
2808 #[tokio::test]
2809 async fn test_chaos_reset_restores_all_overrides() {
2810 let state = chaos_state_with_node("a:5432").await;
2811 state.node_health.write().await.insert(
2812 "b:5432".to_string(),
2813 NodeHealth {
2814 address: "b:5432".to_string(),
2815 healthy: true,
2816 last_check: chrono::Utc::now(),
2817 failure_count: 0,
2818 last_error: None,
2819 latency_ms: 1.0,
2820 replication_lag_bytes: None,
2821 },
2822 );
2823 for addr in &["a:5432", "b:5432"] {
2824 let body = format!(r#"{{"action":"force_unhealthy","target_node":"{}"}}"#, addr);
2825 let _ = AdminServer::handle_chaos_request(Some(&body), &state)
2826 .await
2827 .unwrap();
2828 }
2829 let (status, value) =
2830 AdminServer::handle_chaos_request(Some(r#"{"action":"reset"}"#), &state)
2831 .await
2832 .unwrap();
2833 assert_eq!(status, 200);
2834 assert_eq!(value["reset"], true);
2835 let restored = value["restored"].as_array().unwrap();
2836 assert_eq!(restored.len(), 2);
2837 for addr in &["a:5432", "b:5432"] {
2839 assert!(state.node_health.read().await[*addr].healthy);
2840 }
2841 assert!(state.chaos_overrides.read().await.is_empty());
2842 }
2843
2844 #[tokio::test]
2845 async fn test_chaos_force_unhealthy_404s_when_node_unknown() {
2846 let state = Arc::new(AdminState::new());
2847 let body = r#"{"action":"force_unhealthy","target_node":"missing.svc:5432"}"#;
2848 let (status, _) = AdminServer::handle_chaos_request(Some(body), &state)
2849 .await
2850 .expect("handler returns Ok");
2851 assert_eq!(status, 404);
2852 }
2853
2854 #[tokio::test]
2855 async fn test_chaos_400_on_malformed_body() {
2856 let state = Arc::new(AdminState::new());
2857 let (status, _) = AdminServer::handle_chaos_request(Some("not json"), &state)
2858 .await
2859 .expect("handler returns Ok");
2860 assert_eq!(status, 400);
2861 }
2862
2863 #[tokio::test]
2864 async fn test_chaos_400_on_unknown_action() {
2865 let state = Arc::new(AdminState::new());
2866 let body = r#"{"action":"format_disk","target_node":"x"}"#;
2867 let (status, _) = AdminServer::handle_chaos_request(Some(body), &state)
2868 .await
2869 .expect("handler returns Ok");
2870 assert_eq!(status, 400);
2871 }
2872
2873 #[cfg(feature = "ha-tr")]
2874 #[tokio::test]
2875 async fn test_shadow_400_on_malformed_body() {
2876 let (status, _) = AdminServer::handle_shadow_request(Some("not json"))
2877 .await
2878 .expect("handler returns Ok");
2879 assert_eq!(status, 400);
2880 }
2881
2882 #[cfg(feature = "ha-tr")]
2883 #[tokio::test]
2884 async fn test_shadow_500_on_source_unreachable() {
2885 let body = r#"{
2888 "sql": "SELECT 1",
2889 "source_host": "127.0.0.1",
2890 "source_port": 1,
2891 "shadow_host": "127.0.0.1",
2892 "shadow_port": 1
2893 }"#;
2894 let (status, value) = AdminServer::handle_shadow_request(Some(body))
2895 .await
2896 .expect("handler returns Ok");
2897 assert_eq!(status, 500);
2898 let err = value["error"].as_str().expect("error field");
2899 assert!(
2900 err.contains("source connect"),
2901 "expected source connect error, got {}",
2902 err
2903 );
2904 }
2905
2906 #[cfg(feature = "ha-tr")]
2907 #[tokio::test]
2908 async fn test_shadow_errors_on_empty_body() {
2909 let err = AdminServer::handle_shadow_request(None).await;
2910 assert!(err.is_err(), "empty body must surface as Err");
2911 }
2912
2913 #[cfg(feature = "anomaly-detection")]
2914 #[tokio::test]
2915 async fn test_anomalies_returns_503_when_detector_unattached() {
2916 let state = Arc::new(AdminState::new());
2917 let (status, value) = AdminServer::handle_anomalies_list("/anomalies", &state)
2918 .await
2919 .expect("handler returns Ok");
2920 assert_eq!(status, 503);
2921 assert_eq!(value["error"], "anomaly detector not attached");
2922 }
2923
2924 #[cfg(feature = "anomaly-detection")]
2925 #[tokio::test]
2926 async fn test_anomalies_returns_attached_detector_events() {
2927 use crate::anomaly::{AnomalyConfig, AnomalyDetector, QueryObservation};
2928 let state = Arc::new(AdminState::new());
2929 let det = Arc::new(AnomalyDetector::new(AnomalyConfig::default()));
2930 let _ = det.record_query(&QueryObservation {
2932 tenant: "test".into(),
2933 fingerprint: "fp".into(),
2934 sql: "SELECT * FROM users WHERE id = 1 OR 1=1 --".into(),
2935 timestamp: std::time::Instant::now(),
2936 });
2937 state.with_anomaly_detector(det.clone()).await;
2938
2939 let (status, value) = AdminServer::handle_anomalies_list("/anomalies", &state)
2940 .await
2941 .expect("handler returns Ok");
2942 assert_eq!(status, 200);
2943 let count = value["count"].as_u64().expect("count field");
2944 assert!(count > 0, "expected at least one event, got {}", count);
2945 }
2946
2947 #[cfg(feature = "anomaly-detection")]
2948 #[tokio::test]
2949 async fn test_anomalies_limit_query_string_respected() {
2950 use crate::anomaly::{AnomalyConfig, AnomalyDetector, QueryObservation};
2951 let state = Arc::new(AdminState::new());
2952 let det = Arc::new(AnomalyDetector::new(AnomalyConfig::default()));
2953 for i in 0..50 {
2954 let fp = format!("fp{}", i);
2955 let _ = det.record_query(&QueryObservation {
2956 tenant: "test".into(),
2957 fingerprint: fp,
2958 sql: "SELECT 1".into(),
2959 timestamp: std::time::Instant::now(),
2960 });
2961 }
2962 state.with_anomaly_detector(det).await;
2963
2964 let (status, value) = AdminServer::handle_anomalies_list("/anomalies?limit=5", &state)
2965 .await
2966 .expect("handler returns Ok");
2967 assert_eq!(status, 200);
2968 assert_eq!(value["limit"].as_u64().unwrap(), 5);
2969 assert_eq!(value["events"].as_array().unwrap().len(), 5);
2970 }
2971
2972 #[cfg(any(feature = "anomaly-detection", feature = "query-analytics"))]
2973 #[test]
2974 fn test_parse_limit_query_helper() {
2975 assert_eq!(parse_limit_query("/anomalies", 100, 1024), 100);
2976 assert_eq!(parse_limit_query("/anomalies?limit=42", 100, 1024), 42);
2977 assert_eq!(parse_limit_query("/anomalies?limit=99999", 100, 1024), 1024);
2978 assert_eq!(parse_limit_query("/anomalies?limit=abc", 100, 1024), 100);
2979 assert_eq!(
2980 parse_limit_query("/anomalies?other=x&limit=7", 100, 1024),
2981 7
2982 );
2983 }
2984
2985 #[cfg(feature = "edge-proxy")]
2986 async fn edge_state() -> Arc<AdminState> {
2987 use crate::edge::{EdgeCache, EdgeRegistry};
2988 use std::time::Duration;
2989 let s = Arc::new(AdminState::new());
2990 let cache = Arc::new(EdgeCache::new(100));
2991 let registry = Arc::new(EdgeRegistry::new(8, Duration::from_secs(60)));
2992 s.with_edge(cache, registry).await;
2993 s
2994 }
2995
2996 #[cfg(feature = "edge-proxy")]
2997 #[tokio::test]
2998 async fn test_edge_status_returns_empty_lists_initially() {
2999 let s = edge_state().await;
3000 let (status, value) = AdminServer::handle_edge_status(&s)
3001 .await
3002 .expect("handler returns Ok");
3003 assert_eq!(status, 200);
3004 assert_eq!(value["edge_count"].as_u64().unwrap(), 0);
3005 assert_eq!(value["registered"].as_array().unwrap().len(), 0);
3006 assert!(value["cache"].is_object(), "cache stats present");
3007 }
3008
3009 #[cfg(feature = "edge-proxy")]
3010 #[tokio::test]
3011 async fn test_edge_register_then_status_lists_edge() {
3012 let s = edge_state().await;
3013 let body = r#"{"edge_id":"e1","region":"us-east","base_url":"https://e1.svc"}"#;
3014 let (status, _) = AdminServer::handle_edge_register(Some(body), &s)
3015 .await
3016 .expect("handler ok");
3017 assert_eq!(status, 201);
3018 let (status2, value2) = AdminServer::handle_edge_status(&s).await.unwrap();
3019 assert_eq!(status2, 200);
3020 assert_eq!(value2["edge_count"].as_u64().unwrap(), 1);
3021 assert_eq!(value2["registered"][0]["edge_id"].as_str().unwrap(), "e1");
3022 }
3023
3024 #[cfg(feature = "edge-proxy")]
3025 #[tokio::test]
3026 async fn test_edge_register_400_on_malformed_body() {
3027 let s = edge_state().await;
3028 let (status, _) = AdminServer::handle_edge_register(Some("not json"), &s)
3029 .await
3030 .expect("handler ok");
3031 assert_eq!(status, 400);
3032 }
3033
3034 #[cfg(feature = "edge-proxy")]
3035 #[tokio::test]
3036 async fn test_edge_invalidate_drops_local_cache_entries() {
3037 use crate::edge::{CacheEntry, CacheKey};
3038 use std::time::{Duration, Instant};
3039 let s = edge_state().await;
3040 let cache = s.edge_cache.read().await.clone().unwrap();
3042 cache.insert(
3043 CacheKey::new("fp1", "p1"),
3044 CacheEntry {
3045 version: 1,
3046 response_bytes: bytes::Bytes::from_static(b"row"),
3047 tables: vec!["users".into()],
3048 expires_at: Instant::now() + Duration::from_secs(60),
3049 },
3050 );
3051 assert!(cache.get(&CacheKey::new("fp1", "p1")).is_some());
3052
3053 let body = r#"{"tables":["users"]}"#;
3054 let (status, value) = AdminServer::handle_edge_invalidate(Some(body), &s)
3055 .await
3056 .expect("handler ok");
3057 assert_eq!(status, 200);
3058 assert_eq!(value["dropped_local"].as_u64().unwrap(), 1);
3059 assert!(cache.get(&CacheKey::new("fp1", "p1")).is_none());
3060 }
3061
3062 #[cfg(feature = "edge-proxy")]
3063 #[tokio::test]
3064 async fn test_edge_invalidate_503_when_cache_unattached() {
3065 let s = Arc::new(AdminState::new());
3066 let body = r#"{"tables":["users"]}"#;
3067 let (status, _) = AdminServer::handle_edge_invalidate(Some(body), &s)
3068 .await
3069 .expect("handler ok");
3070 assert_eq!(status, 503);
3071 }
3072
3073 #[cfg(feature = "edge-proxy")]
3076 #[test]
3077 fn test_parse_query_params_decodes_and_defaults() {
3078 let p = parse_query_params(
3079 "/api/edge/subscribe?edge_id=e1®ion=us-east&base_url=http%3A%2F%2Fe1.svc%3A9090",
3080 );
3081 assert_eq!(p.get("edge_id").unwrap(), "e1");
3082 assert_eq!(p.get("region").unwrap(), "us-east");
3083 assert_eq!(p.get("base_url").unwrap(), "http://e1.svc:9090");
3084 assert!(parse_query_params("/api/edge/subscribe").is_empty());
3086 let p2 = parse_query_params("/x?flag&edge_id=a%2&b=%zz");
3089 assert!(!p2.contains_key("flag"));
3090 assert_eq!(p2.get("edge_id").unwrap(), "a%2");
3091 assert_eq!(p2.get("b").unwrap(), "%zz");
3092 }
3093
3094 #[cfg(feature = "edge-proxy")]
3098 async fn connect_admin(state: Arc<AdminState>) -> tokio::net::TcpStream {
3099 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3100 let addr = listener.local_addr().unwrap();
3101 tokio::spawn(async move {
3102 let (stream, peer) = listener.accept().await.unwrap();
3103 let _ = AdminServer::handle_connection(stream, peer, state).await;
3104 });
3105 tokio::net::TcpStream::connect(addr).await.unwrap()
3106 }
3107
3108 #[cfg(feature = "edge-proxy")]
3111 async fn read_until(stream: &mut tokio::net::TcpStream, needle: &str) -> String {
3112 let mut buf = Vec::new();
3113 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
3114 loop {
3115 let mut chunk = [0u8; 1024];
3116 let n = tokio::time::timeout_at(deadline, stream.read(&mut chunk))
3117 .await
3118 .expect("read timed out")
3119 .expect("read failed");
3120 if n == 0 {
3121 break;
3122 }
3123 buf.extend_from_slice(&chunk[..n]);
3124 if String::from_utf8_lossy(&buf).contains(needle) {
3125 break;
3126 }
3127 }
3128 String::from_utf8_lossy(&buf).into_owned()
3129 }
3130
3131 #[cfg(feature = "edge-proxy")]
3132 #[tokio::test]
3133 async fn test_edge_subscribe_unauthenticated_gets_401() {
3134 let s = edge_state().await;
3135 *s.auth_token.write().await = Some("s3cret".to_string());
3136 let mut c = connect_admin(s).await;
3137 c.write_all(b"GET /api/edge/subscribe?edge_id=e1 HTTP/1.1\r\n\r\n")
3138 .await
3139 .unwrap();
3140 let resp = read_until(&mut c, "401").await;
3141 assert!(resp.starts_with("HTTP/1.1 401"), "got: {resp}");
3142 }
3143
3144 #[cfg(feature = "edge-proxy")]
3145 #[tokio::test]
3146 async fn test_edge_subscribe_400_without_edge_id() {
3147 let s = edge_state().await;
3148 let mut c = connect_admin(s).await;
3149 c.write_all(b"GET /api/edge/subscribe?region=us-east HTTP/1.1\r\n\r\n")
3150 .await
3151 .unwrap();
3152 let resp = read_until(&mut c, "edge_id").await;
3153 assert!(resp.starts_with("HTTP/1.1 400"), "got: {resp}");
3154 assert!(resp.contains("edge_id query parameter is required"));
3155 }
3156
3157 #[cfg(feature = "edge-proxy")]
3158 #[tokio::test]
3159 async fn test_edge_subscribe_503_when_registry_full() {
3160 use crate::edge::{EdgeCache, EdgeRegistry};
3161 let s = Arc::new(AdminState::new());
3162 s.with_edge(
3163 Arc::new(EdgeCache::new(16)),
3164 Arc::new(EdgeRegistry::new(1, std::time::Duration::from_secs(60))),
3165 )
3166 .await;
3167 let registry = s.edge_registry.read().await.clone().unwrap();
3168 let _held = registry.register("occupant", "r", "u", "ts").unwrap();
3171 let mut c = connect_admin(s).await;
3172 c.write_all(b"GET /api/edge/subscribe?edge_id=e2 HTTP/1.1\r\n\r\n")
3173 .await
3174 .unwrap();
3175 let resp = read_until(&mut c, "full").await;
3176 assert!(resp.starts_with("HTTP/1.1 503"), "got: {resp}");
3177 }
3178
3179 #[cfg(feature = "edge-proxy")]
3180 #[tokio::test]
3181 async fn test_edge_subscribe_streams_preamble_and_invalidations() {
3182 let s = edge_state().await;
3183 *s.auth_token.write().await = Some("s3cret".to_string());
3184 let registry = s.edge_registry.read().await.clone().unwrap();
3185 let mut c = connect_admin(s).await;
3186 c.write_all(
3187 b"GET /api/edge/subscribe?edge_id=e1®ion=eu&base_url=http%3A%2F%2Fe1 HTTP/1.1\r\nAuthorization: Bearer s3cret\r\n\r\n",
3188 )
3189 .await
3190 .unwrap();
3191 let preamble = read_until(&mut c, "\r\n\r\n").await;
3192 assert!(preamble.starts_with("HTTP/1.1 200 OK"), "got: {preamble}");
3193 assert!(preamble.contains("Content-Type: text/event-stream"));
3194
3195 let nodes = registry.list();
3199 assert_eq!(nodes.len(), 1);
3200 assert_eq!(nodes[0].edge_id, "e1");
3201 assert_eq!(nodes[0].region, "eu");
3202 assert_eq!(nodes[0].base_url, "http://e1");
3203
3204 let mut hello = preamble;
3210 if !hello.contains("}\n\n") {
3211 hello.push_str(&read_until(&mut c, "}\n\n").await);
3212 }
3213 assert!(hello.contains("event: invalidate"), "got: {hello}");
3214 assert!(hello.contains("\"epoch\":"), "got: {hello}");
3215 assert!(
3216 !hello.contains("\"epoch\":0}"),
3217 "hello epoch must be non-zero: {hello}"
3218 );
3219
3220 let (sent, _) = registry
3222 .broadcast(InvalidationEvent {
3223 up_to_version: 7,
3224 tables: vec!["users".into()],
3225 committed_at: "ts".into(),
3226 epoch: 0,
3227 })
3228 .await;
3229 assert_eq!(sent, 1);
3230 let frame = read_until(&mut c, "\"up_to_version\":7").await;
3231 assert!(frame.contains("event: invalidate"), "got: {frame}");
3232 assert!(frame.contains("\"up_to_version\":7"), "got: {frame}");
3233 }
3234}