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 if let Some(ref port_range) = opts.metrics_port {
63 let range_len = port_range.len();
64 if range_len != 1 && range_len != opts.count {
65 return Err(Error::PortRangeMismatch {
66 range_len,
67 count: opts.count,
68 });
69 }
70 }
71
72 let install_dir = binary::binary_install_dir()?;
74 let resolved = binary::resolve_binary(&opts.binary_source, &install_dir, progress).await?;
75 let cached_binary = resolved.path;
76 let version = resolved.version;
77
78 let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
80
81 let mut nodes_added = Vec::with_capacity(opts.count as usize);
83 let env_map: HashMap<String, String> = opts.env_variables.into_iter().collect();
84
85 let binary_file_name = binary::BINARY_NAME;
87
88 for i in 0..opts.count {
89 let node_port = resolve_port(&opts.node_port, i, opts.count);
90 let metrics_port = resolve_port(&opts.metrics_port, i, opts.count);
91
92 let placeholder_id = 0;
94
95 let data_dir = node_data_dir(&opts.data_dir_path, placeholder_id);
96 let log_dir = node_log_dir(&opts.log_dir_path, placeholder_id);
97
98 let config = NodeConfig {
99 id: placeholder_id,
100 service_name: String::new(), rewards_address: rewards_address.clone(),
102 data_dir,
103 log_dir,
104 node_port,
105 metrics_port,
106 network_id: Some(opts.network_id),
107 binary_path: PathBuf::new(), version: version.clone(),
109 env_variables: env_map.clone(),
110 bootstrap_peers: opts.bootstrap_peers.clone(),
111 upgrade_channel: opts.upgrade_channel,
112 };
113
114 let assigned_id = registry.add(config);
115
116 let node = registry.get_mut(assigned_id)?;
118 node.data_dir = node_data_dir(&opts.data_dir_path, assigned_id);
119 node.log_dir = node_log_dir(&opts.log_dir_path, assigned_id);
120
121 std::fs::create_dir_all(&node.data_dir)?;
123 if let Some(ref log_dir) = node.log_dir {
124 std::fs::create_dir_all(log_dir)?;
125 }
126
127 let node_binary = node.data_dir.join(binary_file_name);
131 std::fs::copy(&cached_binary, &node_binary)?;
132 #[cfg(unix)]
133 {
134 use std::os::unix::fs::PermissionsExt;
135 std::fs::set_permissions(&node_binary, std::fs::Permissions::from_mode(0o755))?;
136 }
137 node.binary_path = node_binary;
138
139 if let Some(ref bp_path) = resolved.bootstrap_peers_path {
142 let dest = node.data_dir.join(binary::BOOTSTRAP_PEERS_FILE);
143 std::fs::copy(bp_path, &dest)?;
144 }
145
146 nodes_added.push(node.clone());
147 }
148
149 registry.save()?;
150
151 Ok(AddNodeResult { nodes_added })
152}
153
154pub fn remove_node(node_id: u32, registry_path: &Path) -> Result<RemoveNodeResult> {
158 let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
159 let removed = registry.remove(node_id)?;
160 registry.save()?;
161 Ok(RemoveNodeResult { removed })
162}
163
164pub fn reset(registry_path: &Path) -> Result<ResetResult> {
175 let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
176
177 let mut data_dirs_removed = Vec::new();
178 let mut log_dirs_removed = Vec::new();
179 let nodes_cleared = registry.len() as u32;
180
181 for node in registry.list() {
182 if node.data_dir.exists() {
183 std::fs::remove_dir_all(&node.data_dir)?;
184 data_dirs_removed.push(node.data_dir.clone());
185 }
186 if let Some(ref log_dir) = node.log_dir {
187 if log_dir.exists() {
188 std::fs::remove_dir_all(log_dir)?;
189 log_dirs_removed.push(log_dir.clone());
190 }
191 }
192 }
193
194 registry.clear();
195 registry.save()?;
196
197 Ok(ResetResult {
198 nodes_cleared,
199 data_dirs_removed,
200 log_dirs_removed,
201 })
202}
203
204pub fn node_status_offline(registry_path: &Path) -> Result<NodeStatusResult> {
208 let registry = NodeRegistry::load(registry_path)?;
209 let nodes: Vec<NodeStatusSummary> = registry
210 .list()
211 .iter()
212 .map(|config| NodeStatusSummary {
213 node_id: config.id,
214 name: config.service_name.clone(),
215 version: config.version.clone(),
216 status: NodeStatus::Stopped,
217 pid: None,
218 uptime_secs: None,
219 pending_version: None,
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, 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 metrics_port: None,
493 network_id: None,
494 binary_path: PathBuf::from("/usr/bin/antnode"),
495 version: "0.1.0".to_string(),
496 env_variables: HashMap::new(),
497 bootstrap_peers: vec![],
498 upgrade_channel: 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 metrics_port: None,
578 network_id: None,
579 binary_path: PathBuf::from("/usr/bin/antnode"),
580 version: "0.110.0".to_string(),
581 env_variables: HashMap::new(),
582 bootstrap_peers: vec![],
583 upgrade_channel: 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 metrics_port: None,
593 network_id: None,
594 binary_path: PathBuf::from("/usr/bin/antnode"),
595 version: "0.110.0".to_string(),
596 env_variables: HashMap::new(),
597 bootstrap_peers: vec![],
598 upgrade_channel: 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 reset_empty_registry_succeeds() {
625 let tmp = tempfile::tempdir().unwrap();
626 let reg_path = test_registry_path(tmp.path());
627
628 let result = reset(®_path).unwrap();
629 assert_eq!(result.nodes_cleared, 0);
630 assert!(result.data_dirs_removed.is_empty());
631 assert!(result.log_dirs_removed.is_empty());
632 }
633
634 #[tokio::test]
635 async fn reset_then_add_starts_fresh_ids() {
636 let tmp = tempfile::tempdir().unwrap();
637 let binary = create_fake_binary(tmp.path());
638 let reg_path = test_registry_path(tmp.path());
639
640 let opts = AddNodeOpts {
642 count: 2,
643 rewards_address: TEST_ADDR.to_string(),
644 data_dir_path: Some(tmp.path().join("data")),
645 log_dir_path: Some(tmp.path().join("logs")),
646 binary_source: BinarySource::LocalPath(binary.clone()),
647 ..Default::default()
648 };
649 add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
650
651 reset(®_path).unwrap();
653
654 let opts = AddNodeOpts {
656 count: 1,
657 rewards_address: TEST_ADDR.to_string(),
658 data_dir_path: Some(tmp.path().join("data")),
659 log_dir_path: Some(tmp.path().join("logs")),
660 binary_source: BinarySource::LocalPath(binary),
661 ..Default::default()
662 };
663 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
664
665 assert_eq!(result.nodes_added[0].id, 1);
666 assert_eq!(result.nodes_added[0].rewards_address, TEST_ADDR);
667 }
668}