Skip to main content

a3s_box_runtime/volume/
store.rs

1//! Persistent storage for volume configurations.
2//!
3//! Volumes are stored as JSON in `~/.a3s/volumes.json` with atomic writes
4//! (write to tmp file, then rename) to prevent corruption.
5//! Volume data is stored under `~/.a3s/volumes/<name>/`.
6
7use a3s_box_core::error::{BoxError, Result};
8use a3s_box_core::volume::VolumeConfig;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// Persistent store for volume configurations.
13#[derive(Debug)]
14pub struct VolumeStore {
15    /// Path to the JSON file.
16    path: PathBuf,
17    /// Base directory for volume data (~/.a3s/volumes/).
18    volumes_dir: PathBuf,
19}
20
21/// Serializable wrapper for the volumes file.
22#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
23struct VolumesFile {
24    volumes: HashMap<String, VolumeConfig>,
25}
26
27impl VolumeStore {
28    /// Create a new store at the given path.
29    pub fn new(path: impl Into<PathBuf>, volumes_dir: impl Into<PathBuf>) -> Self {
30        Self {
31            path: path.into(),
32            volumes_dir: volumes_dir.into(),
33        }
34    }
35
36    /// Create a store at the default location (`~/.a3s/volumes.json`).
37    pub fn default_path() -> Result<Self> {
38        let home = a3s_box_core::dirs_home();
39        Ok(Self::new(home.join("volumes.json"), home.join("volumes")))
40    }
41
42    /// Load all volumes from disk.
43    pub fn load(&self) -> Result<HashMap<String, VolumeConfig>> {
44        if !self.path.exists() {
45            return Ok(HashMap::new());
46        }
47
48        let data = std::fs::read_to_string(&self.path).map_err(|e| {
49            BoxError::ConfigError(format!(
50                "failed to read volumes file {}: {}",
51                self.path.display(),
52                e
53            ))
54        })?;
55
56        // A corrupt/old-schema volumes file must not brick the runtime: quarantine
57        // it and start from an empty set (create repopulates) rather than failing
58        // every volume operation. Mirrors the boxes.json hardening.
59        let file: VolumesFile = match serde_json::from_str(&data) {
60            Ok(f) => f,
61            Err(e) => {
62                let preserved = crate::store_io::quarantine_label(&self.path);
63                tracing::warn!(
64                    "volumes file {} is corrupt ({e}); preserved a copy at {preserved} \
65                     and started from an empty volume set",
66                    self.path.display(),
67                );
68                return Ok(HashMap::new());
69            }
70        };
71
72        Ok(file.volumes)
73    }
74
75    /// Save all volumes to disk (atomic write).
76    pub fn save(&self, volumes: &HashMap<String, VolumeConfig>) -> Result<()> {
77        if let Some(parent) = self.path.parent() {
78            std::fs::create_dir_all(parent).map_err(|e| {
79                BoxError::ConfigError(format!(
80                    "failed to create directory {}: {}",
81                    parent.display(),
82                    e
83                ))
84            })?;
85        }
86
87        let file = VolumesFile {
88            volumes: volumes.clone(),
89        };
90
91        let json = serde_json::to_string_pretty(&file).map_err(|e| {
92            BoxError::SerializationError(format!("failed to serialize volumes: {}", e))
93        })?;
94
95        let tmp_path = self.path.with_extension("json.tmp");
96        std::fs::write(&tmp_path, &json).map_err(|e| {
97            BoxError::ConfigError(format!(
98                "failed to write tmp file {}: {}",
99                tmp_path.display(),
100                e
101            ))
102        })?;
103
104        std::fs::rename(&tmp_path, &self.path).map_err(|e| {
105            BoxError::ConfigError(format!(
106                "failed to rename {} → {}: {}",
107                tmp_path.display(),
108                self.path.display(),
109                e
110            ))
111        })?;
112
113        Ok(())
114    }
115
116    /// Run `f` over the volume map under a cross-process advisory lock,
117    /// re-loading fresh from disk inside the lock and saving the result.
118    ///
119    /// `create`/`remove`/`update`/`modify`/`get_or_create` all funnel through
120    /// here so concurrent `a3s-box` processes cannot lose each other's writes.
121    /// The atomic tmp+rename in `save` only prevents a *torn* read — two
122    /// processes that both load, mutate a different entry, and save would still
123    /// clobber one update (and, for attach/detach, silently drop a volume's
124    /// `in_use_by` entry, letting `prune`/`remove` delete data a live box still
125    /// has mounted). `save` itself stays lock-free: the guard is held here for
126    /// the whole load → mutate → save, and the lock is non-reentrant.
127    fn with_write_lock<F, R>(&self, f: F) -> Result<R>
128    where
129        F: FnOnce(&mut HashMap<String, VolumeConfig>) -> Result<R>,
130    {
131        let _lock = crate::file_lock::FileLock::acquire(&self.path).map_err(|e| {
132            BoxError::ConfigError(format!(
133                "failed to lock volumes file {}: {e}",
134                self.path.display()
135            ))
136        })?;
137        let mut volumes = self.load()?;
138        let r = f(&mut volumes)?;
139        self.save(&volumes)?;
140        Ok(r)
141    }
142
143    /// Get a single volume by name.
144    pub fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
145        let volumes = self.load()?;
146        Ok(volumes.get(name).cloned())
147    }
148
149    /// Create a new named volume. Returns the host mount point path.
150    ///
151    /// Creates the volume data directory under `~/.a3s/volumes/<name>/`.
152    /// Errors if the name already exists (use [`Self::get_or_create`] for the
153    /// idempotent auto-create on the `run -v name:/path` path).
154    pub fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
155        self.with_write_lock(|volumes| {
156            if volumes.contains_key(&config.name) {
157                return Err(BoxError::ConfigError(format!(
158                    "volume '{}' already exists",
159                    config.name
160                )));
161            }
162            self.materialize(config, volumes)
163        })
164    }
165
166    /// Return the existing volume, or create it if absent — atomic under the
167    /// cross-process lock. Two concurrent first-time `run -v name:/path` then
168    /// share one volume instead of one racing to an "already exists" error.
169    pub fn get_or_create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
170        self.with_write_lock(|volumes| {
171            if let Some(existing) = volumes.get(&config.name) {
172                return Ok(existing.clone());
173            }
174            self.materialize(config, volumes)
175        })
176    }
177
178    /// Create the volume's data directory, set its mount point, and insert it
179    /// into `volumes`. Caller must already hold the write lock.
180    fn materialize(
181        &self,
182        mut config: VolumeConfig,
183        volumes: &mut HashMap<String, VolumeConfig>,
184    ) -> Result<VolumeConfig> {
185        let vol_dir = self.volumes_dir.join(&config.name);
186        std::fs::create_dir_all(&vol_dir).map_err(|e| {
187            BoxError::ConfigError(format!(
188                "failed to create volume directory {}: {}",
189                vol_dir.display(),
190                e
191            ))
192        })?;
193        config.mount_point = vol_dir.to_string_lossy().into_owned();
194        volumes.insert(config.name.clone(), config.clone());
195        Ok(config)
196    }
197
198    /// Remove a volume by name. Returns error if in use.
199    pub fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
200        let config = self.with_write_lock(|volumes| {
201            let config = volumes
202                .remove(name)
203                .ok_or_else(|| BoxError::ConfigError(format!("volume '{}' not found", name)))?;
204
205            if config.is_in_use() && !force {
206                // Put it back
207                volumes.insert(name.to_string(), config.clone());
208                return Err(BoxError::ConfigError(format!(
209                    "volume '{}' is in use by {} box(es); use --force to remove",
210                    name,
211                    config.in_use_by.len()
212                )));
213            }
214            Ok(config)
215        })?;
216
217        // Remove the data directory outside the lock; it is keyed by name and
218        // the removal is idempotent.
219        let vol_dir = self.volumes_dir.join(name);
220        if vol_dir.exists() {
221            std::fs::remove_dir_all(&vol_dir).ok();
222        }
223
224        Ok(config)
225    }
226
227    /// List all volumes.
228    pub fn list(&self) -> Result<Vec<VolumeConfig>> {
229        let volumes = self.load()?;
230        Ok(volumes.into_values().collect())
231    }
232
233    /// Replace a volume's config wholesale under the cross-process lock.
234    ///
235    /// For attach/detach prefer [`Self::modify`]: a `get` → mutate → `update`
236    /// reads outside the lock and would lose a concurrent update made between
237    /// the two calls.
238    pub fn update(&self, config: &VolumeConfig) -> Result<()> {
239        self.with_write_lock(|volumes| {
240            if !volumes.contains_key(&config.name) {
241                return Err(BoxError::ConfigError(format!(
242                    "volume '{}' not found",
243                    config.name
244                )));
245            }
246            volumes.insert(config.name.clone(), config.clone());
247            Ok(())
248        })
249    }
250
251    /// Atomically mutate one volume's config under the cross-process lock.
252    ///
253    /// Re-reads the current entry inside the lock so concurrent attach/detach
254    /// accumulate correctly — the canonical fix for the split `get` → mutate →
255    /// `update` race that could drop a volume's `in_use_by` entry. Returns
256    /// `false` if the volume does not exist.
257    pub fn modify<F>(&self, name: &str, f: F) -> Result<bool>
258    where
259        F: FnOnce(&mut VolumeConfig),
260    {
261        self.with_write_lock(|volumes| match volumes.get_mut(name) {
262            Some(config) => {
263                f(config);
264                Ok(true)
265            }
266            None => Ok(false),
267        })
268    }
269
270    /// Remove all volumes that are not in use. Returns names of removed volumes.
271    pub fn prune(&self) -> Result<Vec<String>> {
272        let volumes = self.load()?;
273        let mut pruned = Vec::new();
274
275        for (name, config) in &volumes {
276            if !config.is_in_use() {
277                pruned.push(name.clone());
278            }
279        }
280
281        for name in &pruned {
282            self.remove(name, false).ok();
283        }
284
285        Ok(pruned)
286    }
287
288    /// Get the volume data directory for a named volume.
289    pub fn volume_dir(&self, name: &str) -> PathBuf {
290        self.volumes_dir.join(name)
291    }
292
293    /// Get the store file path.
294    pub fn path(&self) -> &Path {
295        &self.path
296    }
297}
298
299impl a3s_box_core::traits::VolumeStoreBackend for VolumeStore {
300    fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
301        self.get(name)
302    }
303
304    fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
305        self.create(config)
306    }
307
308    fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
309        self.remove(name, force)
310    }
311
312    fn list(&self) -> Result<Vec<VolumeConfig>> {
313        self.list()
314    }
315
316    fn update(&self, config: &VolumeConfig) -> Result<()> {
317        self.update(config)
318    }
319
320    fn prune(&self) -> Result<Vec<String>> {
321        self.prune()
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn temp_store() -> (tempfile::TempDir, VolumeStore) {
330        let dir = tempfile::tempdir().unwrap();
331        let store = VolumeStore::new(dir.path().join("volumes.json"), dir.path().join("volumes"));
332        (dir, store)
333    }
334
335    #[test]
336    fn test_load_empty() {
337        let (_dir, store) = temp_store();
338        let volumes = store.load().unwrap();
339        assert!(volumes.is_empty());
340    }
341
342    #[test]
343    fn test_create_and_load() {
344        let (_dir, store) = temp_store();
345        let vol = VolumeConfig::new("mydata", "");
346        store.create(vol).unwrap();
347
348        let volumes = store.load().unwrap();
349        assert_eq!(volumes.len(), 1);
350        assert!(volumes.contains_key("mydata"));
351    }
352
353    #[test]
354    fn test_create_sets_mount_point() {
355        let (_dir, store) = temp_store();
356        let vol = VolumeConfig::new("mydata", "");
357        let created = store.create(vol).unwrap();
358
359        assert!(created.mount_point.contains("mydata"));
360        assert!(PathBuf::from(&created.mount_point).exists());
361    }
362
363    #[test]
364    fn test_create_duplicate() {
365        let (_dir, store) = temp_store();
366        let v1 = VolumeConfig::new("mydata", "");
367        let v2 = VolumeConfig::new("mydata", "");
368
369        store.create(v1).unwrap();
370        assert!(store.create(v2).is_err());
371    }
372
373    #[test]
374    fn test_get_existing() {
375        let (_dir, store) = temp_store();
376        store.create(VolumeConfig::new("mydata", "")).unwrap();
377
378        let found = store.get("mydata").unwrap();
379        assert!(found.is_some());
380        assert_eq!(found.unwrap().name, "mydata");
381    }
382
383    #[test]
384    fn test_get_nonexistent() {
385        let (_dir, store) = temp_store();
386        let found = store.get("nope").unwrap();
387        assert!(found.is_none());
388    }
389
390    #[test]
391    fn test_remove() {
392        let (_dir, store) = temp_store();
393        store.create(VolumeConfig::new("mydata", "")).unwrap();
394
395        let removed = store.remove("mydata", false).unwrap();
396        assert_eq!(removed.name, "mydata");
397
398        let volumes = store.load().unwrap();
399        assert!(volumes.is_empty());
400    }
401
402    #[test]
403    fn test_remove_nonexistent() {
404        let (_dir, store) = temp_store();
405        assert!(store.remove("nope", false).is_err());
406    }
407
408    #[test]
409    fn test_remove_in_use_fails() {
410        let (_dir, store) = temp_store();
411        let mut vol = VolumeConfig::new("mydata", "");
412        vol.attach("box-1");
413        // Manually insert since create() doesn't set in_use_by
414        let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
415        let mut updated = created;
416        updated.attach("box-1");
417        store.update(&updated).unwrap();
418
419        assert!(store.remove("mydata", false).is_err());
420    }
421
422    #[test]
423    fn test_remove_in_use_force() {
424        let (_dir, store) = temp_store();
425        let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
426        let mut updated = created;
427        updated.attach("box-1");
428        store.update(&updated).unwrap();
429
430        let removed = store.remove("mydata", true).unwrap();
431        assert_eq!(removed.name, "mydata");
432    }
433
434    #[test]
435    fn test_list() {
436        let (_dir, store) = temp_store();
437        store.create(VolumeConfig::new("vol1", "")).unwrap();
438        store.create(VolumeConfig::new("vol2", "")).unwrap();
439
440        let list = store.list().unwrap();
441        assert_eq!(list.len(), 2);
442    }
443
444    #[test]
445    fn test_update() {
446        let (_dir, store) = temp_store();
447        let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
448
449        let mut updated = created;
450        updated.attach("box-1");
451        store.update(&updated).unwrap();
452
453        let loaded = store.get("mydata").unwrap().unwrap();
454        assert_eq!(loaded.in_use_by, vec!["box-1"]);
455    }
456
457    #[test]
458    fn test_update_nonexistent() {
459        let (_dir, store) = temp_store();
460        let vol = VolumeConfig::new("nope", "/tmp");
461        assert!(store.update(&vol).is_err());
462    }
463
464    #[test]
465    fn test_prune() {
466        let (_dir, store) = temp_store();
467        store.create(VolumeConfig::new("unused1", "")).unwrap();
468        store.create(VolumeConfig::new("unused2", "")).unwrap();
469
470        let created = store.create(VolumeConfig::new("in_use", "")).unwrap();
471        let mut updated = created;
472        updated.attach("box-1");
473        store.update(&updated).unwrap();
474
475        let pruned = store.prune().unwrap();
476        assert_eq!(pruned.len(), 2);
477        assert!(pruned.contains(&"unused1".to_string()));
478        assert!(pruned.contains(&"unused2".to_string()));
479
480        // in_use should remain
481        let remaining = store.list().unwrap();
482        assert_eq!(remaining.len(), 1);
483        assert_eq!(remaining[0].name, "in_use");
484    }
485
486    #[test]
487    fn test_atomic_write() {
488        let (_dir, store) = temp_store();
489        store.create(VolumeConfig::new("mydata", "")).unwrap();
490
491        let data = std::fs::read_to_string(store.path()).unwrap();
492        let _: serde_json::Value = serde_json::from_str(&data).unwrap();
493
494        let tmp = store.path().with_extension("json.tmp");
495        assert!(!tmp.exists());
496    }
497
498    #[test]
499    fn test_creates_parent_directory() {
500        let dir = tempfile::tempdir().unwrap();
501        let store = VolumeStore::new(
502            dir.path().join("subdir").join("volumes.json"),
503            dir.path().join("subdir").join("volumes"),
504        );
505
506        store.create(VolumeConfig::new("mydata", "")).unwrap();
507        assert!(store.path().exists());
508    }
509
510    #[test]
511    fn test_remove_cleans_up_directory() {
512        let (_dir, store) = temp_store();
513        let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
514        let vol_dir = PathBuf::from(&created.mount_point);
515        assert!(vol_dir.exists());
516
517        store.remove("mydata", false).unwrap();
518        assert!(!vol_dir.exists());
519    }
520
521    #[test]
522    fn test_get_or_create_is_idempotent() {
523        let (_dir, store) = temp_store();
524        let first = store
525            .get_or_create(VolumeConfig::new("shared", ""))
526            .unwrap();
527        let second = store
528            .get_or_create(VolumeConfig::new("shared", ""))
529            .unwrap();
530        assert_eq!(first.mount_point, second.mount_point);
531        assert_eq!(store.list().unwrap().len(), 1);
532    }
533
534    #[test]
535    fn test_get_or_create_preserves_existing_config() {
536        let (_dir, store) = temp_store();
537        let created = store
538            .get_or_create(VolumeConfig::with_size_limit("shared", "", 4096))
539            .unwrap();
540        let mut updated = created;
541        updated.attach("box-1");
542        store.update(&updated).unwrap();
543
544        let reused = store
545            .get_or_create(VolumeConfig::with_size_limit("shared", "/ignored", 8192))
546            .unwrap();
547
548        assert_eq!(reused.size_limit, 4096);
549        assert_eq!(reused.in_use_by, vec!["box-1"]);
550        assert!(PathBuf::from(&reused.mount_point).exists());
551    }
552
553    #[test]
554    fn test_modify_missing_returns_false() {
555        let (_dir, store) = temp_store();
556        assert!(!store.modify("nope", |c| c.attach("box-1")).unwrap());
557    }
558
559    #[test]
560    fn test_modify_existing_returns_true_and_persists() {
561        let (_dir, store) = temp_store();
562        store.create(VolumeConfig::new("shared", "")).unwrap();
563
564        let modified = store.modify("shared", |c| c.attach("box-1")).unwrap();
565
566        assert!(modified);
567        assert_eq!(
568            store.get("shared").unwrap().unwrap().in_use_by,
569            vec!["box-1"]
570        );
571    }
572
573    #[test]
574    fn test_volume_dir_joins_name_under_base_directory() {
575        let (dir, store) = temp_store();
576        assert_eq!(
577            store.volume_dir("mydata"),
578            dir.path().join("volumes").join("mydata")
579        );
580    }
581
582    #[test]
583    fn test_volume_store_backend_trait_dispatch() {
584        let (_dir, store) = temp_store();
585        let backend: &dyn a3s_box_core::traits::VolumeStoreBackend = &store;
586
587        let created = backend.create(VolumeConfig::new("shared", "")).unwrap();
588        assert_eq!(created.name, "shared");
589        assert!(backend.get("shared").unwrap().is_some());
590
591        let mut updated = created;
592        updated.attach("box-1");
593        backend.update(&updated).unwrap();
594        assert_eq!(backend.list().unwrap().len(), 1);
595
596        let err = backend.remove("shared", false).unwrap_err().to_string();
597        assert!(err.contains("is in use"));
598
599        let removed = backend.remove("shared", true).unwrap();
600        assert_eq!(removed.name, "shared");
601        assert!(backend.list().unwrap().is_empty());
602    }
603
604    // The advisory lock is per-open-file-description, so separate
605    // FileLock::acquire calls serialize even across threads in one process —
606    // which is exactly what lets this exercise the lost-update fix in-process.
607    #[test]
608    fn concurrent_attaches_accumulate_without_lost_update() {
609        use std::sync::Arc;
610        use std::thread;
611
612        let dir = tempfile::tempdir().unwrap();
613        let store = Arc::new(VolumeStore::new(
614            dir.path().join("volumes.json"),
615            dir.path().join("volumes"),
616        ));
617        store.create(VolumeConfig::new("shared", "")).unwrap();
618
619        let n = 16;
620        let handles: Vec<_> = (0..n)
621            .map(|i| {
622                let store = Arc::clone(&store);
623                thread::spawn(move || {
624                    store
625                        .modify("shared", |c| c.attach(&format!("box-{i}")))
626                        .unwrap();
627                })
628            })
629            .collect();
630        for h in handles {
631            h.join().unwrap();
632        }
633
634        let cfg = store.get("shared").unwrap().unwrap();
635        assert_eq!(
636            cfg.in_use_by.len(),
637            n,
638            "every concurrent attach must persist (no lost update): {:?}",
639            cfg.in_use_by
640        );
641        for i in 0..n {
642            assert!(cfg.in_use_by.contains(&format!("box-{i}")));
643        }
644    }
645
646    #[test]
647    fn concurrent_creates_persist_every_volume() {
648        use std::sync::Arc;
649        use std::thread;
650
651        let dir = tempfile::tempdir().unwrap();
652        let store = Arc::new(VolumeStore::new(
653            dir.path().join("volumes.json"),
654            dir.path().join("volumes"),
655        ));
656
657        let n = 16;
658        let handles: Vec<_> = (0..n)
659            .map(|i| {
660                let store = Arc::clone(&store);
661                thread::spawn(move || {
662                    store
663                        .create(VolumeConfig::new(&format!("vol-{i}"), ""))
664                        .unwrap();
665                })
666            })
667            .collect();
668        for h in handles {
669            h.join().unwrap();
670        }
671
672        assert_eq!(
673            store.list().unwrap().len(),
674            n,
675            "every concurrent create must persist (no lost update)"
676        );
677    }
678
679    #[test]
680    fn corrupt_volumes_file_is_quarantined_not_fatal() {
681        let dir = tempfile::tempdir().unwrap();
682        let path = dir.path().join("volumes.json");
683        std::fs::write(&path, "{ not valid json").unwrap();
684        let store = VolumeStore::new(path.clone(), dir.path().join("volumes"));
685
686        // load() must succeed (empty) instead of erroring every volume op.
687        assert!(store.load().unwrap().is_empty());
688        let quarantined = std::fs::read_dir(dir.path())
689            .unwrap()
690            .filter_map(|e| e.ok())
691            .any(|e| {
692                e.file_name()
693                    .to_string_lossy()
694                    .contains("volumes.json.corrupt-")
695            });
696        assert!(quarantined, "corrupt volumes.json must be quarantined");
697    }
698}