arcbox_computer_runtime/rootfs/
build.rs1use 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
11pub const ROOTFS_CAPACITY_GRANULARITY: u64 = 128 * 1024 * 1024;
14
15pub enum RootfsSource {
16 Directory(PathBuf),
18 Image(oci2rootfs::ImageSource),
20}
21
22pub struct RootfsSpec {
23 pub source: RootfsSource,
24 pub out: PathBuf,
26 pub size: u64,
28}
29
30impl RootfsBuilder {
31 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 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 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 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 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
182fn 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}