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 eviction: config.eviction.clone(),
218 }
219 })
220 .collect();
221 let total_stopped = nodes.len() as u32;
222 Ok(NodeStatusResult {
223 nodes,
224 total_running: 0,
225 total_stopped,
226 })
227}
228
229fn validate_rewards_address(address: &str) -> Result<()> {
233 let address = address.trim();
234 if address.is_empty() {
235 return Err(Error::InvalidRewardsAddress(
236 "rewards address cannot be empty".to_string(),
237 ));
238 }
239 if !address.starts_with("0x") && !address.starts_with("0X") {
240 return Err(Error::InvalidRewardsAddress(format!(
241 "rewards address must start with '0x', got '{address}'"
242 )));
243 }
244 let hex_part = &address[2..];
245 if hex_part.len() != 40 {
246 return Err(Error::InvalidRewardsAddress(format!(
247 "rewards address must be 42 characters (0x + 40 hex), got {} characters",
248 address.len()
249 )));
250 }
251 if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
252 return Err(Error::InvalidRewardsAddress(format!(
253 "rewards address contains non-hex characters: '{address}'"
254 )));
255 }
256 Ok(())
257}
258
259fn node_data_dir(custom_prefix: &Option<PathBuf>, node_id: u32) -> PathBuf {
261 match custom_prefix {
262 Some(prefix) => prefix.join(format!("node-{node_id}")),
263 None => config::data_dir()
264 .expect("Could not determine data directory")
265 .join("nodes")
266 .join(format!("node-{node_id}")),
267 }
268}
269
270fn node_log_dir(custom_prefix: &Option<PathBuf>, node_id: u32) -> Option<PathBuf> {
273 custom_prefix
274 .as_ref()
275 .map(|prefix| prefix.join(format!("node-{node_id}")).join("logs"))
276}
277
278fn resolve_port(range: &Option<types::PortRange>, index: u16, _count: u16) -> Option<u16> {
280 range.as_ref().and_then(|r| r.port_at(index))
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::node::binary::NoopProgress;
287 use crate::node::types::{BinarySource, EvmNetwork, PortRange};
288
289 const TEST_ADDR: &str = "0x1234567890abcdef1234567890abcdef12345678";
291
292 fn test_registry_path(dir: &std::path::Path) -> PathBuf {
293 dir.join("node_registry.json")
294 }
295
296 fn create_fake_binary(dir: &std::path::Path) -> PathBuf {
299 #[cfg(unix)]
300 {
301 let binary_path = dir.join("fake-antnode");
302 std::fs::write(&binary_path, "#!/bin/sh\necho \"antnode 0.1.0-test\"\n").unwrap();
303 use std::os::unix::fs::PermissionsExt;
304 std::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755)).unwrap();
305 binary_path
306 }
307 #[cfg(windows)]
308 {
309 let binary_path = dir.join("fake-antnode.cmd");
310 std::fs::write(&binary_path, "@echo off\r\necho antnode 0.1.0-test\r\n").unwrap();
311 binary_path
312 }
313 }
314
315 #[tokio::test]
316 async fn add_single_node_with_local_binary() {
317 let tmp = tempfile::tempdir().unwrap();
318 let binary = create_fake_binary(tmp.path());
319 let reg_path = test_registry_path(tmp.path());
320
321 let opts = AddNodeOpts {
322 count: 1,
323 rewards_address: TEST_ADDR.to_string(),
324 data_dir_path: Some(tmp.path().join("data")),
325 log_dir_path: Some(tmp.path().join("logs")),
326 binary_source: BinarySource::LocalPath(binary),
327 ..Default::default()
328 };
329
330 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
331 assert_eq!(result.nodes_added.len(), 1);
332 assert_eq!(result.nodes_added[0].rewards_address, TEST_ADDR);
333 assert_eq!(result.nodes_added[0].id, 1);
334 assert!(result.nodes_added[0].data_dir.exists());
335 assert!(result.nodes_added[0].log_dir.as_ref().unwrap().exists());
336
337 let reg = NodeRegistry::load(®_path).unwrap();
339 assert_eq!(reg.len(), 1);
340 }
341
342 #[tokio::test]
343 async fn add_multiple_nodes_with_port_range() {
344 let tmp = tempfile::tempdir().unwrap();
345 let binary = create_fake_binary(tmp.path());
346 let reg_path = test_registry_path(tmp.path());
347
348 let opts = AddNodeOpts {
349 count: 3,
350 rewards_address: TEST_ADDR.to_string(),
351 node_port: Some(PortRange::Range(12000, 12002)),
352 data_dir_path: Some(tmp.path().join("data")),
353 log_dir_path: Some(tmp.path().join("logs")),
354 binary_source: BinarySource::LocalPath(binary),
355 ..Default::default()
356 };
357
358 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
359 assert_eq!(result.nodes_added.len(), 3);
360 assert_eq!(result.nodes_added[0].node_port, Some(12000));
361 assert_eq!(result.nodes_added[1].node_port, Some(12001));
362 assert_eq!(result.nodes_added[2].node_port, Some(12002));
363 assert_eq!(result.nodes_added[0].id, 1);
364 assert_eq!(result.nodes_added[1].id, 2);
365 assert_eq!(result.nodes_added[2].id, 3);
366 }
367
368 #[tokio::test]
369 async fn add_nodes_rejects_port_range_mismatch() {
370 let tmp = tempfile::tempdir().unwrap();
371 let binary = create_fake_binary(tmp.path());
372 let reg_path = test_registry_path(tmp.path());
373
374 let opts = AddNodeOpts {
375 count: 3,
376 rewards_address: TEST_ADDR.to_string(),
377 node_port: Some(PortRange::Range(12000, 12001)), binary_source: BinarySource::LocalPath(binary),
379 ..Default::default()
380 };
381
382 let result = add_nodes(opts, ®_path, &NoopProgress).await;
383 assert!(result.is_err());
384 assert!(matches!(
385 result.unwrap_err(),
386 Error::PortRangeMismatch { .. }
387 ));
388 }
389
390 #[tokio::test]
391 async fn add_nodes_rejects_empty_rewards_address() {
392 let tmp = tempfile::tempdir().unwrap();
393 let reg_path = test_registry_path(tmp.path());
394
395 let opts = AddNodeOpts {
396 count: 1,
397 rewards_address: " ".to_string(),
398 ..Default::default()
399 };
400
401 let result = add_nodes(opts, ®_path, &NoopProgress).await;
402 assert!(result.is_err());
403 assert!(matches!(
404 result.unwrap_err(),
405 Error::InvalidRewardsAddress(_)
406 ));
407 }
408
409 #[test]
410 fn validate_rewards_address_rejects_missing_prefix() {
411 let result = validate_rewards_address("1234567890abcdef1234567890abcdef12345678");
412 assert!(result.is_err());
413 }
414
415 #[test]
416 fn validate_rewards_address_rejects_short_address() {
417 let result = validate_rewards_address("0xabc123");
418 assert!(result.is_err());
419 }
420
421 #[test]
422 fn validate_rewards_address_rejects_non_hex() {
423 let result = validate_rewards_address("0xGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG");
424 assert!(result.is_err());
425 }
426
427 #[test]
428 fn validate_rewards_address_accepts_valid() {
429 let result = validate_rewards_address(TEST_ADDR);
430 assert!(result.is_ok());
431 }
432
433 #[test]
434 fn validate_rewards_address_accepts_uppercase_hex() {
435 let result = validate_rewards_address("0xABCDEF1234567890ABCDEF1234567890ABCDEF12");
436 assert!(result.is_ok());
437 }
438
439 #[tokio::test]
440 async fn add_nodes_with_custom_data_dir() {
441 let tmp = tempfile::tempdir().unwrap();
442 let binary = create_fake_binary(tmp.path());
443 let reg_path = test_registry_path(tmp.path());
444 let custom_data = tmp.path().join("custom-data");
445
446 let opts = AddNodeOpts {
447 count: 1,
448 rewards_address: TEST_ADDR.to_string(),
449 data_dir_path: Some(custom_data.clone()),
450 binary_source: BinarySource::LocalPath(binary),
451 ..Default::default()
452 };
453
454 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
455 assert!(result.nodes_added[0].data_dir.starts_with(&custom_data));
456 }
457
458 #[tokio::test]
459 async fn add_nodes_without_log_dir_sets_none() {
460 let tmp = tempfile::tempdir().unwrap();
461 let binary = create_fake_binary(tmp.path());
462 let reg_path = test_registry_path(tmp.path());
463
464 let opts = AddNodeOpts {
465 count: 1,
466 rewards_address: TEST_ADDR.to_string(),
467 data_dir_path: Some(tmp.path().join("data")),
468 binary_source: BinarySource::LocalPath(binary),
470 ..Default::default()
471 };
472
473 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
474 assert!(result.nodes_added[0].log_dir.is_none());
475 }
476
477 #[test]
478 fn remove_node_from_registry() {
479 let tmp = tempfile::tempdir().unwrap();
480 let reg_path = test_registry_path(tmp.path());
481
482 let (mut registry, _lock) = NodeRegistry::load_locked(®_path).unwrap();
484 registry.add(NodeConfig {
485 id: 0,
486 service_name: String::new(),
487 rewards_address: "0xtest".to_string(),
488 data_dir: PathBuf::from("/tmp/test"),
489 log_dir: None,
490 node_port: None,
491 binary_path: PathBuf::from("/usr/bin/antnode"),
492 version: "0.1.0".to_string(),
493 env_variables: HashMap::new(),
494 bootstrap_peers: vec![],
495 upgrade_channel: None,
496 evm_network: EvmNetwork::default(),
497 eviction: None,
498 });
499 registry.save().unwrap();
500 drop(_lock);
501
502 let result = remove_node(1, ®_path).unwrap();
503 assert_eq!(result.removed.rewards_address, "0xtest");
504
505 let reg = NodeRegistry::load(®_path).unwrap();
506 assert!(reg.is_empty());
507 }
508
509 #[test]
510 fn remove_nonexistent_node_errors() {
511 let tmp = tempfile::tempdir().unwrap();
512 let reg_path = test_registry_path(tmp.path());
513
514 let result = remove_node(999, ®_path);
515 assert!(result.is_err());
516 assert!(matches!(result.unwrap_err(), Error::NodeNotFound(999)));
517 }
518
519 #[tokio::test]
520 async fn reset_clears_all_nodes_and_directories() {
521 let tmp = tempfile::tempdir().unwrap();
522 let binary = create_fake_binary(tmp.path());
523 let reg_path = test_registry_path(tmp.path());
524
525 let opts = AddNodeOpts {
527 count: 2,
528 rewards_address: TEST_ADDR.to_string(),
529 data_dir_path: Some(tmp.path().join("data")),
530 log_dir_path: Some(tmp.path().join("logs")),
531 binary_source: BinarySource::LocalPath(binary),
532 ..Default::default()
533 };
534
535 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
536 assert_eq!(result.nodes_added.len(), 2);
537
538 for node in &result.nodes_added {
540 assert!(node.data_dir.exists());
541 assert!(node.log_dir.as_ref().unwrap().exists());
542 }
543
544 let reset_result = reset(®_path).unwrap();
546 assert_eq!(reset_result.nodes_cleared, 2);
547 assert_eq!(reset_result.data_dirs_removed.len(), 2);
548 assert_eq!(reset_result.log_dirs_removed.len(), 2);
549
550 for node in &result.nodes_added {
552 assert!(!node.data_dir.exists());
553 assert!(!node.log_dir.as_ref().unwrap().exists());
554 }
555
556 let reg = NodeRegistry::load(®_path).unwrap();
558 assert!(reg.is_empty());
559 assert_eq!(reg.next_id, 1);
560 }
561
562 #[test]
563 fn node_status_offline_shows_all_stopped() {
564 let tmp = tempfile::tempdir().unwrap();
565 let reg_path = test_registry_path(tmp.path());
566
567 let (mut registry, _lock) = NodeRegistry::load_locked(®_path).unwrap();
569 registry.add(NodeConfig {
570 id: 0,
571 service_name: String::new(),
572 rewards_address: "0xtest".to_string(),
573 data_dir: PathBuf::from("/tmp/test1"),
574 log_dir: None,
575 node_port: None,
576 binary_path: PathBuf::from("/usr/bin/antnode"),
577 version: "0.110.0".to_string(),
578 env_variables: HashMap::new(),
579 bootstrap_peers: vec![],
580 upgrade_channel: None,
581 evm_network: EvmNetwork::default(),
582 eviction: None,
583 });
584 registry.add(NodeConfig {
585 id: 0,
586 service_name: String::new(),
587 rewards_address: "0xtest".to_string(),
588 data_dir: PathBuf::from("/tmp/test2"),
589 log_dir: None,
590 node_port: None,
591 binary_path: PathBuf::from("/usr/bin/antnode"),
592 version: "0.110.0".to_string(),
593 env_variables: HashMap::new(),
594 bootstrap_peers: vec![],
595 upgrade_channel: None,
596 evm_network: EvmNetwork::default(),
597 eviction: None,
598 });
599 registry.save().unwrap();
600 drop(_lock);
601
602 let result = node_status_offline(®_path).unwrap();
603 assert_eq!(result.nodes.len(), 2);
604 assert_eq!(result.total_running, 0);
605 assert_eq!(result.total_stopped, 2);
606 for node in &result.nodes {
607 assert_eq!(node.status, NodeStatus::Stopped);
608 }
609 }
610
611 #[test]
612 fn node_status_offline_empty_registry() {
613 let tmp = tempfile::tempdir().unwrap();
614 let reg_path = test_registry_path(tmp.path());
615
616 let result = node_status_offline(®_path).unwrap();
617 assert!(result.nodes.is_empty());
618 assert_eq!(result.total_running, 0);
619 assert_eq!(result.total_stopped, 0);
620 }
621
622 #[test]
623 fn node_status_offline_reports_evicted_from_marker() {
624 use crate::node::types::EvictionRecord;
625
626 let tmp = tempfile::tempdir().unwrap();
627 let reg_path = test_registry_path(tmp.path());
628
629 let (mut registry, _lock) = NodeRegistry::load_locked(®_path).unwrap();
630 registry.add(NodeConfig {
631 id: 0,
632 service_name: String::new(),
633 rewards_address: "0xtest".to_string(),
634 data_dir: PathBuf::from("/tmp/test-evicted"),
635 log_dir: None,
636 node_port: None,
637 binary_path: PathBuf::from("/usr/bin/antnode"),
638 version: "0.110.0".to_string(),
639 env_variables: HashMap::new(),
640 bootstrap_peers: vec![],
641 upgrade_channel: None,
642 evm_network: EvmNetwork::default(),
643 eviction: Some(EvictionRecord {
644 reason: "Low disk space".to_string(),
645 evicted_at: 1_700_000_000,
646 reclaimed_bytes: 1_000_000,
647 }),
648 });
649 registry.save().unwrap();
650 drop(_lock);
651
652 let result = node_status_offline(®_path).unwrap();
653 assert_eq!(result.nodes.len(), 1);
654 assert_eq!(result.nodes[0].status, NodeStatus::Evicted);
657 assert_eq!(
658 result.nodes[0].eviction.as_ref().unwrap().reason,
659 "Low disk space"
660 );
661 }
662
663 #[test]
664 fn reset_empty_registry_succeeds() {
665 let tmp = tempfile::tempdir().unwrap();
666 let reg_path = test_registry_path(tmp.path());
667
668 let result = reset(®_path).unwrap();
669 assert_eq!(result.nodes_cleared, 0);
670 assert!(result.data_dirs_removed.is_empty());
671 assert!(result.log_dirs_removed.is_empty());
672 }
673
674 #[tokio::test]
675 async fn reset_then_add_starts_fresh_ids() {
676 let tmp = tempfile::tempdir().unwrap();
677 let binary = create_fake_binary(tmp.path());
678 let reg_path = test_registry_path(tmp.path());
679
680 let opts = AddNodeOpts {
682 count: 2,
683 rewards_address: TEST_ADDR.to_string(),
684 data_dir_path: Some(tmp.path().join("data")),
685 log_dir_path: Some(tmp.path().join("logs")),
686 binary_source: BinarySource::LocalPath(binary.clone()),
687 ..Default::default()
688 };
689 add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
690
691 reset(®_path).unwrap();
693
694 let opts = AddNodeOpts {
696 count: 1,
697 rewards_address: TEST_ADDR.to_string(),
698 data_dir_path: Some(tmp.path().join("data")),
699 log_dir_path: Some(tmp.path().join("logs")),
700 binary_source: BinarySource::LocalPath(binary),
701 ..Default::default()
702 };
703 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
704
705 assert_eq!(result.nodes_added[0].id, 1);
706 assert_eq!(result.nodes_added[0].rewards_address, TEST_ADDR);
707 }
708}