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        let file: VolumesFile = serde_json::from_str(&data).map_err(|e| {
57            BoxError::SerializationError(format!("failed to parse volumes file: {}", e))
58        })?;
59
60        Ok(file.volumes)
61    }
62
63    /// Save all volumes to disk (atomic write).
64    pub fn save(&self, volumes: &HashMap<String, VolumeConfig>) -> Result<()> {
65        if let Some(parent) = self.path.parent() {
66            std::fs::create_dir_all(parent).map_err(|e| {
67                BoxError::ConfigError(format!(
68                    "failed to create directory {}: {}",
69                    parent.display(),
70                    e
71                ))
72            })?;
73        }
74
75        let file = VolumesFile {
76            volumes: volumes.clone(),
77        };
78
79        let json = serde_json::to_string_pretty(&file).map_err(|e| {
80            BoxError::SerializationError(format!("failed to serialize volumes: {}", e))
81        })?;
82
83        let tmp_path = self.path.with_extension("json.tmp");
84        std::fs::write(&tmp_path, &json).map_err(|e| {
85            BoxError::ConfigError(format!(
86                "failed to write tmp file {}: {}",
87                tmp_path.display(),
88                e
89            ))
90        })?;
91
92        std::fs::rename(&tmp_path, &self.path).map_err(|e| {
93            BoxError::ConfigError(format!(
94                "failed to rename {} → {}: {}",
95                tmp_path.display(),
96                self.path.display(),
97                e
98            ))
99        })?;
100
101        Ok(())
102    }
103
104    /// Run `f` over the volume map under a cross-process advisory lock,
105    /// re-loading fresh from disk inside the lock and saving the result.
106    ///
107    /// `create`/`remove`/`update`/`modify`/`get_or_create` all funnel through
108    /// here so concurrent `a3s-box` processes cannot lose each other's writes.
109    /// The atomic tmp+rename in `save` only prevents a *torn* read — two
110    /// processes that both load, mutate a different entry, and save would still
111    /// clobber one update (and, for attach/detach, silently drop a volume's
112    /// `in_use_by` entry, letting `prune`/`remove` delete data a live box still
113    /// has mounted). `save` itself stays lock-free: the guard is held here for
114    /// the whole load → mutate → save, and the lock is non-reentrant.
115    fn with_write_lock<F, R>(&self, f: F) -> Result<R>
116    where
117        F: FnOnce(&mut HashMap<String, VolumeConfig>) -> Result<R>,
118    {
119        let _lock = crate::file_lock::FileLock::acquire(&self.path).map_err(|e| {
120            BoxError::ConfigError(format!(
121                "failed to lock volumes file {}: {e}",
122                self.path.display()
123            ))
124        })?;
125        let mut volumes = self.load()?;
126        let r = f(&mut volumes)?;
127        self.save(&volumes)?;
128        Ok(r)
129    }
130
131    /// Get a single volume by name.
132    pub fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
133        let volumes = self.load()?;
134        Ok(volumes.get(name).cloned())
135    }
136
137    /// Create a new named volume. Returns the host mount point path.
138    ///
139    /// Creates the volume data directory under `~/.a3s/volumes/<name>/`.
140    /// Errors if the name already exists (use [`Self::get_or_create`] for the
141    /// idempotent auto-create on the `run -v name:/path` path).
142    pub fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
143        self.with_write_lock(|volumes| {
144            if volumes.contains_key(&config.name) {
145                return Err(BoxError::ConfigError(format!(
146                    "volume '{}' already exists",
147                    config.name
148                )));
149            }
150            self.materialize(config, volumes)
151        })
152    }
153
154    /// Return the existing volume, or create it if absent — atomic under the
155    /// cross-process lock. Two concurrent first-time `run -v name:/path` then
156    /// share one volume instead of one racing to an "already exists" error.
157    pub fn get_or_create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
158        self.with_write_lock(|volumes| {
159            if let Some(existing) = volumes.get(&config.name) {
160                return Ok(existing.clone());
161            }
162            self.materialize(config, volumes)
163        })
164    }
165
166    /// Create the volume's data directory, set its mount point, and insert it
167    /// into `volumes`. Caller must already hold the write lock.
168    fn materialize(
169        &self,
170        mut config: VolumeConfig,
171        volumes: &mut HashMap<String, VolumeConfig>,
172    ) -> Result<VolumeConfig> {
173        let vol_dir = self.volumes_dir.join(&config.name);
174        std::fs::create_dir_all(&vol_dir).map_err(|e| {
175            BoxError::ConfigError(format!(
176                "failed to create volume directory {}: {}",
177                vol_dir.display(),
178                e
179            ))
180        })?;
181        config.mount_point = vol_dir.to_string_lossy().into_owned();
182        volumes.insert(config.name.clone(), config.clone());
183        Ok(config)
184    }
185
186    /// Remove a volume by name. Returns error if in use.
187    pub fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
188        let config = self.with_write_lock(|volumes| {
189            let config = volumes
190                .remove(name)
191                .ok_or_else(|| BoxError::ConfigError(format!("volume '{}' not found", name)))?;
192
193            if config.is_in_use() && !force {
194                // Put it back
195                volumes.insert(name.to_string(), config.clone());
196                return Err(BoxError::ConfigError(format!(
197                    "volume '{}' is in use by {} box(es); use --force to remove",
198                    name,
199                    config.in_use_by.len()
200                )));
201            }
202            Ok(config)
203        })?;
204
205        // Remove the data directory outside the lock; it is keyed by name and
206        // the removal is idempotent.
207        let vol_dir = self.volumes_dir.join(name);
208        if vol_dir.exists() {
209            std::fs::remove_dir_all(&vol_dir).ok();
210        }
211
212        Ok(config)
213    }
214
215    /// List all volumes.
216    pub fn list(&self) -> Result<Vec<VolumeConfig>> {
217        let volumes = self.load()?;
218        Ok(volumes.into_values().collect())
219    }
220
221    /// Replace a volume's config wholesale under the cross-process lock.
222    ///
223    /// For attach/detach prefer [`Self::modify`]: a `get` → mutate → `update`
224    /// reads outside the lock and would lose a concurrent update made between
225    /// the two calls.
226    pub fn update(&self, config: &VolumeConfig) -> Result<()> {
227        self.with_write_lock(|volumes| {
228            if !volumes.contains_key(&config.name) {
229                return Err(BoxError::ConfigError(format!(
230                    "volume '{}' not found",
231                    config.name
232                )));
233            }
234            volumes.insert(config.name.clone(), config.clone());
235            Ok(())
236        })
237    }
238
239    /// Atomically mutate one volume's config under the cross-process lock.
240    ///
241    /// Re-reads the current entry inside the lock so concurrent attach/detach
242    /// accumulate correctly — the canonical fix for the split `get` → mutate →
243    /// `update` race that could drop a volume's `in_use_by` entry. Returns
244    /// `false` if the volume does not exist.
245    pub fn modify<F>(&self, name: &str, f: F) -> Result<bool>
246    where
247        F: FnOnce(&mut VolumeConfig),
248    {
249        self.with_write_lock(|volumes| match volumes.get_mut(name) {
250            Some(config) => {
251                f(config);
252                Ok(true)
253            }
254            None => Ok(false),
255        })
256    }
257
258    /// Remove all volumes that are not in use. Returns names of removed volumes.
259    pub fn prune(&self) -> Result<Vec<String>> {
260        let volumes = self.load()?;
261        let mut pruned = Vec::new();
262
263        for (name, config) in &volumes {
264            if !config.is_in_use() {
265                pruned.push(name.clone());
266            }
267        }
268
269        for name in &pruned {
270            self.remove(name, false).ok();
271        }
272
273        Ok(pruned)
274    }
275
276    /// Get the volume data directory for a named volume.
277    pub fn volume_dir(&self, name: &str) -> PathBuf {
278        self.volumes_dir.join(name)
279    }
280
281    /// Get the store file path.
282    pub fn path(&self) -> &Path {
283        &self.path
284    }
285}
286
287impl a3s_box_core::traits::VolumeStoreBackend for VolumeStore {
288    fn get(&self, name: &str) -> Result<Option<VolumeConfig>> {
289        self.get(name)
290    }
291
292    fn create(&self, config: VolumeConfig) -> Result<VolumeConfig> {
293        self.create(config)
294    }
295
296    fn remove(&self, name: &str, force: bool) -> Result<VolumeConfig> {
297        self.remove(name, force)
298    }
299
300    fn list(&self) -> Result<Vec<VolumeConfig>> {
301        self.list()
302    }
303
304    fn update(&self, config: &VolumeConfig) -> Result<()> {
305        self.update(config)
306    }
307
308    fn prune(&self) -> Result<Vec<String>> {
309        self.prune()
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn temp_store() -> (tempfile::TempDir, VolumeStore) {
318        let dir = tempfile::tempdir().unwrap();
319        let store = VolumeStore::new(dir.path().join("volumes.json"), dir.path().join("volumes"));
320        (dir, store)
321    }
322
323    #[test]
324    fn test_load_empty() {
325        let (_dir, store) = temp_store();
326        let volumes = store.load().unwrap();
327        assert!(volumes.is_empty());
328    }
329
330    #[test]
331    fn test_create_and_load() {
332        let (_dir, store) = temp_store();
333        let vol = VolumeConfig::new("mydata", "");
334        store.create(vol).unwrap();
335
336        let volumes = store.load().unwrap();
337        assert_eq!(volumes.len(), 1);
338        assert!(volumes.contains_key("mydata"));
339    }
340
341    #[test]
342    fn test_create_sets_mount_point() {
343        let (_dir, store) = temp_store();
344        let vol = VolumeConfig::new("mydata", "");
345        let created = store.create(vol).unwrap();
346
347        assert!(created.mount_point.contains("mydata"));
348        assert!(PathBuf::from(&created.mount_point).exists());
349    }
350
351    #[test]
352    fn test_create_duplicate() {
353        let (_dir, store) = temp_store();
354        let v1 = VolumeConfig::new("mydata", "");
355        let v2 = VolumeConfig::new("mydata", "");
356
357        store.create(v1).unwrap();
358        assert!(store.create(v2).is_err());
359    }
360
361    #[test]
362    fn test_get_existing() {
363        let (_dir, store) = temp_store();
364        store.create(VolumeConfig::new("mydata", "")).unwrap();
365
366        let found = store.get("mydata").unwrap();
367        assert!(found.is_some());
368        assert_eq!(found.unwrap().name, "mydata");
369    }
370
371    #[test]
372    fn test_get_nonexistent() {
373        let (_dir, store) = temp_store();
374        let found = store.get("nope").unwrap();
375        assert!(found.is_none());
376    }
377
378    #[test]
379    fn test_remove() {
380        let (_dir, store) = temp_store();
381        store.create(VolumeConfig::new("mydata", "")).unwrap();
382
383        let removed = store.remove("mydata", false).unwrap();
384        assert_eq!(removed.name, "mydata");
385
386        let volumes = store.load().unwrap();
387        assert!(volumes.is_empty());
388    }
389
390    #[test]
391    fn test_remove_nonexistent() {
392        let (_dir, store) = temp_store();
393        assert!(store.remove("nope", false).is_err());
394    }
395
396    #[test]
397    fn test_remove_in_use_fails() {
398        let (_dir, store) = temp_store();
399        let mut vol = VolumeConfig::new("mydata", "");
400        vol.attach("box-1");
401        // Manually insert since create() doesn't set in_use_by
402        let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
403        let mut updated = created;
404        updated.attach("box-1");
405        store.update(&updated).unwrap();
406
407        assert!(store.remove("mydata", false).is_err());
408    }
409
410    #[test]
411    fn test_remove_in_use_force() {
412        let (_dir, store) = temp_store();
413        let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
414        let mut updated = created;
415        updated.attach("box-1");
416        store.update(&updated).unwrap();
417
418        let removed = store.remove("mydata", true).unwrap();
419        assert_eq!(removed.name, "mydata");
420    }
421
422    #[test]
423    fn test_list() {
424        let (_dir, store) = temp_store();
425        store.create(VolumeConfig::new("vol1", "")).unwrap();
426        store.create(VolumeConfig::new("vol2", "")).unwrap();
427
428        let list = store.list().unwrap();
429        assert_eq!(list.len(), 2);
430    }
431
432    #[test]
433    fn test_update() {
434        let (_dir, store) = temp_store();
435        let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
436
437        let mut updated = created;
438        updated.attach("box-1");
439        store.update(&updated).unwrap();
440
441        let loaded = store.get("mydata").unwrap().unwrap();
442        assert_eq!(loaded.in_use_by, vec!["box-1"]);
443    }
444
445    #[test]
446    fn test_update_nonexistent() {
447        let (_dir, store) = temp_store();
448        let vol = VolumeConfig::new("nope", "/tmp");
449        assert!(store.update(&vol).is_err());
450    }
451
452    #[test]
453    fn test_prune() {
454        let (_dir, store) = temp_store();
455        store.create(VolumeConfig::new("unused1", "")).unwrap();
456        store.create(VolumeConfig::new("unused2", "")).unwrap();
457
458        let created = store.create(VolumeConfig::new("in_use", "")).unwrap();
459        let mut updated = created;
460        updated.attach("box-1");
461        store.update(&updated).unwrap();
462
463        let pruned = store.prune().unwrap();
464        assert_eq!(pruned.len(), 2);
465        assert!(pruned.contains(&"unused1".to_string()));
466        assert!(pruned.contains(&"unused2".to_string()));
467
468        // in_use should remain
469        let remaining = store.list().unwrap();
470        assert_eq!(remaining.len(), 1);
471        assert_eq!(remaining[0].name, "in_use");
472    }
473
474    #[test]
475    fn test_atomic_write() {
476        let (_dir, store) = temp_store();
477        store.create(VolumeConfig::new("mydata", "")).unwrap();
478
479        let data = std::fs::read_to_string(store.path()).unwrap();
480        let _: serde_json::Value = serde_json::from_str(&data).unwrap();
481
482        let tmp = store.path().with_extension("json.tmp");
483        assert!(!tmp.exists());
484    }
485
486    #[test]
487    fn test_creates_parent_directory() {
488        let dir = tempfile::tempdir().unwrap();
489        let store = VolumeStore::new(
490            dir.path().join("subdir").join("volumes.json"),
491            dir.path().join("subdir").join("volumes"),
492        );
493
494        store.create(VolumeConfig::new("mydata", "")).unwrap();
495        assert!(store.path().exists());
496    }
497
498    #[test]
499    fn test_remove_cleans_up_directory() {
500        let (_dir, store) = temp_store();
501        let created = store.create(VolumeConfig::new("mydata", "")).unwrap();
502        let vol_dir = PathBuf::from(&created.mount_point);
503        assert!(vol_dir.exists());
504
505        store.remove("mydata", false).unwrap();
506        assert!(!vol_dir.exists());
507    }
508
509    #[test]
510    fn test_get_or_create_is_idempotent() {
511        let (_dir, store) = temp_store();
512        let first = store
513            .get_or_create(VolumeConfig::new("shared", ""))
514            .unwrap();
515        let second = store
516            .get_or_create(VolumeConfig::new("shared", ""))
517            .unwrap();
518        assert_eq!(first.mount_point, second.mount_point);
519        assert_eq!(store.list().unwrap().len(), 1);
520    }
521
522    #[test]
523    fn test_modify_missing_returns_false() {
524        let (_dir, store) = temp_store();
525        assert!(!store.modify("nope", |c| c.attach("box-1")).unwrap());
526    }
527
528    // The advisory lock is per-open-file-description, so separate
529    // FileLock::acquire calls serialize even across threads in one process —
530    // which is exactly what lets this exercise the lost-update fix in-process.
531    #[test]
532    fn concurrent_attaches_accumulate_without_lost_update() {
533        use std::sync::Arc;
534        use std::thread;
535
536        let dir = tempfile::tempdir().unwrap();
537        let store = Arc::new(VolumeStore::new(
538            dir.path().join("volumes.json"),
539            dir.path().join("volumes"),
540        ));
541        store.create(VolumeConfig::new("shared", "")).unwrap();
542
543        let n = 16;
544        let handles: Vec<_> = (0..n)
545            .map(|i| {
546                let store = Arc::clone(&store);
547                thread::spawn(move || {
548                    store
549                        .modify("shared", |c| c.attach(&format!("box-{i}")))
550                        .unwrap();
551                })
552            })
553            .collect();
554        for h in handles {
555            h.join().unwrap();
556        }
557
558        let cfg = store.get("shared").unwrap().unwrap();
559        assert_eq!(
560            cfg.in_use_by.len(),
561            n,
562            "every concurrent attach must persist (no lost update): {:?}",
563            cfg.in_use_by
564        );
565        for i in 0..n {
566            assert!(cfg.in_use_by.contains(&format!("box-{i}")));
567        }
568    }
569
570    #[test]
571    fn concurrent_creates_persist_every_volume() {
572        use std::sync::Arc;
573        use std::thread;
574
575        let dir = tempfile::tempdir().unwrap();
576        let store = Arc::new(VolumeStore::new(
577            dir.path().join("volumes.json"),
578            dir.path().join("volumes"),
579        ));
580
581        let n = 16;
582        let handles: Vec<_> = (0..n)
583            .map(|i| {
584                let store = Arc::clone(&store);
585                thread::spawn(move || {
586                    store
587                        .create(VolumeConfig::new(&format!("vol-{i}"), ""))
588                        .unwrap();
589                })
590            })
591            .collect();
592        for h in handles {
593            h.join().unwrap();
594        }
595
596        assert_eq!(
597            store.list().unwrap().len(),
598            n,
599            "every concurrent create must persist (no lost update)"
600        );
601    }
602}