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 crate::node::types::EvmNetwork;
136    use std::collections::HashMap;
137    use tempfile::NamedTempFile;
138
139    fn make_config(id: u32) -> NodeConfig {
140        NodeConfig {
141            id,
142            service_name: String::new(),
143            rewards_address: "0xtest".to_string(),
144            data_dir: PathBuf::from("/tmp/test"),
145            log_dir: None,
146            node_port: None,
147            binary_path: PathBuf::from("/usr/bin/antnode"),
148            version: "0.1.0".to_string(),
149            env_variables: HashMap::new(),
150            bootstrap_peers: vec![],
151            upgrade_channel: None,
152            evm_network: EvmNetwork::default(),
153            eviction: None,
154        }
155    }
156
157    #[test]
158    fn load_creates_empty_registry() {
159        let tmp = NamedTempFile::new().unwrap();
160        let path = tmp.path().with_extension("json");
161        // File doesn't exist at this path
162        let reg = NodeRegistry::load(&path).unwrap();
163        assert!(reg.is_empty());
164        assert_eq!(reg.next_id, 1);
165    }
166
167    #[test]
168    fn add_and_get() {
169        let tmp = NamedTempFile::new().unwrap();
170        let path = tmp.path().with_extension("json");
171        let mut reg = NodeRegistry::load(&path).unwrap();
172        let id = reg.add(make_config(0));
173        assert_eq!(id, 1);
174        assert_eq!(reg.get(id).unwrap().rewards_address, "0xtest");
175    }
176
177    #[test]
178    fn loads_pre_upgrade_registry_file() {
179        // A registry written by an older daemon: each node still carries the now-removed
180        // `network_id`/`metrics_port` fields and lacks `evm_network`/`eviction`. A new daemon must
181        // load it (unknown fields ignored, new fields defaulted) rather than erroring on upgrade.
182        let legacy = r#"{
183            "schema_version": 1,
184            "nodes": {
185                "1": {
186                    "id": 1,
187                    "service_name": "node1",
188                    "rewards_address": "0xabc",
189                    "data_dir": "/data/node-1",
190                    "log_dir": "/logs/node-1",
191                    "node_port": 12000,
192                    "metrics_port": 13000,
193                    "network_id": 2,
194                    "binary_path": "/bin/antnode",
195                    "version": "0.1.0",
196                    "env_variables": {},
197                    "bootstrap_peers": ["peer1"],
198                    "upgrade_channel": null
199                }
200            },
201            "next_id": 2
202        }"#;
203
204        let tmp = NamedTempFile::new().unwrap();
205        let path = tmp.path().with_extension("json");
206        std::fs::write(&path, legacy).unwrap();
207
208        let reg = NodeRegistry::load(&path).unwrap();
209        let node = reg.get(1).unwrap();
210        // Preserved fields survive the round-trip.
211        assert_eq!(node.node_port, Some(12000));
212        assert_eq!(node.bootstrap_peers, vec!["peer1".to_string()]);
213        // Removed fields are ignored (no deny_unknown_fields); new fields take their defaults.
214        assert!(node.eviction.is_none());
215        assert_eq!(node.evm_network, crate::node::types::EvmNetwork::default());
216        assert_eq!(reg.next_id, 2);
217    }
218
219    #[test]
220    fn save_and_reload() {
221        let tmp = NamedTempFile::new().unwrap();
222        let path = tmp.path().with_extension("json");
223        let mut reg = NodeRegistry::load(&path).unwrap();
224        reg.add(make_config(0));
225        reg.save().unwrap();
226
227        let reg2 = NodeRegistry::load(&path).unwrap();
228        assert_eq!(reg2.len(), 1);
229    }
230
231    #[test]
232    fn add_batch_assigns_sequential_ids() {
233        let tmp = NamedTempFile::new().unwrap();
234        let path = tmp.path().with_extension("json");
235        let mut reg = NodeRegistry::load(&path).unwrap();
236        let configs = vec![make_config(0), make_config(0), make_config(0)];
237        let ids = reg.add_batch(configs);
238        assert_eq!(ids, vec![1, 2, 3]);
239        assert_eq!(reg.len(), 3);
240        assert_eq!(reg.next_id, 4);
241    }
242
243    #[test]
244    fn load_locked_creates_lock_file() {
245        let tmp = NamedTempFile::new().unwrap();
246        let path = tmp.path().with_extension("json");
247        let (reg, _lock) = NodeRegistry::load_locked(&path).unwrap();
248        assert!(reg.is_empty());
249        assert!(path.with_extension("lock").exists());
250    }
251
252    #[test]
253    fn remove_returns_config() {
254        let tmp = NamedTempFile::new().unwrap();
255        let path = tmp.path().with_extension("json");
256        let mut reg = NodeRegistry::load(&path).unwrap();
257        let id = reg.add(make_config(0));
258        let removed = reg.remove(id).unwrap();
259        assert_eq!(removed.rewards_address, "0xtest");
260        assert!(reg.is_empty());
261    }
262
263    #[test]
264    fn remove_missing_node_errors() {
265        let tmp = NamedTempFile::new().unwrap();
266        let path = tmp.path().with_extension("json");
267        let mut reg = NodeRegistry::load(&path).unwrap();
268        let result = reg.remove(999);
269        assert!(result.is_err());
270    }
271
272    #[test]
273    fn clear_empties_registry_and_resets_next_id() {
274        let tmp = NamedTempFile::new().unwrap();
275        let path = tmp.path().with_extension("json");
276        let mut reg = NodeRegistry::load(&path).unwrap();
277        reg.add(make_config(0));
278        reg.add(make_config(0));
279        assert_eq!(reg.len(), 2);
280        assert_eq!(reg.next_id, 3);
281
282        reg.clear();
283        assert!(reg.is_empty());
284        assert_eq!(reg.next_id, 1);
285    }
286
287    #[test]
288    fn save_is_atomic_no_tmp_file_remains() {
289        let tmp = NamedTempFile::new().unwrap();
290        let path = tmp.path().with_extension("json");
291        let mut reg = NodeRegistry::load(&path).unwrap();
292        reg.add(make_config(0));
293        reg.save().unwrap();
294
295        // The temp file should not remain after a successful save
296        assert!(path.exists());
297        assert!(!path.with_extension("tmp").exists());
298    }
299}