phase2 attach postmortem
Scope. How wootc's Phase-2 boot went from "never attaches root.disk β emergency shell" to a working attach chain, told as the sequence of distinct root causes we peeled β each proven by the next run getting further β plus the wrong turns, the diagnostic techniques that actually worked, and the runner-ops traps that cost the most time.
One-line summary. Phase 2 failed for six independent reasons stacked on top of
each other, one of which was a self-inflicted observability regression that masked
the others and sent us chasing phantom host bugs. Reading the built artifacts off
disk (data.qcow2) with libguestfs β not serial logs β is what finally cracked it.
Background: the mechanism under testβ
wootc installs a bootc Linux image "Wubi-style": the Linux root filesystem lives in a
raw root.disk image hosted on the Windows NTFS partition. At Phase-2 boot the
initramfs must:
- mount the Windows NTFS,
losetup-attachroot.disk(with--partscanso its partitions appear),- let udev create
/dev/disk/by-uuid/<root>, - so the ordinary
sysroot.mount(root=UUID=β¦from the BLS entry) proceeds and ostree pivots.
The Phase-2 initramfs is a pure-systemd ostree initramfs β it never runs
dracut-initqueue. That single fact is the origin of half the bugs below: code
written as an initqueue hook is dead there, and the systemd replacement has to be
wired and ordered exactly right.
The layers, in the order we peeled themβ
0. (Pre-session) initqueue hook β systemd serviceβ
The attach logic began as a dracut initqueue hook. Proven dead: the Phase-2
initramfs runs dracut-initqueue zero times. Replaced with a systemd oneshot
(wootc-attach.service) ordered Before=sysroot.mount, wanted by
initrd-root-device.target. Correct in theory β but the next four bugs were all in
getting that service to actually exist, be wired, run, and succeed.
1. Wiring written to /etc, verified in /usr/lib β regen abortedβ
module-setup.sh wired the service with systemctl add-wants --root, which writes
the wants symlink under /etc/systemd/system/β¦wants/, but the build-time check
looked in /usr/lib/systemd/system/β¦. When add-wants succeeded (environment-
dependent β it failed offline, so offline validation passed), the fallback ln was
skipped, the /usr/lib check found nothing, dfatal fired, and the whole dracut
regen aborted.
Fix (ed86d13): create the wants symlink directly in $unitdir, drop
add-wants, verify with -L (symlink exists) not -e (target resolves).
Also: deploy.sh now exit 1s on a failed regen instead of logging it as a
"problem" and booting a stale/hookless initramfs anyway.
Real bug, but not why Phase 2 stayed broken.
2. π₯ Self-inflicted: set -e abort masked everythingβ
An observability commit (f48fcdc) added, inside the deploy-monitor loop:
echo "$NEW_OUTPUT" | grep -aoE "guard: β¦" | while read gl; do info "PHASE-1 GUARD: $gl"; done
The script runs under set -euo pipefail. On the common serial chunk with no
guard-line match, grep exits 1, pipefail propagates it, and set -e killed the
deploy-monitor loop on the very first chunk β surfacing as Deploying (0m) then
failure on every runner.
This masked all subsequent progress and produced two false diagnoses (see Wrong
Turns). The one run that had reached Phase 2 cleanly (29715469136) simply predated
this commit.
Fix (421fc20): capture with || true, then feed the loop.
Lesson: adding observability under set -euo pipefail is a landmine β any bare
grep/pipeline that can legitimately match nothing must end in || true.
3. π― Dangling wants symlink β the actual reason Phase 2 never bootedβ
With #1 and #2 fixed, the service still never ran (attach-loop entered = 0, zero
systemd trace). Found by disk archaeology: libguestfs read the booted Phase-2
initramfs straight out of data.qcow2's ESP (/EFI/wootc/phase2-initramfs.img) and
lsinitrd'd it. The initrd-root-device.target.wants/wootc-attach.service symlink
was present β but its target usr/lib/systemd/system/wootc-attach.service was
absent. A dangling symlink: systemd had nothing to start.
Cause: the deployer's module-setup.sh staged module-setup.sh +
wootc-attach-loop.sh into the deployer initramfs but never the .service unit
file, so deploy.sh's cp -a β¦/99wootc-boot/. carried no unit, and the Phase-2
regen's inst_simple "$moddir/wootc-attach.service" silently installed nothing while
ln -sf still made the symlink. A leftover from the hookβservice switch. The old
guard only checked the symlink, so it passed while the unit dangled.
Fix (d5365db): stage the .service; dfatal if the unit doesn't land; the
deploy guard now requires the unit file, not just the symlink.
Result: the service ran for the first time ever (attach-loop entered = 2).
4. Service ran, gave up before udev created the host deviceβ
The service ran, then bailed: host NTFS β¦ not present yet (initqueue will retry).
But it's a systemd oneshot β nothing retries. It looked before udev had processed
the Windows disk; After=systemd-udev-trigger only means the trigger fired, not
that processing finished.
Fix (dc44fa1): the script now udevadm settles and polls for the host device
(up to 60s) instead of assuming a retry; unit ordered
After=/Wants=systemd-udev-settle.service; udevadm staged into the initramfs.
Result: the host NTFS was found.
5. No NTFS driver in the image at allβ
Now it found the NTFS and tried to mount it β and failed:
cannot mount host NTFS rw (no ntfs3, no ntfs-3g) β¦ ntfs3=0 ntfs-3g=no.
Confirmed via podman: yellowfin (EL10, kernel 6.12) has zero NTFS support β
EL disables CONFIG_NTFS3, and ntfs-3g isn't installed. The deployer's runtime
ntfs-3g injection is therefore load-bearing, but it failed for two reasons:
- 5a. It ran a plain
dnf install ntfs-3g, which fails on EL becausentfs-3glives in EPEL, not the base repos. Fix (91ff455): enable EPEL (+CRB) first. Verified against yellowfin (installsntfs-3g-2026.2.25.el10). - 5b. Even then it failed β and the timing gave it away: "injecting ntfs-3g"
and "install failed" were stamped the same second (a real dnf install takes
seconds), i.e. the
podman runnever started the container. In the deployer's minimal initramfs, podman's default netavark path errors (nft did not return successfully) β the container netns can't be created. Fix (5ed3591):podman run --network=hostreuses the deployer VM's host netns (the one bootc-pull already succeeds on) + its resolv.conf, skipping netavark. Result β the breakthrough:host NTFS mounted via fuse-ntfs-3g,attached raw root.disk β¦ as /dev/loop0,post-attach partitions: loop0p1/p2/p3. The FUSE userspace driver mounts the NTFS; the whole fight was just getting that binary into the initramfs.
6. Partitions attached, but no by-uuid symlink β sysroot.mount timeoutβ
sysroot.mount then waited for dev-disk-by-uuid-<ext4 root>.device and timed out.
The tell: the loop partition nodes existed, yet not one ext4 UUID appeared in
/dev/disk/by-uuid/ (only the Windows disk's vfat/NTFS ones). losetup --partscan
creates the device nodes, but its partition-add uevents are missed when udev has
already settled by the time the service runs β so udev never blkids the loop
partitions and never makes the by-uuid symlinks.
Fix (27fbf1f): after losetup, re-read the partition table and re-trigger udev
add events for the loop device + partitions, then udevadm settle.
Status at time of writing: validating.
What workedβ
- Disk archaeology over serial capture. The decisive tool was
podman run --privileged -v storage:/s fedora β¦ libguestfs-toolsβvirt-cat/lsinitrdreadingdeployer.logand the booted initramfs straight out ofdata.qcow2. The serial is overwritten by later boots and the persisted log is unreachable once Phase 2 hits a Linux emergency shell β the disk has ground truth. This is now automated in CI (e2e-hosted.ymldisk post-mortem step) so every hosted failure self-diagnoses, including a::warning::for the dangling-symlink signature. - Assert the property, not a proxy. Every recurring bug was "status derived from
a proxy": the guard checked the symlink not the unit file; offline validation
checked
add-wantsreturn code not the symlink in the built image; the harness checked a marker's presence not that it came from this run. The guards now assert the real thing (unit file present; symlink is-L;matches=AND unit-file count). --network=hostfor podman in a minimal initramfs. bootc pull works via the host netns; a freshpodman runneeds netavark+nft, which the stripped initramfs can't provide.--network=hostsidesteps it entirely.- FUSE
ntfs-3gas the NTFS driver. Kernelntfs3is absent on EL; the userspace FUSE driver is kernel-independent and the right answer β the only trick is getting the binary into the initramfs. - Confirming the fix from the right log level. The
injecting/failedsame-second timestamp proved "container never started" vs "dnf failed" β a one-line diagnosis that decided the fix.
What didn't work / wrong turns (and why)β
- Blaming himachal's host for the
Deploying (0m)deaths. It was theset -eregression (#2) aborting on every runner. Lesson: identical failures across different hosts = a harness bug, not N host failures. We even wiped himachal's storage chasing a stale-OVMF-vars theory β pure waste. - Filing a "restore path broke" bug (#44). Same
set -eregression, not a real restore-path defect. - Inferring from our own speculative error string. deploy.sh's
[WARN] ntfs-3g install failed (network/repo?)is a guess we wrote; acting on it nearly sent us to fix DNS. The real error (podman runnever started) only showed in the timing. Always get the actual error, not your own hypothesis string. - Over-reading a Phase-2-only serial. "The dfatal is gone β my fix worked" was invalid: the dfatal/regen/guard all happen in the Phase-1 deployer boot, absent from a Phase-2 serial. Know which boot your log is from.
Runner-ops traps (the real time sink)β
- dockur names its QEMU process
windows, notqemu-system. Everypkill -f qemu-system/ps -C qemu-system-x86_64cleanup silently missed leaked VMs. A leaked 8 GB QEMU namedwindowsstarved memory and made QEMU die ~t=203s mid-deploy β which looked like a host crash but was OOM from our own accumulated relaunches. for d in /proc/*; do grep -qa qemu-system-x86_64 "$d/cmdline"false-matches the ssh command's own shell (its args literally contain the search string). It always reports phantom procs. Matchargv[1]viaps -eo pid,args | awk '$2=="run-e2e.sh"'.pkill -f run-e2eself-kills the ssh shell (its args contain "run-e2e").- The E2E lock is
flock -non fd 9 ofstorage/.run-e2e.lock; a leaked child inherits fd 9 and holds it.pgrepmisses it; find holders via/proc/*/fd(nofuseron himachal) and kill them, thenrmthe lock. - Do NOT
podman system reseta runner to "clean up" β it also destroys the user's own long-lived containers (builds, monitoring stack). A targeted clean (podman rm -f wootc-e2e-windows, clear lock, wipestorage/keepingcustom.iso,git reset --hard) is enough. - himachal's container bridge networking hits netavark/nftables errors β use
WOOTC_E2E_NETWORK_MODE=slirp4netnsfor the host container. (Distinct from the in-VM netavark issue that--network=hostsolves for the ntfs-3g injection.) - himachal's
git fetchintermittently lags a just-pushed commit β re-fetch iforigin/mainlooks stale.
Infra we built along the wayβ
- GHCR/ORAS Windows base-image snapshot (
e2e-snapshot.yml+--skip-installrestore) β primes a pristine, cleanly-shut-down Windows once so hosted runs restore in ~2 min instead of reinstalling (~20-30 min). Clean shutdown (not a live fsfreeze) avoids a dirty-NTFS image. Restore path needs a re-verify pass now that theset -ebug (its apparent breakage) is fixed. use_base_imagetoggle β forces a full install when needed.- Disk post-mortem CI step β libguestfs auto-diagnosis on every hosted run.
Layer 7+: the ostree pivot and the composefs/ostree boot modeβ
Once the attach chain was green (all of #1β#6), systemd.log_level=debug proved
Phase 2 got all the way to the ostree pivot: sysroot.mount status=0,
ostree-prepare-root status=0 β then emergency, because:
bootc-root-setup.service: ConditionKernelCommandLine=composefs failed.
initrd-root-fs.target: held back, waiting for: bootc-root-setup.service
- 7. returnβexit (
e4bd537): the attach script was written as a sourced initqueue hook and used top-levelreturn 0; run by the service as/bin/sh script,returnoutside a function is dash exit code 2 β the oneshot was marked failed β itsBefore=sysroot.mountbecame a failed dependency. Also removed the deprecatedsystemd-udev-settledep (it timed out and delayed the service past the device timeout) and the early initqueue hook (it attached at tβ1.8s, before systemd-udevd tracked devices, and set the re-entrancy guard so the correctly-ordered service no-op'd). - 8. composefs mode (
37b08c1β66ef7c5):bootc-root-setup.service(which mounts the real composefs root) is gated on thecomposefskarg. The E2E coupled composefs to the bootloader (grub2 β composefs=0), but composefs is a property of the IMAGE (/usr/lib/ostree/prepare-root.conf [composefs] enabled). Final fix: the deployer auto-detects composefs from the image (wootc.composefs=auto) and installs to match β composefs (no bootupctl) vs traditional ostree (bootupctl + GRUB). - 9. verify umount hang (
996a8cf): with composefs the deployer's verify step couldn't mount the installed root and left a loop/overlay pinning/mnt/ntfs, so the finalumountblocked "target is busy" forever and the whole deploy timed out β even though the install had succeeded. Made the teardown bounded + lazy fallback so the deploy always reaches the reboot into Phase 2.
Root blocker underneath all of layer 8: the target IMAGE. yellowfin (EL10)
shipped composefs enabled=yes but EL10 should be traditional ostree (it hasn't
migrated). On that misbuilt image neither mode works: composefs=0 skips
bootc-root-setup (image declares composefs) β root never mounts; composefs=1
breaks the Phase-2 boot even earlier (no attach, device timeout). Filed
tuna-os/wootc#28 (support both modes) and tuna-os/tunaOS#748 (fix the EL10 image β
ostree). #748 resolved; ostree yellowfin rebuild incoming. The wootc side is done
(auto-detect); a clean 1β2 is expected on the new ostree image, where the attach
path is already proven green and bootc-root-setup won't be gated off.
Open itemsβ
- Re-run on the new ostree yellowfin build: auto-detect β composefs=0 β the
proven-green attach path + real root mount β Phase 2 boots, then
--phase3graduate β the 1β2β3 goal. - Verify the traditional-ostree
bootupctl+ GRUB boot-entry path on the ostree image (tuna-os/wootc#28). - Then the GUI-driven leg (CDP for the Windows installer, AT-SPI/dogtail for the Linux Phase-3 GTK app).
- Re-verify the base-image restore path (was masked by the
set -ebug). - Durable alternative to runtime ntfs-3g injection: ship
ntfs-3gin the yellowfin image (its Containerfile, with EPEL) or a static/muslntfs-3gbundled in the module. Deletes the whole deploy-time-injection failure class; Phase 2 and Phase 3 both fundamentally need to mount the NTFS-hosted root.disk.
Earlier pitfalls (from the commit history, before this session)β
The layers above sit on top of a longer trail. These recur enough to be worth naming β most are the same failure classes seen again this session.
The NTFS injection has bitten repeatedly (this session was the latest layer)β
46a767afirst identified the root cause via a fast container repro, not a 20-min deploy: EL kernels ship nontfs3and yellowfin ships nontfs-3g, so the loop-attach hook can't mount the Windows volume. Introducedensure_ntfs_support()(inject via the image's own repos so glibc matches).469f4e7: the injection usedpodman run -dβ detached mode does not work in the deployer initramfs ("could not start the container"); all its other podman calls are foreground. (This session's--network=hostfix is the next layer on the same container-won't-start problem.) Same commit: the capability probe misreadCONFIG_NTFS3_FS=yβ a built-in ntfs3 has no.koand no binary yet mounts fine; consult/proc/filesystemsand the kernel config, not just files.b945a0dβ24c48f8: making injection failure a hard error broke deploys that actually worked (images with built-in ntfs3). Reverted to best-effort. Takeaway: the capability check is not authoritative; the runtime fallback is the real guard.
Status-from-a-proxy false positives (the dominant bug class)β
61e974d: "Phase 2 booted" fired from the initramfs β the detector matchedostree=in the cmdline echoed by the initramfs, so a boot that then failedsysroot.mountwas reported as a PASS; the downstream symptom (no Linux QGA) sent debugging to the wrong place. Require evidence of the real root (multi-user/graphical target or login), fail loud on emergency.ff3f827: a kernelreboot: Restarting systemwas treated as deploy success β but the watchdog reboots that way too. Only the deployer's own reboot message implies success.aa00b24: a dead serial feed was silently stale β a stale capture read as "quiet guest". Make it loud.
The in-guest watchdog saga (8187fa9 and its three predecessors)β
Four attempts. The bug was structural, not a cancellation detail: any background
job in the deploy hook is a child of dracut-initqueue, and initqueue waits for its
children β so a watchdog blocks the very thing it guards. ( sleep; reboot ) & never
cancelled (blocked initqueue 45 min); + kill/wait blocked forever when the kill
missed; + setsid put the sleep beyond both pid- and process-group-kill. Final answer:
delete the in-guest watchdog; the host covers all cases.
Serial saturation (18c8fd7, reverting 93e87e1)β
Adding a direct /dev/console write to log() meant every line went out three
times over a 115200 baud serial (stdout + kmsg-forwarded + direct). During the
verbose bootc install the link saturated and a blocking console write stalled every
deploy at phase: verification. <27> (KERN_ERR) already reaches the console under
quiet, so the extra write bought nothing. Low-volume boot paths can write to
/dev/console; the high-volume installer must not.
Boot-chain handoffβ
75f8be3:bcdedit /set {fwbootmgr} bootsequence {GUID}β PowerShell parsed the bare{GUID}as a script block, bcdedit got garbage and silently failed, so the one-shot Phase-2 boot was never set and every "reboot into Phase 2" just went back to Windows. This is why no automated run reached a Phase-2 boot for a long time even after deploys succeeded. Fix: PowerShell stop-parsing (--%) + verify the bootsequence took.028ab37: the Phase-2grub.cfgwas written only to/EFI/<vendor>/, but the target-signed grub loads from\EFI\fedora, resolves its prefix to its own dir, and read a stale deployer menu β "file not found", booting neither OS. Write the menu to all three prefixes (/EFI/{vendor,fedora,wootc}/grub.cfg).d1dd5a9: chain systemd-boot through the Debian shim.
Timeouts / hangsβ
c5de470: an unboundedchroot dracutregen can hang forever and writes nothing to the journal β indistinguishable from a dead deployer (the 31-min silent hang). Bound every regen (timeout 900), announce it, treat a timeout as HARD failure.bc504a1: wait loops counted ticks, not wall-clock β measured 0.68Γ real, so a "45-min" timeout was really ~66 min and progress under-reported by half an hour.4ad34e2: a hung QGAexecfroze runs indefinitely β bound every QGA call.
Data-safety near-misses (North Star)β
aded90f: the rung-3 graduate targeted "the first disk that isn't root's" β which in Phase 2 is the Windows disk (root lives on the loopback).bootc install --wipethere destroys Windows. Fixed to pick a blank disk (emptiness, not non-rootness) β seepick-blank-disk.sh. Same commit: the migration opt-out was cosmetic βwootc-manifest-guiwrote a selection file that nothing read, so turning "Games" off still migrated Steam. Both are "shipped-looking but broken", caught by adversarial review.4efb71c: gate look/wifi/wsl on the chooser and make every gate fail OPEN.
Harness self-harmβ
709857a: the harness was filling its own runners' disks with un-pruned artifacts.43xxx/1c6d713: the pre-deployer snapshot was accidentally load-bearing β disabling it exposed a race the 10-20 min freeze/copy had been hiding as an incidentalsleep.54fe1db/70c2799/etc.: repeated preflight disk-size mis-sizing (120 GiB excluded hosted runners; 90 was right).6757300β deleted: the qemu-nbd self-contained closure (26-library) failed on a libfuse3 soname mismatch β the cross-distro binary-bundling trap that also argues for the raw-losetupswitch and against bundling foreign binaries.
Commit trailβ
| Commit | Layer | What |
|---|---|---|
ed86d13 | 1 | wire wants in $unitdir not /etc; abort on failed regen |
f48fcdc | (regression) | guard-verdict observability β introduced the set -e abort |
421fc20 | 2 | fix the set -e abort (|| true) |
d5365db | 3 | stage the .service unit file (dangling-symlink fix) |
dc44fa1 | 4 | wait for the host NTFS instead of assuming an initqueue retry |
91ff455 | 5a | enable EPEL to install ntfs-3g |
5ed3591 | 5b | --network=host so the injection container can start |
27fbf1f | 6 | udev-trigger after losetup so by-uuid symlinks appear |
e4bd537 | 7 | attach script exit not return; drop udev-settle + early hook |
37b08c1β66ef7c5 | 8 | composefs is per-image; deployer auto-detects it |
996a8cf | 9 | bounded /mnt/ntfs umount so a stuck verify can't hang the deploy |