purge package-manager state from rootfs and initramfs images - #203
purge package-manager state from rootfs and initramfs images#203mobileoverlord wants to merge 2 commits into
Conversation
dnf installs into the shared sysroots leave package-manager bookkeeping
behind, and `cp -a` carries it into the work copy that gets imaged.
Measured on a qemux86-64 build:
rootfs (138M): 2.0M var/lib/rpm, 6.4M var/cache/dnf
initramfs (123M): 2.5M var/lib/rpm, 4.3M var/lib/dnf,
6.4M var/cache/dnf
Two problems. It is dead weight — there is no runtime package manager on
target, so nothing ever reads it. And it is the reason the images are not
reproducible: the rpmdb records INSTALLTIME and INSTALLTID per package
(240 of them in the initramfs), var/lib/dnf/history.sqlite records the
transaction, and var/cache/dnf holds generated repodata plus solvfiles.
While any of that is in the tree, the same package set produces different
image bytes on every install, no matter how the archive step is invoked.
Remove all three from the work copy after post_install, so state left by
a hook's own dnf call is caught too.
The pristine sysroots keep their rpmdb: the AVOCADO_OS_BUILD_ID queries
read $ROOTFS_SYSROOT / $INITRAMFS_SYSROOT, and the installroot seeding in
`ext install`, `ext dnf`, `runtime install` and `runtime dnf` copies from
$AVOCADO_PREFIX/rootfs. None of those touch the work copy.
There was a problem hiding this comment.
Pull request overview
This PR removes DNF/RPM package-manager bookkeeping from the work-copy trees used to produce rootfs and initramfs images, reducing image size and eliminating a key source of non-reproducibility while preserving the shared sysroot state used for build ID calculation and extension priming.
Changes:
- Purge
var/lib/rpm,var/lib/dnf, andvar/cache/dnffrom the rootfs work copy before filesystem image creation. - Purge the same paths from the initramfs work copy before cpio archive creation.
- Add regression tests to assert the purge targets the work copy (not the sysroot) and occurs before the imaging/archiving step.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/commands/rootfs/image.rs |
Adds a purge step before mkfs.* and a regression test to keep the sysroot rpmdb intact while preventing package-manager state from being imaged. |
src/commands/initramfs/image.rs |
Adds a purge step before cpio --reproducible and a regression test to ensure initramfs archives don’t include DNF/RPM state. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
jetm
left a comment
There was a problem hiding this comment.
The size win is real and I could not find a consumer this breaks. I traced every rpmdb reader — the build-ID query at rootfs/image.rs:160, lockfile.rs:107-155, the installroot seeding at ext/install.rs:603, ext/dnf.rs:270, runtime/install.rs:491, runtime/dnf.rs:220 — and each targets a pristine sysroot or a separate installroot, never the work copy. Nothing later reads the work copy, no code mounts or extracts a built image, and the purge sits correctly after permissions, post_install and both os-release injections. set -e is safe here since a recursive remove on a missing path exits 0 and both work vars carry :- defaults. cargo fmt, clippy -D warnings and cargo test --bin avocado purges_package_manager all pass at 7f78038.
Where I would push back is on the second half of the stated goal. The size reduction lands; reproducibility does not.
Two identical builds still emit different bytes. cpio --reproducible is --device-independent on GNU cpio 2.15 and does not touch mtime, and the removal itself restamps var/lib and var/cache with wall-clock time. I reproduced it: two identical trees, purge, cpio --reproducible -o -H newc — var/lib mtimes 13:40:48 vs 13:40:50, archives diverge at byte 518, different sha. initramfs/image.rs:108.
And the purge misses dnf's own logs. rootfs/install.rs:951 omits $DNF_SDK_HOST_OPTS (container.rs:2470) — the same omission that puts var/cache/dnf and var/lib/dnf in the sysroot also puts dnf.log, dnf.rpm.log and hawkey.log in $SYSROOT/var/log, and those carry per-line wall-clock timestamps. I confirmed the mechanism with dnf5 --dump-main-config: logdir is installroot-prefixed. fetch.rs:468+487 reads like the repo already knowing about this path. Same on initramfs.
Nits:
src/commands/ext/image.rs:895— extension.rawimages mkfs straight from the ext sysroot, whichext/install.rs:603seeds with a full rootfs rpmdb copy. Same problem, not covered here, and--exclude-pathis already wired there forvar_excludes.src/commands/rootfs/image.rs:599— the!contains("$ROOTFS_SYSROOT/var/lib/rpm")test guard is vacuous: that string has 0 occurrences at the merge-base, and the build-ID query it claims to protect is spelled--root "$ROOTFS_SYSROOT"with--dbpath. Same atinitramfs/image.rs:464. The three positive assertions do real work and fail if the purge line is deleted; this one asserts nothing.- No
CHANGELOG.mdUnreleased entry for a user-visible artifact change. Closest precedent in the same subsystem (#185) added one.
Two things I could not verify, stated as gaps rather than findings: my sandbox refused every mkfs.erofs invocation including --help, so whether the rootfs erofs shares the mtime problem (-T is passed, --ignore-mtime is not) is untested; and I could not run a real SDK-container build, so the var/log/dnf.log point rests on reading the code paths rather than on an observed file.
The purge missed var/log. rootfs/install.rs omits $DNF_SDK_HOST_OPTS,
which is what redirects logdir/cachedir/persistdir at the SDK prefix --
so the same omission that puts var/cache/dnf and var/lib/dnf in the
sysroot puts dnf.log, dnf.rpm.log and hawkey.log in var/log, every line
wall-clock stamped. Both images now remove them, and both tests assert
all six paths; deleting the new `rm -f` fails them.
Dropped the `!contains("$ROOTFS_SYSROOT/var/lib/rpm")` guard from both
tests. That string has no occurrences to begin with, so the assertion
could not fail, and the build-ID query it claimed to protect is spelled
`--root "$ROOTFS_SYSROOT"` with `--dbpath`. A test that cannot fail is
worse than no test: it reads as coverage.
Scoped the reproducibility claim to what it earns. Removing this state
is necessary but not sufficient -- `cpio --reproducible` is
--device-independent on GNU cpio 2.15 and does not normalize mtime, and
the removal itself restamps the directories it empties. That half is
#199's. The comments say so rather than implying the image lands
reproducible here.
Adds the CHANGELOG entry, including the one consumer this can surprise:
anything running `rpm -qa` against a built image rather than the sysroot.
|
Pushed in 4a9774e. Both findings taken, plus two of the three nits. The Both images now Reproducibility. Agreed, and the wording is scoped now — comments and PR body both say removal is necessary and not sufficient. Two notes on where we already agreed: the body's "Interaction with #199" section called out the restamp ( Your cpio measurement settles the Vacuous test guard. Deleted from both files, not repaired. You are right that it asserts nothing, and the honest fix is removal rather than rewriting it into a check of something else: the sysroot's safety rests on the consumer trace in the PR body, not on a substring. A test that cannot fail is worse than an absent one because it reads as coverage.
CHANGELOG. Added under On your two stated gaps — the erofs |
jetm
left a comment
There was a problem hiding this comment.
Re-reviewed the increment since 7f78038. Two of the three claims in the subject hold up: the dropped assertion really was vacuous and nothing real went with it, and the scoped claim is accurate for the rpmdb/cache half. The log purge is the problem - three blocking findings inline, all one root cause.
Two advisory notes were withheld rather than appended here. One item has no diff line to anchor to: the PR body's Safety section still says "Tests assert the sysroot rpmdb is not removed, so that stays true", which the same body's Test section now contradicts - worth fixing the body.
| # 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" | ||
| rm -f "$ROOTFS_WORK/var/log/dnf.log" "$ROOTFS_WORK/var/log/dnf.rpm.log" "$ROOTFS_WORK/var/log/hawkey.log" |
There was a problem hiding this comment.
dnf log purge removes nothing; it is a no-op
dnf never writes its logs into an --installroot, so these three operands match nothing in the staged rootfs. The mechanism the new comment states at line 189 - "the same omission that lands var/cache/dnf and var/lib/dnf in the sysroot also lands dnf.log, dnf.rpm.log and hawkey.log in var/log" - is not how dnf behaves.
dnf 4.24's dnf/cli/cli.py:1033 loops prepend_installroot over ('cachedir', 'persistdir') only. logdir is never installroot-relative, so the logs land in the SDK prefix rather than the image.
Checked against six real avocado build volumes on this box (five imx93-frdm, one qemux86-64): zero .log files anywhere under any rootfs/ or initramfs/ tree, var/log -> volatile/log resolves to an empty directory in every one, and all four dnf logs sit in sdk/x86_64/var/log.
So the rm -f exits 0 having deleted nothing while the build prints "Purging package-manager state from rootfs image". The two new test cases assert only that the generated script contains these strings, so they pass without detecting any of it.
| # var/cache/dnf), none of it read by anything in an initrd. | ||
| 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" | ||
| rm -f "$INITRAMFS_WORK/var/log/dnf.log" "$INITRAMFS_WORK/var/log/dnf.rpm.log" "$INITRAMFS_WORK/var/log/hawkey.log" |
There was a problem hiding this comment.
Same no-op log purge on the initramfs path
Identical dead rm -f here, and the reworded comment above it now points the reader at the rootfs comment "including why dnf's logs come too" - propagating the same incorrect mechanism to a second site.
initramfs/var/volatile/log (the target of var/log) is empty in every build volume I inspected, including the qemux86-64 one the PR body measures. The line deletes nothing on any build.
| ### Changed | ||
| - **Rootfs and initramfs images no longer ship package-manager state.** The | ||
| rpmdb, `var/lib/dnf`, `var/cache/dnf` and dnf's own logs (`dnf.log`, | ||
| `dnf.rpm.log`, `hawkey.log`) are removed from the staged copy before the |
There was a problem hiding this comment.
Changelog documents removing files that were never staged
The entry tells users dnf's logs "are removed from the staged copy before the image is built". No dnf log has ever been in the staged copy, so this documents a removal that does not happen.
The cost is that a reader believes wall-clock-stamped log files were stripped from their image and stops looking for them. And if a future config change ever did put them there, nothing in this change would catch it - the purge is unconditional and its tests only match strings in the generated script.
The rpmdb/cache half of this entry is accurate; it is specifically the log clause that describes something the code does not do.
Third of the set alongside #203 and #204. Independent of both — no overlapping files. ## Are we doing exclude-path today? No. `--exclude-path` is only emitted for user-configured `var_files` patterns, and `get_ext_var_files` returns an empty vec when the key is absent (`config.rs:1233`). There is no default, so a project that sets no `var_files` gets no excludes at all. Confirmed in the shipped images rather than inferred from the sysroot — grepping the built `.raw` files: | image | size | `rpmdb.sqlite` | `SQLite format 3` | `history.sqlite` | |---|---|---|---|---| | `config-dev-0.1.0.raw` | 848K | ✓ | ✓ | — | | `avocado-ext-tunnels-2024.1.0.raw` | 9.9M | ✓ | ✓ (×9) | ✓ | ## Scale `avocado-ext-tunnels` sysroot is 22M: 13.4M of package-manager state (2.8M `var/lib/rpm`, 4.2M `var/lib/dnf`, 6.4M `var/cache/dnf`) against an 8.2M `/usr` payload. The bookkeeping is larger than the extension. Every extension carries a floor of it — `ext install` and `ext dnf` seed each installroot with `cp -rf $AVOCADO_PREFIX/rootfs/var/lib/rpm` so dependencies resolve against what the rootfs already provides, and nothing removes it before the sysroot becomes an image. `config-dev-0.1.0.raw` and `avocado-bsp-qemux86-64-2024.1.0.raw` are both **exactly 868,352 bytes**: two unrelated extensions, identical size, because both are dominated by that same seeded rpmdb instead of their own payloads. ## Why it matters beyond size Nothing on target can read it. `systemd-sysext`/`confext` merge `/usr`, `/opt` and `/etc` — never `/var`. It is shipped and never mounted. And it is what stops the images being reproducible across a reinstall: the rpmdb stamps `INSTALLTIME`/`INSTALLTID` per package, `history.sqlite` records the transaction, `var/cache/dnf` holds generated repodata and `.solv` files. Worth being precise, since the content-addressed IDs in connect do dedupe today and that is not a contradiction. `INSTALLTIME` is written at install time and then sits unchanged, and `dnf` is a no-op when packages are already present, so **re-imaging** an unchanged sysroot is byte-stable and dedupes exactly as observed. What fails is **re-installing** — clean machine, post-`avocado clean`, a different CI runner. That is the independent-rebuild property, and it is the one the dedup metric can't see because it never re-installs. ## Excluded, not deleted Deliberate, and the difference from #203. `ext image` runs mkfs directly against the live `$AVOCADO_EXT_SYSROOTS/<name>` — there is no work copy, unlike the rootfs and initramfs paths — and later `ext dnf` / `ext install` calls resolve against that rpmdb. Deleting it would clobber live state; excluding costs nothing and uses the mechanism already there for `var_files`. ## Operational note This changes every extension's content hash exactly once. Against a content-addressed store that means a one-time dedup miss and a full re-upload wave in connect — everything looks new for one cycle. Harmless, but probably worth timing deliberately rather than landing mid-release. ## Tests Excludes applied on both the erofs and mksquashfs branches; `var_files` patterns still excluded alongside rather than displaced. Also filled the gaps in what was pinned for extension reproducibility, since none of it was covered: erofs `-U 0000…` (else every build gets a fresh UUID), `--all-root` (else ownership comes from the build user), `-T "$SOURCE_DATE_EPOCH"`, and on the squashfs side `-reproducible` and `-no-xattrs`. One existing test changed meaning: `test_create_build_script_no_var_files_no_excludes` asserted that no `var_files` meant no excludes at all, which is no longer true. Rewritten as `test_no_var_files_leaves_only_the_pkg_state_excludes`, asserting exactly three excludes — same spirit (nothing unexpected gets excluded), and it now also catches accidental duplicates. Verified `mksquashfs` tolerates the repeated `-e` form the existing codegen emits, so nothing changed there. Full suite green (1404 passing), clippy clean with `-D warnings`.
Found while reviewing #199. Splitting it out because it stands on its own and touches both image paths, not just the cpio.
What
dnfinstalls into the shared sysroots leave package-manager bookkeeping behind, andcp -acarries it into the work copy that gets imaged. Measured on aqemux86-64build:rootfsvar/lib/rpm, 6.4Mvar/cache/dnfinitramfsvar/lib/rpm, 4.3Mvar/lib/dnf, 6.4Mvar/cache/dnfdnf's logs come along too —
var/log/dnf.log,dnf.rpm.log,hawkey.log, each line wall-clock stamped.rootfs/install.rsomits$DNF_SDK_HOST_OPTS, which is what redirectslogdir/cachedir/persistdirat the SDK prefix, so the single omission that putsvar/cache/dnfandvar/lib/dnfin the sysroot puts the logs invar/log. Caught by @jetm in review.Two problems with shipping it.
It's dead weight. There is no runtime package manager on target, so nothing ever reads any of it. That's ~13M off the rootfs and ~14M off the initramfs (11% of the staged tree).
It blocks reproducibility. Every package records a wall-clock stamp in the rpmdb:
240 of them in the initramfs. On top of that
var/lib/dnf/history.sqliterecords the transaction itself, andvar/cache/dnfholds generated repodata plus per-repo.solvsolvfiles. While any of that is in the tree, the same package set produces different image bytes on every install — a runtime build and a standalone build will never agree — regardless of how the archive ormkfsstep is invoked.To be exact about what this earns: removing it is necessary and not sufficient.
cpio --reproducibleis--device-independenton GNU cpio 2.15 and does not touch mtime, and thermitself restamps the directories it empties. See the #199 section below.How
One
rm -rfplus onerm -fper script, placed afterpost_installso state left by a hook's owndnfcall is caught too.Deliberately not added to
DEFAULT_ROOTFS_POST_INSTALL/DEFAULT_INITRAMFS_POST_INSTALL: a user who definespost_installreplaces those lists wholesale, and would silently lose both the size win and reproducibility.Safety
The pristine sysroots keep their rpmdb. Checked every consumer:
AVOCADO_OS_BUILD_IDqueries read$ROOTFS_SYSROOT/$INITRAMFS_SYSROOText install,ext dnf,runtime install,runtime dnfcopies from$AVOCADO_PREFIX/rootfsNone of them touch the work copy. Tests assert the sysroot rpmdb is not removed, so that stays true.
Interaction with #199
Overlapping hunk — both insert into the same spot between the release-file injection and the archive step, and
initramfs/image.rsgains amod testsin both. On rebase the purge must land before #199's mtime-normalization pass, otherwise thermre-stamps/var/liband/var/cachewith wall-clock time and undoes part of what that pass is for.Neither PR alone gives a reproducible initramfs: #199 normalizes metadata, this normalizes contents. Both are needed.
Test
Two regression tests, one per script: all six paths are purged from the work copy and the purge precedes
mkfs/cpio. Deleting eitherrmline fails them.These originally also asserted
!contains("$ROOTFS_SYSROOT/var/lib/rpm"), which @jetm pointed out was vacuous — that string has no occurrences to begin with, and the build-ID query it claimed to protect is spelled--root "$ROOTFS_SYSROOT"with--dbpath. Removed rather than repaired; the sysroot's safety is argued above and is not something a substring check establishes.Full suite green, clippy clean with
-D warnings.