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