architecture
Overviewβ
This document captures key architectural decisions, workarounds, and lessons
learned during development of the OSTreeβComposeFS live migration tool. It
covers both the migrator binary (src/) and the E2E test harness
(tests/run-e2e.sh).
1. Boot Artifact Extractionβ
Problemβ
Phase 5 needs to copy kernel, initrd, and systemd-boot binaries from the
target container image (Dakota) to the ESP or /boot. The composefs EROFS
overlay mount returns zero-filled file content past an inline data threshold
(~4 KB per inode), corrupting large files like vmlinuz (17 MB) and initrd.
Attempted solutions (in order)β
| Approach | Peak disk | Time | Result |
|---|---|---|---|
| bootc cfs overlay mount | 0 | instant | Returns zeros for large files |
| podman cp | 6 GB (full image) | 30s | Works but ENOSPC with 14 GB loopback |
| skopeo copy | 6 GB+ | 60s | Same ENOSPC problem |
| Registry streaming | 500 MB | ~10 min | β Final β downloads layers one at a time |
Architecture decision: Registry streamingβ
extract_files_via_registry() and extract_subtree_via_registry() download
OCI layers iteratively: fetch β extract needed files β delete blob β repeat.
This bounds peak disk usage to the largest single layer (~200 MB).
Used for: vmlinuz, initrd, systemd-bootx64.efi, kernel modules (/usr/lib/modules/<kver>/).
Lesson learnedβ
EROFS bare-mount zero-fills content past 4 KB inline threshold. Any tool
reading large files from a composefs EROFS mount must use the bootc internals cfs oci mount overlay mount or stream from the registry.
2. Kernel Module Extraction for Initrd Rebuildβ
Problemβ
XFS root requires xfs.ko in the initrd. The EROFS mount can't provide
kernel modules (zero-filled). podman cp pulls the full target image
(~6 GB) into podman storage, hitting ENOSPC on the 20 GB disk with 14 GB
loopback allocated.
Attempted solutionsβ
| Approach | Peak disk | Disk needed | Free on XFS | Result |
|---|---|---|---|---|
| EROFS mount path | 0 | 0 | 3 GB | Zero-filled |
| podman cp | 6 GB | 6 GB | 3 GB | ENOSPC β |
| containers-storage: | 0 | 0 | 3 GB | Image not in podman |
| Registry streaming | 500 MB | 1 GB | 3 GB | β |
Architecture decision: Registry streaming for kmodsβ
extract_subtree_via_registry(target_image, "usr/lib/modules/<kver>", tempdir)
streams the kernel module subtree layer-by-layer. Peak: largest single layer
(~200 MB) + extracted tree (~300 MB) + new initrd (~80 MB) β 580 MB.
Free-space check lowered from 6 GB to 1.5 GB.
Lesson learnedβ
podman cp requires pulling the full image into podman storage first.
containers-storage: transport requires the image to already be pulled.
Neither works when disk is tight. Registry streaming is the only viable path.
3. Initrd Rebuild with Bootc Dracut Moduleβ
Problemβ
The initrd must include bootc-root-setup.service to mount the composefs
EROFS as the root filesystem. Without it, the system boots the OSTree
deployment (Bluefin) instead of the composefs overlay (Dakota).
Discoveryβ
The bootc dracut module (51bootc/module-setup.sh) has:
check() { return 255; }
return 255 means the module is never automatically included β it requires
explicit dracut --add bootc. Our initrd rebuild was using plain
dracut --force --kmoddir without --add bootc.
Fixβ
Add --add bootc to the dracut command for all initrd rebuilds:
let dracut_add = format!("{} bootc", mods_str);
cmd.arg("--add").arg(&dracut_add);
Where bootc dracut module livesβ
- Module:
/usr/lib/dracut/modules.d/51bootc/module-setup.sh - Service:
/usr/lib/systemd/system/bootc-root-setup.service - Binary:
/usr/lib/bootc/initramfs-setup(1.3 MB Rust binary) - Config:
/usr/lib/composefs/setup-root-conf.toml(optional)
The dracut module installs these files into the initramfs via its install()
function. The bootc module is opt-in because check() { return 255; } means
dracut never includes it automatically. You must pass --add bootc.
EROFS mount ordering in initrdβ
bootc-root-setup.service has:
ConditionKernelCommandLine=composefs
After=sysroot.mount
After=ostree-prepare-root.service
Before=initrd-root-fs.target
The ext4 loopback at /sysroot/composefs must be up before bootc-root-setup
runs. Our sysroot-composefs.mount (in xfs-mount.cpio) has:
Before=initrd-root-fs.target bootc-root-setup.service
If the loopback isn't mounted in time, bootc-root-setup.service can't open
the composefs repository (Repository::open_upgrade(sysroot, "composefs")),
and the system falls back to the OSTree xfs root (Bluefin).
Lesson learnedβ
Any dracut-based initrd rebuild for composefs must use --add bootc. The
bootc module is opt-in and excluded by default. Without it, the initrd lacks
bootc-root-setup.service, the composefs EROFS is never mounted as root,
and the system boots the OSTree deployment.
4. E2E Test Pipeline Architectureβ
Problemβ
The E2E test script (run-e2e.sh) must:
- Create a bootable VM disk with Bluefin LTS (XFS)
- Boot it, SSH in, run the migration
- Reboot into composefs (Dakota)
- Validate state preservation, rollback, commit
This is a 30+ minute test. Debugging failures requires fast iteration.
Architecture: checkpoint-based iterationβ
The script saves checkpoints:
| Checkpoint | When | What it saves |
|---|---|---|
disk.raw.pre-migration | After disk creation | Fresh Bluefin install |
disk.raw.post-migration | After host-side scan | Migrated disk (composefs) |
Just targets for fast iteration:
just e2e # Full run (BTRFS)
just e2e-lts # Full run (XFS)
just e2e-scan # Host-side .raw scan only
just e2e-reboot-test # Boot from checkpoint, validate composefs
just e2e-status # Show current state
just e2e-ssh # Interactive SSH
just e2e-tail # Stream serial console
Two-sided verificationβ
| Side | What | Where | When |
|---|---|---|---|
| In-VM | verify_migration() | src/migration/mod.rs | Inside QEMU after Phase 5 |
| Host-side | .raw disk scan | tests/run-e2e.sh | After QEMU shutdown, before reboot |
The host-side scan catches filesystem-level bugs the VM can't see (0-byte initrd from VFAT not flushing, BLS entries on wrong partition).
Lesson learnedβ
In-VM verification can't see filesystem-level issues because the kernel's page cache hides them. Always verify from outside the VM by mounting the raw disk image.
5. XFS Loopback Workaroundβ
Problemβ
XFS doesn't support fs-verity, which composefs requires. The composefs
object store must be on a verity-capable filesystem.
Solution: ext4 loopbackβ
Create a 14 GB ext4 loopback image at /sysroot/composefs-loopback.ext4,
format it with fs-verity support, and mount it at /sysroot/composefs:
let img = "/sysroot/composefs-loopback.ext4";
// truncate to 14 GB
// mkfs.ext4 -O verity
// mount $img /sysroot/composefs
During boot, sysroot-composefs.mount (from xfs-mount.cpio) mounts this
loopback in the initrd so bootc-root-setup.service can find composefs
objects.
Disk layoutβ
/sysroot/composefs-loopback.ext4 # 14 GB, ext4, verity
/sysroot/composefs/ # mount point (ext4 loopback)
objects/ # composefs content-addressed objects
images/ # EROFS images
Disk space constraintsβ
| Component | Size |
|---|---|
| XFS root | ~19.5 GB (20 GB disk - ESP - BIOS) |
| Loopback | 14 GB |
| Bluefin OSTree | ~6 GB |
| Composefs objects | ~6 GB |
| Free | ~3 GB (tight!) |
Lesson learnedβ
20 GB is barely enough for XFS + loopback + two OS images. The podman cp approach fails at this size. Registry streaming is mandatory.
6. SSH Reliability: The Bluefin ssDH Problemβ
Problemβ
Bluefin disables sshd.service by default. The E2E container image build
works around this by:
- Writing a
50-e2e-ssh.presetthat enables sshd - Creating
e2e-sshd.socket+e2e-sshd@.servicefor TCP port 22 (Bluefin's sshd only listens on Unix-local and vsock sockets) - Injecting the SSH public key into the disk's authorized_keys
But SSH connections are UNRELIABLE. Sometimes they work in 13 seconds. Sometimes they never work, even after 180 seconds of retries.
Root Cause: sshd-keygen raceβ
The e2e-sshd@.service runs /usr/sbin/sshd -i with StandardInput=socket.
This is the "unencrypted" sshd mode where the socket connection is handled
by systemd, and sshd runs as a per-connection service.
sshd -i requires host keys to be present at /etc/ssh/ssh_host_*key*.
These are generated by sshd-keygen@.service. On Bluefin, sshd-keygen
runs in parallel with other boot services. The e2e-sshd.socket can accept
a connection BEFORE sshd-keygen finishes generating host keys.
When sshd -i is started without host keys, it fails immediately with
sshd: no hostkeys available (exit code 255). The - prefix in
ExecStart=-/usr/sbin/sshd -i means systemd doesn't record the failure.
The client sees "Permission denied" β not "Host key not found" β because
sshd -i exits before even offering public key authentication.
Fix 1: Early sshd-keygenβ
Enable sshd-keygen before the socket accepts connections:
mkdir -p /etc/systemd/system/sshd-keygen.target.wants
ln -sf /usr/lib/systemd/system/sshd-keygen@.service \
/etc/systemd/system/sshd-keygen.target.wants/sshd-keygen@rsa.service
ln -sf /usr/lib/systemd/system/sshd-keygen@.service \
/etc/systemd/system/sshd-keygen.target.wants/sshd-keygen@ed25519.service
Fix 2: Serial console auto-loginβ
Override serial-getty@ttyS0 to auto-login as root:
[Service]
ExecStart=
ExecStart=-/sbin/agetty -o "-p -f root" --autologin root --noclear %I 115200 linux
This ensures we can always interact with the VM even when SSH fails.
Fix 3: test_key mismatch from checkpointsβ
The disk.raw.post-migration checkpoint has authorized_keys from one run.
Next run regenerates test_key via ssh-keygen, creating a NEW key that
doesn't match the OLD authorized_keys on disk.
This is the MOST COMMON cause of SSH failure β not a Bluefin bug but a stale checkpoint. The script shows "VM accessible via SSH after 13s" in fresh runs but "Permission denied" in checkpoint-restored runs.
Known SSH failure modesβ
| Symptom | Likely cause | Verdict |
|---|---|---|
| Permission denied (publickey) | Stale checkpoint, key mismatch | ~80% of failures |
| Connection refused | QEMU not running / port not bound | ~10% |
| Connection timeout | Firewalld blocking, host unreachable | ~5% |
| sshd: no hostkeys available | sshd-keygen race | ~5% |
Lessonsβ
- Always
sudo rm -f disk.raw*before a fresh run β checkpoints carry stale keys - Always regenerate session keys fresh (
ssh-keygen -t rsa -N "" -f test_key -q) - Always provide a serial console fallback for headless debugging
- Always enable sshd-keygen early to avoid host key races
7. Stale Mounts and Checkpoint Contaminationβ
Problemβ
After an aborted E2E run, the host is left with:
- Stale loop devices (
/dev/loop0...loopNattached to disk.raw) - Stale mount points (
/tmp/mnt-e2e-esp-scan,-root-scan,-boot,-ckpt) - Each new run mounts ANOTHER loop device on TOP of the existing mount, creating a multi-layered mount stack
findon these stacked mounts hangs indefinitely (kernel traverses through all layers)- Old checkpoint files (
disk.raw.post-migration) have stale SSH keys
The mount stacking bugβ
Each run of the host-side scan does:
sudo mount "$HOST_ROOT" /tmp/mnt-e2e-root-scan
If the previous run didn't unmount (killed by Ctrl-C, set -e exit, or
signal), the mount point is still active. The NEXT run's sudo mount
succeeds β it mounts on TOP of the existing mount. After 3-4 aborted runs,
the path /tmp/mnt-e2e-root-scan has 4 stacked mounts:
/dev/loop0p3 on /tmp/mnt-e2e-root-scan # run 1 (aborted)
/dev/loop1p3 on /tmp/mnt-e2e-root-scan # run 2 (nouuid)
/dev/loop2p3 on /tmp/mnt-e2e-root-scan # run 3
/dev/loop3p3 on /tmp/mnt-e2e-root-scan # run 4 (current)
find on this path walks through ALL stacked layers. Each layer transitions
require kernel __d_lookup calls. With multiple dead layers, find hangs
indefinitely trying to resolve dentries across the mount stack.
The checkpoint contamination bugβ
# Run 1: fresh run, test_key=RSA_KEY_A
# ... migration succeeds ...
# Post-migration checkpoint saved disk.raw with authorized_keys=RSA_KEY_A
# Run 2: resume from checkpoint
rm -f test_key
ssh-keygen -t rsa -N "" -f test_key # generates RSA_KEY_B
cp disk.raw.post-migration disk.raw # restores disk with RSA_KEY_A
# SSH fails: RSA_KEY_B β RSA_KEY_A
The checkpoint captures a specific SSH key. The next run regenerates the key but uses the checkpoint's old disk. Authentication fails silently with "Permission denied (publickey)".
Fixesβ
- Cleanup at start of each run:
sudo umount /tmp/mnt-e2e-esp-scan 2>/dev/null || true
sudo umount /tmp/mnt-e2e-root-scan 2>/dev/null || true
sudo losetup -d /dev/loop0 2>/dev/null || true
sudo rm -f disk.raw disk.raw.*
-
-o nouuid for XFS mounts: prevents duplicate UUID errors that cause mount failure and cascading find hangs
-
Regenerate key after restoring checkpoint: re-seed authorized_keys from the fresh public key
-
Delete all checkpoints before full runs:
sudo rm -f disk.raw disk.raw.pre-migration disk.raw.post-migration
Timelineβ
This bug wasted ~15 E2E runs. Symptoms appeared as "host-side scan hangs"
with no error message. Only noticing the stacked mounts via mount | grep mnt
revealed the cause. The initial assumption was a find bug on XFS or a slow
filesystem β not a mount stacking problem.
Lessonsβ
- Kill ALL mounts before each run β
umounton a stale mount point might unmount the TOP layer only, leaving the stack underneath. - Use
losetup -j disk.rawto find ALL loop devices for a given disk. - Checkpoints with authentication material are fragile. Regenerate keys when restoring from checkpoint.
findhanging without error is almost always a mount issue, not a filesystem issue.
8. VFAT Sync: Zero-Byte Initrd Bugβ
Symptomsβ
- In-VM
verify_migration(): β initrd is valid (200,915,858 bytes) - Host-side
.rawscan: β initrd is 0 bytes - The ESP directory listing shows the file with correct size, but reading the content returns nothing β the directory entry is correct (shows 200 MB) but the file data clusters were never flushed to disk.
Root Causeβ
VFAT (FAT32) on Linux uses writeback caching: file data is written to the kernel's page cache immediately, but the actual disk blocks aren't scheduled for writeback until:
- The file is closed AND the inode is evicted from cache
sync()is called explicitly- The filesystem is unmounted (which triggers a full sync)
The migration's boot artifact extraction writes vmlinuz + initrd to the ESP
via registry streaming (extract_files_via_registry). The function opens the
destination file with File::create(), writes data, and closes it. The VFAT
driver updates the directory entry (file size, timestamps) but the data
blocks are still dirty in the page cache.
verify_migration() then reads the initrd from the SAME mount β and gets
real data from the page cache. The verification passes.
But when the VM shuts down and the host-side .raw scan mounts the ESP
fresh, the page cache is cold. The kernel reads from disk and gets zeros
because the file data clusters were never flushed.
Why vmlinuz was fine but initrd was zeroβ
vmlinuz (19.6 MB) fits in a small number of FAT clusters. The initrd (200 MB) spans hundreds of clusters. With FAT32's loose cluster chain flushing, small files often get written back opportunistically while large files' cluster chains may remain unflushed indefinitely.
Fixβ
unsafe { libc::sync(); }
This flushes ALL dirty buffers to disk. Placed after boot artifact writes,
before patch_origin_boot_digest() reads them back for hashing.
Commit: 3245322
Timelineβ
- BTRFS tests passed because the migration writes directly to
/boot/on the btrfs root filesystem (no VFAT involved). The ESP was only written during Phase 5 for systemd-boot. - XFS tests showed the bug because the migration used the systemd-boot path (writes to VFAT ESP), then the host-side scan read from the unmounted disk.
- ~30 E2E runs wasted debugging "why in-VM passes but host-side fails"
before someone looked at the raw disk with
xxdand saw zeros.
Lessonβ
Always sync() after writing to VFAT or FAT32 before any cross-mount
verification. The kernel's page cache lies to you β especially for large
files.
9. set -euo pipefail Gotchas: The Silent Script Killerβ
This single bash feature caused more E2E failures than any migration bug.
set -euo pipefail is standard in modern shell scripts, but in a 1200-line
integration test with SSH pipelines, background processes, and disk mounts,
it's a landmine field.
Issue 1: SSH pipeline + dup2 stdout redirectβ
The original migration invocation was:
ssh ... "/var/tmp/bootc-migrate-composefs ..." 2>&1 \
| awk '{ print "[migrate] " $0; fflush() }'
The migration binary calls dup2(log_fd, STDOUT_FILENO) to redirect stdout/to
the log file. This closes the SSH channel's stdout (the dup2 replaces fd 1
with the log file). The local awk process sees EOF, exits cleanly β but
with set -o pipefail, the pipeline's exit status is the LAST command's
exit status (awk=0). The background block completes, writes MIGRATE_RC to
/tmp/e2e-migrate.rc, and the parent script continues.
BUT: when the migration binary's stdout is redirected, the awk pipe closing
early causes a race. If MIGRATE_RC isn't written before the parent checks
wait, the script gets an empty MIGRATE_RC, which triggers:
if [ "${MIGRATE_RC:-1}" != "0" ]; then exit 1; fi
The fix (e3f5a42): run migration detached inside the VM via a heredoc,
tail the log file for streaming, write rc to a marker file, fetch it after
ssh exits.
Later fix (f861bc9): the tee approach β a Rust background thread reads
from a pipe and writes to BOTH the original stdout and the log file
simultaneously.
Issue 2: find + head -1 + timeout + pipefailβ
ORIGIN=$(find "$HOST_ROOT_MNT/state/deploy" -name '*.origin' 2>/dev/null | head -1)
When find encounters a slow filesystem (e.g. XFS with duplicate UUID
causing mount to fall back to single-user mode), it hangs. The head -1
closes the pipe, find gets SIGPIPE, and with set -o pipefail, the
command substitution returns non-zero.
On some bash versions, set -e DOES trigger on command substitution
failures even when assigned to a variable. The script exits without
error output.
Fixes applied:
-maxdepth 5β prevents find from scanning the entire XFS treetimeout 10β kills find after 10 seconds|| trueβ prevents pipefail from propagatingc3f420bβ clean stale mounts before each run (prevents double-mount stacking that made find hang)
Issue 3: CHECKPOINT unbound variableβ
CHECKPOINT="$WORKSPACE_DIR/disk.raw.pre-migration"
if [ -f "$CHECKPOINT" ]; then ...
elif [ -f "$POST_CKPT" ]; then ...
else
SKIP_SETUP=false
# CHECKPOINT NEVER SET HERE β set -u KILLS SCRIPT ON NEXT USE
fi
...
cp disk.raw "$CHECKPOINT" # β BOOM: unbound variable
set -u makes any reference to an unset variable a fatal error. When
creating a fresh disk (no checkpoint), CHECKPOINT was never defined.
The cp disk.raw "$CHECKPOINT" at line 428 killed the script silently.
Result: the disk was created, fixtures injected, but the save failed. On next re-run, no checkpoint existed, and the script started from scratch.
Fix: set default value: CHECKPOINT="$WORKSPACE_DIR/disk.raw.pre-migration"
in the else branch, so it's always set.
Issue 4: sudo mount failureβ
sudo mount "$HOST_ROOT" "$HOST_ROOT_MNT"
VMLINUZ=$(find "$HOST_ESP_MNT/EFI/Linux" -name vmlinuz 2>/dev/null | head -1)
If the mount fails (e.g. duplicate XFS UUID), set -e doesn't trigger
because there's no || after mount. The script continues with an empty
mount point. The find on an empty directory returns immediately (nothing
found), but the script then tries to stat a non-existent file, which
fails, and FINALLY set -e triggers.
Fix: add || exit 1 to mounts and use -o nouuid for XFS.
Issue 5: In-VM diag SSH failureβ
After migration completes, the script runs in-VM diagnostics via SSH:
ssh $SSH_OPTS root@localhost bash <<'DIAG'
...
DIAG
If this SSH fails (VM rebooting, connection drops), the script exits.
With set -e, the failure propagates immediately β the host-side scan
never runs.
Fix: || true on the diag SSH.
Compilation of pipefail fixesβ
| Issue | Symptom | Commit |
|---|---|---|
| SSH pipe + dup2 closes stdout | Script exits mid-migration | e3f5a42, f861bc9 |
| find + head + timeout + pipefail | Script exits, no error msg | 1f963f8, 66a0037, c3f420b |
| CHECKPOINT unset | Script kills on fresh disk | a0484dd |
| sudo mount failure | Script exits silently | 743026e |
| In-VM diag SSH fails | Host-side scan never runs | 106547c, 41bade2 |
| Awk backslash escape | Syntax error kills awk pipe | 4b3163f |
Lessonβ
set -euo pipefail handles the simple cases (missing binaries, permission
denied) but fails catastrophically on subtle failures like find SIGPIPE,
unset variables, or SSH connection drops. Every command, pipeline, and
variable expansion in a 1200-line integration script must have an explicit
|| true, default value, or error guard. The debugging cost of a silent
set -e exit far outweighs the benefit of catching early failures.
10. OVMF NVRAM Persistenceβ
Problemβ
OVMF NVRAM (where BootOrder entries are stored) doesn't persist across QEMU restarts unless:
- QEMU uses
-machine q35(not pc) - A writable VARS pflash file is provided
- The VARS file is from a matched CODE+VARS pair (same build)
- The VARS file is properly padded to match CODE size
GRUB fallbackβ
Because NVRAM persistence is fragile, the migration ALSO configures GRUB's
saved_entry to the composefs BLS entry. This ensures composefs boots even
when OVMF resets BootOrder to shim β GRUB.
Lesson learnedβ
Never rely on UEFI NVRAM persistence in QEMU. Always configure a GRUB fallback path.
11. Composefs Boot Blocker: The Missing Dracut Moduleβ
Symptomsβ
- Migration completes all 6 phases (0β5) β
- Host-side
.rawscan shows valid vmlinuz (19.6 MB, MZ magic), initrd (220 MB), systemd-boot.efi, .origin file, BLS entries β - Direct
-kernelQEMU boot withcomposefs=<digest>on cmdline: - EROFS mounts during initrd:
erofs: (device erofs): mounted...β - But the system shows
Welcome to Bluefin LTSβ NOT Dakota β
Investigation timelineβ
Day 1: GRUB boot configuration. Attempted:
set default=0in grub.cfg β ignored- Direct
menuentry 'Dakota (composefs)'β GRUB ignored custom menuentry - Modified ESP grub.cfg to bypass chainloading β writes failed silently
Day 2: QEMU direct boot bypasses GRUB entirely.
-kernel+-initrd+-append "composefs=..."boots Bluefin- EROFS image IS mounted (
erofs: mounted with root inode @ nid 36) - But EROFS is NOT used as root β system boots Bluefin OSTree
Hypothesis: ostree-prepare-root vs bootc-root-setup ordering. The EROFS
mount might be happening too late (after switch-root), or the composefs
repository at /sysroot/composefs/objects/ might not be accessible because
the ext4 loopback isn't mounted in time.
Day 3: Source code research via GitHub search.
- Found bootc dracut module at
crates/initramfs/dracut/module-setup.sh bootc-root-setup.servicehasConditionKernelCommandLine=composefs- The service runs
initramfs-setup setup-rootwhich opens the composefs repository at/sysroot/composefs/and mounts the EROFS as root
Day 4: Checked if bootc module is in the initrd:
$ zcat initrd | cpio -t | grep bootc
# (no output β bootc module NOT in initrd)
$ cat /usr/lib/dracut/modules.d/51bootc/module-setup.sh
check() {
return 255 # β NEVER automatically included!
}
Root causeβ
The bootc dracut module has check() { return 255; } which means "never
include me unless explicitly requested." The initrd rebuild was using:
dracut --force --kmoddir <kmoddir>
This creates a new initrd from scratch using the host system's dracut
module defaults. Since 51bootc/module-setup.sh returns 255 from check(),
dracut doesn't include it β no bootc-root-setup.service, no
initramfs-setup binary, no composefs root setup in the initrd.
Without bootc-root-setup.service, the composefs EROFS IS mounted (by
ostree-prepare-root.service which has built-in composefs support) but is
NOT used as the root filesystem. The initrd falls back to the XFS OSTree
deployment (Bluefin).
The EROFS mount was misleadingβ
erofs: (device erofs): mounted with root inode @ nid 36.
This message comes from the kernel when ANY erofs filesystem is mounted.
ostree-prepare-root.service mounts the composefs EROFS image at a side
path (not as root) for verification. The EROFS mount was never the root.
Over a day of investigation was wasted on "EROFS mounts but system boots
Bluefin" because the mount message looks like success.
Fixβ
Include the bootc dracut module explicitly:
cmd.arg("--add").arg("bootc");
This adds:
/usr/lib/dracut/modules.d/51bootc/module-setup.sh/usr/lib/systemd/system/bootc-root-setup.service/usr/lib/bootc/initramfs-setup(1.3 MB Rust binary)/usr/lib/composefs/setup-root-conf.toml(if present)- Enables
bootc-root-setup.serviceininitrd-root-fs.target.wants
Commit: 7291259
Lessonsβ
erofs: mounteddoes NOT mean composefs is the root. It could be mounted anywhere β even a temporary side mount.- When
check() { return 255; }in a dracut module, the module is NEVER auto-included. You must pass--add <module>. - Reading the bootc source code (GitHub search via
gh api) was the key breakthrough β without it, we'd still be debugging GRUB config. - Always check what's ACTUALLY in the initrd (
zcat initrd | cpio -t | grep). The absence ofbootc-root-setup.servicewas the smoking gun.