1use std::net::SocketAddr;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Instant;
5
6use axum::extract::{Path, State};
7use axum::http::StatusCode;
8use axum::response::sse::{Event, Sse};
9use axum::response::{Html, IntoResponse};
10use axum::routing::{get, post};
11use axum::{Json, Router};
12use tokio::sync::broadcast;
13use tokio::sync::RwLock;
14use tokio_util::sync::CancellationToken;
15
16use crate::error::Result;
17use crate::node::binary::NoopProgress;
18use crate::node::daemon::forward::runner::{ForwarderHandle, DEFAULT_POLL_INTERVAL};
19use crate::node::daemon::forward::{
20 apply_enable, classify_nodes, spawn_log_forwarder, ElasticsearchSink, LogForwardConfig,
21 LogForwardEnableRequest, LogForwardResult, LogForwardStatus, LogSink,
22};
23use crate::node::daemon::health::{DiskThresholds, FleetHealth};
24use crate::node::daemon::supervisor::{
25 spawn_eviction_monitor, spawn_liveness_monitor, Supervisor, EVICTION_POLL_INTERVAL,
26 LIVENESS_POLL_INTERVAL,
27};
28use crate::node::events::NodeEvent;
29use crate::node::registry::NodeRegistry;
30use crate::node::types::{
31 AddNodeOpts, AddNodeResult, DaemonConfig, DaemonStatus, NodeInfo, NodeStarted, NodeStatus,
32 NodeStatusResult, NodeStatusSummary, NodeStopped, RemoveNodeResult, ResetResult,
33 StartNodeResult, StopNodeResult,
34};
35
36pub struct AppState {
38 pub registry: Arc<RwLock<NodeRegistry>>,
39 pub supervisor: Arc<RwLock<Supervisor>>,
40 pub event_tx: broadcast::Sender<NodeEvent>,
41 pub start_time: Instant,
42 pub config: DaemonConfig,
43 pub bound_port: u16,
45 pub health: Arc<RwLock<FleetHealth>>,
48 pub forwarder: Arc<RwLock<Option<ForwarderHandle>>>,
50 pub shutdown: CancellationToken,
53}
54
55pub async fn start(
59 config: DaemonConfig,
60 mut registry: NodeRegistry,
61 shutdown: CancellationToken,
62) -> Result<SocketAddr> {
63 let (event_tx, _) = broadcast::channel(256);
64
65 let addr = SocketAddr::new(config.listen_addr, config.port.unwrap_or(0));
66 let listener = tokio::net::TcpListener::bind(addr)
67 .await
68 .map_err(|e| crate::error::Error::BindError(e.to_string()))?;
69 let bound_addr = listener
70 .local_addr()
71 .map_err(|e| crate::error::Error::BindError(e.to_string()))?;
72
73 reconcile_registry_versions(&mut registry).await;
78
79 let registry = Arc::new(RwLock::new(registry));
80 let supervisor = Arc::new(RwLock::new(Supervisor::new(event_tx.clone())));
81
82 {
87 let reg = registry.read().await;
88 let mut sup = supervisor.write().await;
89 let adopted = sup.adopt_from_registry(®);
90 if !adopted.is_empty() {
91 tracing::info!(
92 "Adopted {} running node(s) from a previous daemon instance: {:?}",
93 adopted.len(),
94 adopted
95 );
96 }
97 }
98
99 let health = Arc::new(RwLock::new(FleetHealth::healthy()));
100
101 let state = Arc::new(AppState {
102 registry: registry.clone(),
103 supervisor: supervisor.clone(),
104 event_tx: event_tx.clone(),
105 start_time: Instant::now(),
106 config: config.clone(),
107 bound_port: bound_addr.port(),
108 health: health.clone(),
109 forwarder: Arc::new(RwLock::new(None)),
110 shutdown: shutdown.clone(),
111 });
112
113 start_forwarder_if_enabled(&state).await;
117
118 spawn_eviction_monitor(
123 registry.clone(),
124 supervisor.clone(),
125 event_tx.clone(),
126 health,
127 DiskThresholds::default(),
128 EVICTION_POLL_INTERVAL,
129 shutdown.clone(),
130 );
131
132 spawn_liveness_monitor(
136 registry,
137 supervisor,
138 event_tx,
139 LIVENESS_POLL_INTERVAL,
140 shutdown.clone(),
141 );
142
143 let app = build_router(state.clone());
144
145 write_file(&config.port_file_path, &bound_addr.port().to_string())?;
147 write_file(&config.pid_file_path, &std::process::id().to_string())?;
148
149 let port_file = config.port_file_path.clone();
150 let pid_file = config.pid_file_path.clone();
151
152 tokio::spawn(async move {
153 axum::serve(listener, app)
154 .with_graceful_shutdown(shutdown.cancelled_owned())
155 .await
156 .ok();
157
158 let _ = std::fs::remove_file(&port_file);
160 let _ = std::fs::remove_file(&pid_file);
161 });
162
163 Ok(bound_addr)
164}
165
166fn build_router(state: Arc<AppState>) -> Router {
167 use axum::http::HeaderValue;
168 use tower_http::cors::{Any, CorsLayer};
169
170 let origin = format!("http://127.0.0.1:{}", state.bound_port);
174 let cors = CorsLayer::new()
175 .allow_origin([origin.parse::<HeaderValue>().unwrap()])
176 .allow_methods(Any)
177 .allow_headers(Any);
178
179 Router::new()
180 .route("/console", get(get_console))
181 .route("/api/v1/status", get(get_status))
182 .route("/api/v1/health", get(get_health))
183 .route("/api/v1/events", get(get_events))
184 .route("/api/v1/nodes/status", get(get_nodes_status))
185 .route("/api/v1/nodes", post(post_nodes))
186 .route(
187 "/api/v1/nodes/{id}",
188 get(get_node_detail).delete(delete_node),
189 )
190 .route("/api/v1/nodes/{id}/start", post(post_start_node))
191 .route("/api/v1/nodes/start-all", post(post_start_all))
192 .route("/api/v1/nodes/{id}/stop", post(post_stop_node))
193 .route("/api/v1/nodes/stop-all", post(post_stop_all))
194 .route("/api/v1/reset", post(post_reset))
195 .route("/api/v1/logs/forward", get(get_log_forward))
196 .route("/api/v1/logs/forward/enable", post(post_log_forward_enable))
197 .route(
198 "/api/v1/logs/forward/disable",
199 post(post_log_forward_disable),
200 )
201 .route("/api/v1/openapi.json", get(get_openapi))
202 .layer(cors)
203 .with_state(state)
204}
205
206async fn get_status(State(state): State<Arc<AppState>>) -> Json<DaemonStatus> {
207 let registry = state.registry.read().await;
208 let supervisor = state.supervisor.read().await;
209 let (running, stopped, errored) = supervisor.node_counts();
210
211 Json(DaemonStatus {
212 running: true,
213 pid: Some(std::process::id()),
214 port: Some(state.bound_port),
215 uptime_secs: Some(state.start_time.elapsed().as_secs()),
216 nodes_total: registry.len() as u32,
217 nodes_running: running,
218 nodes_stopped: stopped,
219 nodes_errored: errored,
220 })
221}
222
223async fn get_health(State(state): State<Arc<AppState>>) -> Json<FleetHealth> {
227 Json(state.health.read().await.clone())
228}
229
230async fn get_events(
231 State(state): State<Arc<AppState>>,
232) -> Sse<impl futures_core::Stream<Item = std::result::Result<Event, std::convert::Infallible>>> {
233 let mut rx = state.event_tx.subscribe();
234
235 let stream = async_stream::stream! {
236 loop {
237 match rx.recv().await {
238 Ok(event) => {
239 let event_type = event.event_type().to_string();
240 if let Ok(data) = serde_json::to_string(&event) {
241 yield Ok(Event::default().event(event_type).data(data));
242 }
243 }
244 Err(broadcast::error::RecvError::Lagged(_)) => continue,
245 Err(broadcast::error::RecvError::Closed) => break,
246 }
247 }
248 };
249
250 Sse::new(stream)
251}
252
253async fn get_nodes_status(State(state): State<Arc<AppState>>) -> Json<NodeStatusResult> {
255 let registry = state.registry.read().await;
256 let supervisor = state.supervisor.read().await;
257
258 let mut nodes = Vec::new();
259 let mut total_running = 0u32;
260 let mut total_stopped = 0u32;
261
262 for config in registry.list() {
263 let status = if config.eviction.is_some() {
266 NodeStatus::Evicted
267 } else {
268 supervisor
269 .node_status(config.id)
270 .unwrap_or(NodeStatus::Stopped)
271 };
272
273 match status {
274 NodeStatus::Running | NodeStatus::Starting => total_running += 1,
275 _ => total_stopped += 1,
276 }
277
278 let (pid, uptime_secs) = if config.eviction.is_some() {
279 (None, None)
280 } else {
281 (
282 supervisor.node_pid(config.id),
283 supervisor.node_uptime_secs(config.id),
284 )
285 };
286
287 nodes.push(NodeStatusSummary {
288 node_id: config.id,
289 name: config.service_name.clone(),
290 version: config.version.clone(),
291 status,
292 pid,
293 uptime_secs,
294 eviction: config.eviction.clone(),
295 });
296 }
297
298 Json(NodeStatusResult {
299 nodes,
300 total_running,
301 total_stopped,
302 })
303}
304
305async fn get_node_detail(
307 State(state): State<Arc<AppState>>,
308 Path(id): Path<u32>,
309) -> std::result::Result<Json<NodeInfo>, (StatusCode, Json<serde_json::Value>)> {
310 let registry = state.registry.read().await;
311 let config = match registry.get(id) {
312 Ok(config) => config.clone(),
313 Err(_) => {
314 return Err((
315 StatusCode::NOT_FOUND,
316 Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
317 ))
318 }
319 };
320
321 let supervisor = state.supervisor.read().await;
322 let (status, pid, uptime_secs) = if config.eviction.is_some() {
324 (NodeStatus::Evicted, None, None)
325 } else {
326 (
327 supervisor.node_status(id).unwrap_or(NodeStatus::Stopped),
328 supervisor.node_pid(id),
329 supervisor.node_uptime_secs(id),
330 )
331 };
332
333 Ok(Json(NodeInfo {
334 config,
335 status,
336 pid,
337 uptime_secs,
338 }))
339}
340
341async fn post_nodes(
343 State(state): State<Arc<AppState>>,
344 Json(opts): Json<AddNodeOpts>,
345) -> std::result::Result<(StatusCode, Json<AddNodeResult>), (StatusCode, Json<serde_json::Value>)> {
346 let registry_path = state.config.registry_path.clone();
347 let progress = NoopProgress;
348
349 match crate::node::add_nodes(opts, ®istry_path, &progress).await {
350 Ok(result) => {
351 let mut registry = state.registry.write().await;
353 if let Ok(fresh) = NodeRegistry::load(®istry_path) {
354 *registry = fresh;
355 }
356 Ok((StatusCode::CREATED, Json(result)))
357 }
358 Err(e) => Err((
359 StatusCode::BAD_REQUEST,
360 Json(serde_json::json!({ "error": e.to_string() })),
361 )),
362 }
363}
364
365async fn delete_node(
367 State(state): State<Arc<AppState>>,
368 Path(id): Path<u32>,
369) -> std::result::Result<Json<RemoveNodeResult>, (StatusCode, Json<serde_json::Value>)> {
370 let supervisor = state.supervisor.read().await;
372 if supervisor.is_running(id) {
373 return Err((
374 StatusCode::CONFLICT,
375 Json(serde_json::json!({
376 "error": format!("Cannot remove node {id} while it is running. Stop it first."),
377 "current_state": { "node_id": id, "status": "running" }
378 })),
379 ));
380 }
381 drop(supervisor);
382
383 let registry_path = state.config.registry_path.clone();
384
385 match crate::node::remove_node(id, ®istry_path) {
386 Ok(result) => {
387 let mut registry = state.registry.write().await;
389 if let Ok(fresh) = NodeRegistry::load(®istry_path) {
390 *registry = fresh;
391 }
392 Ok(Json(result))
393 }
394 Err(crate::error::Error::NodeNotFound(id)) => Err((
395 StatusCode::NOT_FOUND,
396 Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
397 )),
398 Err(e) => Err((
399 StatusCode::INTERNAL_SERVER_ERROR,
400 Json(serde_json::json!({ "error": e.to_string() })),
401 )),
402 }
403}
404
405async fn post_start_node(
407 State(state): State<Arc<AppState>>,
408 Path(id): Path<u32>,
409) -> std::result::Result<Json<NodeStarted>, (StatusCode, Json<serde_json::Value>)> {
410 let registry = state.registry.read().await;
411 let config = match registry.get(id) {
412 Ok(config) => config.clone(),
413 Err(_) => {
414 return Err((
415 StatusCode::NOT_FOUND,
416 Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
417 ))
418 }
419 };
420 drop(registry);
421
422 if config.eviction.is_some() {
425 return Err((
426 StatusCode::CONFLICT,
427 Json(serde_json::json!({
428 "error": format!(
429 "Node {id} has been evicted and cannot be started. Dismiss it with \
430 `ant node dismiss {id}` and add a new node instead."
431 ),
432 "current_state": { "node_id": id, "status": "evicted" }
433 })),
434 ));
435 }
436
437 let supervisor_ref = state.supervisor.clone();
438
439 let mut supervisor = state.supervisor.write().await;
441 if supervisor.is_running(id) {
442 let pid = supervisor.node_pid(id);
443 let uptime_secs = supervisor.node_uptime_secs(id);
444 return Err((
445 StatusCode::CONFLICT,
446 Json(serde_json::json!({
447 "error": format!("Node {id} is already running"),
448 "current_state": {
449 "node_id": id,
450 "status": "running",
451 "pid": pid,
452 "uptime_secs": uptime_secs,
453 }
454 })),
455 ));
456 }
457
458 let registry_ref = state.registry.clone();
459 match supervisor
460 .start_node(&config, supervisor_ref, registry_ref)
461 .await
462 {
463 Ok(started) => Ok(Json(started)),
464 Err(crate::error::Error::NodeAlreadyRunning(id)) => {
465 let pid = supervisor.node_pid(id);
466 let uptime_secs = supervisor.node_uptime_secs(id);
467 Err((
468 StatusCode::CONFLICT,
469 Json(serde_json::json!({
470 "error": format!("Node {id} is already running"),
471 "current_state": {
472 "node_id": id,
473 "status": "running",
474 "pid": pid,
475 "uptime_secs": uptime_secs,
476 }
477 })),
478 ))
479 }
480 Err(e) => Err((
481 StatusCode::INTERNAL_SERVER_ERROR,
482 Json(serde_json::json!({ "error": e.to_string() })),
483 )),
484 }
485}
486
487async fn post_start_all(State(state): State<Arc<AppState>>) -> Json<StartNodeResult> {
489 let registry = state.registry.read().await;
490 let configs: Vec<_> = registry
493 .list()
494 .into_iter()
495 .filter(|c| c.eviction.is_none())
496 .cloned()
497 .collect();
498 drop(registry);
499
500 let mut started = Vec::new();
501 let mut failed = Vec::new();
502 let mut already_running = Vec::new();
503
504 let supervisor_ref = state.supervisor.clone();
505 let registry_ref = state.registry.clone();
506
507 for config in &configs {
508 let mut supervisor = state.supervisor.write().await;
509 if supervisor.is_running(config.id) {
510 already_running.push(config.id);
511 continue;
512 }
513
514 match supervisor
515 .start_node(config, supervisor_ref.clone(), registry_ref.clone())
516 .await
517 {
518 Ok(result) => started.push(result),
519 Err(crate::error::Error::NodeAlreadyRunning(id)) => {
520 already_running.push(id);
521 }
522 Err(e) => {
523 failed.push(crate::node::types::NodeStartFailed {
524 node_id: config.id,
525 service_name: config.service_name.clone(),
526 error: e.to_string(),
527 });
528 }
529 }
530 }
531
532 Json(StartNodeResult {
533 started,
534 failed,
535 already_running,
536 })
537}
538
539async fn post_stop_node(
541 State(state): State<Arc<AppState>>,
542 Path(id): Path<u32>,
543) -> std::result::Result<Json<NodeStopped>, (StatusCode, Json<serde_json::Value>)> {
544 let registry = state.registry.read().await;
545 let config = match registry.get(id) {
546 Ok(config) => config.clone(),
547 Err(_) => {
548 return Err((
549 StatusCode::NOT_FOUND,
550 Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
551 ))
552 }
553 };
554 drop(registry);
555
556 if config.eviction.is_some() {
558 return Err((
559 StatusCode::CONFLICT,
560 Json(serde_json::json!({
561 "error": format!(
562 "Node {id} has been evicted; there is nothing to stop. Dismiss it with \
563 `ant node dismiss {id}`."
564 ),
565 "current_state": { "node_id": id, "status": "evicted" }
566 })),
567 ));
568 }
569
570 let mut supervisor = state.supervisor.write().await;
572 if !supervisor.is_running(id) {
573 let status = supervisor
574 .node_status(id)
575 .unwrap_or(crate::node::types::NodeStatus::Stopped);
576 return Err((
577 StatusCode::CONFLICT,
578 Json(serde_json::json!({
579 "error": format!("Node {id} is not running"),
580 "current_state": {
581 "node_id": id,
582 "status": status,
583 }
584 })),
585 ));
586 }
587
588 match supervisor.stop_node(id).await {
589 Ok(()) => Ok(Json(NodeStopped {
590 node_id: id,
591 service_name: config.service_name,
592 })),
593 Err(crate::error::Error::NodeNotRunning(id)) => {
594 let status = supervisor
595 .node_status(id)
596 .unwrap_or(crate::node::types::NodeStatus::Stopped);
597 Err((
598 StatusCode::CONFLICT,
599 Json(serde_json::json!({
600 "error": format!("Node {id} is not running"),
601 "current_state": {
602 "node_id": id,
603 "status": status,
604 }
605 })),
606 ))
607 }
608 Err(e) => Err((
609 StatusCode::INTERNAL_SERVER_ERROR,
610 Json(serde_json::json!({ "error": e.to_string() })),
611 )),
612 }
613}
614
615async fn post_stop_all(State(state): State<Arc<AppState>>) -> Json<StopNodeResult> {
617 let registry = state.registry.read().await;
618 let configs: Vec<(u32, String)> = registry
620 .list()
621 .into_iter()
622 .filter(|c| c.eviction.is_none())
623 .map(|c| (c.id, c.service_name.clone()))
624 .collect();
625 drop(registry);
626
627 let mut supervisor = state.supervisor.write().await;
628 let result = supervisor.stop_all_nodes(&configs).await;
629
630 Json(result)
631}
632
633async fn post_reset(
635 State(state): State<Arc<AppState>>,
636) -> std::result::Result<Json<ResetResult>, (StatusCode, Json<serde_json::Value>)> {
637 let supervisor = state.supervisor.write().await;
640 let (running, _, _) = supervisor.node_counts();
641 if running > 0 {
642 return Err((
643 StatusCode::CONFLICT,
644 Json(serde_json::json!({
645 "error": format!("Cannot reset while nodes are running ({running} node(s) still running). Stop all nodes first."),
646 "nodes_running": running,
647 })),
648 ));
649 }
650 drop(supervisor);
651
652 let registry_path = state.config.registry_path.clone();
653
654 match crate::node::reset(®istry_path) {
655 Ok(result) => {
656 let mut registry = state.registry.write().await;
658 if let Ok(fresh) = NodeRegistry::load(®istry_path) {
659 *registry = fresh;
660 }
661 Ok(Json(result))
662 }
663 Err(e) => Err((
664 StatusCode::INTERNAL_SERVER_ERROR,
665 Json(serde_json::json!({ "error": e.to_string() })),
666 )),
667 }
668}
669
670fn load_forward_config() -> LogForwardConfig {
675 LogForwardConfig::default_path()
676 .and_then(|path| LogForwardConfig::load(&path))
677 .unwrap_or_else(|error| {
678 tracing::warn!("log forwarding: could not read the saved config: {error}");
679 LogForwardConfig::disabled()
680 })
681}
682
683fn build_sink(config: &LogForwardConfig) -> Result<Arc<dyn LogSink>> {
685 let sink = ElasticsearchSink::new(config.endpoint_base(), &config.token)?;
686 Ok(Arc::new(sink))
687}
688
689async fn start_forwarder_if_enabled(state: &Arc<AppState>) {
691 let config = load_forward_config();
692 if !config.enabled {
693 return;
694 }
695 if let Err(error) = start_forwarder(state, config).await {
696 tracing::warn!("log forwarding: could not start: {error}");
697 }
698}
699
700async fn start_forwarder(state: &Arc<AppState>, config: LogForwardConfig) -> Result<()> {
702 let sink = build_sink(&config)?;
703 let offsets_path = crate::node::daemon::forward::OffsetStore::default_path()?;
704 let endpoint = sink.describe();
705
706 let handle = spawn_log_forwarder(
707 state.registry.clone(),
708 config,
709 sink,
710 offsets_path,
711 DEFAULT_POLL_INTERVAL,
712 state.shutdown.clone(),
713 );
714
715 let mut slot = state.forwarder.write().await;
716 if let Some(previous) = slot.replace(handle) {
717 previous.stop_and_wait().await;
720 }
721 tracing::info!("log forwarding: shipping node logs to {endpoint}");
722 Ok(())
723}
724
725async fn forward_status(state: &Arc<AppState>) -> LogForwardStatus {
732 let config = load_forward_config();
733 let mut status = LogForwardStatus::inactive(&config);
734
735 {
736 let registry = state.registry.read().await;
737 let (forwarding, skipped) = classify_nodes(®istry);
738 status.nodes_forwarding = forwarding;
739 status.nodes_skipped = skipped;
740 }
741
742 let slot = state.forwarder.read().await;
743 if let Some(handle) = slot.as_ref().filter(|handle| !handle.is_stopped()) {
744 status.active = true;
745 status.stats = handle.snapshot().await.stats;
746 }
747
748 status
749}
750
751async fn get_log_forward(State(state): State<Arc<AppState>>) -> Json<LogForwardStatus> {
753 Json(forward_status(&state).await)
754}
755
756async fn post_log_forward_enable(
761 State(state): State<Arc<AppState>>,
762 Json(request): Json<LogForwardEnableRequest>,
763) -> std::result::Result<Json<LogForwardResult>, (StatusCode, Json<serde_json::Value>)> {
764 let stored = load_forward_config();
765 let was_enabled = stored.enabled;
766
767 let config = apply_enable(&stored, &request).map_err(|error| {
768 (
769 StatusCode::BAD_REQUEST,
770 Json(serde_json::json!({ "error": error.to_string() })),
771 )
772 })?;
773
774 let path = LogForwardConfig::default_path().map_err(internal_error)?;
775 config.save(&path).map_err(internal_error)?;
776
777 start_forwarder(&state, config.clone())
778 .await
779 .map_err(internal_error)?;
780
781 let registry = state.registry.read().await;
782 let (nodes_forwarding, nodes_skipped) = classify_nodes(®istry);
783
784 Ok(Json(LogForwardResult {
785 enabled: true,
786 already_in_state: was_enabled,
787 endpoint: config.endpoint.clone(),
788 min_level: config.min_level,
789 nodes_forwarding,
790 nodes_skipped,
791 pending_daemon_start: false,
792 }))
793}
794
795async fn post_log_forward_disable(
799 State(state): State<Arc<AppState>>,
800) -> std::result::Result<Json<LogForwardResult>, (StatusCode, Json<serde_json::Value>)> {
801 let mut config = load_forward_config();
802 let was_enabled = config.enabled;
803
804 config.enabled = false;
805 let path = LogForwardConfig::default_path().map_err(internal_error)?;
806 config.save(&path).map_err(internal_error)?;
807
808 if let Some(handle) = state.forwarder.write().await.take() {
811 handle.stop_and_wait().await;
812 }
813
814 Ok(Json(LogForwardResult {
815 enabled: false,
816 already_in_state: !was_enabled,
817 endpoint: config.endpoint.clone(),
818 min_level: config.min_level,
819 nodes_forwarding: Vec::new(),
820 nodes_skipped: Vec::new(),
821 pending_daemon_start: false,
822 }))
823}
824
825fn internal_error(error: crate::error::Error) -> (StatusCode, Json<serde_json::Value>) {
826 (
827 StatusCode::INTERNAL_SERVER_ERROR,
828 Json(serde_json::json!({ "error": error.to_string() })),
829 )
830}
831
832async fn get_openapi() -> impl IntoResponse {
833 let spec = serde_json::json!({
836 "openapi": "3.1.0",
837 "info": {
838 "title": "Ant Daemon API",
839 "version": "0.1.0",
840 "description": "REST API for the ant node management daemon"
841 },
842 "paths": {
843 "/api/v1/status": {
844 "get": {
845 "summary": "Daemon status",
846 "description": "Returns daemon health, uptime, and node count summary",
847 "responses": {
848 "200": {
849 "description": "Daemon status",
850 "content": {
851 "application/json": {
852 "schema": { "$ref": "#/components/schemas/DaemonStatus" }
853 }
854 }
855 }
856 }
857 }
858 },
859 "/api/v1/events": {
860 "get": {
861 "summary": "Event stream",
862 "description": "SSE stream of real-time node events",
863 "responses": {
864 "200": {
865 "description": "SSE event stream"
866 }
867 }
868 }
869 },
870 "/api/v1/nodes": {
871 "post": {
872 "summary": "Add nodes",
873 "description": "Add one or more nodes to the registry",
874 "requestBody": {
875 "required": true,
876 "content": {
877 "application/json": {
878 "schema": { "$ref": "#/components/schemas/AddNodeOpts" }
879 }
880 }
881 },
882 "responses": {
883 "201": {
884 "description": "Nodes added",
885 "content": {
886 "application/json": {
887 "schema": { "$ref": "#/components/schemas/AddNodeResult" }
888 }
889 }
890 },
891 "400": {
892 "description": "Invalid request"
893 }
894 }
895 }
896 },
897 "/api/v1/nodes/{id}": {
898 "delete": {
899 "summary": "Remove node",
900 "description": "Remove a node from the registry",
901 "parameters": [{
902 "name": "id",
903 "in": "path",
904 "required": true,
905 "schema": { "type": "integer" }
906 }],
907 "responses": {
908 "200": {
909 "description": "Node removed",
910 "content": {
911 "application/json": {
912 "schema": { "$ref": "#/components/schemas/RemoveNodeResult" }
913 }
914 }
915 },
916 "404": {
917 "description": "Node not found"
918 }
919 }
920 }
921 },
922 "/api/v1/nodes/{id}/start": {
923 "post": {
924 "summary": "Start a node",
925 "description": "Start a specific node by ID. Returns 409 if already running with current_state.",
926 "parameters": [{
927 "name": "id",
928 "in": "path",
929 "required": true,
930 "schema": { "type": "integer" }
931 }],
932 "responses": {
933 "200": {
934 "description": "Node started",
935 "content": {
936 "application/json": {
937 "schema": { "$ref": "#/components/schemas/NodeStarted" }
938 }
939 }
940 },
941 "404": {
942 "description": "Node not found"
943 },
944 "409": {
945 "description": "Node already running (includes current_state)"
946 },
947 "500": {
948 "description": "Failed to start node"
949 }
950 }
951 }
952 },
953 "/api/v1/nodes/start-all": {
954 "post": {
955 "summary": "Start all nodes",
956 "description": "Start all registered nodes. Returns per-node results.",
957 "responses": {
958 "200": {
959 "description": "Start results",
960 "content": {
961 "application/json": {
962 "schema": { "$ref": "#/components/schemas/StartNodeResult" }
963 }
964 }
965 }
966 }
967 }
968 },
969 "/api/v1/nodes/{id}/stop": {
970 "post": {
971 "summary": "Stop a node",
972 "description": "Stop a specific node by ID. Returns 409 if already stopped with current_state.",
973 "parameters": [{
974 "name": "id",
975 "in": "path",
976 "required": true,
977 "schema": { "type": "integer" }
978 }],
979 "responses": {
980 "200": {
981 "description": "Node stopped",
982 "content": {
983 "application/json": {
984 "schema": { "$ref": "#/components/schemas/NodeStopped" }
985 }
986 }
987 },
988 "404": {
989 "description": "Node not found"
990 },
991 "409": {
992 "description": "Node not running (includes current_state)"
993 },
994 "500": {
995 "description": "Failed to stop node"
996 }
997 }
998 }
999 },
1000 "/api/v1/nodes/stop-all": {
1001 "post": {
1002 "summary": "Stop all nodes",
1003 "description": "Stop all running nodes. Returns per-node results.",
1004 "responses": {
1005 "200": {
1006 "description": "Stop results",
1007 "content": {
1008 "application/json": {
1009 "schema": { "$ref": "#/components/schemas/StopNodeResult" }
1010 }
1011 }
1012 }
1013 }
1014 }
1015 },
1016 "/api/v1/reset": {
1017 "post": {
1018 "summary": "Reset all node state",
1019 "description": "Remove all node data directories, log directories, and clear the registry. Fails if any nodes are running.",
1020 "responses": {
1021 "200": {
1022 "description": "Reset successful",
1023 "content": {
1024 "application/json": {
1025 "schema": { "$ref": "#/components/schemas/ResetResult" }
1026 }
1027 }
1028 },
1029 "409": {
1030 "description": "Nodes still running"
1031 }
1032 }
1033 }
1034 },
1035 "/api/v1/logs/forward": {
1036 "get": {
1037 "summary": "Log forwarding status",
1038 "description": "Whether beta log forwarding is enabled, which nodes are being tailed, which are skipped for having no log directory, and delivery counters. Never returns the write token, only a fingerprint of it.",
1039 "responses": {
1040 "200": {
1041 "description": "Forwarding status",
1042 "content": {
1043 "application/json": {
1044 "schema": { "$ref": "#/components/schemas/LogForwardStatus" }
1045 }
1046 }
1047 }
1048 }
1049 }
1050 },
1051 "/api/v1/logs/forward/enable": {
1052 "post": {
1053 "summary": "Enable log forwarding",
1054 "description": "Opt into forwarding managed nodes' logs to the beta endpoint. This call is the consent act. Omitted fields reuse the stored configuration, so re-enabling after a disable needs no arguments. Idempotent.",
1055 "requestBody": {
1056 "required": false,
1057 "content": {
1058 "application/json": {
1059 "schema": { "$ref": "#/components/schemas/LogForwardEnableRequest" }
1060 }
1061 }
1062 },
1063 "responses": {
1064 "200": {
1065 "description": "Forwarding enabled",
1066 "content": {
1067 "application/json": {
1068 "schema": { "$ref": "#/components/schemas/LogForwardResult" }
1069 }
1070 }
1071 },
1072 "400": {
1073 "description": "No token has ever been supplied, or the endpoint is not an http(s) URL"
1074 }
1075 }
1076 }
1077 },
1078 "/api/v1/logs/forward/disable": {
1079 "post": {
1080 "summary": "Disable log forwarding",
1081 "description": "Stop forwarding. Nothing else about any node changes: no restart, no argument change, no data touched. Idempotent.",
1082 "responses": {
1083 "200": {
1084 "description": "Forwarding disabled",
1085 "content": {
1086 "application/json": {
1087 "schema": { "$ref": "#/components/schemas/LogForwardResult" }
1088 }
1089 }
1090 }
1091 }
1092 }
1093 }
1094 },
1095 "components": {
1096 "schemas": {
1097 "LogLevel": {
1098 "type": "string",
1099 "enum": ["trace", "debug", "info", "warn", "error"]
1100 },
1101 "ForwardingNode": {
1102 "type": "object",
1103 "properties": {
1104 "node_id": { "type": "integer" },
1105 "service": { "type": "string" },
1106 "log_dir": { "type": "string" }
1107 }
1108 },
1109 "SkippedNode": {
1110 "type": "object",
1111 "description": "A node that cannot be forwarded, with the reason. Almost always a node added without --log-dir-path, which writes no log files at all.",
1112 "properties": {
1113 "node_id": { "type": "integer" },
1114 "service": { "type": "string" },
1115 "reason": { "type": "string" }
1116 }
1117 },
1118 "ForwardStats": {
1119 "type": "object",
1120 "description": "Counters since the daemon started; reset on restart.",
1121 "properties": {
1122 "events_forwarded": { "type": "integer" },
1123 "events_dropped_by_level": { "type": "integer" },
1124 "events_dropped_by_overflow": { "type": "integer" },
1125 "batches_sent": { "type": "integer" },
1126 "batches_failed": { "type": "integer" },
1127 "last_success_unix": { "type": "integer", "nullable": true },
1128 "last_error": { "type": "string", "nullable": true }
1129 }
1130 },
1131 "LogForwardStatus": {
1132 "type": "object",
1133 "properties": {
1134 "enabled": { "type": "boolean" },
1135 "endpoint": { "type": "string" },
1136 "index_prefix": { "type": "string" },
1137 "min_level": { "$ref": "#/components/schemas/LogLevel" },
1138 "token_fingerprint": { "type": "string", "nullable": true, "description": "Short non-reversible identifier for the configured token. The token itself is never returned." },
1139 "active": { "type": "boolean", "description": "Whether the background forwarder is currently running." },
1140 "nodes_forwarding": { "type": "array", "items": { "$ref": "#/components/schemas/ForwardingNode" } },
1141 "nodes_skipped": { "type": "array", "items": { "$ref": "#/components/schemas/SkippedNode" } },
1142 "stats": { "$ref": "#/components/schemas/ForwardStats" }
1143 }
1144 },
1145 "LogForwardEnableRequest": {
1146 "type": "object",
1147 "properties": {
1148 "token": { "type": "string", "nullable": true, "description": "Write-only Elasticsearch API key. Reuses the stored one when omitted." },
1149 "endpoint": { "type": "string", "nullable": true },
1150 "min_level": { "$ref": "#/components/schemas/LogLevel", "nullable": true }
1151 }
1152 },
1153 "LogForwardResult": {
1154 "type": "object",
1155 "properties": {
1156 "enabled": { "type": "boolean" },
1157 "already_in_state": { "type": "boolean" },
1158 "endpoint": { "type": "string" },
1159 "min_level": { "$ref": "#/components/schemas/LogLevel" },
1160 "nodes_forwarding": { "type": "array", "items": { "$ref": "#/components/schemas/ForwardingNode" } },
1161 "nodes_skipped": { "type": "array", "items": { "$ref": "#/components/schemas/SkippedNode" } },
1162 "pending_daemon_start": { "type": "boolean", "description": "Set when the config was saved but no forwarder could be started because the daemon is not running." }
1163 }
1164 },
1165 "DaemonStatus": {
1166 "type": "object",
1167 "properties": {
1168 "running": { "type": "boolean" },
1169 "pid": { "type": "integer", "nullable": true },
1170 "port": { "type": "integer", "nullable": true },
1171 "uptime_secs": { "type": "integer", "nullable": true },
1172 "nodes_total": { "type": "integer" },
1173 "nodes_running": { "type": "integer" },
1174 "nodes_stopped": { "type": "integer" },
1175 "nodes_errored": { "type": "integer" }
1176 }
1177 }
1178 }
1179 }
1180 });
1181 Json(spec)
1182}
1183
1184async fn get_console() -> Html<&'static str> {
1185 Html(include_str!("console.html"))
1186}
1187
1188fn write_file(path: &PathBuf, contents: &str) -> Result<()> {
1189 if let Some(parent) = path.parent() {
1190 std::fs::create_dir_all(parent)?;
1191 }
1192 std::fs::write(path, contents)?;
1193 Ok(())
1194}
1195
1196async fn reconcile_registry_versions(registry: &mut NodeRegistry) {
1202 let node_ids: Vec<u32> = registry.list().iter().map(|c| c.id).collect();
1203 let mut changed = false;
1204
1205 for id in node_ids {
1206 let (binary_path, recorded_version) = match registry.get(id) {
1207 Ok(c) => (c.binary_path.clone(), c.version.clone()),
1208 Err(_) => continue,
1209 };
1210
1211 if !binary_path.exists() {
1212 continue;
1213 }
1214
1215 let Ok(disk_version) = crate::node::binary::extract_version(&binary_path).await else {
1216 continue;
1217 };
1218
1219 if disk_version == recorded_version {
1220 continue;
1221 }
1222
1223 if let Ok(entry) = registry.get_mut(id) {
1224 entry.version = disk_version;
1225 changed = true;
1226 }
1227 }
1228
1229 if changed {
1230 let _ = registry.save();
1231 }
1232}
1233
1234#[cfg(all(test, unix))]
1235mod tests {
1236 use super::*;
1237 use crate::node::registry::NodeRegistry;
1238 use crate::node::types::{EvmNetwork, NodeConfig};
1239 use std::collections::HashMap;
1240 use std::os::unix::fs::PermissionsExt;
1241
1242 fn write_fake_binary(path: &std::path::Path, stdout: &str) {
1243 let script = format!("#!/bin/sh\nprintf '%s\\n' '{stdout}'\n");
1244 std::fs::write(path, script).unwrap();
1245 let mut perm = std::fs::metadata(path).unwrap().permissions();
1246 perm.set_mode(0o755);
1247 std::fs::set_permissions(path, perm).unwrap();
1248 }
1249
1250 fn seed_config(binary_path: PathBuf, version: &str, data_dir: PathBuf) -> NodeConfig {
1251 NodeConfig {
1252 id: 0,
1253 service_name: String::new(),
1254 rewards_address: "0x0".into(),
1255 data_dir,
1256 log_dir: None,
1257 node_port: None,
1258 binary_path,
1259 version: version.into(),
1260 env_variables: HashMap::new(),
1261 bootstrap_peers: vec![],
1262 upgrade_channel: None,
1263 evm_network: EvmNetwork::default(),
1264 eviction: None,
1265 }
1266 }
1267
1268 #[tokio::test]
1269 async fn reconcile_updates_stale_version_and_persists() {
1270 let tmp = tempfile::tempdir().unwrap();
1271 let reg_path = tmp.path().join("registry.json");
1272 let bin_path = tmp.path().join("ant-node");
1273 write_fake_binary(&bin_path, "ant-node 0.10.11-rc.1");
1274
1275 let mut registry = NodeRegistry::load(®_path).unwrap();
1276 let id = registry.add(seed_config(
1277 bin_path.clone(),
1278 "0.10.1",
1279 tmp.path().join("data"),
1280 ));
1281 registry.save().unwrap();
1282
1283 reconcile_registry_versions(&mut registry).await;
1284
1285 assert_eq!(registry.get(id).unwrap().version, "0.10.11-rc.1");
1286
1287 let reloaded = NodeRegistry::load(®_path).unwrap();
1288 assert_eq!(reloaded.get(id).unwrap().version, "0.10.11-rc.1");
1289 }
1290
1291 #[tokio::test]
1292 async fn reconcile_leaves_matching_version_alone() {
1293 let tmp = tempfile::tempdir().unwrap();
1294 let reg_path = tmp.path().join("registry.json");
1295 let bin_path = tmp.path().join("ant-node");
1296 write_fake_binary(&bin_path, "ant-node 0.10.1");
1297
1298 let mut registry = NodeRegistry::load(®_path).unwrap();
1299 let id = registry.add(seed_config(
1300 bin_path.clone(),
1301 "0.10.1",
1302 tmp.path().join("data"),
1303 ));
1304
1305 reconcile_registry_versions(&mut registry).await;
1306
1307 assert_eq!(registry.get(id).unwrap().version, "0.10.1");
1308 }
1309
1310 #[tokio::test]
1311 async fn reconcile_skips_missing_binary() {
1312 let tmp = tempfile::tempdir().unwrap();
1313 let reg_path = tmp.path().join("registry.json");
1314
1315 let mut registry = NodeRegistry::load(®_path).unwrap();
1316 let id = registry.add(seed_config(
1317 tmp.path().join("does-not-exist"),
1318 "0.10.1",
1319 tmp.path().join("data"),
1320 ));
1321
1322 reconcile_registry_versions(&mut registry).await;
1323
1324 assert_eq!(registry.get(id).unwrap().version, "0.10.1");
1325 }
1326}