Skip to main content

a3s_box_runtime/rootfs/
provider.rs

1//! Rootfs provider — abstracts how a rootfs directory is prepared for a box.
2//!
3//! Two built-in providers:
4//! - `CopyProvider` — full recursive copy (works everywhere, current default)
5//! - `OverlayProvider` — Linux overlayfs mount (near-instant, CoW)
6
7use std::path::{Path, PathBuf};
8
9use a3s_box_core::error::{BoxError, Result};
10
11/// Abstracts how a rootfs directory is prepared for a box from a cached lower layer.
12pub trait RootfsProvider: Send + Sync {
13    /// Prepare a rootfs at `box_dir` from the cached read-only layer at `cache_dir`.
14    /// Returns the path to use as `InstanceSpec.rootfs_path`.
15    fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf>;
16
17    /// Cleanup after box stops.
18    ///
19    /// When `persistent` is true, the writable layer (overlay upper dir or copy
20    /// rootfs) is preserved on disk so changes survive the next start.
21    /// When false, the writable layer is wiped for a clean slate.
22    fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()>;
23
24    /// Human-readable name for logging.
25    fn name(&self) -> &'static str;
26}
27
28/// Full recursive copy provider — works on all platforms.
29///
30/// This is the original behavior: copies the entire cached rootfs into
31/// `box_dir/rootfs/`. Safe but slow for large images.
32pub struct CopyProvider;
33
34impl RootfsProvider for CopyProvider {
35    fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf> {
36        let rootfs = box_dir.join("rootfs");
37        // Reuse existing rootfs when persistent and already populated
38        if rootfs.exists() {
39            tracing::info!(path = %rootfs.display(), "Reusing persistent rootfs");
40            return Ok(rootfs);
41        }
42        crate::cache::layer_cache::copy_dir_recursive(cache_dir, &rootfs)?;
43        Ok(rootfs)
44    }
45
46    fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()> {
47        if persistent {
48            tracing::info!("Persistent box: keeping rootfs on disk");
49            return Ok(());
50        }
51        let rootfs = box_dir.join("rootfs");
52        if rootfs.exists() {
53            std::fs::remove_dir_all(&rootfs).map_err(|e| {
54                BoxError::BuildError(format!(
55                    "Failed to remove rootfs {}: {}",
56                    rootfs.display(),
57                    e
58                ))
59            })?;
60        }
61        Ok(())
62    }
63
64    fn name(&self) -> &'static str {
65        "copy"
66    }
67}
68
69/// Overlayfs provider — near-instant CoW mounts (Linux only).
70///
71/// Layout:
72/// ```text
73/// cache_dir/           ← lower (read-only, shared across boxes)
74/// box_dir/upper/       ← upper (per-box writes)
75/// box_dir/work/        ← overlayfs workdir
76/// box_dir/merged/      ← merged view → InstanceSpec.rootfs_path
77/// ```
78pub struct OverlayProvider;
79
80impl RootfsProvider for OverlayProvider {
81    fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result<PathBuf> {
82        let upper = box_dir.join("upper");
83        let work = box_dir.join("work");
84        let merged = box_dir.join("merged");
85
86        for dir in [&upper, &work, &merged] {
87            std::fs::create_dir_all(dir).map_err(|e| {
88                BoxError::BuildError(format!(
89                    "Failed to create overlay dir {}: {}",
90                    dir.display(),
91                    e
92                ))
93            })?;
94        }
95
96        // Idempotent: a restart re-runs prepare(); without this guard each call
97        // stacks another overlay on `merged` (the leaked double/triple mounts).
98        if super::is_mountpoint(&merged) {
99            tracing::debug!(merged = %merged.display(), "Overlay already mounted; reusing");
100            return Ok(merged);
101        }
102
103        super::overlay::overlay_mount(cache_dir, &upper, &work, &merged)?;
104
105        tracing::info!(
106            lower = %cache_dir.display(),
107            merged = %merged.display(),
108            "Overlay mount ready"
109        );
110
111        Ok(merged)
112    }
113
114    fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()> {
115        let merged = box_dir.join("merged");
116        // Bounded unmount-retry rather than a single attempt: a transient EBUSY
117        // must not leave the overlay mounted, or the remove_dir_all below would
118        // recurse into the live mount and leak it. Mirrors the cleanup paths in
119        // cleanup_stopped_box/cleanup_removed_box.
120        super::unmount_box_overlay(&merged);
121
122        if persistent {
123            // Keep upper (writes) and remove only merged/work (not needed at rest)
124            tracing::info!("Persistent box: keeping overlay upper layer on disk");
125            for dir_name in &["merged", "work"] {
126                let dir = box_dir.join(dir_name);
127                if dir.exists() {
128                    if let Err(e) = std::fs::remove_dir_all(&dir) {
129                        tracing::warn!(path = %dir.display(), error = %e, "Failed to remove overlay dir");
130                    }
131                }
132            }
133            return Ok(());
134        }
135
136        for dir_name in &["upper", "work", "merged"] {
137            let dir = box_dir.join(dir_name);
138            if dir.exists() {
139                if let Err(e) = std::fs::remove_dir_all(&dir) {
140                    tracing::warn!(
141                        path = %dir.display(),
142                        error = %e,
143                        "Failed to remove overlay dir"
144                    );
145                }
146            }
147        }
148
149        Ok(())
150    }
151
152    fn name(&self) -> &'static str {
153        "overlay"
154    }
155}
156
157/// Auto-detect the best available rootfs provider for the current platform.
158pub fn default_provider() -> Box<dyn RootfsProvider> {
159    if super::overlay::is_overlay_supported() {
160        tracing::info!("Using overlayfs rootfs provider");
161        return Box::new(OverlayProvider);
162    }
163
164    tracing::info!("Overlayfs not available, using copy provider");
165    Box::new(CopyProvider)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use tempfile::TempDir;
172
173    fn make_sample_rootfs(dir: &Path) {
174        std::fs::create_dir_all(dir.join("etc")).unwrap();
175        std::fs::create_dir_all(dir.join("bin")).unwrap();
176        std::fs::write(dir.join("etc/hostname"), "testbox").unwrap();
177        std::fs::write(dir.join("bin/hello"), "#!/bin/sh\necho hi").unwrap();
178    }
179
180    #[test]
181    fn test_copy_provider_prepare() {
182        let tmp = TempDir::new().unwrap();
183        let cache_dir = tmp.path().join("cache");
184        let box_dir = tmp.path().join("box");
185        std::fs::create_dir_all(&cache_dir).unwrap();
186        std::fs::create_dir_all(&box_dir).unwrap();
187        make_sample_rootfs(&cache_dir);
188
189        let provider = CopyProvider;
190        let rootfs = provider.prepare(&box_dir, &cache_dir).unwrap();
191
192        assert_eq!(rootfs, box_dir.join("rootfs"));
193        assert!(rootfs.join("etc/hostname").exists());
194        assert_eq!(
195            std::fs::read_to_string(rootfs.join("etc/hostname")).unwrap(),
196            "testbox"
197        );
198        assert!(rootfs.join("bin/hello").exists());
199    }
200
201    #[test]
202    fn test_copy_provider_prepare_reuses_existing_rootfs_without_overwriting() {
203        let tmp = TempDir::new().unwrap();
204        let cache_dir = tmp.path().join("cache");
205        let box_dir = tmp.path().join("box");
206        let rootfs = box_dir.join("rootfs");
207        std::fs::create_dir_all(&cache_dir).unwrap();
208        std::fs::create_dir_all(rootfs.join("etc")).unwrap();
209        make_sample_rootfs(&cache_dir);
210        std::fs::write(rootfs.join("etc/hostname"), "persistent-host").unwrap();
211
212        let provider = CopyProvider;
213        let prepared = provider.prepare(&box_dir, &cache_dir).unwrap();
214
215        assert_eq!(prepared, rootfs);
216        assert_eq!(
217            std::fs::read_to_string(prepared.join("etc/hostname")).unwrap(),
218            "persistent-host"
219        );
220        assert!(
221            !prepared.join("bin/hello").exists(),
222            "existing persistent rootfs must not be overwritten from cache"
223        );
224    }
225
226    #[test]
227    fn test_copy_provider_cleanup() {
228        let tmp = TempDir::new().unwrap();
229        let cache_dir = tmp.path().join("cache");
230        let box_dir = tmp.path().join("box");
231        std::fs::create_dir_all(&cache_dir).unwrap();
232        std::fs::create_dir_all(&box_dir).unwrap();
233        make_sample_rootfs(&cache_dir);
234
235        let provider = CopyProvider;
236        let rootfs = provider.prepare(&box_dir, &cache_dir).unwrap();
237        assert!(rootfs.exists());
238
239        provider.cleanup(&box_dir, false).unwrap();
240        assert!(!rootfs.exists());
241    }
242
243    #[test]
244    fn test_copy_provider_cleanup_persistent_keeps_rootfs() {
245        let tmp = TempDir::new().unwrap();
246        let box_dir = tmp.path().join("box");
247        let rootfs = box_dir.join("rootfs");
248        std::fs::create_dir_all(rootfs.join("etc")).unwrap();
249        std::fs::write(rootfs.join("etc/hostname"), "kept").unwrap();
250
251        CopyProvider.cleanup(&box_dir, true).unwrap();
252
253        assert_eq!(
254            std::fs::read_to_string(rootfs.join("etc/hostname")).unwrap(),
255            "kept"
256        );
257    }
258
259    #[test]
260    fn test_copy_provider_cleanup_nonexistent() {
261        let tmp = TempDir::new().unwrap();
262        let provider = CopyProvider;
263        // Should not error on missing dir
264        provider.cleanup(tmp.path(), false).unwrap();
265    }
266
267    #[test]
268    fn test_copy_provider_name() {
269        assert_eq!(CopyProvider.name(), "copy");
270    }
271
272    #[test]
273    fn test_overlay_provider_name() {
274        assert_eq!(OverlayProvider.name(), "overlay");
275    }
276
277    #[test]
278    fn test_overlay_provider_cleanup_persistent_keeps_upper_only() {
279        let tmp = TempDir::new().unwrap();
280        let box_dir = tmp.path().join("box");
281        for dir in ["upper", "work", "merged"] {
282            std::fs::create_dir_all(box_dir.join(dir)).unwrap();
283        }
284        std::fs::write(box_dir.join("upper/data.txt"), "state").unwrap();
285        std::fs::write(box_dir.join("work/scratch.txt"), "work").unwrap();
286        std::fs::write(box_dir.join("merged/view.txt"), "merged").unwrap();
287
288        OverlayProvider.cleanup(&box_dir, true).unwrap();
289
290        assert_eq!(
291            std::fs::read_to_string(box_dir.join("upper/data.txt")).unwrap(),
292            "state"
293        );
294        assert!(!box_dir.join("work").exists());
295        assert!(!box_dir.join("merged").exists());
296    }
297
298    #[test]
299    fn test_overlay_provider_cleanup_nonpersistent_removes_all_overlay_dirs() {
300        let tmp = TempDir::new().unwrap();
301        let box_dir = tmp.path().join("box");
302        for dir in ["upper", "work", "merged"] {
303            std::fs::create_dir_all(box_dir.join(dir)).unwrap();
304            std::fs::write(box_dir.join(dir).join("file.txt"), "data").unwrap();
305        }
306
307        OverlayProvider.cleanup(&box_dir, false).unwrap();
308
309        assert!(!box_dir.join("upper").exists());
310        assert!(!box_dir.join("work").exists());
311        assert!(!box_dir.join("merged").exists());
312    }
313
314    #[test]
315    fn test_default_provider_returns_something() {
316        let provider = default_provider();
317        // On any platform, we should get a provider
318        assert!(!provider.name().is_empty());
319    }
320
321    #[cfg(target_os = "linux")]
322    #[test]
323    fn test_overlay_provider_prepare_and_cleanup() {
324        if !super::super::overlay::is_overlay_supported() {
325            // Skip if overlay not available (e.g., in container without privileges)
326            return;
327        }
328
329        let tmp = TempDir::new().unwrap();
330        let cache_dir = tmp.path().join("cache");
331        let box_dir = tmp.path().join("box");
332        std::fs::create_dir_all(&cache_dir).unwrap();
333        std::fs::create_dir_all(&box_dir).unwrap();
334        make_sample_rootfs(&cache_dir);
335
336        let provider = OverlayProvider;
337        let merged = provider.prepare(&box_dir, &cache_dir).unwrap();
338
339        assert_eq!(merged, box_dir.join("merged"));
340        assert!(merged.join("etc/hostname").exists());
341        assert_eq!(
342            std::fs::read_to_string(merged.join("etc/hostname")).unwrap(),
343            "testbox"
344        );
345
346        // Write to merged — should go to upper
347        std::fs::write(merged.join("etc/newfile"), "overlay write").unwrap();
348        assert!(box_dir.join("upper/etc/newfile").exists());
349
350        provider.cleanup(&box_dir, false).unwrap();
351        assert!(!box_dir.join("merged").exists());
352        assert!(!box_dir.join("upper").exists());
353        assert!(!box_dir.join("work").exists());
354    }
355}