- Status: done (config validated locally; full boot proof + real-box acceptance are CI/live, see Known gaps)
- Date: 2026-07-09
- Specs touched:
ENVIRONMENT.md(# Boot — new dual-firmware boot bullet);docs/dev/hosted-boot-proof.md(the lane now has a legacy-BIOS scenario)
Closes #277. A box provisioned from the hosted cloud image did not boot on Hetzner Cloud CX (Intel) shared-vCPU types: the console printed SeaBIOS's Booting from Hard Disk and then hung before any kernel output — dark to ICMP and every TCP port. The same image booted normally on CPX (AMD) types. It was reproducible with no seed on both, so this was a boot-level/platform issue, not first-boot provisioning.
The image was UEFI-only. dev/cloud/mkosi.conf set Bootloader=systemd-boot (systemd-boot is a UEFI-only bootloader) with an ESP carrying systemd-bootx64.efi, and mkosi.repart/ defined only an ESP + ext4 root — no BIOS Boot Partition and no MBR boot code. That boots fine anywhere the firmware is UEFI, but the Hetzner CX (Intel) line boots the VM under legacy BIOS (SeaBIOS) — the literal Booting from Hard Disk is SeaBIOS's boot-from-disk message. SeaBIOS reads the protective MBR, finds no boot code (there is none on a UEFI-only GPT image), and hangs. CPX (AMD) presents UEFI firmware, finds the ESP, and boots. So the outcome was purely a function of which firmware the provider's server type presents, and the image only satisfied one of the two.
Why it shipped undetected: the QEMU boot-proof (dev/cloud/run-cloud-tests.sh) booted only under OVMF (UEFI) — every scenario attached -drive if=pflash,…OVMF_CODE…. The legacy-BIOS boot path was never exercised, so the lane could not have caught this (the same shape of gap as the growfs-never-ran miss in hosted-grow-root-fs.md).
The fix makes the image dual-firmware — bootable under both UEFI and legacy BIOS — the same posture Debian/Ubuntu cloud images ship, so a box boots regardless of which firmware a given (or future) provider server type presents. It is deliberately the minimal, surgical form: the proven UEFI/systemd-boot path is left completely untouched and BIOS boot is added alongside it, using mkosi's independent Bootloader= (EFI) and BiosBootloader= (BIOS) knobs.
dev/cloud/mkosi.conf— keepBootloader=systemd-boot; addBiosBootloader=grub. mkosi treats the two independently: UEFI stays systemd-boot, and for BIOS it builds a GRUBcore.img(grub-mkimage --format i386-pc), embeds it in the BIOS Boot Partition + writesboot.imgto the protective MBR (grub-bios-setup), and appends agrub_platform=="pc"menuentryto the ESPgrub.cfgthat loads the same kernel + initrds +root=PARTUUID=…cmdline. No UKIs to chainload on the BIOS side — grub boots the plain kernel directly off the vfat ESP (whereCopyFiles=/boot:/already puts it), so no ext4 grub module is needed.dev/cloud/mkosi.confPackages=— addgrub-common(shipsgrub-mkimage) andgrub-pc-bin(ships thei386-pcmodules and Debian's/usr/lib/grub/i386-pc/grub-bios-setup, exactly where mkosi looks). Deliberately nogrub-efi-*— the UEFI path stays systemd-boot, so nothing about it changes. The build-timegrub-mkimage/grub-bios-setupbinaries come from mkosi's default tools tree, which already carries them; these two image packages provide only the modules mkosi reads from the image root.dev/cloud/mkosi.repart/05-bios.conf(new) — a 1 MiB BIOS Boot Partition,Type=21686148-6449-6e6f-744e-656564454649(the well-known GRUB BIOS-boot GPT type; noteType=biosis not a systemd-repart alias — the raw GUID is required), noFormat=/CopyFiles=(it holds rawcore.img, not a filesystem). Sorts between00-espand10-root. The ESP stays 512M — mkosi bumps the ESP to 1G only for grub-EFI images (which duplicate kernels onto the ESP because they can't use UKIs); here EFI is systemd-boot, the kernel sits on the ESP once, and 512M is unchanged and ample.dev/cloud/expected-packages.txt— the lean guard is an exact manifest match, so the two grub packages plus their transitive libs (dmsetup,gettext-base,libbrotli1,libdevmapper1.02.1,libefiboot1t64,libefivar1t64,libfreetype6,libfuse3-4,libpng16-16t64) are added, with a header note explaining why grub is now in the lean set. No systemd-boot entries were removed (UEFI still uses it).dev/cloud/run-cloud-tests.sh— the boot-proof gains a legacy-BIOS scenario (bios):run_bootnow attaches the OVMF pflash only forfirmware=uefi, so abiosboot uses QEMU's built-in SeaBIOS — the legacy-BIOS firmware a CX box presents. It re-boots the same image under SeaBIOS on its own fresh overlay (so it never perturbs the UEFI provisioning sequence) and reuses the un-seeded assertion (control plane up, SSO gate armed) as the "did it boot and come up" proof. This is the check that would have caught #277; it is added to the default full run and to the CI publish gate..github/workflows/ci-cloud-image.yml— the seeded-boot gate now runsMALMO_CLOUD_BOOTS="unseeded seeded bios", so a build that can't boot under legacy BIOS fails before publish.
The test image (dev/cloud/test/) Include=..'s the production config and defines no [Content]/repart of its own, so it inherits BiosBootloader=grub, the grub packages, and the BIOS Boot Partition — the boot-proof therefore tests the fixed image, not an unfixed one.
- Config validated locally with
mkosi 26(the exact version CI pins) viamkosi -C dev/cloud summary: the main image resolves toBootable: enabled,Bootloader: systemd-boot,BIOS Bootloader: grub, withgrub-common+grub-pc-binin Packages andsystemd-boot*retained; no parse warnings.Bootable=yesmakes any missing BIOS prerequisite (modules, partition, ESP, root, binaries) a hard build error, so a misconfiguration fails the build loudly rather than silently shipping a UEFI-only image. - The grub dependency closure was computed against a real
debian:trixieapt resolver (recommends off, matchingWithRecommends=no) to pre-populate the lean lockfile exactly. bash -nclean onrun-cloud-tests.sh.
- Not built/booted locally. Per
CLAUDE.md, the cloud image is a CI job (mkosi build needs root +/dev/kvm; local builds are fragile). The full proof — image builds, lean check passes, the UEFI boots stay green, and the new SeaBIOS boot passes — runs inCI / Cloud image(-f publish=false). If the first build's lean check reports a manifest delta (the trixie apt closure differing from the mkosi image by a package), it prints the exact UNEXPECTED/MISSING names to reconcile intoexpected-packages.txt. - The
systemd-boot+grubpairing has no upstream test. mkosi's docs nameBootloader=grub+BiosBootloader=grub(grub for both) verbatim; the code paths forsystemd-boot+BiosBootloader=grub(Option B, chosen here to leave the UEFI path untouched) are correct-by-tracing but not covered by an upstream test. If CI shows the pairing misbehaving, the fallback is Option A —Bootloader=grubfor both firmwares (addsgrub-efi-amd64-bin), the docs-blessed combination — at the cost of replacing systemd-boot on the UEFI path. - Real-CX acceptance is still live-only. The QEMU SeaBIOS boot proves the image is legacy-BIOS-bootable in general; a real Hetzner CX (Intel) provision is the final acceptance that this specific platform now boots (mirroring the CL6 live-run deferral of the other hosted-image entries). Not run here.
- Run
CI / Cloud imageon this branch (gh workflow run "CI / Cloud image" --ref fix/277-hetzner-cx-boot -f publish=false); reconcile any lean-check delta; confirm all four boots (unseeded/seeded UEFI + the BIOS smoke) are green. - A real Hetzner CX (Intel) provision from a published image, as the live acceptance for the platform this issue named.
- Possibly out of scope but related: the appliance install ISO (
dev/test-qemuand the real installer) is likewiseBootloader=systemd-bootonly. BYO-x86 "old laptop in the pantry" machines that boot legacy BIOS would hit the same wall. Not touched here (this issue is hosted-only); worth a separate issue if the appliance targets legacy-BIOS hardware.