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::health::{DiskThresholds, FleetHealth};
19use crate::node::daemon::supervisor::{
20 spawn_eviction_monitor, spawn_liveness_monitor, Supervisor, EVICTION_POLL_INTERVAL,
21 LIVENESS_POLL_INTERVAL,
22};
23use crate::node::events::NodeEvent;
24use crate::node::registry::NodeRegistry;
25use crate::node::types::{
26 AddNodeOpts, AddNodeResult, DaemonConfig, DaemonStatus, NodeInfo, NodeStarted, NodeStatus,
27 NodeStatusResult, NodeStatusSummary, NodeStopped, RemoveNodeResult, ResetResult,
28 StartNodeResult, StopNodeResult,
29};
30
31pub struct AppState {
33 pub registry: Arc<RwLock<NodeRegistry>>,
34 pub supervisor: Arc<RwLock<Supervisor>>,
35 pub event_tx: broadcast::Sender<NodeEvent>,
36 pub start_time: Instant,
37 pub config: DaemonConfig,
38 pub bound_port: u16,
40 pub health: Arc<RwLock<FleetHealth>>,
43}
44
45pub async fn start(
49 config: DaemonConfig,
50 mut registry: NodeRegistry,
51 shutdown: CancellationToken,
52) -> Result<SocketAddr> {
53 let (event_tx, _) = broadcast::channel(256);
54
55 let addr = SocketAddr::new(config.listen_addr, config.port.unwrap_or(0));
56 let listener = tokio::net::TcpListener::bind(addr)
57 .await
58 .map_err(|e| crate::error::Error::BindError(e.to_string()))?;
59 let bound_addr = listener
60 .local_addr()
61 .map_err(|e| crate::error::Error::BindError(e.to_string()))?;
62
63 reconcile_registry_versions(&mut registry).await;
68
69 let registry = Arc::new(RwLock::new(registry));
70 let supervisor = Arc::new(RwLock::new(Supervisor::new(event_tx.clone())));
71
72 {
77 let reg = registry.read().await;
78 let mut sup = supervisor.write().await;
79 let adopted = sup.adopt_from_registry(®);
80 if !adopted.is_empty() {
81 tracing::info!(
82 "Adopted {} running node(s) from a previous daemon instance: {:?}",
83 adopted.len(),
84 adopted
85 );
86 }
87 }
88
89 let health = Arc::new(RwLock::new(FleetHealth::healthy()));
90
91 let state = Arc::new(AppState {
92 registry: registry.clone(),
93 supervisor: supervisor.clone(),
94 event_tx: event_tx.clone(),
95 start_time: Instant::now(),
96 config: config.clone(),
97 bound_port: bound_addr.port(),
98 health: health.clone(),
99 });
100
101 spawn_eviction_monitor(
106 registry.clone(),
107 supervisor.clone(),
108 event_tx.clone(),
109 health,
110 DiskThresholds::default(),
111 EVICTION_POLL_INTERVAL,
112 shutdown.clone(),
113 );
114
115 spawn_liveness_monitor(
119 registry,
120 supervisor,
121 event_tx,
122 LIVENESS_POLL_INTERVAL,
123 shutdown.clone(),
124 );
125
126 let app = build_router(state.clone());
127
128 write_file(&config.port_file_path, &bound_addr.port().to_string())?;
130 write_file(&config.pid_file_path, &std::process::id().to_string())?;
131
132 let port_file = config.port_file_path.clone();
133 let pid_file = config.pid_file_path.clone();
134
135 tokio::spawn(async move {
136 axum::serve(listener, app)
137 .with_graceful_shutdown(shutdown.cancelled_owned())
138 .await
139 .ok();
140
141 let _ = std::fs::remove_file(&port_file);
143 let _ = std::fs::remove_file(&pid_file);
144 });
145
146 Ok(bound_addr)
147}
148
149fn build_router(state: Arc<AppState>) -> Router {
150 use axum::http::HeaderValue;
151 use tower_http::cors::{Any, CorsLayer};
152
153 let origin = format!("http://127.0.0.1:{}", state.bound_port);
157 let cors = CorsLayer::new()
158 .allow_origin([origin.parse::<HeaderValue>().unwrap()])
159 .allow_methods(Any)
160 .allow_headers(Any);
161
162 Router::new()
163 .route("/console", get(get_console))
164 .route("/api/v1/status", get(get_status))
165 .route("/api/v1/health", get(get_health))
166 .route("/api/v1/events", get(get_events))
167 .route("/api/v1/nodes/status", get(get_nodes_status))
168 .route("/api/v1/nodes", post(post_nodes))
169 .route(
170 "/api/v1/nodes/{id}",
171 get(get_node_detail).delete(delete_node),
172 )
173 .route("/api/v1/nodes/{id}/start", post(post_start_node))
174 .route("/api/v1/nodes/start-all", post(post_start_all))
175 .route("/api/v1/nodes/{id}/stop", post(post_stop_node))
176 .route("/api/v1/nodes/stop-all", post(post_stop_all))
177 .route("/api/v1/reset", post(post_reset))
178 .route("/api/v1/openapi.json", get(get_openapi))
179 .layer(cors)
180 .with_state(state)
181}
182
183async fn get_status(State(state): State<Arc<AppState>>) -> Json<DaemonStatus> {
184 let registry = state.registry.read().await;
185 let supervisor = state.supervisor.read().await;
186 let (running, stopped, errored) = supervisor.node_counts();
187
188 Json(DaemonStatus {
189 running: true,
190 pid: Some(std::process::id()),
191 port: Some(state.bound_port),
192 uptime_secs: Some(state.start_time.elapsed().as_secs()),
193 nodes_total: registry.len() as u32,
194 nodes_running: running,
195 nodes_stopped: stopped,
196 nodes_errored: errored,
197 })
198}
199
200async fn get_health(State(state): State<Arc<AppState>>) -> Json<FleetHealth> {
204 Json(state.health.read().await.clone())
205}
206
207async fn get_events(
208 State(state): State<Arc<AppState>>,
209) -> Sse<impl futures_core::Stream<Item = std::result::Result<Event, std::convert::Infallible>>> {
210 let mut rx = state.event_tx.subscribe();
211
212 let stream = async_stream::stream! {
213 loop {
214 match rx.recv().await {
215 Ok(event) => {
216 let event_type = event.event_type().to_string();
217 if let Ok(data) = serde_json::to_string(&event) {
218 yield Ok(Event::default().event(event_type).data(data));
219 }
220 }
221 Err(broadcast::error::RecvError::Lagged(_)) => continue,
222 Err(broadcast::error::RecvError::Closed) => break,
223 }
224 }
225 };
226
227 Sse::new(stream)
228}
229
230async fn get_nodes_status(State(state): State<Arc<AppState>>) -> Json<NodeStatusResult> {
232 let registry = state.registry.read().await;
233 let supervisor = state.supervisor.read().await;
234
235 let mut nodes = Vec::new();
236 let mut total_running = 0u32;
237 let mut total_stopped = 0u32;
238
239 for config in registry.list() {
240 let status = if config.eviction.is_some() {
243 NodeStatus::Evicted
244 } else {
245 supervisor
246 .node_status(config.id)
247 .unwrap_or(NodeStatus::Stopped)
248 };
249
250 match status {
251 NodeStatus::Running | NodeStatus::Starting => total_running += 1,
252 _ => total_stopped += 1,
253 }
254
255 let (pid, uptime_secs) = if config.eviction.is_some() {
256 (None, None)
257 } else {
258 (
259 supervisor.node_pid(config.id),
260 supervisor.node_uptime_secs(config.id),
261 )
262 };
263
264 nodes.push(NodeStatusSummary {
265 node_id: config.id,
266 name: config.service_name.clone(),
267 version: config.version.clone(),
268 status,
269 pid,
270 uptime_secs,
271 eviction: config.eviction.clone(),
272 });
273 }
274
275 Json(NodeStatusResult {
276 nodes,
277 total_running,
278 total_stopped,
279 })
280}
281
282async fn get_node_detail(
284 State(state): State<Arc<AppState>>,
285 Path(id): Path<u32>,
286) -> std::result::Result<Json<NodeInfo>, (StatusCode, Json<serde_json::Value>)> {
287 let registry = state.registry.read().await;
288 let config = match registry.get(id) {
289 Ok(config) => config.clone(),
290 Err(_) => {
291 return Err((
292 StatusCode::NOT_FOUND,
293 Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
294 ))
295 }
296 };
297
298 let supervisor = state.supervisor.read().await;
299 let (status, pid, uptime_secs) = if config.eviction.is_some() {
301 (NodeStatus::Evicted, None, None)
302 } else {
303 (
304 supervisor.node_status(id).unwrap_or(NodeStatus::Stopped),
305 supervisor.node_pid(id),
306 supervisor.node_uptime_secs(id),
307 )
308 };
309
310 Ok(Json(NodeInfo {
311 config,
312 status,
313 pid,
314 uptime_secs,
315 }))
316}
317
318async fn post_nodes(
320 State(state): State<Arc<AppState>>,
321 Json(opts): Json<AddNodeOpts>,
322) -> std::result::Result<(StatusCode, Json<AddNodeResult>), (StatusCode, Json<serde_json::Value>)> {
323 let registry_path = state.config.registry_path.clone();
324 let progress = NoopProgress;
325
326 match crate::node::add_nodes(opts, ®istry_path, &progress).await {
327 Ok(result) => {
328 let mut registry = state.registry.write().await;
330 if let Ok(fresh) = NodeRegistry::load(®istry_path) {
331 *registry = fresh;
332 }
333 Ok((StatusCode::CREATED, Json(result)))
334 }
335 Err(e) => Err((
336 StatusCode::BAD_REQUEST,
337 Json(serde_json::json!({ "error": e.to_string() })),
338 )),
339 }
340}
341
342async fn delete_node(
344 State(state): State<Arc<AppState>>,
345 Path(id): Path<u32>,
346) -> std::result::Result<Json<RemoveNodeResult>, (StatusCode, Json<serde_json::Value>)> {
347 let supervisor = state.supervisor.read().await;
349 if supervisor.is_running(id) {
350 return Err((
351 StatusCode::CONFLICT,
352 Json(serde_json::json!({
353 "error": format!("Cannot remove node {id} while it is running. Stop it first."),
354 "current_state": { "node_id": id, "status": "running" }
355 })),
356 ));
357 }
358 drop(supervisor);
359
360 let registry_path = state.config.registry_path.clone();
361
362 match crate::node::remove_node(id, ®istry_path) {
363 Ok(result) => {
364 let mut registry = state.registry.write().await;
366 if let Ok(fresh) = NodeRegistry::load(®istry_path) {
367 *registry = fresh;
368 }
369 Ok(Json(result))
370 }
371 Err(crate::error::Error::NodeNotFound(id)) => Err((
372 StatusCode::NOT_FOUND,
373 Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
374 )),
375 Err(e) => Err((
376 StatusCode::INTERNAL_SERVER_ERROR,
377 Json(serde_json::json!({ "error": e.to_string() })),
378 )),
379 }
380}
381
382async fn post_start_node(
384 State(state): State<Arc<AppState>>,
385 Path(id): Path<u32>,
386) -> std::result::Result<Json<NodeStarted>, (StatusCode, Json<serde_json::Value>)> {
387 let registry = state.registry.read().await;
388 let config = match registry.get(id) {
389 Ok(config) => config.clone(),
390 Err(_) => {
391 return Err((
392 StatusCode::NOT_FOUND,
393 Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
394 ))
395 }
396 };
397 drop(registry);
398
399 if config.eviction.is_some() {
402 return Err((
403 StatusCode::CONFLICT,
404 Json(serde_json::json!({
405 "error": format!(
406 "Node {id} has been evicted and cannot be started. Dismiss it with \
407 `ant node dismiss {id}` and add a new node instead."
408 ),
409 "current_state": { "node_id": id, "status": "evicted" }
410 })),
411 ));
412 }
413
414 let supervisor_ref = state.supervisor.clone();
415
416 let mut supervisor = state.supervisor.write().await;
418 if supervisor.is_running(id) {
419 let pid = supervisor.node_pid(id);
420 let uptime_secs = supervisor.node_uptime_secs(id);
421 return Err((
422 StatusCode::CONFLICT,
423 Json(serde_json::json!({
424 "error": format!("Node {id} is already running"),
425 "current_state": {
426 "node_id": id,
427 "status": "running",
428 "pid": pid,
429 "uptime_secs": uptime_secs,
430 }
431 })),
432 ));
433 }
434
435 let registry_ref = state.registry.clone();
436 match supervisor
437 .start_node(&config, supervisor_ref, registry_ref)
438 .await
439 {
440 Ok(started) => Ok(Json(started)),
441 Err(crate::error::Error::NodeAlreadyRunning(id)) => {
442 let pid = supervisor.node_pid(id);
443 let uptime_secs = supervisor.node_uptime_secs(id);
444 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 Err(e) => Err((
458 StatusCode::INTERNAL_SERVER_ERROR,
459 Json(serde_json::json!({ "error": e.to_string() })),
460 )),
461 }
462}
463
464async fn post_start_all(State(state): State<Arc<AppState>>) -> Json<StartNodeResult> {
466 let registry = state.registry.read().await;
467 let configs: Vec<_> = registry
470 .list()
471 .into_iter()
472 .filter(|c| c.eviction.is_none())
473 .cloned()
474 .collect();
475 drop(registry);
476
477 let mut started = Vec::new();
478 let mut failed = Vec::new();
479 let mut already_running = Vec::new();
480
481 let supervisor_ref = state.supervisor.clone();
482 let registry_ref = state.registry.clone();
483
484 for config in &configs {
485 let mut supervisor = state.supervisor.write().await;
486 if supervisor.is_running(config.id) {
487 already_running.push(config.id);
488 continue;
489 }
490
491 match supervisor
492 .start_node(config, supervisor_ref.clone(), registry_ref.clone())
493 .await
494 {
495 Ok(result) => started.push(result),
496 Err(crate::error::Error::NodeAlreadyRunning(id)) => {
497 already_running.push(id);
498 }
499 Err(e) => {
500 failed.push(crate::node::types::NodeStartFailed {
501 node_id: config.id,
502 service_name: config.service_name.clone(),
503 error: e.to_string(),
504 });
505 }
506 }
507 }
508
509 Json(StartNodeResult {
510 started,
511 failed,
512 already_running,
513 })
514}
515
516async fn post_stop_node(
518 State(state): State<Arc<AppState>>,
519 Path(id): Path<u32>,
520) -> std::result::Result<Json<NodeStopped>, (StatusCode, Json<serde_json::Value>)> {
521 let registry = state.registry.read().await;
522 let config = match registry.get(id) {
523 Ok(config) => config.clone(),
524 Err(_) => {
525 return Err((
526 StatusCode::NOT_FOUND,
527 Json(serde_json::json!({ "error": format!("Node not found: {id}") })),
528 ))
529 }
530 };
531 drop(registry);
532
533 if config.eviction.is_some() {
535 return Err((
536 StatusCode::CONFLICT,
537 Json(serde_json::json!({
538 "error": format!(
539 "Node {id} has been evicted; there is nothing to stop. Dismiss it with \
540 `ant node dismiss {id}`."
541 ),
542 "current_state": { "node_id": id, "status": "evicted" }
543 })),
544 ));
545 }
546
547 let mut supervisor = state.supervisor.write().await;
549 if !supervisor.is_running(id) {
550 let status = supervisor
551 .node_status(id)
552 .unwrap_or(crate::node::types::NodeStatus::Stopped);
553 return Err((
554 StatusCode::CONFLICT,
555 Json(serde_json::json!({
556 "error": format!("Node {id} is not running"),
557 "current_state": {
558 "node_id": id,
559 "status": status,
560 }
561 })),
562 ));
563 }
564
565 match supervisor.stop_node(id).await {
566 Ok(()) => Ok(Json(NodeStopped {
567 node_id: id,
568 service_name: config.service_name,
569 })),
570 Err(crate::error::Error::NodeNotRunning(id)) => {
571 let status = supervisor
572 .node_status(id)
573 .unwrap_or(crate::node::types::NodeStatus::Stopped);
574 Err((
575 StatusCode::CONFLICT,
576 Json(serde_json::json!({
577 "error": format!("Node {id} is not running"),
578 "current_state": {
579 "node_id": id,
580 "status": status,
581 }
582 })),
583 ))
584 }
585 Err(e) => Err((
586 StatusCode::INTERNAL_SERVER_ERROR,
587 Json(serde_json::json!({ "error": e.to_string() })),
588 )),
589 }
590}
591
592async fn post_stop_all(State(state): State<Arc<AppState>>) -> Json<StopNodeResult> {
594 let registry = state.registry.read().await;
595 let configs: Vec<(u32, String)> = registry
597 .list()
598 .into_iter()
599 .filter(|c| c.eviction.is_none())
600 .map(|c| (c.id, c.service_name.clone()))
601 .collect();
602 drop(registry);
603
604 let mut supervisor = state.supervisor.write().await;
605 let result = supervisor.stop_all_nodes(&configs).await;
606
607 Json(result)
608}
609
610async fn post_reset(
612 State(state): State<Arc<AppState>>,
613) -> std::result::Result<Json<ResetResult>, (StatusCode, Json<serde_json::Value>)> {
614 let supervisor = state.supervisor.write().await;
617 let (running, _, _) = supervisor.node_counts();
618 if running > 0 {
619 return Err((
620 StatusCode::CONFLICT,
621 Json(serde_json::json!({
622 "error": format!("Cannot reset while nodes are running ({running} node(s) still running). Stop all nodes first."),
623 "nodes_running": running,
624 })),
625 ));
626 }
627 drop(supervisor);
628
629 let registry_path = state.config.registry_path.clone();
630
631 match crate::node::reset(®istry_path) {
632 Ok(result) => {
633 let mut registry = state.registry.write().await;
635 if let Ok(fresh) = NodeRegistry::load(®istry_path) {
636 *registry = fresh;
637 }
638 Ok(Json(result))
639 }
640 Err(e) => Err((
641 StatusCode::INTERNAL_SERVER_ERROR,
642 Json(serde_json::json!({ "error": e.to_string() })),
643 )),
644 }
645}
646
647async fn get_openapi() -> impl IntoResponse {
648 let spec = serde_json::json!({
651 "openapi": "3.1.0",
652 "info": {
653 "title": "Ant Daemon API",
654 "version": "0.1.0",
655 "description": "REST API for the ant node management daemon"
656 },
657 "paths": {
658 "/api/v1/status": {
659 "get": {
660 "summary": "Daemon status",
661 "description": "Returns daemon health, uptime, and node count summary",
662 "responses": {
663 "200": {
664 "description": "Daemon status",
665 "content": {
666 "application/json": {
667 "schema": { "$ref": "#/components/schemas/DaemonStatus" }
668 }
669 }
670 }
671 }
672 }
673 },
674 "/api/v1/events": {
675 "get": {
676 "summary": "Event stream",
677 "description": "SSE stream of real-time node events",
678 "responses": {
679 "200": {
680 "description": "SSE event stream"
681 }
682 }
683 }
684 },
685 "/api/v1/nodes": {
686 "post": {
687 "summary": "Add nodes",
688 "description": "Add one or more nodes to the registry",
689 "requestBody": {
690 "required": true,
691 "content": {
692 "application/json": {
693 "schema": { "$ref": "#/components/schemas/AddNodeOpts" }
694 }
695 }
696 },
697 "responses": {
698 "201": {
699 "description": "Nodes added",
700 "content": {
701 "application/json": {
702 "schema": { "$ref": "#/components/schemas/AddNodeResult" }
703 }
704 }
705 },
706 "400": {
707 "description": "Invalid request"
708 }
709 }
710 }
711 },
712 "/api/v1/nodes/{id}": {
713 "delete": {
714 "summary": "Remove node",
715 "description": "Remove a node from the registry",
716 "parameters": [{
717 "name": "id",
718 "in": "path",
719 "required": true,
720 "schema": { "type": "integer" }
721 }],
722 "responses": {
723 "200": {
724 "description": "Node removed",
725 "content": {
726 "application/json": {
727 "schema": { "$ref": "#/components/schemas/RemoveNodeResult" }
728 }
729 }
730 },
731 "404": {
732 "description": "Node not found"
733 }
734 }
735 }
736 },
737 "/api/v1/nodes/{id}/start": {
738 "post": {
739 "summary": "Start a node",
740 "description": "Start a specific node by ID. Returns 409 if already running with current_state.",
741 "parameters": [{
742 "name": "id",
743 "in": "path",
744 "required": true,
745 "schema": { "type": "integer" }
746 }],
747 "responses": {
748 "200": {
749 "description": "Node started",
750 "content": {
751 "application/json": {
752 "schema": { "$ref": "#/components/schemas/NodeStarted" }
753 }
754 }
755 },
756 "404": {
757 "description": "Node not found"
758 },
759 "409": {
760 "description": "Node already running (includes current_state)"
761 },
762 "500": {
763 "description": "Failed to start node"
764 }
765 }
766 }
767 },
768 "/api/v1/nodes/start-all": {
769 "post": {
770 "summary": "Start all nodes",
771 "description": "Start all registered nodes. Returns per-node results.",
772 "responses": {
773 "200": {
774 "description": "Start results",
775 "content": {
776 "application/json": {
777 "schema": { "$ref": "#/components/schemas/StartNodeResult" }
778 }
779 }
780 }
781 }
782 }
783 },
784 "/api/v1/nodes/{id}/stop": {
785 "post": {
786 "summary": "Stop a node",
787 "description": "Stop a specific node by ID. Returns 409 if already stopped with current_state.",
788 "parameters": [{
789 "name": "id",
790 "in": "path",
791 "required": true,
792 "schema": { "type": "integer" }
793 }],
794 "responses": {
795 "200": {
796 "description": "Node stopped",
797 "content": {
798 "application/json": {
799 "schema": { "$ref": "#/components/schemas/NodeStopped" }
800 }
801 }
802 },
803 "404": {
804 "description": "Node not found"
805 },
806 "409": {
807 "description": "Node not running (includes current_state)"
808 },
809 "500": {
810 "description": "Failed to stop node"
811 }
812 }
813 }
814 },
815 "/api/v1/nodes/stop-all": {
816 "post": {
817 "summary": "Stop all nodes",
818 "description": "Stop all running nodes. Returns per-node results.",
819 "responses": {
820 "200": {
821 "description": "Stop results",
822 "content": {
823 "application/json": {
824 "schema": { "$ref": "#/components/schemas/StopNodeResult" }
825 }
826 }
827 }
828 }
829 }
830 },
831 "/api/v1/reset": {
832 "post": {
833 "summary": "Reset all node state",
834 "description": "Remove all node data directories, log directories, and clear the registry. Fails if any nodes are running.",
835 "responses": {
836 "200": {
837 "description": "Reset successful",
838 "content": {
839 "application/json": {
840 "schema": { "$ref": "#/components/schemas/ResetResult" }
841 }
842 }
843 },
844 "409": {
845 "description": "Nodes still running"
846 }
847 }
848 }
849 }
850 },
851 "components": {
852 "schemas": {
853 "DaemonStatus": {
854 "type": "object",
855 "properties": {
856 "running": { "type": "boolean" },
857 "pid": { "type": "integer", "nullable": true },
858 "port": { "type": "integer", "nullable": true },
859 "uptime_secs": { "type": "integer", "nullable": true },
860 "nodes_total": { "type": "integer" },
861 "nodes_running": { "type": "integer" },
862 "nodes_stopped": { "type": "integer" },
863 "nodes_errored": { "type": "integer" }
864 }
865 }
866 }
867 }
868 });
869 Json(spec)
870}
871
872async fn get_console() -> Html<&'static str> {
873 Html(include_str!("console.html"))
874}
875
876fn write_file(path: &PathBuf, contents: &str) -> Result<()> {
877 if let Some(parent) = path.parent() {
878 std::fs::create_dir_all(parent)?;
879 }
880 std::fs::write(path, contents)?;
881 Ok(())
882}
883
884async fn reconcile_registry_versions(registry: &mut NodeRegistry) {
890 let node_ids: Vec<u32> = registry.list().iter().map(|c| c.id).collect();
891 let mut changed = false;
892
893 for id in node_ids {
894 let (binary_path, recorded_version) = match registry.get(id) {
895 Ok(c) => (c.binary_path.clone(), c.version.clone()),
896 Err(_) => continue,
897 };
898
899 if !binary_path.exists() {
900 continue;
901 }
902
903 let Ok(disk_version) = crate::node::binary::extract_version(&binary_path).await else {
904 continue;
905 };
906
907 if disk_version == recorded_version {
908 continue;
909 }
910
911 if let Ok(entry) = registry.get_mut(id) {
912 entry.version = disk_version;
913 changed = true;
914 }
915 }
916
917 if changed {
918 let _ = registry.save();
919 }
920}
921
922#[cfg(all(test, unix))]
923mod tests {
924 use super::*;
925 use crate::node::registry::NodeRegistry;
926 use crate::node::types::{EvmNetwork, NodeConfig};
927 use std::collections::HashMap;
928 use std::os::unix::fs::PermissionsExt;
929
930 fn write_fake_binary(path: &std::path::Path, stdout: &str) {
931 let script = format!("#!/bin/sh\nprintf '%s\\n' '{stdout}'\n");
932 std::fs::write(path, script).unwrap();
933 let mut perm = std::fs::metadata(path).unwrap().permissions();
934 perm.set_mode(0o755);
935 std::fs::set_permissions(path, perm).unwrap();
936 }
937
938 fn seed_config(binary_path: PathBuf, version: &str, data_dir: PathBuf) -> NodeConfig {
939 NodeConfig {
940 id: 0,
941 service_name: String::new(),
942 rewards_address: "0x0".into(),
943 data_dir,
944 log_dir: None,
945 node_port: None,
946 binary_path,
947 version: version.into(),
948 env_variables: HashMap::new(),
949 bootstrap_peers: vec![],
950 upgrade_channel: None,
951 evm_network: EvmNetwork::default(),
952 eviction: None,
953 }
954 }
955
956 #[tokio::test]
957 async fn reconcile_updates_stale_version_and_persists() {
958 let tmp = tempfile::tempdir().unwrap();
959 let reg_path = tmp.path().join("registry.json");
960 let bin_path = tmp.path().join("ant-node");
961 write_fake_binary(&bin_path, "ant-node 0.10.11-rc.1");
962
963 let mut registry = NodeRegistry::load(®_path).unwrap();
964 let id = registry.add(seed_config(
965 bin_path.clone(),
966 "0.10.1",
967 tmp.path().join("data"),
968 ));
969 registry.save().unwrap();
970
971 reconcile_registry_versions(&mut registry).await;
972
973 assert_eq!(registry.get(id).unwrap().version, "0.10.11-rc.1");
974
975 let reloaded = NodeRegistry::load(®_path).unwrap();
976 assert_eq!(reloaded.get(id).unwrap().version, "0.10.11-rc.1");
977 }
978
979 #[tokio::test]
980 async fn reconcile_leaves_matching_version_alone() {
981 let tmp = tempfile::tempdir().unwrap();
982 let reg_path = tmp.path().join("registry.json");
983 let bin_path = tmp.path().join("ant-node");
984 write_fake_binary(&bin_path, "ant-node 0.10.1");
985
986 let mut registry = NodeRegistry::load(®_path).unwrap();
987 let id = registry.add(seed_config(
988 bin_path.clone(),
989 "0.10.1",
990 tmp.path().join("data"),
991 ));
992
993 reconcile_registry_versions(&mut registry).await;
994
995 assert_eq!(registry.get(id).unwrap().version, "0.10.1");
996 }
997
998 #[tokio::test]
999 async fn reconcile_skips_missing_binary() {
1000 let tmp = tempfile::tempdir().unwrap();
1001 let reg_path = tmp.path().join("registry.json");
1002
1003 let mut registry = NodeRegistry::load(®_path).unwrap();
1004 let id = registry.add(seed_config(
1005 tmp.path().join("does-not-exist"),
1006 "0.10.1",
1007 tmp.path().join("data"),
1008 ));
1009
1010 reconcile_registry_versions(&mut registry).await;
1011
1012 assert_eq!(registry.get(id).unwrap().version, "0.10.1");
1013 }
1014}