mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
fix(image): publish the built image atomically
mke2fs creates and sizes the output file before it populates it, so a build that failed mid-way left a recent-looking image full of zeros at the final name - newer than the tarball, and trusted by the freshness check forever after. The first run after the libarchive fix booted exactly that wreckage: EXT4-fs "Can't find ext4 filesystem". The build now lands in a sibling .part file and renames over the image only on success, and the freshness check additionally requires the ext4 superblock magic (s_magic, at byte 1080) before trusting an existing image - caches already carrying wreckage rebuild over it on the next run instead of needing a manual clean.
This commit is contained in:
1 parent
0347309eea
commit
4affe9230c
2 files changed
+76
-5
No files matched your search
@@ -50,9 +50,49 @@ pub(crate) fn build_rootfs_image(
|
||||
result
|
||||
}
|
||||
|
||||
/// Run mke2fs over the archive. The filesystem size and inode count
|
||||
/// come from a scan of the archive: mke2fs derives neither from a tar.
|
||||
/// Whether `image` carries an ext4 superblock: the superblock starts
|
||||
/// at byte 1024 of the file and s_magic sits 56 bytes into it, so the
|
||||
/// pair is at byte 1080, little-endian. Anything else is the remains
|
||||
/// of an interrupted or failed build, and the freshness check
|
||||
/// rebuilds over it instead of booting zeros.
|
||||
pub(crate) fn looks_like_ext4(image: &Path) -> bool {
|
||||
use std::io::{Read, Seek};
|
||||
let mut magic = [0u8; 2];
|
||||
std::fs::File::open(image)
|
||||
.and_then(|mut f| {
|
||||
f.seek(std::io::SeekFrom::Start(1080))?;
|
||||
f.read_exact(&mut magic)?;
|
||||
Ok(magic)
|
||||
})
|
||||
.map(|m| m == [0x53, 0xEF])
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build the entry image: mke2fs consumes the archive (merged first
|
||||
/// when the tarball is an OCI layer bundle) and the result publishes
|
||||
/// atomically.
|
||||
fn mkfs_from_tar(tarball: &Path, image: &Path) -> anyhow::Result<()> {
|
||||
// The build lands in a sibling file first and renames over the
|
||||
// image only on success. mke2fs creates and sizes the file before
|
||||
// it populates, so a failed build would otherwise leave a
|
||||
// recent-looking image full of zeros at the final name — newer
|
||||
// than the tarball it came from, and trusted by the freshness
|
||||
// check forever after.
|
||||
let staging = image.with_extension("part");
|
||||
let built = build_ext4(tarball, &staging);
|
||||
if built.is_ok() {
|
||||
std::fs::rename(&staging, image)
|
||||
.with_context(|| format!("publishing {}", image.display()))?;
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&staging);
|
||||
}
|
||||
built
|
||||
}
|
||||
|
||||
/// Create `image`, size it from a scan of the archive, and run mke2fs
|
||||
/// over it. The filesystem size and inode count come from the scan:
|
||||
/// mke2fs derives neither from a tar.
|
||||
fn build_ext4(tarball: &Path, image: &Path) -> anyhow::Result<()> {
|
||||
let (bytes, entries) = tar_scan(tarball)?;
|
||||
// Headroom for the journal, the inode tables and block rounding.
|
||||
// The file is created sparse, so generous headroom costs nothing
|
||||
@@ -265,6 +305,28 @@ mod tests {
|
||||
build_rootfs_image(tarball, image, &crate::progress::NoProgress).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_without_the_magic_is_not_an_image() {
|
||||
let work = tempfile::tempdir().unwrap();
|
||||
let path = work.path().join("img");
|
||||
// Freshly truncated: allocated but never populated.
|
||||
std::fs::write(&path, vec![0u8; 4096]).unwrap();
|
||||
assert!(!looks_like_ext4(&path));
|
||||
// Too short to hold a superblock at all.
|
||||
std::fs::write(&path, vec![0u8; 512]).unwrap();
|
||||
assert!(!looks_like_ext4(&path));
|
||||
// The magic, in place and little-endian.
|
||||
let mut bytes = vec![0u8; 4096];
|
||||
bytes[1024] = 0x00;
|
||||
bytes[1025] = 0x08;
|
||||
bytes[1080] = 0x53;
|
||||
bytes[1081] = 0xEF;
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
assert!(looks_like_ext4(&path));
|
||||
// Absent files are not images either.
|
||||
assert!(!looks_like_ext4(&work.path().join("absent")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_image_carries_the_tarball_ownership() {
|
||||
if !have_e2fsprogs() {
|
||||
@@ -277,6 +339,11 @@ mod tests {
|
||||
let image = work.path().join("rootfs.img");
|
||||
build(&tarball, &image);
|
||||
|
||||
// A finished build publishes an ext4 image and leaves no
|
||||
// staging file behind.
|
||||
assert!(looks_like_ext4(&image), "the built image lacks the magic");
|
||||
assert!(!work.path().join("rootfs.part").exists());
|
||||
|
||||
let out = Command::new("debugfs")
|
||||
.arg("-R")
|
||||
.arg("stat /etc/marker")
|
||||
|
||||
@@ -95,15 +95,19 @@ impl RootfsCache {
|
||||
}
|
||||
|
||||
/// The entry's image, built from the entry tarball when absent or
|
||||
/// older than the tarball. Runs copy the image; the tarball is the
|
||||
/// build input only. One writer per entry, like the download path.
|
||||
/// older than the tarball, or when the file on disk is not an ext4
|
||||
/// image at all — the remains of a build that died before its
|
||||
/// atomic rename. Runs copy the image; the tarball is the build
|
||||
/// input only. One writer per entry, like the download path.
|
||||
fn ensure_image(&self, tarball: &Path, image: &Path, progress: &dyn Progress) -> Result<()> {
|
||||
let mtime = |p: &Path| {
|
||||
std::fs::metadata(p)
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
};
|
||||
let fresh = || image.exists() && mtime(image) >= mtime(tarball);
|
||||
let fresh = || {
|
||||
image.exists() && crate::image::looks_like_ext4(image) && mtime(image) >= mtime(tarball)
|
||||
};
|
||||
if fresh() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user