1pub mod binary;
2pub mod daemon;
3#[cfg(feature = "devnet")]
6pub mod devnet;
7pub mod events;
8pub mod process;
9pub mod registry;
10pub mod types;
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use crate::config;
16use crate::error::{Error, Result};
17use crate::node::binary::ProgressReporter;
18use crate::node::registry::NodeRegistry;
19use crate::node::types::{
20 AddNodeOpts, AddNodeResult, NodeConfig, NodeStatus, NodeStatusResult, NodeStatusSummary,
21 RemoveNodeResult, ResetResult,
22};
23
24pub async fn add_nodes(
35 opts: AddNodeOpts,
36 registry_path: &Path,
37 progress: &dyn ProgressReporter,
38) -> Result<AddNodeResult> {
39 validate_rewards_address(&opts.rewards_address)?;
41 let rewards_address = opts.rewards_address.trim().to_string();
42
43 const MAX_NODES_PER_CALL: u16 = 1000;
45 if opts.count > MAX_NODES_PER_CALL {
46 return Err(Error::InvalidNodeCount {
47 count: opts.count,
48 max: MAX_NODES_PER_CALL,
49 });
50 }
51
52 if let Some(ref port_range) = opts.node_port {
54 let range_len = port_range.len();
55 if range_len != 1 && range_len != opts.count {
56 return Err(Error::PortRangeMismatch {
57 range_len,
58 count: opts.count,
59 });
60 }
61 }
62
63 let install_dir = binary::binary_install_dir()?;
65 let resolved = binary::resolve_binary(
66 &opts.binary_source,
67 opts.upgrade_channel,
68 &install_dir,
69 progress,
70 )
71 .await?;
72 let cached_binary = resolved.path;
73 let version = resolved.version;
74
75 let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
77
78 let mut nodes_added = Vec::with_capacity(opts.count as usize);
80 let env_map: HashMap<String, String> = opts.env_variables.into_iter().collect();
81
82 let binary_file_name = binary::BINARY_NAME;
84
85 for i in 0..opts.count {
86 let node_port = resolve_port(&opts.node_port, i, opts.count);
87
88 let placeholder_id = 0;
90
91 let data_dir = node_data_dir(&opts.data_dir_path, placeholder_id);
92 let log_dir = node_log_dir(&opts.log_dir_path, placeholder_id);
93
94 let config = NodeConfig {
95 id: placeholder_id,
96 service_name: String::new(), rewards_address: rewards_address.clone(),
98 data_dir,
99 log_dir,
100 node_port,
101 binary_path: PathBuf::new(), version: version.clone(),
103 env_variables: env_map.clone(),
104 bootstrap_peers: opts.bootstrap_peers.clone(),
105 upgrade_channel: opts.upgrade_channel,
106 evm_network: opts.evm_network,
107 eviction: None,
108 };
109
110 let assigned_id = registry.add(config);
111
112 let node = registry.get_mut(assigned_id)?;
114 node.data_dir = node_data_dir(&opts.data_dir_path, assigned_id);
115 node.log_dir = node_log_dir(&opts.log_dir_path, assigned_id);
116
117 std::fs::create_dir_all(&node.data_dir)?;
119 if let Some(ref log_dir) = node.log_dir {
120 std::fs::create_dir_all(log_dir)?;
121 }
122
123 let node_binary = node.data_dir.join(binary_file_name);
127 std::fs::copy(&cached_binary, &node_binary)?;
128 #[cfg(unix)]
129 {
130 use std::os::unix::fs::PermissionsExt;
131 std::fs::set_permissions(&node_binary, std::fs::Permissions::from_mode(0o755))?;
132 }
133 node.binary_path = node_binary;
134
135 if let Some(ref bp_path) = resolved.bootstrap_peers_path {
138 let dest = node.data_dir.join(binary::BOOTSTRAP_PEERS_FILE);
139 std::fs::copy(bp_path, &dest)?;
140 }
141
142 nodes_added.push(node.clone());
143 }
144
145 registry.save()?;
146
147 Ok(AddNodeResult { nodes_added })
148}
149
150pub fn remove_node(node_id: u32, registry_path: &Path) -> Result<RemoveNodeResult> {
154 let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
155 let removed = registry.remove(node_id)?;
156 registry.save()?;
157 Ok(RemoveNodeResult { removed })
158}
159
160pub fn reset(registry_path: &Path) -> Result<ResetResult> {
171 let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
172
173 let mut data_dirs_removed = Vec::new();
174 let mut log_dirs_removed = Vec::new();
175 let nodes_cleared = registry.len() as u32;
176
177 for node in registry.list() {
178 if node.data_dir.exists() {
179 std::fs::remove_dir_all(&node.data_dir)?;
180 data_dirs_removed.push(node.data_dir.clone());
181 }
182 if let Some(ref log_dir) = node.log_dir {
183 if log_dir.exists() {
184 std::fs::remove_dir_all(log_dir)?;
185 log_dirs_removed.push(log_dir.clone());
186 }
187 }
188 }
189
190 registry.clear();
191 registry.save()?;
192
193 Ok(ResetResult {
194 nodes_cleared,
195 data_dirs_removed,
196 log_dirs_removed,
197 })
198}
199
200pub fn node_status_offline(registry_path: &Path) -> Result<NodeStatusResult> {
206 let registry = NodeRegistry::load(registry_path)?;
207 let nodes: Vec<NodeStatusSummary> = registry
208 .list()
209 .iter()
210 .map(|config| {
211 let status = if config.eviction.is_some() {
212 NodeStatus::Evicted
213 } else {
214 NodeStatus::Stopped
215 };
216 NodeStatusSummary {
217 node_id: config.id,
218 name: config.service_name.clone(),
219 version: config.version.clone(),
220 status,
221 pid: None,
222 uptime_secs: None,
223 eviction: config.eviction.clone(),
224 }
225 })
226 .collect();
227 let total_stopped = nodes.len() as u32;
228 Ok(NodeStatusResult {
229 nodes,
230 total_running: 0,
231 total_stopped,
232 })
233}
234
235fn validate_rewards_address(address: &str) -> Result<()> {
239 let address = address.trim();
240 if address.is_empty() {
241 return Err(Error::InvalidRewardsAddress(
242 "rewards address cannot be empty".to_string(),
243 ));
244 }
245 if !address.starts_with("0x") && !address.starts_with("0X") {
246 return Err(Error::InvalidRewardsAddress(format!(
247 "rewards address must start with '0x', got '{address}'"
248 )));
249 }
250 let hex_part = &address[2..];
251 if hex_part.len() != 40 {
252 return Err(Error::InvalidRewardsAddress(format!(
253 "rewards address must be 42 characters (0x + 40 hex), got {} characters",
254 address.len()
255 )));
256 }
257 if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
258 return Err(Error::InvalidRewardsAddress(format!(
259 "rewards address contains non-hex characters: '{address}'"
260 )));
261 }
262 Ok(())
263}
264
265fn node_data_dir(custom_prefix: &Option<PathBuf>, node_id: u32) -> PathBuf {
267 match custom_prefix {
268 Some(prefix) => prefix.join(format!("node-{node_id}")),
269 None => config::data_dir()
270 .expect("Could not determine data directory")
271 .join("nodes")
272 .join(format!("node-{node_id}")),
273 }
274}
275
276fn node_log_dir(custom_prefix: &Option<PathBuf>, node_id: u32) -> Option<PathBuf> {
279 custom_prefix
280 .as_ref()
281 .map(|prefix| prefix.join(format!("node-{node_id}")).join("logs"))
282}
283
284fn resolve_port(range: &Option<types::PortRange>, index: u16, _count: u16) -> Option<u16> {
286 range.as_ref().and_then(|r| r.port_at(index))
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::node::binary::NoopProgress;
293 use crate::node::types::{BinarySource, EvmNetwork, PortRange};
294
295 const TEST_ADDR: &str = "0x1234567890abcdef1234567890abcdef12345678";
297
298 fn test_registry_path(dir: &std::path::Path) -> PathBuf {
299 dir.join("node_registry.json")
300 }
301
302 fn create_fake_binary(dir: &std::path::Path) -> PathBuf {
305 #[cfg(unix)]
306 {
307 let binary_path = dir.join("fake-antnode");
308 std::fs::write(&binary_path, "#!/bin/sh\necho \"antnode 0.1.0-test\"\n").unwrap();
309 use std::os::unix::fs::PermissionsExt;
310 std::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755)).unwrap();
311 binary_path
312 }
313 #[cfg(windows)]
314 {
315 let binary_path = dir.join("fake-antnode.cmd");
316 std::fs::write(&binary_path, "@echo off\r\necho antnode 0.1.0-test\r\n").unwrap();
317 binary_path
318 }
319 }
320
321 #[tokio::test]
322 async fn add_single_node_with_local_binary() {
323 let tmp = tempfile::tempdir().unwrap();
324 let binary = create_fake_binary(tmp.path());
325 let reg_path = test_registry_path(tmp.path());
326
327 let opts = AddNodeOpts {
328 count: 1,
329 rewards_address: TEST_ADDR.to_string(),
330 data_dir_path: Some(tmp.path().join("data")),
331 log_dir_path: Some(tmp.path().join("logs")),
332 binary_source: BinarySource::LocalPath(binary),
333 ..Default::default()
334 };
335
336 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
337 assert_eq!(result.nodes_added.len(), 1);
338 assert_eq!(result.nodes_added[0].rewards_address, TEST_ADDR);
339 assert_eq!(result.nodes_added[0].id, 1);
340 assert!(result.nodes_added[0].data_dir.exists());
341 assert!(result.nodes_added[0].log_dir.as_ref().unwrap().exists());
342
343 let reg = NodeRegistry::load(®_path).unwrap();
345 assert_eq!(reg.len(), 1);
346 }
347
348 #[tokio::test]
349 async fn add_multiple_nodes_with_port_range() {
350 let tmp = tempfile::tempdir().unwrap();
351 let binary = create_fake_binary(tmp.path());
352 let reg_path = test_registry_path(tmp.path());
353
354 let opts = AddNodeOpts {
355 count: 3,
356 rewards_address: TEST_ADDR.to_string(),
357 node_port: Some(PortRange::Range(12000, 12002)),
358 data_dir_path: Some(tmp.path().join("data")),
359 log_dir_path: Some(tmp.path().join("logs")),
360 binary_source: BinarySource::LocalPath(binary),
361 ..Default::default()
362 };
363
364 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
365 assert_eq!(result.nodes_added.len(), 3);
366 assert_eq!(result.nodes_added[0].node_port, Some(12000));
367 assert_eq!(result.nodes_added[1].node_port, Some(12001));
368 assert_eq!(result.nodes_added[2].node_port, Some(12002));
369 assert_eq!(result.nodes_added[0].id, 1);
370 assert_eq!(result.nodes_added[1].id, 2);
371 assert_eq!(result.nodes_added[2].id, 3);
372 }
373
374 #[tokio::test]
375 async fn add_nodes_rejects_port_range_mismatch() {
376 let tmp = tempfile::tempdir().unwrap();
377 let binary = create_fake_binary(tmp.path());
378 let reg_path = test_registry_path(tmp.path());
379
380 let opts = AddNodeOpts {
381 count: 3,
382 rewards_address: TEST_ADDR.to_string(),
383 node_port: Some(PortRange::Range(12000, 12001)), binary_source: BinarySource::LocalPath(binary),
385 ..Default::default()
386 };
387
388 let result = add_nodes(opts, ®_path, &NoopProgress).await;
389 assert!(result.is_err());
390 assert!(matches!(
391 result.unwrap_err(),
392 Error::PortRangeMismatch { .. }
393 ));
394 }
395
396 #[tokio::test]
397 async fn add_nodes_rejects_empty_rewards_address() {
398 let tmp = tempfile::tempdir().unwrap();
399 let reg_path = test_registry_path(tmp.path());
400
401 let opts = AddNodeOpts {
402 count: 1,
403 rewards_address: " ".to_string(),
404 ..Default::default()
405 };
406
407 let result = add_nodes(opts, ®_path, &NoopProgress).await;
408 assert!(result.is_err());
409 assert!(matches!(
410 result.unwrap_err(),
411 Error::InvalidRewardsAddress(_)
412 ));
413 }
414
415 #[test]
416 fn validate_rewards_address_rejects_missing_prefix() {
417 let result = validate_rewards_address("1234567890abcdef1234567890abcdef12345678");
418 assert!(result.is_err());
419 }
420
421 #[test]
422 fn validate_rewards_address_rejects_short_address() {
423 let result = validate_rewards_address("0xabc123");
424 assert!(result.is_err());
425 }
426
427 #[test]
428 fn validate_rewards_address_rejects_non_hex() {
429 let result = validate_rewards_address("0xGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG");
430 assert!(result.is_err());
431 }
432
433 #[test]
434 fn validate_rewards_address_accepts_valid() {
435 let result = validate_rewards_address(TEST_ADDR);
436 assert!(result.is_ok());
437 }
438
439 #[test]
440 fn validate_rewards_address_accepts_uppercase_hex() {
441 let result = validate_rewards_address("0xABCDEF1234567890ABCDEF1234567890ABCDEF12");
442 assert!(result.is_ok());
443 }
444
445 #[tokio::test]
446 async fn add_nodes_with_custom_data_dir() {
447 let tmp = tempfile::tempdir().unwrap();
448 let binary = create_fake_binary(tmp.path());
449 let reg_path = test_registry_path(tmp.path());
450 let custom_data = tmp.path().join("custom-data");
451
452 let opts = AddNodeOpts {
453 count: 1,
454 rewards_address: TEST_ADDR.to_string(),
455 data_dir_path: Some(custom_data.clone()),
456 binary_source: BinarySource::LocalPath(binary),
457 ..Default::default()
458 };
459
460 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
461 assert!(result.nodes_added[0].data_dir.starts_with(&custom_data));
462 }
463
464 #[tokio::test]
465 async fn add_nodes_without_log_dir_sets_none() {
466 let tmp = tempfile::tempdir().unwrap();
467 let binary = create_fake_binary(tmp.path());
468 let reg_path = test_registry_path(tmp.path());
469
470 let opts = AddNodeOpts {
471 count: 1,
472 rewards_address: TEST_ADDR.to_string(),
473 data_dir_path: Some(tmp.path().join("data")),
474 binary_source: BinarySource::LocalPath(binary),
476 ..Default::default()
477 };
478
479 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
480 assert!(result.nodes_added[0].log_dir.is_none());
481 }
482
483 #[test]
484 fn remove_node_from_registry() {
485 let tmp = tempfile::tempdir().unwrap();
486 let reg_path = test_registry_path(tmp.path());
487
488 let (mut registry, _lock) = NodeRegistry::load_locked(®_path).unwrap();
490 registry.add(NodeConfig {
491 id: 0,
492 service_name: String::new(),
493 rewards_address: "0xtest".to_string(),
494 data_dir: PathBuf::from("/tmp/test"),
495 log_dir: None,
496 node_port: None,
497 binary_path: PathBuf::from("/usr/bin/antnode"),
498 version: "0.1.0".to_string(),
499 env_variables: HashMap::new(),
500 bootstrap_peers: vec![],
501 upgrade_channel: None,
502 evm_network: EvmNetwork::default(),
503 eviction: None,
504 });
505 registry.save().unwrap();
506 drop(_lock);
507
508 let result = remove_node(1, ®_path).unwrap();
509 assert_eq!(result.removed.rewards_address, "0xtest");
510
511 let reg = NodeRegistry::load(®_path).unwrap();
512 assert!(reg.is_empty());
513 }
514
515 #[test]
516 fn remove_nonexistent_node_errors() {
517 let tmp = tempfile::tempdir().unwrap();
518 let reg_path = test_registry_path(tmp.path());
519
520 let result = remove_node(999, ®_path);
521 assert!(result.is_err());
522 assert!(matches!(result.unwrap_err(), Error::NodeNotFound(999)));
523 }
524
525 #[tokio::test]
526 async fn reset_clears_all_nodes_and_directories() {
527 let tmp = tempfile::tempdir().unwrap();
528 let binary = create_fake_binary(tmp.path());
529 let reg_path = test_registry_path(tmp.path());
530
531 let opts = AddNodeOpts {
533 count: 2,
534 rewards_address: TEST_ADDR.to_string(),
535 data_dir_path: Some(tmp.path().join("data")),
536 log_dir_path: Some(tmp.path().join("logs")),
537 binary_source: BinarySource::LocalPath(binary),
538 ..Default::default()
539 };
540
541 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
542 assert_eq!(result.nodes_added.len(), 2);
543
544 for node in &result.nodes_added {
546 assert!(node.data_dir.exists());
547 assert!(node.log_dir.as_ref().unwrap().exists());
548 }
549
550 let reset_result = reset(®_path).unwrap();
552 assert_eq!(reset_result.nodes_cleared, 2);
553 assert_eq!(reset_result.data_dirs_removed.len(), 2);
554 assert_eq!(reset_result.log_dirs_removed.len(), 2);
555
556 for node in &result.nodes_added {
558 assert!(!node.data_dir.exists());
559 assert!(!node.log_dir.as_ref().unwrap().exists());
560 }
561
562 let reg = NodeRegistry::load(®_path).unwrap();
564 assert!(reg.is_empty());
565 assert_eq!(reg.next_id, 1);
566 }
567
568 #[test]
569 fn node_status_offline_shows_all_stopped() {
570 let tmp = tempfile::tempdir().unwrap();
571 let reg_path = test_registry_path(tmp.path());
572
573 let (mut registry, _lock) = NodeRegistry::load_locked(®_path).unwrap();
575 registry.add(NodeConfig {
576 id: 0,
577 service_name: String::new(),
578 rewards_address: "0xtest".to_string(),
579 data_dir: PathBuf::from("/tmp/test1"),
580 log_dir: None,
581 node_port: None,
582 binary_path: PathBuf::from("/usr/bin/antnode"),
583 version: "0.110.0".to_string(),
584 env_variables: HashMap::new(),
585 bootstrap_peers: vec![],
586 upgrade_channel: None,
587 evm_network: EvmNetwork::default(),
588 eviction: None,
589 });
590 registry.add(NodeConfig {
591 id: 0,
592 service_name: String::new(),
593 rewards_address: "0xtest".to_string(),
594 data_dir: PathBuf::from("/tmp/test2"),
595 log_dir: None,
596 node_port: None,
597 binary_path: PathBuf::from("/usr/bin/antnode"),
598 version: "0.110.0".to_string(),
599 env_variables: HashMap::new(),
600 bootstrap_peers: vec![],
601 upgrade_channel: None,
602 evm_network: EvmNetwork::default(),
603 eviction: None,
604 });
605 registry.save().unwrap();
606 drop(_lock);
607
608 let result = node_status_offline(®_path).unwrap();
609 assert_eq!(result.nodes.len(), 2);
610 assert_eq!(result.total_running, 0);
611 assert_eq!(result.total_stopped, 2);
612 for node in &result.nodes {
613 assert_eq!(node.status, NodeStatus::Stopped);
614 }
615 }
616
617 #[test]
618 fn node_status_offline_empty_registry() {
619 let tmp = tempfile::tempdir().unwrap();
620 let reg_path = test_registry_path(tmp.path());
621
622 let result = node_status_offline(®_path).unwrap();
623 assert!(result.nodes.is_empty());
624 assert_eq!(result.total_running, 0);
625 assert_eq!(result.total_stopped, 0);
626 }
627
628 #[test]
629 fn node_status_offline_reports_evicted_from_marker() {
630 use crate::node::types::EvictionRecord;
631
632 let tmp = tempfile::tempdir().unwrap();
633 let reg_path = test_registry_path(tmp.path());
634
635 let (mut registry, _lock) = NodeRegistry::load_locked(®_path).unwrap();
636 registry.add(NodeConfig {
637 id: 0,
638 service_name: String::new(),
639 rewards_address: "0xtest".to_string(),
640 data_dir: PathBuf::from("/tmp/test-evicted"),
641 log_dir: None,
642 node_port: None,
643 binary_path: PathBuf::from("/usr/bin/antnode"),
644 version: "0.110.0".to_string(),
645 env_variables: HashMap::new(),
646 bootstrap_peers: vec![],
647 upgrade_channel: None,
648 evm_network: EvmNetwork::default(),
649 eviction: Some(EvictionRecord {
650 reason: "Low disk space".to_string(),
651 evicted_at: 1_700_000_000,
652 reclaimed_bytes: 1_000_000,
653 }),
654 });
655 registry.save().unwrap();
656 drop(_lock);
657
658 let result = node_status_offline(®_path).unwrap();
659 assert_eq!(result.nodes.len(), 1);
660 assert_eq!(result.nodes[0].status, NodeStatus::Evicted);
663 assert_eq!(
664 result.nodes[0].eviction.as_ref().unwrap().reason,
665 "Low disk space"
666 );
667 }
668
669 #[test]
670 fn reset_empty_registry_succeeds() {
671 let tmp = tempfile::tempdir().unwrap();
672 let reg_path = test_registry_path(tmp.path());
673
674 let result = reset(®_path).unwrap();
675 assert_eq!(result.nodes_cleared, 0);
676 assert!(result.data_dirs_removed.is_empty());
677 assert!(result.log_dirs_removed.is_empty());
678 }
679
680 #[tokio::test]
681 async fn reset_then_add_starts_fresh_ids() {
682 let tmp = tempfile::tempdir().unwrap();
683 let binary = create_fake_binary(tmp.path());
684 let reg_path = test_registry_path(tmp.path());
685
686 let opts = AddNodeOpts {
688 count: 2,
689 rewards_address: TEST_ADDR.to_string(),
690 data_dir_path: Some(tmp.path().join("data")),
691 log_dir_path: Some(tmp.path().join("logs")),
692 binary_source: BinarySource::LocalPath(binary.clone()),
693 ..Default::default()
694 };
695 add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
696
697 reset(®_path).unwrap();
699
700 let opts = AddNodeOpts {
702 count: 1,
703 rewards_address: TEST_ADDR.to_string(),
704 data_dir_path: Some(tmp.path().join("data")),
705 log_dir_path: Some(tmp.path().join("logs")),
706 binary_source: BinarySource::LocalPath(binary),
707 ..Default::default()
708 };
709 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
710
711 assert_eq!(result.nodes_added[0].id, 1);
712 assert_eq!(result.nodes_added[0].rewards_address, TEST_ADDR);
713 }
714}