Skip to main content

a3s_box_runtime/network/
store.rs

1//! Persistent storage for network configurations.
2//!
3//! Networks are stored as JSON in `~/.a3s/networks.json` with atomic writes
4//! (write to tmp file, then rename) to prevent corruption.
5
6use a3s_box_core::error::{BoxError, Result};
7use a3s_box_core::network::NetworkConfig;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11/// Persistent store for network configurations.
12#[derive(Debug)]
13pub struct NetworkStore {
14    /// Path to the JSON file.
15    path: PathBuf,
16}
17
18/// Serializable wrapper for the networks file.
19#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
20struct NetworksFile {
21    networks: HashMap<String, NetworkConfig>,
22}
23
24impl NetworkStore {
25    /// Create a new store at the given path.
26    pub fn new(path: impl Into<PathBuf>) -> Self {
27        Self { path: path.into() }
28    }
29
30    /// Create a store at the default location (`~/.a3s/networks.json`).
31    pub fn default_path() -> Result<Self> {
32        let home = a3s_box_core::dirs_home();
33        Ok(Self::new(home.join("networks.json")))
34    }
35
36    /// Load all networks from disk.
37    pub fn load(&self) -> Result<HashMap<String, NetworkConfig>> {
38        if !self.path.exists() {
39            return Ok(HashMap::new());
40        }
41
42        let data = std::fs::read_to_string(&self.path).map_err(|e| {
43            BoxError::NetworkError(format!(
44                "failed to read networks file {}: {}",
45                self.path.display(),
46                e
47            ))
48        })?;
49
50        let file: NetworksFile = serde_json::from_str(&data)
51            .map_err(|e| BoxError::NetworkError(format!("failed to parse networks file: {}", e)))?;
52
53        Ok(file.networks)
54    }
55
56    /// Save all networks to disk (atomic write).
57    pub fn save(&self, networks: &HashMap<String, NetworkConfig>) -> Result<()> {
58        // Ensure parent directory exists
59        if let Some(parent) = self.path.parent() {
60            std::fs::create_dir_all(parent).map_err(|e| {
61                BoxError::NetworkError(format!(
62                    "failed to create directory {}: {}",
63                    parent.display(),
64                    e
65                ))
66            })?;
67        }
68
69        let file = NetworksFile {
70            networks: networks.clone(),
71        };
72
73        let json = serde_json::to_string_pretty(&file)
74            .map_err(|e| BoxError::NetworkError(format!("failed to serialize networks: {}", e)))?;
75
76        // Atomic write: write to tmp, then rename
77        let tmp_path = self.path.with_extension("json.tmp");
78        std::fs::write(&tmp_path, &json).map_err(|e| {
79            BoxError::NetworkError(format!(
80                "failed to write tmp file {}: {}",
81                tmp_path.display(),
82                e
83            ))
84        })?;
85
86        std::fs::rename(&tmp_path, &self.path).map_err(|e| {
87            BoxError::NetworkError(format!(
88                "failed to rename {} → {}: {}",
89                tmp_path.display(),
90                self.path.display(),
91                e
92            ))
93        })?;
94
95        Ok(())
96    }
97
98    /// Get a single network by name.
99    pub fn get(&self, name: &str) -> Result<Option<NetworkConfig>> {
100        let networks = self.load()?;
101        Ok(networks.get(name).cloned())
102    }
103
104    /// Run `f` against the full networks map under the **cross-process write
105    /// lock** (load-fresh → mutate → save). Use this to make a
106    /// get-modify-update sequence atomic — e.g. allocate-an-IP-then-connect —
107    /// so concurrent boots cannot assign duplicate IPs/MACs or silently lose
108    /// each other's endpoints. The map is saved only if `f` returns `Ok`.
109    ///
110    /// The lock is held across the whole load/mutate/save; `save` is itself
111    /// lock-free, so there is no re-entrant `flock` (which would self-deadlock).
112    pub fn with_write_lock<F, R, E>(&self, f: F) -> std::result::Result<R, E>
113    where
114        F: FnOnce(&mut HashMap<String, NetworkConfig>) -> std::result::Result<R, E>,
115        E: From<BoxError>,
116    {
117        let _lock = crate::file_lock::FileLock::acquire(&self.path).map_err(|e| {
118            E::from(BoxError::NetworkError(format!(
119                "failed to lock networks file {}: {e}",
120                self.path.display()
121            )))
122        })?;
123        let mut networks = self.load().map_err(E::from)?;
124        let r = f(&mut networks)?;
125        self.save(&networks).map_err(E::from)?;
126        Ok(r)
127    }
128
129    /// Create a new network. Returns error if name already exists.
130    pub fn create(&self, config: NetworkConfig) -> Result<()> {
131        self.with_write_lock(|networks| {
132            if networks.contains_key(&config.name) {
133                return Err(BoxError::NetworkError(format!(
134                    "network '{}' already exists",
135                    config.name
136                )));
137            }
138            networks.insert(config.name.clone(), config);
139            Ok(())
140        })
141    }
142
143    /// Remove a network by name. Returns the removed config or error if not found.
144    pub fn remove(&self, name: &str) -> Result<NetworkConfig> {
145        self.with_write_lock(|networks| {
146            let config = networks
147                .remove(name)
148                .ok_or_else(|| BoxError::NetworkError(format!("network '{}' not found", name)))?;
149
150            if !config.endpoints.is_empty() {
151                // Returning Err skips the save, so the in-memory removal is not
152                // persisted — the network stays intact.
153                return Err(BoxError::NetworkError(format!(
154                    "network '{}' has {} connected endpoint(s); disconnect them first or use --force",
155                    name,
156                    config.endpoints.len()
157                )));
158            }
159            Ok(config)
160        })
161    }
162
163    /// List all network names.
164    pub fn list(&self) -> Result<Vec<NetworkConfig>> {
165        let networks = self.load()?;
166        Ok(networks.into_values().collect())
167    }
168
169    /// Update a network in-place (used for connect/disconnect).
170    ///
171    /// Prefer [`with_write_lock`](Self::with_write_lock) for a
172    /// get-modify-update sequence: `update` re-loads under the lock, but a
173    /// caller that read the network *before* calling `update` decided its
174    /// mutation on a possibly-stale snapshot.
175    pub fn update(&self, config: &NetworkConfig) -> Result<()> {
176        self.with_write_lock(|networks| {
177            if !networks.contains_key(&config.name) {
178                return Err(BoxError::NetworkError(format!(
179                    "network '{}' not found",
180                    config.name
181                )));
182            }
183            networks.insert(config.name.clone(), config.clone());
184            Ok(())
185        })
186    }
187
188    /// Get the store file path.
189    pub fn path(&self) -> &Path {
190        &self.path
191    }
192}
193
194impl a3s_box_core::traits::NetworkStoreBackend for NetworkStore {
195    fn get(&self, name: &str) -> Result<Option<NetworkConfig>> {
196        self.get(name)
197    }
198
199    fn create(&self, config: NetworkConfig) -> Result<()> {
200        self.create(config)
201    }
202
203    fn remove(&self, name: &str) -> Result<NetworkConfig> {
204        self.remove(name)
205    }
206
207    fn list(&self) -> Result<Vec<NetworkConfig>> {
208        self.list()
209    }
210
211    fn update(&self, config: &NetworkConfig) -> Result<()> {
212        self.update(config)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn temp_store() -> (tempfile::TempDir, NetworkStore) {
221        let dir = tempfile::tempdir().unwrap();
222        let store = NetworkStore::new(dir.path().join("networks.json"));
223        (dir, store)
224    }
225
226    #[test]
227    fn test_load_empty() {
228        let (_dir, store) = temp_store();
229        let networks = store.load().unwrap();
230        assert!(networks.is_empty());
231    }
232
233    #[test]
234    fn test_create_and_load() {
235        let (_dir, store) = temp_store();
236        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
237        store.create(net).unwrap();
238
239        let networks = store.load().unwrap();
240        assert_eq!(networks.len(), 1);
241        assert!(networks.contains_key("mynet"));
242    }
243
244    #[test]
245    fn test_create_duplicate() {
246        let (_dir, store) = temp_store();
247        let net1 = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
248        let net2 = NetworkConfig::new("mynet", "10.89.0.0/24").unwrap();
249
250        store.create(net1).unwrap();
251        assert!(store.create(net2).is_err());
252    }
253
254    #[test]
255    fn test_get_existing() {
256        let (_dir, store) = temp_store();
257        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
258        store.create(net).unwrap();
259
260        let found = store.get("mynet").unwrap();
261        assert!(found.is_some());
262        assert_eq!(found.unwrap().name, "mynet");
263    }
264
265    #[test]
266    fn test_get_nonexistent() {
267        let (_dir, store) = temp_store();
268        let found = store.get("nope").unwrap();
269        assert!(found.is_none());
270    }
271
272    #[test]
273    fn test_remove() {
274        let (_dir, store) = temp_store();
275        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
276        store.create(net).unwrap();
277
278        let removed = store.remove("mynet").unwrap();
279        assert_eq!(removed.name, "mynet");
280
281        let networks = store.load().unwrap();
282        assert!(networks.is_empty());
283    }
284
285    #[test]
286    fn test_remove_nonexistent() {
287        let (_dir, store) = temp_store();
288        assert!(store.remove("nope").is_err());
289    }
290
291    #[test]
292    fn test_remove_with_endpoints() {
293        let (_dir, store) = temp_store();
294        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
295        net.connect("box-1", "web").unwrap();
296        store.create(net).unwrap();
297
298        // Should fail because endpoints are connected
299        assert!(store.remove("mynet").is_err());
300    }
301
302    #[test]
303    fn test_list() {
304        let (_dir, store) = temp_store();
305        store
306            .create(NetworkConfig::new("net1", "10.88.0.0/24").unwrap())
307            .unwrap();
308        store
309            .create(NetworkConfig::new("net2", "10.89.0.0/24").unwrap())
310            .unwrap();
311
312        let list = store.list().unwrap();
313        assert_eq!(list.len(), 2);
314    }
315
316    #[test]
317    fn test_update() {
318        let (_dir, store) = temp_store();
319        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
320        store.create(net).unwrap();
321
322        // Connect a box
323        let mut net = store.get("mynet").unwrap().unwrap();
324        net.connect("box-1", "web").unwrap();
325        store.update(&net).unwrap();
326
327        // Verify persistence
328        let loaded = store.get("mynet").unwrap().unwrap();
329        assert_eq!(loaded.endpoints.len(), 1);
330    }
331
332    #[test]
333    fn test_update_nonexistent() {
334        let (_dir, store) = temp_store();
335        let net = NetworkConfig::new("nope", "10.88.0.0/24").unwrap();
336        assert!(store.update(&net).is_err());
337    }
338
339    #[test]
340    fn test_atomic_write() {
341        let (_dir, store) = temp_store();
342        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
343        store.create(net).unwrap();
344
345        // Verify the file exists and is valid JSON
346        let data = std::fs::read_to_string(store.path()).unwrap();
347        let _: serde_json::Value = serde_json::from_str(&data).unwrap();
348
349        // Verify no tmp file left behind
350        let tmp = store.path().with_extension("json.tmp");
351        assert!(!tmp.exists());
352    }
353
354    #[test]
355    fn test_creates_parent_directory() {
356        let dir = tempfile::tempdir().unwrap();
357        let store = NetworkStore::new(dir.path().join("subdir").join("networks.json"));
358
359        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
360        store.create(net).unwrap();
361
362        assert!(store.path().exists());
363    }
364
365    #[test]
366    fn concurrent_connects_allocate_distinct_ips() {
367        use std::collections::HashSet;
368        use std::sync::Arc;
369
370        let dir = tempfile::tempdir().unwrap();
371        let store = Arc::new(NetworkStore::new(dir.path().join("networks.json")));
372        store
373            .create(NetworkConfig::new("dev", "10.88.0.0/24").unwrap())
374            .unwrap();
375
376        // Many threads connect concurrently. with_write_lock must serialize the
377        // load → allocate → save so every box gets a distinct IP and no endpoint
378        // is lost — the bug allocated duplicate IPs and dropped endpoints.
379        let handles: Vec<_> = (0..16)
380            .map(|i| {
381                let store = Arc::clone(&store);
382                std::thread::spawn(move || {
383                    store
384                        .with_write_lock(|nets| {
385                            nets.get_mut("dev")
386                                .unwrap()
387                                .connect(&format!("box-{i}"), &format!("name-{i}"))
388                                .map_err(BoxError::NetworkError)
389                        })
390                        .unwrap()
391                        .ip_address
392                })
393            })
394            .collect();
395
396        let ips: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
397        let unique: HashSet<_> = ips.iter().collect();
398        assert_eq!(
399            unique.len(),
400            ips.len(),
401            "concurrent connects must allocate distinct IPs (got {ips:?})"
402        );
403        assert_eq!(
404            store.get("dev").unwrap().unwrap().endpoints.len(),
405            16,
406            "every concurrent endpoint must be persisted (no lost writes)"
407        );
408    }
409}