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
63    // Resolve the binary (downloads to cache if needed)
64    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    // Load registry with file lock
70    let (mut registry, _lock) = NodeRegistry::load_locked(registry_path)?;
71
72    // Build node configs
73    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    // Each node gets its own copy under the plain binary name
77    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        // We use a placeholder ID (0) here; the registry will assign the real one
83        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(), // assigned by registry.add()
91            rewards_address: rewards_address.clone(),
92            data_dir,
93            log_dir,
94            node_port,
95            binary_path: PathBuf::new(), // placeholder, updated below
96            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        // Now update paths with the actual assigned ID
106        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        // Create directories
111        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        // Copy the binary into this node's data directory so each node
117        // has its own copy. This allows safe per-node upgrades without
118        // affecting running nodes.
119        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        // Copy bootstrap_peers.toml alongside the binary so the node can
129        // discover production network peers on startup.
130        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
143/// Remove a node from the registry.
144///
145/// Does NOT stop the node. Does NOT require the daemon.
146pub 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
153/// Reset all node state: remove all data directories, log directories, and clear the registry.
154///
155/// This function:
156/// 1. Loads the registry (with file lock)
157/// 2. Iterates over all registered nodes
158/// 3. Removes each node's data directory
159/// 4. Removes each node's log directory (if set)
160/// 5. Clears the registry (empties nodes, resets next_id to 1)
161///
162/// Does NOT check if nodes are running — callers must verify that first.
163pub 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
193/// Get the status of all registered nodes without the daemon.
194///
195/// Since the daemon is not running, all nodes are reported as `Stopped`.
196pub 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
219/// Validate that a rewards address is a valid Ethereum-style address.
220///
221/// Must be `0x` followed by exactly 40 hexadecimal characters.
222fn 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
249/// Determine the data directory for a node.
250fn 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
260/// Determine the log directory for a node.
261/// Returns `None` when no custom log dir prefix was provided (no logging by default).
262fn 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
268/// Resolve a port from a PortRange for a given node index.
269fn 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    /// A valid Ethereum address for use in tests.
280    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    /// Create a fake binary that responds to --version.
287    /// On Windows, uses a .cmd extension so the shell can execute it.
288    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, &reg_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        // Verify registry was saved
328        let reg = NodeRegistry::load(&reg_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, &reg_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)), // 2 ports, 3 nodes
368            binary_source: BinarySource::LocalPath(binary),
369            ..Default::default()
370        };
371
372        let result = add_nodes(opts, &reg_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, &reg_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, &reg_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            // log_dir_path not set — defaults to None
459            binary_source: BinarySource::LocalPath(binary),
460            ..Default::default()
461        };
462
463        let result = add_nodes(opts, &reg_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        // First add a node directly to the registry
473        let (mut registry, _lock) = NodeRegistry::load_locked(&reg_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, &reg_path).unwrap();
492        assert_eq!(result.removed.rewards_address, "0xtest");
493
494        let reg = NodeRegistry::load(&reg_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, &reg_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        // Add 2 nodes
515        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, &reg_path, &NoopProgress).await.unwrap();
525        assert_eq!(result.nodes_added.len(), 2);
526
527        // Verify directories exist
528        for node in &result.nodes_added {
529            assert!(node.data_dir.exists());
530            assert!(node.log_dir.as_ref().unwrap().exists());
531        }
532
533        // Reset
534        let reset_result = reset(&reg_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        // Verify directories were removed
540        for node in &result.nodes_added {
541            assert!(!node.data_dir.exists());
542            assert!(!node.log_dir.as_ref().unwrap().exists());
543        }
544
545        // Verify registry is empty and next_id reset
546        let reg = NodeRegistry::load(&reg_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        // Add two nodes directly to the registry
557        let (mut registry, _lock) = NodeRegistry::load_locked(&reg_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(&reg_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(&reg_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(&reg_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        // Add 2 nodes
627        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, &reg_path, &NoopProgress).await.unwrap();
636
637        // Reset
638        reset(&reg_path).unwrap();
639
640        // Add again — IDs should restart from 1
641        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, &reg_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}