Skip to content

purge package-manager state from rootfs and initramfs images - #203

Open
mobileoverlord wants to merge 2 commits into
mainfrom
beni-review/purge-pkg-state
Open

purge package-manager state from rootfs and initramfs images#203
mobileoverlord wants to merge 2 commits into
mainfrom
beni-review/purge-pkg-state

Conversation

@mobileoverlord

@mobileoverlord mobileoverlord commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Found while reviewing #199. Splitting it out because it stands on its own and touches both image paths, not just the cpio.

What

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:

tree size pkg-manager state
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

dnf's logs come along too — var/log/dnf.log, dnf.rpm.log, hawkey.log, each line wall-clock stamped. rootfs/install.rs omits $DNF_SDK_HOST_OPTS, which is what redirects logdir/cachedir/persistdir at the SDK prefix, so the single omission that puts var/cache/dnf and var/lib/dnf in the sysroot puts the logs in var/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:

busybox-udhcpc  INSTALLTIME=1773929326  INSTALLTID=1773929326
libsmartcols1   INSTALLTIME=1773929329  INSTALLTID=1773929326

240 of them in the initramfs. On top of that var/lib/dnf/history.sqlite records the transaction itself, and var/cache/dnf holds generated repodata plus per-repo .solv solvfiles. 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 or mkfs step is invoked.

To be exact about what this earns: removing it is necessary and not sufficient. cpio --reproducible is --device-independent on GNU cpio 2.15 and does not touch mtime, and the rm itself restamps the directories it empties. See the #199 section below.

How

One rm -rf plus one rm -f per script, placed after post_install so state left by a hook's own dnf call is caught too.

Deliberately not added to DEFAULT_ROOTFS_POST_INSTALL / DEFAULT_INITRAMFS_POST_INSTALL: a user who defines post_install replaces 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_ID queries read $ROOTFS_SYSROOT / $INITRAMFS_SYSROOT
  • installroot seeding in ext install, ext dnf, runtime install, runtime dnf copies from $AVOCADO_PREFIX/rootfs

None 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.rs gains a mod tests in both. On rebase the purge must land before #199's mtime-normalization pass, otherwise the rm re-stamps /var/lib and /var/cache with 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 either rm line 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.

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.
Copilot AI lite review requested due to automatic review settings August 13, 2026 14:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and var/cache/dnf from 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 jetm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 newcvar/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 .raw images mkfs straight from the ext sysroot, which ext/install.rs:603 seeds with a full rootfs rpmdb copy. Same problem, not covered here, and --exclude-path is already wired there for var_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 at initramfs/image.rs:464. The three positive assertions do real work and fail if the purge line is deleted; this one asserts nothing.
  • No CHANGELOG.md Unreleased 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.
@mobileoverlord

Copy link
Copy Markdown
Contributor Author

Pushed in 4a9774e. Both findings taken, plus two of the three nits.

The var/log miss is real and now fixed. This is the best catch in the review, because the mechanism you inferred is the same omission that produces the state the PR already purges — rootfs/install.rs runs $DNF_SDK_HOST $DNF_SDK_TARGET_REPO_CONF without $DNF_SDK_HOST_OPTS, and that variable is the one setting logdir, cachedir and persistdir at the SDK prefix. So the 6.4M var/cache/dnf I measured is the observed half of exactly the thing you reasoned about for logdir. That is stronger than a code read: the cache being there is proof the redirect is absent.

Both images now rm -f dnf.log, dnf.rpm.log and hawkey.log, and both tests assert all six paths. Deleting the new line fails them (var/log/dnf.log must be purged from the work copy before imaging).

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 (the rm re-stamps /var/lib and /var/cache with wall-clock time) and said neither PR alone gives a reproducible initramfs. What was wrong was the section heading — "It's why the images aren't reproducible" reads as a claim to have fixed it, and a heading is what people take away. It says "It blocks reproducibility" now, which is what I can defend.

Your cpio measurement settles the --reproducible/--device-independent question for good, so thank you for running it rather than reading the man page.

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.

ext/image.rs:895 — that is #205, which was open when you wrote this and which you have since reviewed. Excluded there rather than deleted, for the reason you confirmed on that PR.

CHANGELOG. Added under Changed, naming the one consumer this can surprise: anything running rpm -qa against a built image rather than against the sysroot.

On your two stated gaps — the erofs -T question is #204's territory and I have flagged it as open there too; nobody in this review has erofs-utils available, so it stays unanswered rather than assumed.

@jetm jetm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread CHANGELOG.md
### 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

mobileoverlord added a commit that referenced this pull request Aug 16, 2026
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`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants