Skip to main content

arcbox_computer_runtime/rootfs/
build.rs

1//! Build an image at a caller-owned path and capacity.
2
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use tokio::sync::oneshot;
7
8use super::{RootfsBuilder, is_oci_layout, rootfs_err};
9use crate::error::ComputerError;
10
11/// One ext4 block group with the formatter's 4 KiB block size.
12/// Capacities must contain whole groups because the formatter rounds up.
13pub const ROOTFS_CAPACITY_GRANULARITY: u64 = 128 * 1024 * 1024;
14
15pub enum RootfsSource {
16    /// An OCI image layout or a Docker overlay2 chain-id directory.
17    Directory(PathBuf),
18    /// An image resolved by the caller, which can retain its config and digest.
19    Image(oci2rootfs::ImageSource),
20}
21
22pub struct RootfsSpec {
23    pub source: RootfsSource,
24    /// Destination replaced atomically after conversion and agent injection.
25    pub out: PathBuf,
26    /// Sparse ext4 capacity in bytes, in whole [`ROOTFS_CAPACITY_GRANULARITY`] units.
27    pub size: u64,
28}
29
30impl RootfsBuilder {
31    /// Build an ext4 image with the configured agent at the caller's path.
32    /// The caller owns the output's lifetime; no cache entry is created.
33    /// Cancellation leaves the destination unchanged. The owned worker finishes
34    /// conversion and injection before removing its temporary image.
35    /// Runtime shutdown or process termination can leave a sibling
36    /// `.<uuid>.ext4.tmp` for the caller to remove.
37    pub async fn build_rootfs(&self, spec: RootfsSpec) -> crate::error::Result<()> {
38        if spec.size == 0 || !spec.size.is_multiple_of(ROOTFS_CAPACITY_GRANULARITY) {
39            return Err(ComputerError::Config(format!(
40                "rootfs capacity {} must be a positive multiple of {ROOTFS_CAPACITY_GRANULARITY} bytes (one ext4 block group)",
41                spec.size
42            )));
43        }
44        self.write_and_publish_with(&spec.out, move |path| {
45            write_image(spec.source, path, spec.size)
46                .and_then(|()| verify_geometry(path, spec.size))
47        })
48        .await
49        .map_err(rootfs_err)
50    }
51
52    pub(super) async fn write_and_publish(
53        &self,
54        source: RootfsSource,
55        out: &Path,
56        initial_size: u64,
57    ) -> Result<()> {
58        self.write_and_publish_with(out, move |path| write_image(source, path, initial_size))
59            .await
60    }
61
62    async fn write_and_publish_with(
63        &self,
64        out: &Path,
65        write: impl FnOnce(&Path) -> Result<()> + Send + 'static,
66    ) -> Result<()> {
67        let parent = out
68            .parent()
69            .context("rootfs output path has no parent")?
70            .to_owned();
71        let builder = Self::new(self.paths.clone(), self.block_tools.clone());
72        let (reply, prepared) = oneshot::channel();
73        // This task owns every blocking step until injection releases its mount.
74        // Dropping the receiver cancels publication, not resource cleanup.
75        tokio::spawn(async move {
76            let built = async {
77                tokio::fs::create_dir_all(&parent)
78                    .await
79                    .context("create rootfs output directory")?;
80                let temporary = TemporaryImage {
81                    path: parent.join(format!(".{}.ext4.tmp", uuid::Uuid::new_v4())),
82                    armed: true,
83                };
84                let path = temporary.path.clone();
85                let built = async {
86                    tokio::task::spawn_blocking(move || write(&path))
87                        .await
88                        .context("rootfs conversion task panicked")??;
89                    builder.inject_agent(&temporary.path).await
90                }
91                .await;
92                match built {
93                    Ok(()) => Ok(temporary),
94                    Err(error) => Err(temporary.fail(error)),
95                }
96            }
97            .await;
98            if let Err(Err(error)) = reply.send(built) {
99                tracing::error!(?error, "cancelled rootfs build failed");
100            }
101        });
102        let temporary = prepared.await.context("rootfs build task stopped")??;
103        // No await may separate receipt from publication: a cancelled caller
104        // must never leave a detached rename that can replace the destination.
105        temporary.publish(out)
106    }
107}
108
109#[derive(Debug)]
110struct TemporaryImage {
111    path: PathBuf,
112    armed: bool,
113}
114
115impl TemporaryImage {
116    fn publish(mut self, out: &Path) -> Result<()> {
117        match std::fs::rename(&self.path, out).context("publish rootfs image") {
118            Ok(()) => {
119                self.armed = false;
120                Ok(())
121            }
122            Err(error) => Err(self.fail(error)),
123        }
124    }
125
126    fn fail(mut self, error: anyhow::Error) -> anyhow::Error {
127        match self.cleanup() {
128            Ok(()) => error,
129            Err(cleanup) => error.context(format!("{cleanup:#}")),
130        }
131    }
132
133    fn cleanup(&mut self) -> Result<()> {
134        if !std::mem::take(&mut self.armed) {
135            return Ok(());
136        }
137        match std::fs::remove_file(&self.path) {
138            Ok(()) => Ok(()),
139            // The converter can remove its partial output before returning an error.
140            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
141            Err(error) => {
142                let error = anyhow::Error::from(error).context(format!(
143                    "failed to remove temporary image {}",
144                    self.path.display()
145                ));
146                tracing::error!(?error, "rootfs build cleanup failed");
147                Err(error)
148            }
149        }
150    }
151}
152
153impl Drop for TemporaryImage {
154    fn drop(&mut self) {
155        // The receiver may disappear before taking an already prepared image.
156        // Cleanup records failures before returning, including during Drop.
157        let _ = self.cleanup();
158    }
159}
160
161fn write_image(source: RootfsSource, out: &Path, size: u64) -> Result<()> {
162    let converter = oci2rootfs::Converter::new(out).size(size);
163    match source {
164        RootfsSource::Image(image) => converter.convert(image).context("convert image to ext4")?,
165        RootfsSource::Directory(dir) if is_oci_layout(&dir) => {
166            let source =
167                oci2rootfs::OciLayoutSource::open(&dir).context("open OCI image layout")?;
168            converter
169                .convert(source)
170                .context("convert OCI layout to ext4")?;
171        }
172        RootfsSource::Directory(dir) => {
173            let source = oci2rootfs::Overlay2Source::open(&dir).context("open overlay2 layer")?;
174            converter
175                .convert(source)
176                .context("convert overlay2 layer to ext4")?;
177        }
178    }
179    Ok(())
180}
181
182/// File data and inode pressure can make the formatter add block groups.
183/// Require both the declared geometry and file length to match the requested capacity.
184fn verify_geometry(image: &Path, requested_size: u64) -> Result<()> {
185    let reader = arcbox_ext4::Reader::new(image).context("read converted ext4 image")?;
186    let block = reader.superblock();
187    let block_size = 1024u64 << block.log_block_size;
188    let declared =
189        ((u64::from(block.blocks_count_hi) << 32) | u64::from(block.blocks_count_lo)) * block_size;
190    let actual = std::fs::metadata(image)?.len();
191    if declared != requested_size || actual != requested_size {
192        bail!(
193            "ext4 image declares {declared} bytes and its file holds {actual}; requested rootfs capacity is {requested_size} bytes"
194        );
195    }
196    Ok(())
197}
198
199#[cfg(test)]
200mod tests {
201    use std::sync::Arc;
202
203    use arcbox_snapshot::snapshot_cow::BusyboxBlockTools;
204
205    use super::*;
206    use crate::rootfs::RootfsPaths;
207
208    fn overlay2_layer(root: &Path) -> PathBuf {
209        let layer = root.join("ABCDEF");
210        std::fs::create_dir_all(layer.join("diff/etc")).unwrap();
211        std::fs::write(layer.join("diff/etc/hostname"), b"computer\n").unwrap();
212        std::fs::write(layer.join("link"), "ABCDEF").unwrap();
213        layer
214    }
215
216    fn builder(root: &Path) -> RootfsBuilder {
217        RootfsBuilder::new(
218            RootfsPaths {
219                vm_agent: root.join("absent-vm-agent"),
220                cache_dir: root.join("cache"),
221                busybox: root.join("absent-busybox"),
222            },
223            Arc::new(BusyboxBlockTools::default()),
224        )
225    }
226
227    #[tokio::test]
228    async fn cancellation_waits_for_the_writer_before_removing_its_output() {
229        let dir = tempfile::tempdir().unwrap();
230        let out = dir.path().join("images/rootfs.ext4");
231        std::fs::create_dir_all(out.parent().unwrap()).unwrap();
232        std::fs::write(&out, b"existing-image").unwrap();
233        let builder = builder(dir.path());
234        let destination = out.clone();
235        let (started, writing) = tokio::sync::oneshot::channel();
236        let (release, resume) = std::sync::mpsc::channel();
237        let (finished, written) = tokio::sync::oneshot::channel();
238        let caller = tokio::spawn(async move {
239            builder
240                .write_and_publish_with(&destination, move |path| {
241                    std::fs::write(path, b"partial-image")?;
242                    started.send(path.to_path_buf()).unwrap();
243                    resume.recv().unwrap();
244                    assert!(path.exists(), "cleanup must wait for the writer");
245                    std::fs::write(path, b"complete-image")?;
246                    finished.send(()).unwrap();
247                    Ok(())
248                })
249                .await
250        });
251        let temporary = writing.await.unwrap();
252        caller.abort();
253        assert!(caller.await.unwrap_err().is_cancelled());
254        assert!(temporary.exists());
255        release.send(()).unwrap();
256        written.await.unwrap();
257        tokio::time::timeout(std::time::Duration::from_secs(5), async {
258            while temporary.exists() {
259                tokio::task::yield_now().await;
260            }
261        })
262        .await
263        .expect("the cancelled build must remove the completed temporary image");
264        assert_eq!(std::fs::read(&out).unwrap(), b"existing-image");
265        assert_eq!(std::fs::read_dir(out.parent().unwrap()).unwrap().count(), 1);
266    }
267
268    #[test]
269    fn dropping_a_delivered_image_preserves_the_destination() {
270        let dir = tempfile::tempdir().unwrap();
271        let out = dir.path().join("rootfs.ext4");
272        let path = dir.path().join(".prepared.ext4.tmp");
273        std::fs::write(&out, b"existing-image").unwrap();
274        std::fs::write(&path, b"prepared-image").unwrap();
275        let (reply, prepared) = oneshot::channel();
276        reply
277            .send(TemporaryImage {
278                path: path.clone(),
279                armed: true,
280            })
281            .unwrap();
282
283        drop(prepared);
284
285        assert!(!path.exists());
286        assert_eq!(std::fs::read(&out).unwrap(), b"existing-image");
287        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
288    }
289
290    #[test]
291    fn publication_reports_both_rename_and_cleanup_failures() {
292        let dir = tempfile::tempdir().unwrap();
293        let out = dir.path().join("rootfs.ext4");
294        let path = dir.path().join(".invalid.ext4.tmp");
295        std::fs::write(&out, b"existing-image").unwrap();
296        std::fs::create_dir(&path).unwrap();
297
298        let error = TemporaryImage { path, armed: true }
299            .publish(&out)
300            .unwrap_err();
301        let details = format!("{error:#}");
302        assert!(details.contains("publish rootfs image"), "{details}");
303        assert!(
304            details.contains("failed to remove temporary image"),
305            "{details}"
306        );
307        assert_eq!(std::fs::read(out).unwrap(), b"existing-image");
308    }
309
310    #[test]
311    fn cleanup_failures_are_logged_once_even_when_the_result_is_dropped() {
312        if !super::super::tests::isolated_log_test(
313            "rootfs::build::tests::cleanup_failures_are_logged_once_even_when_the_result_is_dropped",
314        ) {
315            return;
316        }
317        let dir = tempfile::tempdir().unwrap();
318        let log = tempfile::NamedTempFile::new().unwrap();
319        let writer = log.reopen().unwrap();
320        let subscriber = tracing_subscriber::fmt()
321            .with_ansi(false)
322            .without_time()
323            .with_writer(move || writer.try_clone().unwrap())
324            .finish();
325        tracing::subscriber::with_default(subscriber, || {
326            for failed_before_delivery in [true, false] {
327                let path = dir
328                    .path()
329                    .join(format!("{failed_before_delivery}.ext4.tmp"));
330                std::fs::create_dir(&path).unwrap();
331                let image = TemporaryImage { path, armed: true };
332                let result = if failed_before_delivery {
333                    Err(image.fail(anyhow::anyhow!("conversion failed")))
334                } else {
335                    Ok(image)
336                };
337                let (reply, prepared) = oneshot::channel();
338                reply.send(result).unwrap();
339                drop(prepared);
340            }
341        });
342        let logged = std::fs::read_to_string(log.path()).unwrap();
343        assert!(logged.contains("true.ext4.tmp"), "{logged}");
344        assert!(logged.contains("false.ext4.tmp"), "{logged}");
345        assert_eq!(
346            logged.matches("rootfs build cleanup failed").count(),
347            2,
348            "{logged}"
349        );
350    }
351
352    #[tokio::test]
353    async fn invalid_capacity_does_no_work() {
354        let dir = tempfile::tempdir().unwrap();
355        let out = dir.path().join("images/rootfs.ext4");
356        for size in [0, ROOTFS_CAPACITY_GRANULARITY - 1, 30_000_000_000] {
357            let error = builder(dir.path())
358                .build_rootfs(RootfsSpec {
359                    source: RootfsSource::Directory(overlay2_layer(dir.path())),
360                    out: out.clone(),
361                    size,
362                })
363                .await
364                .unwrap_err();
365            assert!(matches!(error, ComputerError::Config(_)), "{error}");
366            assert!(!out.parent().unwrap().exists());
367        }
368    }
369
370    #[test]
371    fn conversion_honors_capacity_and_rejects_overdeclared_geometry() {
372        let dir = tempfile::tempdir().unwrap();
373        for size in [
374            ROOTFS_CAPACITY_GRANULARITY / 2,
375            2 * ROOTFS_CAPACITY_GRANULARITY,
376        ] {
377            let out = dir.path().join(format!("{size}.ext4"));
378            write_image(
379                RootfsSource::Directory(overlay2_layer(dir.path())),
380                &out,
381                size,
382            )
383            .unwrap();
384            assert_eq!(std::fs::metadata(&out).unwrap().len(), size);
385            let geometry = verify_geometry(&out, size);
386            if size < ROOTFS_CAPACITY_GRANULARITY {
387                assert!(geometry.unwrap_err().to_string().contains("declares"));
388            } else {
389                geometry.unwrap();
390                let reader = arcbox_ext4::Reader::new(&out).unwrap();
391                assert!(reader.tree().lookup(Path::new("/etc/hostname")).is_some());
392            }
393        }
394    }
395
396    #[tokio::test]
397    async fn oversized_source_is_rejected_before_injection_and_preserves_destination() {
398        let dir = tempfile::tempdir().unwrap();
399        let layer = overlay2_layer(dir.path());
400        std::fs::File::create(layer.join("diff/payload"))
401            .unwrap()
402            .set_len(ROOTFS_CAPACITY_GRANULARITY + 4096)
403            .unwrap();
404        let out = dir.path().join("images/rootfs.ext4");
405        std::fs::create_dir_all(out.parent().unwrap()).unwrap();
406        std::fs::write(&out, b"existing-image").unwrap();
407
408        let error = builder(dir.path())
409            .build_rootfs(RootfsSpec {
410                source: RootfsSource::Directory(layer),
411                out: out.clone(),
412                size: ROOTFS_CAPACITY_GRANULARITY,
413            })
414            .await
415            .unwrap_err();
416        assert!(
417            error.to_string().contains("requested rootfs capacity"),
418            "capacity must be rejected before agent injection: {error}"
419        );
420        assert_eq!(std::fs::read(&out).unwrap(), b"existing-image");
421        assert_eq!(std::fs::read_dir(out.parent().unwrap()).unwrap().count(), 1);
422    }
423
424    #[tokio::test]
425    async fn a_failed_source_read_leaves_no_temporary_image() {
426        let dir = tempfile::tempdir().unwrap();
427        let source = dir.path().join("layout");
428        std::fs::create_dir(&source).unwrap();
429        std::fs::write(source.join("oci-layout"), b"{}").unwrap();
430        let out = dir.path().join("images/rootfs.ext4");
431        let error = builder(dir.path())
432            .build_rootfs(RootfsSpec {
433                source: RootfsSource::Directory(source),
434                out: out.clone(),
435                size: ROOTFS_CAPACITY_GRANULARITY,
436            })
437            .await
438            .unwrap_err();
439        assert!(
440            error.to_string().contains("open OCI image layout"),
441            "{error}"
442        );
443        assert!(!out.exists());
444        assert_eq!(std::fs::read_dir(out.parent().unwrap()).unwrap().count(), 0);
445    }
446
447    #[tokio::test]
448    async fn injection_failure_preserves_the_destination_and_removes_temporary_files() {
449        let dir = tempfile::tempdir().unwrap();
450        let out = dir.path().join("images/rootfs.ext4");
451        std::fs::create_dir_all(out.parent().unwrap()).unwrap();
452        std::fs::write(&out, b"existing-image").unwrap();
453        let error = builder(dir.path())
454            .build_rootfs(RootfsSpec {
455                source: RootfsSource::Directory(overlay2_layer(dir.path())),
456                out: out.clone(),
457                size: ROOTFS_CAPACITY_GRANULARITY,
458            })
459            .await
460            .unwrap_err();
461        assert!(error.to_string().contains("vm-agent"), "{error}");
462        assert_eq!(std::fs::read(&out).unwrap(), b"existing-image");
463        assert_eq!(std::fs::read_dir(out.parent().unwrap()).unwrap().count(), 1);
464    }
465}