Skip to main content

a3s_box_runtime/
snapshot.rs

1//! VM Snapshot Store — Save and restore VM configuration snapshots.
2//!
3//! Snapshots are stored as directories under `~/.a3s/snapshots/<id>/`:
4//! - `metadata.json` — SnapshotMetadata (config, resources, env, etc.)
5//! - `rootfs/` — Copy of the box's rootfs (or symlink to cache)
6//!
7//! Restore creates a new box from the saved configuration, leveraging
8//! rootfs caching for sub-500ms cold start.
9
10use std::cmp::Reverse;
11use std::path::{Path, PathBuf};
12
13use a3s_box_core::error::{BoxError, Result};
14use a3s_box_core::snapshot::SnapshotMetadata;
15use a3s_box_core::SnapshotStoreBackend;
16
17/// Persistent store for VM snapshots.
18pub struct SnapshotStore {
19    /// Root directory for all snapshots
20    base_dir: PathBuf,
21}
22
23impl SnapshotStore {
24    /// Create a new snapshot store at the given directory.
25    pub fn new(base_dir: &Path) -> Result<Self> {
26        std::fs::create_dir_all(base_dir).map_err(|e| {
27            BoxError::CacheError(format!(
28                "Failed to create snapshot directory {}: {}",
29                base_dir.display(),
30                e
31            ))
32        })?;
33        Ok(Self {
34            base_dir: base_dir.to_path_buf(),
35        })
36    }
37
38    /// Open the default snapshot store at `~/.a3s/snapshots`.
39    pub fn default_path() -> Result<Self> {
40        let home = a3s_box_core::dirs_home();
41        Self::new(&home.join("snapshots"))
42    }
43
44    /// Save a snapshot with the given metadata and rootfs source.
45    ///
46    /// Copies the rootfs directory into the snapshot bundle.
47    /// Returns the updated metadata with `size_bytes` populated.
48    pub fn save(
49        &self,
50        mut metadata: SnapshotMetadata,
51        rootfs_source: &Path,
52    ) -> Result<SnapshotMetadata> {
53        let snap_dir = self.base_dir.join(&metadata.id);
54        if snap_dir.exists() {
55            return Err(BoxError::CacheError(format!(
56                "Snapshot '{}' already exists",
57                metadata.id
58            )));
59        }
60
61        // Build the whole snapshot in a staging dir, then atomically rename it to
62        // `<id>/` only after metadata.json is written. A crash mid-save then
63        // leaves at most a `<id>.staging-*` dir (GC-able), never a partial
64        // `<id>/` that get/list ignore (they key on metadata.json) yet that
65        // blocks re-create and never prunes.
66        use std::sync::atomic::{AtomicU64, Ordering};
67        static STAGE_SEQ: AtomicU64 = AtomicU64::new(0);
68        let staging = self.base_dir.join(format!(
69            ".staging-{}-{}-{}",
70            metadata.id,
71            std::process::id(),
72            STAGE_SEQ.fetch_add(1, Ordering::Relaxed)
73        ));
74        let _ = std::fs::remove_dir_all(&staging);
75        std::fs::create_dir_all(&staging).map_err(|e| {
76            BoxError::CacheError(format!(
77                "Failed to create snapshot staging directory {}: {}",
78                staging.display(),
79                e
80            ))
81        })?;
82
83        // Copy rootfs if source exists
84        let rootfs_dest = staging.join("rootfs");
85        if rootfs_source.exists() {
86            copy_dir_recursive(rootfs_source, &rootfs_dest)?;
87        } else {
88            std::fs::create_dir_all(&rootfs_dest).map_err(|e| {
89                BoxError::CacheError(format!("Failed to create snapshot rootfs directory: {}", e))
90            })?;
91        }
92
93        // Calculate size
94        metadata.size_bytes = dir_size(&staging);
95
96        // Write metadata into the staging dir.
97        let meta_path = staging.join("metadata.json");
98        let json = serde_json::to_string_pretty(&metadata).map_err(|e| {
99            BoxError::SerializationError(format!("Failed to serialize snapshot metadata: {}", e))
100        })?;
101        std::fs::write(&meta_path, &json).map_err(|e| {
102            BoxError::CacheError(format!(
103                "Failed to write snapshot metadata {}: {}",
104                meta_path.display(),
105                e
106            ))
107        })?;
108
109        // Atomic publish: the snapshot becomes visible (with its metadata) in one
110        // step, or not at all.
111        std::fs::rename(&staging, &snap_dir).map_err(|e| {
112            let _ = std::fs::remove_dir_all(&staging);
113            BoxError::CacheError(format!(
114                "Failed to publish snapshot {}: {}",
115                snap_dir.display(),
116                e
117            ))
118        })?;
119
120        Ok(metadata)
121    }
122
123    /// Load snapshot metadata by ID.
124    pub fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
125        let meta_path = self.base_dir.join(id).join("metadata.json");
126        if !meta_path.exists() {
127            return Ok(None);
128        }
129
130        let data = std::fs::read_to_string(&meta_path).map_err(|e| {
131            BoxError::CacheError(format!(
132                "Failed to read snapshot metadata {}: {}",
133                meta_path.display(),
134                e
135            ))
136        })?;
137        let metadata: SnapshotMetadata = serde_json::from_str(&data).map_err(|e| {
138            BoxError::SerializationError(format!("Failed to parse snapshot metadata: {}", e))
139        })?;
140        Ok(Some(metadata))
141    }
142
143    /// Get the rootfs path for a snapshot.
144    pub fn rootfs_path(&self, id: &str) -> PathBuf {
145        self.base_dir.join(id).join("rootfs")
146    }
147
148    /// List all snapshots, sorted by creation time (newest first).
149    pub fn list(&self) -> Result<Vec<SnapshotMetadata>> {
150        let mut snapshots = Vec::new();
151
152        let entries = match std::fs::read_dir(&self.base_dir) {
153            Ok(entries) => entries,
154            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(snapshots),
155            Err(e) => {
156                return Err(BoxError::CacheError(format!(
157                    "Failed to read snapshot directory: {}",
158                    e
159                )));
160            }
161        };
162
163        for entry in entries {
164            let entry = entry.map_err(|e| {
165                BoxError::CacheError(format!("Failed to read snapshot entry: {}", e))
166            })?;
167            let meta_path = entry.path().join("metadata.json");
168            if meta_path.exists() {
169                match std::fs::read_to_string(&meta_path) {
170                    Ok(data) => match serde_json::from_str::<SnapshotMetadata>(&data) {
171                        Ok(meta) => snapshots.push(meta),
172                        // Don't silently skip: a metadata file that won't parse
173                        // makes the snapshot invisible to count/total_size/prune
174                        // while its rootfs keeps consuming disk. Surface it so it
175                        // can be diagnosed and cleaned up.
176                        Err(e) => tracing::warn!(
177                            path = %meta_path.display(),
178                            error = %e,
179                            "Skipping snapshot with unparseable metadata.json (orphaned on disk)"
180                        ),
181                    },
182                    Err(e) => tracing::warn!(
183                        path = %meta_path.display(),
184                        error = %e,
185                        "Skipping snapshot with unreadable metadata.json"
186                    ),
187                }
188            }
189        }
190
191        // Sort newest first
192        snapshots.sort_by_key(|snapshot| Reverse(snapshot.created_at));
193        Ok(snapshots)
194    }
195
196    /// Delete a snapshot by ID.
197    pub fn delete(&self, id: &str) -> Result<bool> {
198        let snap_dir = self.base_dir.join(id);
199        if !snap_dir.exists() {
200            return Ok(false);
201        }
202
203        std::fs::remove_dir_all(&snap_dir).map_err(|e| {
204            BoxError::CacheError(format!("Failed to delete snapshot {}: {}", id, e))
205        })?;
206        Ok(true)
207    }
208
209    /// Count the number of snapshots.
210    pub fn count(&self) -> Result<usize> {
211        Ok(self.list()?.len())
212    }
213
214    /// Calculate total size of all snapshots in bytes.
215    pub fn total_size(&self) -> Result<u64> {
216        Ok(self.list()?.iter().map(|s| s.size_bytes).sum())
217    }
218
219    /// Prune old snapshots to stay within limits.
220    ///
221    /// Removes oldest snapshots first until both `max_count` and `max_bytes`
222    /// constraints are satisfied. A value of 0 means unlimited.
223    pub fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
224        let mut snapshots = self.list()?;
225        let mut removed = Vec::new();
226
227        // Snapshots are sorted newest-first; remove from the end (oldest)
228        while !snapshots.is_empty() {
229            let over_count = max_count > 0 && snapshots.len() > max_count;
230            let total: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
231            let over_size = max_bytes > 0 && total > max_bytes;
232
233            if !over_count && !over_size {
234                break;
235            }
236
237            // Remove the oldest (last in the list)
238            if let Some(oldest) = snapshots.pop() {
239                self.delete(&oldest.id)?;
240                removed.push(oldest.id);
241            }
242        }
243
244        Ok(removed)
245    }
246}
247
248/// Recursively copy a directory.
249fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
250    std::fs::create_dir_all(dst).map_err(|e| {
251        BoxError::CacheError(format!(
252            "Failed to create directory {}: {}",
253            dst.display(),
254            e
255        ))
256    })?;
257
258    for entry in std::fs::read_dir(src).map_err(|e| {
259        BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
260    })? {
261        let entry = entry
262            .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
263        let src_path = entry.path();
264        let dst_path = dst.join(entry.file_name());
265        let file_type = entry.file_type().map_err(|e| {
266            BoxError::CacheError(format!(
267                "Failed to read file type for {}: {}",
268                src_path.display(),
269                e
270            ))
271        })?;
272
273        if file_type.is_symlink() {
274            copy_symlink(&src_path, &dst_path)?;
275        } else if file_type.is_dir() {
276            copy_dir_recursive(&src_path, &dst_path)?;
277        } else {
278            std::fs::copy(&src_path, &dst_path).map_err(|e| {
279                BoxError::CacheError(format!(
280                    "Failed to copy {} → {}: {}",
281                    src_path.display(),
282                    dst_path.display(),
283                    e
284                ))
285            })?;
286        }
287    }
288
289    Ok(())
290}
291
292fn copy_symlink(src: &Path, dst: &Path) -> Result<()> {
293    let target = std::fs::read_link(src).map_err(|e| {
294        BoxError::CacheError(format!("Failed to read symlink {}: {}", src.display(), e))
295    })?;
296
297    #[cfg(unix)]
298    {
299        std::os::unix::fs::symlink(&target, dst).map_err(|e| {
300            BoxError::CacheError(format!(
301                "Failed to create symlink {} → {}: {}",
302                dst.display(),
303                target.display(),
304                e
305            ))
306        })?;
307    }
308
309    #[cfg(windows)]
310    {
311        let is_dir = src.metadata().map(|m| m.is_dir()).unwrap_or(false);
312        let result = if is_dir {
313            std::os::windows::fs::symlink_dir(&target, dst)
314        } else {
315            std::os::windows::fs::symlink_file(&target, dst)
316        };
317        result.map_err(|e| {
318            BoxError::CacheError(format!(
319                "Failed to create symlink {} → {}: {}",
320                dst.display(),
321                target.display(),
322                e
323            ))
324        })?;
325    }
326
327    #[cfg(not(any(unix, windows)))]
328    {
329        let _ = target;
330        return Err(BoxError::CacheError(format!(
331            "Symlink copy is not supported on this platform: {}",
332            src.display()
333        )));
334    }
335
336    Ok(())
337}
338
339/// Calculate the total size of a directory recursively.
340fn dir_size(path: &Path) -> u64 {
341    let mut total = 0u64;
342    if let Ok(entries) = std::fs::read_dir(path) {
343        for entry in entries.flatten() {
344            let p = entry.path();
345            if p.is_dir() {
346                total += dir_size(&p);
347            } else if let Ok(meta) = p.metadata() {
348                total += meta.len();
349            }
350        }
351    }
352    total
353}
354
355impl SnapshotStoreBackend for SnapshotStore {
356    fn save(&self, metadata: SnapshotMetadata, rootfs_source: &Path) -> Result<SnapshotMetadata> {
357        self.save(metadata, rootfs_source)
358    }
359
360    fn get(&self, id: &str) -> Result<Option<SnapshotMetadata>> {
361        self.get(id)
362    }
363
364    fn list(&self) -> Result<Vec<SnapshotMetadata>> {
365        self.list()
366    }
367
368    fn delete(&self, id: &str) -> Result<bool> {
369        self.delete(id)
370    }
371
372    fn count(&self) -> Result<usize> {
373        self.count()
374    }
375
376    fn total_size(&self) -> Result<u64> {
377        self.total_size()
378    }
379
380    fn prune(&self, max_count: usize, max_bytes: u64) -> Result<Vec<String>> {
381        self.prune(max_count, max_bytes)
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use tempfile::TempDir;
389
390    fn make_metadata(id: &str, name: &str) -> SnapshotMetadata {
391        SnapshotMetadata::new(
392            id.to_string(),
393            name.to_string(),
394            "box-source".to_string(),
395            "alpine:latest".to_string(),
396        )
397    }
398
399    fn make_rootfs(tmp: &TempDir) -> PathBuf {
400        let rootfs = tmp.path().join("rootfs");
401        std::fs::create_dir_all(&rootfs).unwrap();
402        std::fs::write(rootfs.join("bin.sh"), "#!/bin/sh\necho hello").unwrap();
403        std::fs::create_dir_all(rootfs.join("etc")).unwrap();
404        std::fs::write(rootfs.join("etc/config"), "key=value").unwrap();
405        rootfs
406    }
407
408    #[test]
409    fn test_snapshot_store_new() {
410        let tmp = TempDir::new().unwrap();
411        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
412        assert!(store.base_dir.exists());
413    }
414
415    #[test]
416    fn test_snapshot_save_and_get() {
417        let tmp = TempDir::new().unwrap();
418        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
419        let rootfs = make_rootfs(&tmp);
420
421        let meta = make_metadata("snap-1", "first");
422        let saved = store.save(meta, &rootfs).unwrap();
423
424        assert_eq!(saved.id, "snap-1");
425        assert!(saved.size_bytes > 0);
426
427        let loaded = store.get("snap-1").unwrap().unwrap();
428        assert_eq!(loaded.id, "snap-1");
429        assert_eq!(loaded.name, "first");
430        assert_eq!(loaded.image, "alpine:latest");
431        assert_eq!(loaded.size_bytes, saved.size_bytes);
432    }
433
434    #[test]
435    fn test_snapshot_get_nonexistent() {
436        let tmp = TempDir::new().unwrap();
437        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
438        assert!(store.get("nonexistent").unwrap().is_none());
439    }
440
441    #[test]
442    fn test_snapshot_save_duplicate_fails() {
443        let tmp = TempDir::new().unwrap();
444        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
445        let rootfs = make_rootfs(&tmp);
446
447        let meta = make_metadata("snap-dup", "dup");
448        store.save(meta.clone(), &rootfs).unwrap();
449
450        let result = store.save(meta, &rootfs);
451        assert!(result.is_err());
452        assert!(result.unwrap_err().to_string().contains("already exists"));
453    }
454
455    #[test]
456    fn test_snapshot_rootfs_copied() {
457        let tmp = TempDir::new().unwrap();
458        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
459        let rootfs = make_rootfs(&tmp);
460
461        let meta = make_metadata("snap-fs", "fs-test");
462        store.save(meta, &rootfs).unwrap();
463
464        let snap_rootfs = store.rootfs_path("snap-fs");
465        assert!(snap_rootfs.join("bin.sh").exists());
466        assert!(snap_rootfs.join("etc/config").exists());
467        assert_eq!(
468            std::fs::read_to_string(snap_rootfs.join("etc/config")).unwrap(),
469            "key=value"
470        );
471    }
472
473    #[test]
474    fn test_snapshot_list_empty() {
475        let tmp = TempDir::new().unwrap();
476        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
477        let list = store.list().unwrap();
478        assert!(list.is_empty());
479    }
480
481    #[test]
482    fn test_snapshot_list_multiple() {
483        let tmp = TempDir::new().unwrap();
484        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
485        let rootfs = make_rootfs(&tmp);
486
487        for i in 0..3 {
488            let meta = make_metadata(&format!("snap-{}", i), &format!("snap-{}", i));
489            store.save(meta, &rootfs).unwrap();
490            std::thread::sleep(std::time::Duration::from_millis(10));
491        }
492
493        let list = store.list().unwrap();
494        assert_eq!(list.len(), 3);
495        // Newest first
496        assert_eq!(list[0].id, "snap-2");
497        assert_eq!(list[2].id, "snap-0");
498    }
499
500    #[test]
501    fn test_snapshot_delete() {
502        let tmp = TempDir::new().unwrap();
503        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
504        let rootfs = make_rootfs(&tmp);
505
506        let meta = make_metadata("snap-del", "delete-me");
507        store.save(meta, &rootfs).unwrap();
508
509        assert!(store.delete("snap-del").unwrap());
510        assert!(store.get("snap-del").unwrap().is_none());
511    }
512
513    #[test]
514    fn test_snapshot_delete_nonexistent() {
515        let tmp = TempDir::new().unwrap();
516        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
517        assert!(!store.delete("nope").unwrap());
518    }
519
520    #[test]
521    fn test_snapshot_count() {
522        let tmp = TempDir::new().unwrap();
523        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
524        let rootfs = make_rootfs(&tmp);
525
526        assert_eq!(store.count().unwrap(), 0);
527
528        store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
529        store.save(make_metadata("s2", "s2"), &rootfs).unwrap();
530        assert_eq!(store.count().unwrap(), 2);
531    }
532
533    #[test]
534    fn test_snapshot_total_size() {
535        let tmp = TempDir::new().unwrap();
536        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
537        let rootfs = make_rootfs(&tmp);
538
539        store.save(make_metadata("s1", "s1"), &rootfs).unwrap();
540        let total = store.total_size().unwrap();
541        assert!(total > 0);
542    }
543
544    #[test]
545    fn test_snapshot_prune_by_count() {
546        let tmp = TempDir::new().unwrap();
547        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
548        let rootfs = make_rootfs(&tmp);
549
550        for i in 0..5 {
551            let meta = make_metadata(&format!("s{}", i), &format!("s{}", i));
552            store.save(meta, &rootfs).unwrap();
553            std::thread::sleep(std::time::Duration::from_millis(10));
554        }
555
556        let removed = store.prune(3, 0).unwrap();
557        assert_eq!(removed.len(), 2);
558        assert_eq!(store.count().unwrap(), 3);
559
560        // Oldest should be removed
561        assert!(store.get("s0").unwrap().is_none());
562        assert!(store.get("s1").unwrap().is_none());
563        // Newest should remain
564        assert!(store.get("s4").unwrap().is_some());
565        assert!(store.get("s3").unwrap().is_some());
566        assert!(store.get("s2").unwrap().is_some());
567    }
568
569    #[test]
570    fn test_snapshot_prune_no_limits() {
571        let tmp = TempDir::new().unwrap();
572        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
573        let rootfs = make_rootfs(&tmp);
574
575        for i in 0..3 {
576            store
577                .save(
578                    make_metadata(&format!("s{}", i), &format!("s{}", i)),
579                    &rootfs,
580                )
581                .unwrap();
582        }
583
584        let removed = store.prune(0, 0).unwrap();
585        assert!(removed.is_empty());
586        assert_eq!(store.count().unwrap(), 3);
587    }
588
589    #[test]
590    fn test_snapshot_save_with_empty_rootfs() {
591        let tmp = TempDir::new().unwrap();
592        let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap();
593        let empty_rootfs = tmp.path().join("nonexistent_rootfs");
594
595        let meta = make_metadata("snap-empty", "empty");
596        let saved = store.save(meta, &empty_rootfs).unwrap();
597        assert_eq!(saved.id, "snap-empty");
598
599        // Rootfs dir should still be created (empty)
600        assert!(store.rootfs_path("snap-empty").exists());
601    }
602
603    #[test]
604    fn test_dir_size() {
605        let tmp = TempDir::new().unwrap();
606        let dir = tmp.path().join("sized");
607        std::fs::create_dir_all(&dir).unwrap();
608        std::fs::write(dir.join("a.txt"), "hello").unwrap();
609        std::fs::write(dir.join("b.txt"), "world!").unwrap();
610
611        let size = dir_size(&dir);
612        assert_eq!(size, 11); // 5 + 6
613    }
614
615    #[test]
616    fn test_dir_size_nested() {
617        let tmp = TempDir::new().unwrap();
618        let dir = tmp.path().join("nested");
619        std::fs::create_dir_all(dir.join("sub")).unwrap();
620        std::fs::write(dir.join("a.txt"), "abc").unwrap();
621        std::fs::write(dir.join("sub/b.txt"), "defgh").unwrap();
622
623        let size = dir_size(&dir);
624        assert_eq!(size, 8); // 3 + 5
625    }
626
627    #[test]
628    fn test_dir_size_empty() {
629        let tmp = TempDir::new().unwrap();
630        let dir = tmp.path().join("empty");
631        std::fs::create_dir_all(&dir).unwrap();
632        assert_eq!(dir_size(&dir), 0);
633    }
634
635    #[test]
636    fn test_copy_dir_recursive() {
637        let tmp = TempDir::new().unwrap();
638        let src = tmp.path().join("src");
639        let dst = tmp.path().join("dst");
640
641        std::fs::create_dir_all(src.join("sub")).unwrap();
642        std::fs::write(src.join("a.txt"), "hello").unwrap();
643        std::fs::write(src.join("sub/b.txt"), "world").unwrap();
644
645        copy_dir_recursive(&src, &dst).unwrap();
646
647        assert!(dst.join("a.txt").exists());
648        assert!(dst.join("sub/b.txt").exists());
649        assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
650        assert_eq!(
651            std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
652            "world"
653        );
654    }
655}