feat(install): all-plain layout + self-sizing first boot, signed origin, auto-updates - #5
Merged
Merged
Conversation
…t dir
Two changes, the second discovered while validating the first.
LVM layout (iso/disk.toml)
--------------------------
Replaces the single [[customizations.filesystem]] entry — the simple and
advanced (customizations.disk) forms are alternatives, not additive.
z170asrock is swapping its boot NVMe from 931 GB to 4 TB. Measured live
2026-07-29, that host has NO LVM today: one 20 GiB xfs partition carries
/sysroot + /var + /etc, and ~911 GB of the drive is unallocated because the dd
install was never grown. (docs/hardware-inventory.md described a vg4base +
vg4home layout that has not existed since the uCore migration.)
vg4base now holds root (16 GiB) and var (10 GiB). Splitting /var out of root is
the one structural change and it earns its keep on an unattended server: if
container images, logs or Postgres fill a shared filesystem you cannot stage an
ostree upgrade or cleanly log in to fix it. FCOS supports a separate /var as a
first-class configuration.
Nothing is needed in the image for this — lvm2, device-mapper, the dracut 70lvm
module and coreos-populate-lvmdevices.service are already present, and lsinitrd
confirms LVM is already in the initramfs.
bootc-image-builder minsize semantics
-------------------------------------
Documented in the file because it is not in the upstream docs and cost two
builds to pin down. `minsize` on the lvm partition is REQUIRED (omitting it
fails with "minsize is required"), and it is NOT the size of the volume group:
it is applied to the ROOT lv, root's own minsize is ignored, other LVs get
their own minsize, and the VG becomes the sum.
VG 26 GiB + root 16 GiB + var 10 GiB -> root=26G var=10G VG=36G raw 37.2 GiB
VG 16 GiB + root 16 GiB + var 10 GiB -> root=16G var=10G VG=26G raw 27.2 GiB
Not just image size: XFS cannot shrink, so an oversized root LV is space that
can never be handed back to var.
Justfile: bib raw output lands in output/image, not output/raw
--------------------------------------------------------------
The post-build step cleared output/${type} before `mv -f`. bib's subdir name
does not track --type: iso -> bootiso (already special-cased), raw -> image,
qcow2 -> qcow2. So for raw builds output/image was never cleared, mv failed
with "cannot overwrite 'output/image': Directory not empty", and the recipe
exited 1 while leaving the PREVIOUS build's disk.raw in place. A green-looking
build that silently ships a stale artifact. Replaced with an explicit
type -> dir mapping.
Verified by building three times and loop-mounting each result:
partition table 1M bios_grub / 200M EFI / 1G xfs /boot / 26G LVM2_member
/boot and EFI outside LVM, as required by the bootloader
vg4base root 16.00g xfs label=root (holds boot, ostree, var)
var 10.00g xfs label=var
APST karg present in /loader.1/entries/ostree-1.conf, so a dd'd
system is protected from its first boot
raw 29208084480 bytes, staged and sha256-verified to
/mnt/pny2xxG/server4homelab/july-29-2026/
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverses course on LVM-on-root/-var. Three layouts were built, loop-mounted and
confirmed byte-correct, and all three failed to boot. FCOS/uCore cannot carry a
boot-critical filesystem on LVM:
1. root on an LV — the initramfs has NO LVM support at all:
dracut modules present : crypt, dm (no lvm)
lvm files in initramfs : none
FCOS finds root by LABEL, nothing can activate the VG, so:
dev-disk-by-label-root.device: Job ... failed with result 'timeout'
Started emergency.service - Emergency Shell
rd.lvm.lv= does not help; there is no LVM tooling to act on it.
2. root plain, /boot omitted — bib only auto-creates /boot when root is on an
LV, and customizations.disk gives exactly what you declare:
error: grub_search_label:527: no such device: boot.
error: grub_loader_boot:196: you need to load the kernel first.
3. root plain + /boot + /var on an LV — boots into the real root, then:
Timed out waiting for device dev-...-<var LV fs UUID>
Dependency failed for var.mount - /var
The image ships lvm2-monitor, lvm2-lvmpolld, lvm-devices-import and
coreos-populate-lvmdevices but NO activation unit (no lvm2-activation*, no
lvm2-pvscan@). And coreos-populate-lvmdevices declares
RequiresMountsFor=/var/lib — it needs /var mounted, which is the very thing
blocked. Circular, so it can never help here.
This layout is all-plain and mirrors what already runs on z170asrock:
1M bios_grub | 200M ESP | 1G xfs /boot (label boot) | 20G xfs / (label root)
Root is last, so it grows with plain `growpart <disk> <n> && xfs_growfs /`.
/boot is declared explicitly because the baked grub.cfg does
`search --label boot` and bib will not create it for a plain root.
VM-verified (qemu, OVMF, emulated NVMe, COW overlay grown to 4T so the GPT
alt-header condition matches a real dd):
GRUB loaded the kernel from /boot
initramfs found /dev/disk/by-label/root; XFS (nvme0n1p4) mounted
ostree-prepare-root: composefs mounted successfully
reached "localhost login:" — no emergency mode
nvme_core.default_ps_max_latency_us=0 present in the running cmdline
LVM is not abandoned, just moved out of the boot path: per-app LVs created
AFTER install and mounted under /var/lib/<app> with nofail and a device
timeout. That is all the snapshot-to-ZFS workflow needs. Documented in the file.
A loop-mount proves the bytes are right, not that it boots — all three failures
passed loop-mount inspection. Always VM-boot a change to this file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pdates on Makes the raw disk image a complete starting point for a new server instead of something that needs a documented list of manual post-install steps. Three changes, all aimed at "dd it and walk away". 1. server4home-firstboot-grow.service — grows root into the real drive on first boot and hands the remainder to an LVM PV + empty VG (vg4data) for app data. Root deliberately stays 20 GiB in the image. The raw is sparse (~5 GB actual for a 22 GiB apparent file) but dd writes the *apparent* size, so baking a 250 GiB root would push a quarter-terabyte of mostly zeros onto every target drive. Growing on the hardware is also the only way one image can fit any boot drive size, which matters now that this image is meant to seed similar servers rather than one specific host. Target size comes from /etc/server4home/root-size (default 250G; the literal "max" takes the whole disk and creates no VG). If the drive is too small for the target, or the leftover would be under 50 GiB, root simply takes everything and no VG is made. The unit does the step that is easy to miss and impossible to skip: sgdisk -e. A small image written to a big drive leaves the GPT describing the *image's* size, so until the backup header is relocated there is literally no free space to grow into and growpart is a no-op. This is exactly the state z170asrock was found in — a 4 TB drive whose GPT reported "last usable sector 44455902, total free space 0 sectors". Root's own partition entry is never rewritten by hand: the unit creates the app-data partition at the point where root should stop, which bounds the gap, then lets growpart fill it exactly. Verified against z170asrock's real geometry — 250G yields a byte-exact 250 GiB root at an aligned boundary with 3.4 TiB left for the VG; 1T, 512G, 8T (too big) and max all behave correctly. Guarded for unattended use on a server: resolves root by filesystem LABEL rather than a hardcoded /dev/nvme0n1 (NVMe enumeration is not stable), refuses anything that is not XFS, grows the real XFS mountpoint (/sysroot — / is a composefs overlay and is not growable), treats growpart's exit 2 as "nothing to do", and keeps an attempt marker so a partial failure surfaces instead of retrying partition edits in a loop. 2. build-raw-ghcr — builds the raw from the published signed registry image. bib bakes whatever image reference it was given as the installed system's ostree origin. Building from localhost/server4home produces a host whose origin is ostree-image-signed:docker://localhost/server4home:stable, which no amount of enabling a timer can ever update — there is no such registry to pull from. This is the state z170asrock is in today. Building from the ghcr reference makes the machine self-updating from first boot and removes the post-install rpm-ostree rebase step entirely. 3. rpm-ostreed-automatic.timer enabled via timers.target.wants. It was inactive on every host, which is why one box sat 8 days behind its own healthy image pipeline. Paired with the existing AutomaticUpdates=stage policy this downloads and stages but leaves the operator to choose when it activates — deliberately not bootc-fetch-apply-updates.timer, which fetches and reboots, and which a storage server should not do unattended. Beszel is intentionally NOT pre-enabled. beszel.container gates on /etc/beszel/hub.enabled specifically so a single host runs the hub; baking that marker would make every server built from this image start its own. The agent gates on /etc/beszel/agent.env, which is a secret and must not ship in an image. Both quadlets and their tmpfiles are already baked, so the gates are the only thing left and they are correctly manual. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 2 — self-sizing first boot, signed origin, auto-updates on
server4home-firstboot-grow.serviceGrows root into the real drive on first boot, then hands the remainder to an LVM PV + empty
vg4data.Root stays 20 GiB in the image on purpose. The raw is sparse (~5 GB actual for a 22 GiB apparent file) but
ddwrites the apparent size, so a baked 250 GiB root would push a quarter-terabyte of mostly zeros onto every install. Growing on the hardware is also the only way one image fits any boot drive size.Size comes from
/etc/server4home/root-size(default250G; the literalmax= whole disk, no VG). Too-small drive, or leftover under 50 GiB → root takes everything and no VG is created.It does the step that is easy to miss and silently fatal:
sgdisk -e. A small image on a big drive leaves the GPT describing the image's size, sogrowpartis a no-op until the backup header is relocated. That is exactly the state z170asrock was found in:Root's own partition entry is never hand-rewritten — the unit creates the app-data partition where root should stop, bounding the gap, then lets
growpartfill it exactly.Verified against z170asrock's real geometry:
250G512G1T8TmaxGuarded for unattended server use: resolves root by filesystem LABEL not
/dev/nvme0n1(NVMe enumeration is not stable), refuses non-XFS, grows/sysrootrather than/(which is a composefs overlay and not growable), treatsgrowpartexit 2 as no-op, and keeps an attempt marker so a partial failure surfaces instead of looping partition edits unattended.build-raw-ghcrbib bakes whatever reference it was given as the installed ostree origin. Building from
localhost/server4homeyieldsostree-image-signed:docker://localhost/server4home:stable— an origin no timer can ever update, because there is no such registry to pull from. That is z170asrock's current state. Building from the ghcr reference makes the host self-updating from first boot and deletes the post-installrpm-ostree rebasestep.rpm-ostreed-automatic.timerenabledInactive on every host, which is why one box sat 8 days behind a healthy image pipeline. Paired with the existing
AutomaticUpdates=stagethis stages but lets the operator pick the activation moment — deliberately notbootc-fetch-apply-updates.timer, which fetches and reboots, which a storage server should not do unattended. (The k3s variant does enable that one; left alone as out of scope.)Beszel deliberately NOT pre-enabled
beszel.containergates on/etc/beszel/hub.enabledprecisely so one host runs the hub — baking that marker would make every server built from this image start its own. The agent gates on/etc/beszel/agent.env, a secret that must not ship in an image. Both quadlets and their tmpfiles are already baked, so the gates are all that remain, and they are correctly manual.VM verification of the first-boot grow — and the bug it caught
Booted against a 4 TiB qcow2 COW overlay over the real
disk.raw, which reproduces exactly what a realddproduces: a small image on a big drive whose GPT still describes the image's size and whose backup header is not at the end. Emulated NVMe so the device isnvme0n1and the label-based discovery is exercised as it will be on hardware. The overlay means the artifact itself is never modified.Run 1 failed, and it was worth finding:
All the partition work had succeeded —
sgdisk -e, thep5placement,growpartreportingCHANGED: ... new: size=524288000 end=526798847. The unit avoided/(composefs, not growable) and targeted/sysroot, which is the XFS — but ostree mounts/sysrootread-only. Result was the worst possible shape: a 250 GiB partition containing a still-20 GiB filesystem, and no PV at all because the script exited beforepvcreate.Note this is invisible from the partition table alone — an independent
qemu-nbdread showedp4 250.0 GiB xfsandp5 3.8 TiB vg4data, which looks like success. Onlyp5lackingLVM2_membergave it away.Fixed by growing via
/var, a bind of the same filesystem that is mountedrw; growing is a superblock operation so it grows the whole XFS either way (xfs_growfs -nreports an identical superblock through both paths). That exposed a second bug in the same block:findmntreports/dev/nvme0n1p4[/ostree/deploy/fedora-coreos/var], so the exact-match device comparison would never have matched/vareven as a candidate.Run 2, clean:
Independently verified from the overlay rather than trusting the guest's report:
sgdisk -pviaqemu-nbdp4250.0 GiB,p53.8 TiBvg4data, last usable sector 8589934558xfs_db -c 'sb 0' -p dblocks65536000 × 4096= exactly 250 GiB (5243130 was the stuck value)LVM2 001+vg4datapresent/var/lib/server4home/firstboot-grow.donewrittendisk.rawunchanged by the testBoot reached
multi-user.targetand a login prompt with no emergency shell. Only failing unit iszincati(no Ignition config in a bare VM; uCore usesrpm-ostreed-automaticregardless) — the same benign failure the earlier layout verification saw.Not exercised: the remount-
/sysroot-rw fallback, since/varwas writable as expected. It exists for a host where no writable mountpoint of the root filesystem is present.Also note CI's
Build and push imagefailure here is infrastructure, not content:OCI runtime error: crun: unknown version specifiedin the ZFS smoke test step.Two changes
iso/disk.toml, verified by booting it in a VM.mvcan no longer leave a previous build'sdisk.rawin place.Why there is no LVM in the boot path
FCOS/uCore cannot carry a boot-critical filesystem on LVM. Three attempts, all of which built cleanly and passed loop-mount inspection:
1 — root on an LV. The initramfs has no LVM support whatsoever:
FCOS locates root by LABEL; with no way to activate the VG the label never appears:
rd.lvm.lv=does not help — there is no LVM tooling in the initramfs to act on the karg.2 — root plain,
/bootomitted. bib only auto-creates/boot"when the root partition is on an LVM logical volume", andcustomizations.diskgives you exactly what you declare:3 — root plain +
/boot+/varon an LV. Boots into the real root, then:The image ships
lvm2-monitor,lvm2-lvmpolld,lvm-devices-importandcoreos-populate-lvmdevices, but no activation unit — nolvm2-activation*, nolvm2-pvscan@. Andcoreos-populate-lvmdevicescannot help: it declaresRequiresMountsFor=/var/lib, so it needs/varmounted, which is the very thing blocked. Circular.The layout that ships
Mirrors what already runs on z170asrock. Root is the last partition, so it grows with a plain
growpart <disk> <n> && xfs_growfs /./bootis declared explicitly because the bakedgrub.cfgdoessearch --label boot.VM verification
qemu + OVMF, emulated NVMe (so the device is
nvme0n1, matching the server), COW overlay over the realdisk.rawgrown to 4 TiB so the GPT-alt-header-not-at-end condition matches a realdd:Two benign messages:
failed to create symbolic link '/var/log'(present in every boot, including working ones) andzincati.servicefailing (no Ignition config in a bare VM; uCore usesrpm-ostreed-automaticanyway).Justfile: bib raw output lands in
output/image, notoutput/rawThe post-build step cleared
output/${type}beforemv -f. bib's subdir name does not track--type:--typeisobootiso(already special-cased)rawimage← the bugqcow2qcow2So
output/imagewas never cleared,mvfailed withcannot overwrite 'output/image': Directory not empty, and the recipe exited 1 while leaving the previous build'sdisk.rawin place. A July 21 image survived a July 29 build this way and was nearly verified as if it were new. Replaced with an explicit type→dir mapping.LVM is not abandoned — just moved out of the boot path
Per-app LVs created after install, mounted under
/var/lib/<app>withnofail,x-systemd.device-timeout=30so a problem degrades instead of hanging a headless box. That is all the snapshot→ZFS workflow needs:Standing rule added to the file
Always VM-boot a change to
disk.tomlbefore writing it to hardware. All three failures above passed loop-mount inspection. Byte-correct on disk is not the same as bootable.🤖 Generated with Claude Code