res-os-builder: container-native bootable image builder (design proposal) #27
Labels
No labels
harness
proj-core-console
proj-core-demo
proj-core-edit
proj-core-init
proj-core-lib
proj-core-login
proj-core-res
proj-core-sh
proj-core-theme
proj-docs
proj-extra-calc
proj-extra-code
proj-extra-playbook
proj-extra-sheets
proj-harness
proj-os
residual
tier-0-trivial
tier-1-easy
tier-2-medium
tier-3-hard
tier-4-major
tier-5-epic
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
residual/.agent#27
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
res-os-builder: container-native bootable image builder (design proposal)
Goal
Build residual boot images from Docker/OCI containers instead of (or alongside) the current
mkimage.shISO pipeline. Desired flow:docker buildaFROM-based OS image — config scripts baked in viaCOPY/RUNddto USBNo privileged build containers, no QEMU in the build path. The whole pipeline must run on standard GitHub runners (fully unprivileged).
Why not bootc
Evaluated and rejected:
res-init— a core design decision of this project.bootc-image-builderis Fedora/RHEL-family oriented ("especially oriented towards Fedora/CentOS bootc or derivatives"). Alpine support is effectively nonexistent and undocumented; prior attempts at Alpine+bootc hit a wall of undocumented Fedora-isms.Hand-rolled is the right call here.
Proposed pipeline
Two Dockerfile layers, bottom to top:
Dockerfile.os(one per flavor: console, desktop, virt, rpi, …):FROM alpine→apk add→ multi-stageCOPYof Go binaries (res-init,res-sh,res-console,residual-login,res-edit) →COPYconfig scripts →RUNscripts. This image is the OS filesystem and runs as a plain Docker image (docker runit) — flavors are just variants of this layer. The knowledge inmkimg.residual*.sh(package lists, config) migrates here.Dockerfile.residual:FROMthe OS image above → adds everything needed to build it into a bootable OS: kernel + modules under/boot, bootloader config, initramfs hooks/config, partition-layout hints. A thin, auditable delta on top of a tested OS image.docker savea tarball) — the tool accepts both.res-os-builder convert(new Go tool, lives atcmd/res-os-builderin this repo):rootfs/(umoci/crane, no daemon needed)/bootper the image contract (built in-image by the distro's native initramfs tooling during the residual-layer build — mkinitfs on Alpine, initramfs-tools on Ubuntu, mkinitcpio on Arch)mksquashfs rootfs/→system.squashfsxorriso, and/or.img: FAT32 ESP viamtools, partitions laid out withsfdisk+ddat offsets — no loop mounts, no privilegesmksquashfs,xorriso,mkfs.ext4 -d,mtoolsall do). Builds on any stock runner.Boot model: immutable OS + overlay for user data
system.squashfsmounted read-only as the overlay lower → immutable systemtmpfs(stateless) or a labeled persistent partition (RESIDUAL_DATA, ext4) when present — same image boots stateless on any machine, persistent where the data partition exists, no flags neededswitch_root→ stock init (OpenRC/sbin/init).res-initruns as a supervised sidecar service inside it — not as PID 1 (see refinements below)mkimg.residual*.sh(package lists, config) migrates intoDockerfile.os.Design refinements (2026-10-09, per user)
Two-layer Dockerfiles: the OS image (
Dockerfile.os, per flavor) is the runnable system — itself a valid Docker image;Dockerfile.residuallayers only the boot machinery on top. Keeps the OS image container-testable and the boot delta thin.res-init as sidecar, not PID 1 (for now): res-init is currently built as a sidecar init that sits within another init system. The bootable image boots stock init; res-init is supervised within it. PID 1 is a future step, not a requirement of this builder.
The builder is a real Go tool, not a script (
cmd/res-os-builder). It owns the whole pipeline end-to-end: builds the OCI image locally from the Dockerfiles, passes it through the residual boot layer, and converts it to the boot artifact for the requested boot setup (ISO vs raw.img, BIOS vs UEFI, …).Custom initramfs still required: a stock initramfs won't carry the persistence drivers. Ours needs exFAT + BTRFS kernel modules and qcow2 support for the persistence backends. Ties into
.agent#10'sinitramfs-init-residualfork decision — this builder consumes that work, it doesn't replace it.Overlay/persistence designed in from day one: no building without it and retrofitting later. No lock-in by deferral.
Distro-generic by contract, not by distro knowledge (2026-10-09, per user): the tool must handle Ubuntu- or Arch-flavored images, not just Alpine. Mechanism: the Go tool stays distro-dumb and defines a residual image contract — a residual-ready image provides
/boot/vmlinuz-*+/boot/initramfs-*(paths discoverable via image labels or/etc/residual/image.toml), and its initramfs contains our portable persistence hook. All distro-specific work (package manager, initramfs builder, hook directory) lives in the Dockerfiles, where the distro's native tooling exists — never in the Go tool.Consequence of the above: initramfs generation moves into the
Dockerfile.residualRUN step (distro-native builder + our hook installed via that builder's hook mechanism); the converter only consumes the artifacts. Our persistence hook ships as portable busybox-ash.The sidecar-init decision is what makes multi-distro feasible: res-init as PID 1 would need per-distro init integration; as a sidecar it just runs as a service under whatever init the distro ships (OpenRC, systemd, …).
Flash drive partition layout (2026-10-09, per user): partition 1 = boot partition holding the immutable residual base layer (kernel, initramfs, squashfs, bootloader). Rest of the drive = either exFAT carrying a qcow2 image file, or a raw BTRFS partition for residual. The exFAT+qcow2 layout is the project-vision default: the data store stays a plain file — visible, copyable, back-up-able from any OS you plug the drive into. Initramfs driver bill of materials: exfat + btrfs kernel modules, nbd kernel module + nbd client (
qemu-nbd) for the qcow2 file. Boot-time discovery: exFAT+qcow2 → nbd-attach → btrfs upper; raw btrfs → direct upper; neither found → tmpfs upper. The initramfs implements the overlay mount with boot-time discovery from the first POC — persistent store found → it becomes the upper; not found → tmpfs upper. Same image, no flags, no later rewrite. Partition layout reserves the data partition from the start.Open decisions
.img, or both from a single build?apk add linux-ltsin the Dockerfile)?docker build/buildkit on the runner, or daemonless layer assembly?Suggested first milestone
/dev/kvm? loop devices?).imgas a workflow artifact