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 };
112
113 let assigned_id = registry.add(config);
114
115 let node = registry.get_mut(assigned_id)?;
117 node.data_dir = node_data_dir(&opts.data_dir_path, assigned_id);
118 node.log_dir = node_log_dir(&opts.log_dir_path, assigned_id);
119
120 std::fs::create_dir_all(&node.data_dir)?;
122 if let Some(ref log_dir) = node.log_dir {
123 std::fs::create_dir_all(log_dir)?;
124 }
125
126 let node_binary = node.data_dir.join(binary_file_name);
130 std::fs::copy(&cached_binary, &node_binary)?;
131 #[cfg(unix)]
132 {
133 use std::os::unix::fs::PermissionsExt;
134 std::fs::set_permissions(&node_binary, std::fs::Permissions::from_mode(0o755))?;
135 }
136 node.binary_path = node_binary;
137
138 if let Some(ref bp_path) = resolved.bootstrap_peers_path {
141 let dest = node.data_dir.join(binary::BOOTSTRAP_PEERS_FILE);
142 std::fs::copy(bp_path, &dest)?;
143 }
144
145 nodes_added.push(node.clone());
146 }
147
148 registry.save()?;
149
150 Ok(AddNodeResult { nodes_added })
151}
152
153pub fn remove_node(node_id: u32, registry_path: &Path) -> Result<RemoveNodeResult> {
157 let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
158 let removed = registry.remove(node_id)?;
159 registry.save()?;
160 Ok(RemoveNodeResult { removed })
161}
162
163pub fn reset(registry_path: &Path) -> Result<ResetResult> {
174 let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
175
176 let mut data_dirs_removed = Vec::new();
177 let mut log_dirs_removed = Vec::new();
178 let nodes_cleared = registry.len() as u32;
179
180 for node in registry.list() {
181 if node.data_dir.exists() {
182 std::fs::remove_dir_all(&node.data_dir)?;
183 data_dirs_removed.push(node.data_dir.clone());
184 }
185 if let Some(ref log_dir) = node.log_dir {
186 if log_dir.exists() {
187 std::fs::remove_dir_all(log_dir)?;
188 log_dirs_removed.push(log_dir.clone());
189 }
190 }
191 }
192
193 registry.clear();
194 registry.save()?;
195
196 Ok(ResetResult {
197 nodes_cleared,
198 data_dirs_removed,
199 log_dirs_removed,
200 })
201}
202
203pub fn node_status_offline(registry_path: &Path) -> Result<NodeStatusResult> {
207 let registry = NodeRegistry::load(registry_path)?;
208 let nodes: Vec<NodeStatusSummary> = registry
209 .list()
210 .iter()
211 .map(|config| NodeStatusSummary {
212 node_id: config.id,
213 name: config.service_name.clone(),
214 version: config.version.clone(),
215 status: NodeStatus::Stopped,
216 pid: None,
217 uptime_secs: None,
218 pending_version: None,
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, 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 metrics_port: None,
492 network_id: None,
493 binary_path: PathBuf::from("/usr/bin/antnode"),
494 version: "0.1.0".to_string(),
495 env_variables: HashMap::new(),
496 bootstrap_peers: vec![],
497 });
498 registry.save().unwrap();
499 drop(_lock);
500
501 let result = remove_node(1, ®_path).unwrap();
502 assert_eq!(result.removed.rewards_address, "0xtest");
503
504 let reg = NodeRegistry::load(®_path).unwrap();
505 assert!(reg.is_empty());
506 }
507
508 #[test]
509 fn remove_nonexistent_node_errors() {
510 let tmp = tempfile::tempdir().unwrap();
511 let reg_path = test_registry_path(tmp.path());
512
513 let result = remove_node(999, ®_path);
514 assert!(result.is_err());
515 assert!(matches!(result.unwrap_err(), Error::NodeNotFound(999)));
516 }
517
518 #[tokio::test]
519 async fn reset_clears_all_nodes_and_directories() {
520 let tmp = tempfile::tempdir().unwrap();
521 let binary = create_fake_binary(tmp.path());
522 let reg_path = test_registry_path(tmp.path());
523
524 let opts = AddNodeOpts {
526 count: 2,
527 rewards_address: TEST_ADDR.to_string(),
528 data_dir_path: Some(tmp.path().join("data")),
529 log_dir_path: Some(tmp.path().join("logs")),
530 binary_source: BinarySource::LocalPath(binary),
531 ..Default::default()
532 };
533
534 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
535 assert_eq!(result.nodes_added.len(), 2);
536
537 for node in &result.nodes_added {
539 assert!(node.data_dir.exists());
540 assert!(node.log_dir.as_ref().unwrap().exists());
541 }
542
543 let reset_result = reset(®_path).unwrap();
545 assert_eq!(reset_result.nodes_cleared, 2);
546 assert_eq!(reset_result.data_dirs_removed.len(), 2);
547 assert_eq!(reset_result.log_dirs_removed.len(), 2);
548
549 for node in &result.nodes_added {
551 assert!(!node.data_dir.exists());
552 assert!(!node.log_dir.as_ref().unwrap().exists());
553 }
554
555 let reg = NodeRegistry::load(®_path).unwrap();
557 assert!(reg.is_empty());
558 assert_eq!(reg.next_id, 1);
559 }
560
561 #[test]
562 fn node_status_offline_shows_all_stopped() {
563 let tmp = tempfile::tempdir().unwrap();
564 let reg_path = test_registry_path(tmp.path());
565
566 let (mut registry, _lock) = NodeRegistry::load_locked(®_path).unwrap();
568 registry.add(NodeConfig {
569 id: 0,
570 service_name: String::new(),
571 rewards_address: "0xtest".to_string(),
572 data_dir: PathBuf::from("/tmp/test1"),
573 log_dir: None,
574 node_port: None,
575 metrics_port: None,
576 network_id: 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 });
582 registry.add(NodeConfig {
583 id: 0,
584 service_name: String::new(),
585 rewards_address: "0xtest".to_string(),
586 data_dir: PathBuf::from("/tmp/test2"),
587 log_dir: None,
588 node_port: None,
589 metrics_port: None,
590 network_id: 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 });
596 registry.save().unwrap();
597 drop(_lock);
598
599 let result = node_status_offline(®_path).unwrap();
600 assert_eq!(result.nodes.len(), 2);
601 assert_eq!(result.total_running, 0);
602 assert_eq!(result.total_stopped, 2);
603 for node in &result.nodes {
604 assert_eq!(node.status, NodeStatus::Stopped);
605 }
606 }
607
608 #[test]
609 fn node_status_offline_empty_registry() {
610 let tmp = tempfile::tempdir().unwrap();
611 let reg_path = test_registry_path(tmp.path());
612
613 let result = node_status_offline(®_path).unwrap();
614 assert!(result.nodes.is_empty());
615 assert_eq!(result.total_running, 0);
616 assert_eq!(result.total_stopped, 0);
617 }
618
619 #[test]
620 fn reset_empty_registry_succeeds() {
621 let tmp = tempfile::tempdir().unwrap();
622 let reg_path = test_registry_path(tmp.path());
623
624 let result = reset(®_path).unwrap();
625 assert_eq!(result.nodes_cleared, 0);
626 assert!(result.data_dirs_removed.is_empty());
627 assert!(result.log_dirs_removed.is_empty());
628 }
629
630 #[tokio::test]
631 async fn reset_then_add_starts_fresh_ids() {
632 let tmp = tempfile::tempdir().unwrap();
633 let binary = create_fake_binary(tmp.path());
634 let reg_path = test_registry_path(tmp.path());
635
636 let opts = AddNodeOpts {
638 count: 2,
639 rewards_address: TEST_ADDR.to_string(),
640 data_dir_path: Some(tmp.path().join("data")),
641 log_dir_path: Some(tmp.path().join("logs")),
642 binary_source: BinarySource::LocalPath(binary.clone()),
643 ..Default::default()
644 };
645 add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
646
647 reset(®_path).unwrap();
649
650 let opts = AddNodeOpts {
652 count: 1,
653 rewards_address: TEST_ADDR.to_string(),
654 data_dir_path: Some(tmp.path().join("data")),
655 log_dir_path: Some(tmp.path().join("logs")),
656 binary_source: BinarySource::LocalPath(binary),
657 ..Default::default()
658 };
659 let result = add_nodes(opts, ®_path, &NoopProgress).await.unwrap();
660
661 assert_eq!(result.nodes_added[0].id, 1);
662 assert_eq!(result.nodes_added[0].rewards_address, TEST_ADDR);
663 }
664}