Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
document they can read first.

### Changed
- **Rootfs and initramfs images no longer ship package-manager state.** The
rpmdb, `var/lib/dnf` and `var/cache/dnf` are removed from the staged copy
before the image is built — measured at ~13MB of a qemux86-64 rootfs and
14MB of a 123MB initramfs. Nothing on target reads any of it; these systems
have no runtime package manager. Only the staged copy is touched, so the
shared sysroot keeps its database and `ext install` / `runtime install`
still seed their installroots from it. (dnf's own logs never land in the
staged copy at all — dnf does not prefix `logdir` with the installroot —
so they need no removal; they live under the SDK prefix.)

Anything inspecting a built image for installed packages — `rpm -qa` against
a loop-mounted rootfs, say — needs to query the sysroot or the lockfile
instead. This is a necessary step toward reproducible images but not a
sufficient one on its own; archive mtime normalization is separate.
- **Extension images no longer ship package-manager state.** `var/lib/rpm`,
`var/lib/dnf` and `var/cache/dnf` are excluded from the built `.raw` —
measured at 13.4M of a 22M `avocado-ext-tunnels` sysroot, against an 8.2M
Expand Down
52 changes: 52 additions & 0 deletions src/commands/initramfs/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ if [ -d "$INITRAMFS_SYSROOT/usr" ]; then
echo "AVOCADO_OS_BUILD_ID=$INITRAMFS_BUILD_ID" >> "$INITRAMFS_WORK/usr/lib/os-release-initrd"
fi

# Purge package-manager bookkeeping from the work copy before archiving.
# Same reasoning as the rootfs image — see the comment in
# `generate_rootfs_build_script`, including why dnf's logs are NOT on the
# list (dnf never writes them into an installroot) and why this is
# necessary but not sufficient for reproducibility. Measured 14MB of a
# 123MB qemux86-64 initramfs (2.5M rpmdb, 4.3M var/lib/dnf, 6.4M
# var/cache/dnf), none of it read by anything in an initrd.
#
# Before the mtime normalization below on purpose: the purge restamps the
# directories it empties, and the mtime pass is what makes that not matter.
echo "Purging package-manager state from initramfs image"
rm -rf "$INITRAMFS_WORK/var/lib/rpm" "$INITRAMFS_WORK/var/lib/dnf" "$INITRAMFS_WORK/var/cache/dnf"

# Normalize mtimes across the staged tree so the cpio is reproducible.
#
# `cpio --reproducible` is only --ignore-devno --ignore-dirnlink
Expand Down Expand Up @@ -516,6 +529,45 @@ mod tests {
);
}

/// Regression: the staged initramfs carried 14MB of dnf/rpm state (2.5M
/// rpmdb, 4.3M var/lib/dnf, 6.4M var/cache/dnf) into the cpio. Nothing in
/// an initrd reads it, and it made the archive unreproducible — the rpmdb
/// records INSTALLTIME/INSTALLTID per package and var/cache/dnf holds
/// generated repodata, so the same package set produced different bytes.
#[test]
fn test_initramfs_purges_package_manager_state_before_archiving() {
let script = generate_initramfs_build_script(
"00000000-0000-0000-0000-000000000000",
"cpio.zst",
None,
"",
);

for path in ["var/lib/rpm", "var/lib/dnf", "var/cache/dnf"] {
assert!(
script.contains(&format!("\"$INITRAMFS_WORK/{path}\"")),
"{path} must be purged from the work copy before archiving"
);
}

// The purge only helps if it runs before the archive is created.
let purge_at = script
.find("Purging package-manager state")
.expect("purge step present");
let cpio_at = script
.find("cpio --reproducible")
.expect("cpio step present");
assert!(purge_at < cpio_at, "purge must precede cpio creation");

// dnf never writes its logs into an installroot (logdir is not
// prefixed by prepend_installroot), so a log purge here would be a
// no-op that reads as coverage. Pin its absence.
assert!(
!script.contains("var/log/dnf.log"),
"the script must not claim to purge dnf logs that are never staged"
);
}

/// Archive order is byte order, not locale collation. Entry order *is*
/// archive order, and with `--renumber-inodes` it also decides the inode
/// numbers, so a collation change would rewrite the whole archive.
Expand Down
68 changes: 68 additions & 0 deletions src/commands/rootfs/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,37 @@ if [ -d "$ROOTFS_SYSROOT/usr" ]; then
sed -i '/^AVOCADO_OS_BUILD_ID=/d' "$ROOTFS_SYSROOT/usr/lib/os-release"
echo "AVOCADO_OS_BUILD_ID=$OS_BUILD_ID" >> "$ROOTFS_SYSROOT/usr/lib/os-release"

# Purge package-manager bookkeeping from the work copy before imaging.
#
# dnf installs into the sysroot leave ~13MB of state behind (measured on a
# qemux86-64 rootfs: 2.0M rpmdb, 6.4M var/cache/dnf). Nothing on target
# consumes it — there is no runtime package manager — and it is what keeps
# the image from being reproducible: the rpmdb records INSTALLTIME and
# INSTALLTID per package, var/lib/dnf/history.sqlite records the
# transaction, and var/cache/dnf holds generated repodata plus solvfiles.
# While those are in the tree, two installs of the same package set never
# produce identical image bytes.
#
# dnf's logs are deliberately NOT on this list. dnf only prepends the
# installroot to cachedir and persistdir (dnf/cli/cli.py, the
# prepend_installroot loop); logdir is never installroot-relative, so the
# logs land in the SDK prefix and there has never been one in the staged
# tree to remove — verified across real build volumes, where var/log
# resolves to an empty volatile/log in every image.
#
# Removing all of this is necessary for a reproducible image but not
# sufficient: the archive's own mtime handling is a separate problem, and
# the removal itself restamps the directories it empties. That is #199's
# half, not this one's.
#
# Runs after post_install so state left by a hook's own dnf call is caught
# too. Safe for identity and for extension priming: the build ID above
# queries $ROOTFS_SYSROOT, and the installroot seeding in `ext install` /
# `runtime install` copies from $AVOCADO_PREFIX/rootfs — all the pristine
# sysroot, never this work copy.
echo "Purging package-manager state from rootfs image"
rm -rf "$ROOTFS_WORK/var/lib/rpm" "$ROOTFS_WORK/var/lib/dnf" "$ROOTFS_WORK/var/cache/dnf"

# Build rootfs image using configured filesystem format
ROOTFS_FS="{rootfs_filesystem}"
ROOTFS_OUTPUT="$OUTPUT_DIR/avocado-image-rootfs-$TARGET_ARCH.$ROOTFS_FS"
Expand Down Expand Up @@ -629,6 +660,43 @@ mod tests {
);
}

#[test]
fn test_rootfs_purges_package_manager_state_before_imaging() {
// Regression: ~13MB of dnf/rpm bookkeeping was being imaged into the
// rootfs. Nothing on target consumes it, and it blocked reproducible
// images — the rpmdb stamps INSTALLTIME/INSTALLTID per package and
// var/cache/dnf holds generated repodata, so the same package set
// produced different image bytes on every install.
let script = generate_rootfs_build_script(
"00000000-0000-0000-0000-000000000000",
"erofs-lz4",
None,
"",
);

for path in ["var/lib/rpm", "var/lib/dnf", "var/cache/dnf"] {
assert!(
script.contains(&format!("\"$ROOTFS_WORK/{path}\"")),
"{path} must be purged from the work copy before imaging"
);
}

// The purge only helps if it runs before the image is built.
let purge_at = script
.find("Purging package-manager state")
.expect("purge step present");
let mkfs_at = script.find("mkfs.erofs").expect("mkfs step present");
assert!(purge_at < mkfs_at, "purge must precede mkfs");

// dnf never writes its logs into an installroot (logdir is not
// prefixed by prepend_installroot), so a log purge here would be a
// no-op that reads as coverage. Pin its absence.
assert!(
!script.contains("var/log/dnf.log"),
"the script must not claim to purge dnf logs that are never staged"
);
}

#[test]
fn test_rootfs_passwd_guard_precedes_permissions_section() {
// The guard only helps if it runs before the user-creation
Expand Down
Loading