Skip to main content

ant_core/node/
registry.rs

1use std::collections::HashMap;
2use std::fs::File;
3use std::path::{Path, PathBuf};
4
5use fs2::FileExt;
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Error, Result};
9use crate::node::types::NodeConfig;
10
11/// Persisted node registry (JSON file on disk).
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct NodeRegistry {
14    pub schema_version: u32,
15    pub(crate) nodes: HashMap<u32, NodeConfig>,
16    pub next_id: u32,
17    /// Path where this registry is persisted. Not serialized.
18    #[serde(skip)]
19    pub path: PathBuf,
20}
21
22impl NodeRegistry {
23    /// Load the registry from disk, or create an empty one if the file doesn't exist.
24    pub fn load(path: &Path) -> Result<Self> {
25        if path.exists() {
26            let contents = std::fs::read_to_string(path)?;
27            let mut registry: Self = serde_json::from_str(&contents)?;
28            registry.path = path.to_path_buf();
29            Ok(registry)
30        } else {
31            Ok(Self {
32                schema_version: 1,
33                nodes: HashMap::new(),
34                next_id: 1,
35                path: path.to_path_buf(),
36            })
37        }
38    }
39
40    /// Load the registry with an exclusive file lock.
41    ///
42    /// Returns the registry and the lock file handle. The lock is held until the
43    /// file handle is dropped, so callers should keep it alive for the duration of
44    /// their read-modify-write cycle.
45    pub fn load_locked(path: &Path) -> Result<(Self, File)> {
46        if let Some(parent) = path.parent() {
47            std::fs::create_dir_all(parent)?;
48        }
49
50        let lock_path = path.with_extension("lock");
51        let lock_file = File::create(&lock_path)?;
52        lock_file.lock_exclusive()?;
53
54        let registry = Self::load(path)?;
55        Ok((registry, lock_file))
56    }
57
58    /// Save the registry to disk atomically.
59    ///
60    /// Writes to a temporary file first, then renames to the target path.
61    /// This prevents registry corruption if the process crashes mid-write.
62    pub fn save(&self) -> Result<()> {
63        if let Some(parent) = self.path.parent() {
64            std::fs::create_dir_all(parent)?;
65        }
66        let contents = serde_json::to_string_pretty(self)?;
67        let tmp_path = self.path.with_extension("tmp");
68        std::fs::write(&tmp_path, &contents)?;
69        std::fs::rename(&tmp_path, &self.path)?;
70        Ok(())
71    }
72
73    /// Get a node by ID.
74    pub fn get(&self, id: u32) -> Result<&NodeConfig> {
75        self.nodes.get(&id).ok_or(Error::NodeNotFound(id))
76    }
77
78    /// Get a mutable reference to a node by ID.
79    pub fn get_mut(&mut self, id: u32) -> Result<&mut NodeConfig> {
80        self.nodes.get_mut(&id).ok_or(Error::NodeNotFound(id))
81    }
82
83    /// Add a node and return its assigned ID.
84    pub fn add(&mut self, mut config: NodeConfig) -> u32 {
85        let id = self.next_id;
86        self.next_id += 1;
87        config.id = id;
88        config.service_name = format!("node{id}");
89        self.nodes.insert(id, config);
90        id
91    }
92
93    /// Add multiple nodes at once and return their assigned IDs.
94    pub fn add_batch(&mut self, configs: Vec<NodeConfig>) -> Vec<u32> {
95        configs.into_iter().map(|config| self.add(config)).collect()
96    }
97
98    /// Remove a node by ID.
99    pub fn remove(&mut self, id: u32) -> Result<NodeConfig> {
100        self.nodes.remove(&id).ok_or(Error::NodeNotFound(id))
101    }
102
103    /// List all nodes.
104    pub fn list(&self) -> Vec<&NodeConfig> {
105        let mut nodes: Vec<_> = self.nodes.values().collect();
106        nodes.sort_by_key(|n| n.id);
107        nodes
108    }
109
110    /// Find a node by its service name.
111    pub fn find_by_service_name(&self, name: &str) -> Option<&NodeConfig> {
112        self.nodes.values().find(|n| n.service_name == name)
113    }
114
115    /// Number of registered nodes.
116    pub fn len(&self) -> usize {
117        self.nodes.len()
118    }
119
120    /// Whether the registry is empty.
121    pub fn is_empty(&self) -> bool {
122        self.nodes.is_empty()
123    }
124
125    /// Clear all nodes and reset the next ID counter.
126    pub fn clear(&mut self) {
127        self.nodes.clear();
128        self.next_id = 1;
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use std::collections::HashMap;
136    use tempfile::NamedTempFile;
137
138    fn make_config(id: u32) -> NodeConfig {
139        NodeConfig {
140            id,
141            service_name: String::new(),
142            rewards_address: "0xtest".to_string(),
143            data_dir: PathBuf::from("/tmp/test"),
144            log_dir: None,
145            node_port: None,
146            metrics_port: None,
147            network_id: None,
148            binary_path: PathBuf::from("/usr/bin/antnode"),
149            version: "0.1.0".to_string(),
150            env_variables: HashMap::new(),
151            bootstrap_peers: vec![],
152            upgrade_channel: None,
153        }
154    }
155
156    #[test]
157    fn load_creates_empty_registry() {
158        let tmp = NamedTempFile::new().unwrap();
159        let path = tmp.path().with_extension("json");
160        // File doesn't exist at this path
161        let reg = NodeRegistry::load(&path).unwrap();
162        assert!(reg.is_empty());
163        assert_eq!(reg.next_id, 1);
164    }
165
166    #[test]
167    fn add_and_get() {
168        let tmp = NamedTempFile::new().unwrap();
169        let path = tmp.path().with_extension("json");
170        let mut reg = NodeRegistry::load(&path).unwrap();
171        let id = reg.add(make_config(0));
172        assert_eq!(id, 1);
173        assert_eq!(reg.get(id).unwrap().rewards_address, "0xtest");
174    }
175
176    #[test]
177    fn save_and_reload() {
178        let tmp = NamedTempFile::new().unwrap();
179        let path = tmp.path().with_extension("json");
180        let mut reg = NodeRegistry::load(&path).unwrap();
181        reg.add(make_config(0));
182        reg.save().unwrap();
183
184        let reg2 = NodeRegistry::load(&path).unwrap();
185        assert_eq!(reg2.len(), 1);
186    }
187
188    #[test]
189    fn add_batch_assigns_sequential_ids() {
190        let tmp = NamedTempFile::new().unwrap();
191        let path = tmp.path().with_extension("json");
192        let mut reg = NodeRegistry::load(&path).unwrap();
193        let configs = vec![make_config(0), make_config(0), make_config(0)];
194        let ids = reg.add_batch(configs);
195        assert_eq!(ids, vec![1, 2, 3]);
196        assert_eq!(reg.len(), 3);
197        assert_eq!(reg.next_id, 4);
198    }
199
200    #[test]
201    fn load_locked_creates_lock_file() {
202        let tmp = NamedTempFile::new().unwrap();
203        let path = tmp.path().with_extension("json");
204        let (reg, _lock) = NodeRegistry::load_locked(&path).unwrap();
205        assert!(reg.is_empty());
206        assert!(path.with_extension("lock").exists());
207    }
208
209    #[test]
210    fn remove_returns_config() {
211        let tmp = NamedTempFile::new().unwrap();
212        let path = tmp.path().with_extension("json");
213        let mut reg = NodeRegistry::load(&path).unwrap();
214        let id = reg.add(make_config(0));
215        let removed = reg.remove(id).unwrap();
216        assert_eq!(removed.rewards_address, "0xtest");
217        assert!(reg.is_empty());
218    }
219
220    #[test]
221    fn remove_missing_node_errors() {
222        let tmp = NamedTempFile::new().unwrap();
223        let path = tmp.path().with_extension("json");
224        let mut reg = NodeRegistry::load(&path).unwrap();
225        let result = reg.remove(999);
226        assert!(result.is_err());
227    }
228
229    #[test]
230    fn clear_empties_registry_and_resets_next_id() {
231        let tmp = NamedTempFile::new().unwrap();
232        let path = tmp.path().with_extension("json");
233        let mut reg = NodeRegistry::load(&path).unwrap();
234        reg.add(make_config(0));
235        reg.add(make_config(0));
236        assert_eq!(reg.len(), 2);
237        assert_eq!(reg.next_id, 3);
238
239        reg.clear();
240        assert!(reg.is_empty());
241        assert_eq!(reg.next_id, 1);
242    }
243
244    #[test]
245    fn save_is_atomic_no_tmp_file_remains() {
246        let tmp = NamedTempFile::new().unwrap();
247        let path = tmp.path().with_extension("json");
248        let mut reg = NodeRegistry::load(&path).unwrap();
249        reg.add(make_config(0));
250        reg.save().unwrap();
251
252        // The temp file should not remain after a successful save
253        assert!(path.exists());
254        assert!(!path.with_extension("tmp").exists());
255    }
256}