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