Skip to main content

ant_core/node/
mod.rs

1pub mod binary;
2pub mod daemon;
3// `LocalDevnet` wraps `ant_node::devnet::Devnet`. Gated behind `devnet`
4// so default builds of ant-core don't link ant-node at all.
5#[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
24/// Add one or more nodes to the registry.
25///
26/// This function:
27/// 1. Resolves the binary (download if needed)
28/// 2. Loads the registry (with file lock)
29/// 3. Validates port ranges match count
30/// 4. Creates data and log directories for each node
31/// 5. Assigns IDs and saves the registry
32///
33/// Does NOT start the nodes. Does NOT require the daemon.
34pub async fn add_nodes(
35    opts: AddNodeOpts,
36    registry_path: &Path,
37    progress: &dyn ProgressReporter,
38) -> Result<AddNodeResult> {
39    // Validate and normalize rewards address
40    validate_rewards_address(&opts.rewards_address)?;
41    let rewards_address = opts.rewards_address.trim().to_string();
42
43    // Cap the number of nodes per call to prevent accidental resource exhaustion
44    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    // Validate port ranges match count
53    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    // Resolve the binary (downloads to cache if needed)
73    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    // Load registry with file lock
79    let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
80
81    // Build node configs
82    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    // Each node gets its own copy under the plain binary name
86    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        // We use a placeholder ID (0) here; the registry will assign the real one
93        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(), // assigned by registry.add()
101            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(), // placeholder, updated below
108            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        // Now update paths with the actual assigned ID
116        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        // Create directories
121        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        // Copy the binary into this node's data directory so each node
127        // has its own copy. This allows safe per-node upgrades without
128        // affecting running nodes.
129        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        // Copy bootstrap_peers.toml alongside the binary so the node can
139        // discover production network peers on startup.
140        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
153/// Remove a node from the registry.
154///
155/// Does NOT stop the node. Does NOT require the daemon.
156pub 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
163/// Reset all node state: remove all data directories, log directories, and clear the registry.
164///
165/// This function:
166/// 1. Loads the registry (with file lock)
167/// 2. Iterates over all registered nodes
168/// 3. Removes each node's data directory
169/// 4. Removes each node's log directory (if set)
170/// 5. Clears the registry (empties nodes, resets next_id to 1)
171///
172/// Does NOT check if nodes are running — callers must verify that first.
173pub 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
203/// Get the status of all registered nodes without the daemon.
204///
205/// Since the daemon is not running, all nodes are reported as `Stopped`.
206pub 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
229/// Validate that a rewards address is a valid Ethereum-style address.
230///
231/// Must be `0x` followed by exactly 40 hexadecimal characters.
232fn 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
259/// Determine the data directory for a node.
260fn 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
270/// Determine the log directory for a node.
271/// Returns `None` when no custom log dir prefix was provided (no logging by default).
272fn 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
278/// Resolve a port from a PortRange for a given node index.
279fn 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    /// A valid Ethereum address for use in tests.
290    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    /// Create a fake binary that responds to --version.
297    /// On Windows, uses a .cmd extension so the shell can execute it.
298    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, &reg_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        // Verify registry was saved
338        let reg = NodeRegistry::load(&reg_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, &reg_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)), // 2 ports, 3 nodes
378            binary_source: BinarySource::LocalPath(binary),
379            ..Default::default()
380        };
381
382        let result = add_nodes(opts, &reg_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, &reg_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, &reg_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            // log_dir_path not set — defaults to None
469            binary_source: BinarySource::LocalPath(binary),
470            ..Default::default()
471        };
472
473        let result = add_nodes(opts, &reg_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        // First add a node directly to the registry
483        let (mut registry, _lock) = NodeRegistry::load_locked(&reg_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, &reg_path).unwrap();
502        assert_eq!(result.removed.rewards_address, "0xtest");
503
504        let reg = NodeRegistry::load(&reg_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, &reg_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        // Add 2 nodes
525        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, &reg_path, &NoopProgress).await.unwrap();
535        assert_eq!(result.nodes_added.len(), 2);
536
537        // Verify directories exist
538        for node in &result.nodes_added {
539            assert!(node.data_dir.exists());
540            assert!(node.log_dir.as_ref().unwrap().exists());
541        }
542
543        // Reset
544        let reset_result = reset(&reg_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        // Verify directories were removed
550        for node in &result.nodes_added {
551            assert!(!node.data_dir.exists());
552            assert!(!node.log_dir.as_ref().unwrap().exists());
553        }
554
555        // Verify registry is empty and next_id reset
556        let reg = NodeRegistry::load(&reg_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        // Add two nodes directly to the registry
567        let (mut registry, _lock) = NodeRegistry::load_locked(&reg_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(&reg_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(&reg_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(&reg_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        // Add 2 nodes
637        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, &reg_path, &NoopProgress).await.unwrap();
646
647        // Reset
648        reset(&reg_path).unwrap();
649
650        // Add again — IDs should restart from 1
651        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, &reg_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}