diff options
| author | Nikolay Govorov <me@govorov.online> | 2026-07-09 04:29:20 +0100 |
|---|---|---|
| committer | Nikolay Govorov <me@govorov.online> | 2026-07-09 04:29:20 +0100 |
| commit | 67286b8141b032462d1510ea22b4873569dfe0f3 (patch) | |
| tree | 92ab2397c565f592fc5ae6a56edb99ed63a4a965 | |
| parent | 522e424f4f6c008e946f3822052c3ce9e693374d (diff) | |
| download | tar tar.gz tar.bz2 tar.lz tar.xz tar.zst zip | |
Rewrite image boot to Rust
Diffstat
| -rw-r--r-- | .gitignore | 4 | +4 −0 |
| -rw-r--r-- | Cargo.lock | 98 | +98 −0 |
| -rw-r--r-- | Cargo.toml | 4 | +4 −0 |
| -rw-r--r-- | crates/hule/Cargo.toml | 2 | +2 −0 |
| -rw-r--r-- | crates/hule/src/main.rs | 286 | +282 −4 |
| -rw-r--r-- | images/alpine/functions | 47 | +0 −47 |
| -rwxr-xr-x | images/alpine/genimg | 126 | +95 −31 |
| -rwxr-xr-x | images/control | 142 | +0 −142 |
| -rw-r--r-- | images/debian/functions | 72 | +0 −72 |
| -rwxr-xr-x | images/debian/genimg | 224 | +165 −59 |
| -rw-r--r-- | images/fedora/functions | 42 | +0 −42 |
| -rwxr-xr-x | images/fedora/genimg | 102 | +83 −19 |
| -rw-r--r-- | images/freebsd/functions | 43 | +0 −43 |
| -rwxr-xr-x | images/freebsd/genimg | 61 | +58 −3 |
| -rw-r--r-- | images/ubuntu/functions | 64 | +0 −64 |
| -rwxr-xr-x | images/ubuntu/genimg | 189 | +151 −38 |
16 files changed, 942 insertions, 564 deletions
diff --git a/.gitignore b/.gitignore index d70a18d..9e0e89b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ __pycache__ *.qcow2 + +# Built machine images and manifests +*.hmi +*.hmm diff --git a/Cargo.lock b/Cargo.lock index 6e0705b..2c164ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,8 @@ version = "0.1.0" dependencies = [ "hule-oci", "hule-vmm", + "serde", + "serde_json", ] [[package]] @@ -17,3 +19,99 @@ version = "0.1.0" [[package]] name = "hule-vmm" version = "0.1.0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 1eb1be9..251b05f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,7 @@ repository = "https://github.com/dimidiumlabs/hule" # local dependencies hule-oci = { path = "crates/hule-oci" } hule-vmm = { path = "crates/hule-vmm" } + +# external dependencies +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/hule/Cargo.toml b/crates/hule/Cargo.toml index e5bf51f..8a5dfa7 100644 --- a/crates/hule/Cargo.toml +++ b/crates/hule/Cargo.toml @@ -15,3 +15,5 @@ repository.workspace = true [dependencies] hule-oci.workspace = true hule-vmm.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/hule/src/main.rs b/crates/hule/src/main.rs index 73d3f4f..ba3a72e 100644 --- a/crates/hule/src/main.rs +++ b/crates/hule/src/main.rs @@ -1,10 +1,288 @@ // SPDX-FileCopyrightText: 2026 Nikolay Govorov // SPDX-License-Identifier: Apache-2.0 -use hule_oci; -use hule_vmm; +//! Manifest-driven VM harness +//! +//! Reads a Hule Machine Manifest (`image.hmm`) from an image directory and +//! boots the guest with qemu according to the boot protocol it declares. No +//! per-distro logic lives here -- everything comes from the manifest. See +//! docs/boot-protocol.md. + +use std::path::{Path, PathBuf}; +use std::process::{Command, exit}; + +use serde::Deserialize; + +#[derive(Deserialize)] +struct MinDefault { + minimum: u64, + default: u64, +} + +#[derive(Deserialize)] +struct System { + family: String, + distro: String, + release: String, +} + +#[derive(Deserialize)] +struct Machine { + arch: String, + cpu: MinDefault, + ram: MinDefault, +} + +#[derive(Deserialize)] +struct Disk { + id: String, + path: String, +} + +#[derive(Deserialize)] +struct FileRef { + path: String, + checksum: String, +} + +/// A boot protocol entry, discriminated by its `id`. `firmware-disk/*` boots a +/// self-contained disk; `linux/*` is direct-kernel and therefore carries the +/// kernel, initrd and cmdline (validated at parse time, not left optional). +enum Boot { + FirmwareDisk { + id: String, + disk: String, + }, + Linux { + id: String, + disk: String, + kernel: FileRef, + initrd: FileRef, + cmdline: String, + }, +} + +impl Boot { + fn id(&self) -> &str { + match self { + Boot::FirmwareDisk { id, .. } | Boot::Linux { id, .. } => id, + } + } + + fn disk(&self) -> &str { + match self { + Boot::FirmwareDisk { disk, .. } | Boot::Linux { disk, .. } => disk, + } + } +} + +impl<'de> Deserialize<'de> for Boot { + fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> { + use serde::de::Error; + + #[derive(Deserialize)] + struct Raw { + id: String, + disk: String, + #[serde(default)] + kernel: Option<FileRef>, + #[serde(default)] + initrd: Option<FileRef>, + #[serde(default)] + cmdline: Option<String>, + } + + let r = Raw::deserialize(d)?; + if r.id.starts_with("linux/") { + Ok(Boot::Linux { + kernel: r.kernel.ok_or_else(|| Error::missing_field("kernel"))?, + initrd: r.initrd.ok_or_else(|| Error::missing_field("initrd"))?, + cmdline: r.cmdline.ok_or_else(|| Error::missing_field("cmdline"))?, + id: r.id, + disk: r.disk, + }) + } else if r.id.starts_with("firmware-disk/") { + Ok(Boot::FirmwareDisk { + id: r.id, + disk: r.disk, + }) + } else { + Err(Error::custom(format!("unknown boot protocol id '{}'", r.id))) + } + } +} + +/// A guest access surface, discriminated by `type`. +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +enum Access { + Ssh { port: u16 }, +} + +#[derive(Deserialize)] +struct Network { + mode: String, + address: String, + gateway: String, +} + +#[derive(Deserialize)] +struct Manifest { + schemaVersion: u8, + kind: String, + + system: System, + machine: Machine, + + boot: Vec<Boot>, + disks: Vec<Disk>, + access: Vec<Access>, + network: Network, +} + +fn die(msg: impl AsRef<str>) -> ! { + eprintln!("hule: {}", msg.as_ref()); + exit(1); +} fn main() { - println!("oci: {}", hule_oci::NAME); - println!("vmm: {}", hule_vmm::NAME); + let args: Vec<String> = std::env::args().collect(); + // hule boot <image-dir> [port] + if args.len() < 3 || args[1] != "boot" { + eprintln!("usage: {} boot <image-dir> [port]", args[0]); + exit(2); + } + let dir = PathBuf::from(&args[2]); + let port: u16 = args.get(3).map_or(8022, |s| { + s.parse().unwrap_or_else(|_| die(format!("invalid port '{s}'"))) + }); + + let manifest_path = dir.join("image.hmm"); + let data = std::fs::read_to_string(&manifest_path) + .unwrap_or_else(|e| die(format!("cannot read {}: {e}", manifest_path.display()))); + let m: Manifest = serde_json::from_str(&data) + .unwrap_or_else(|e| die(format!("invalid manifest {}: {e}", manifest_path.display()))); + + // Negotiation: qemu handles every protocol we emit. Prefer direct-kernel + // (skips firmware+bootloader), else firmware-disk. Manifest order is not + // significant. + let proto = m + .boot + .iter() + .find(|b| matches!(b, Boot::Linux { .. })) + .or_else(|| m.boot.iter().find(|b| matches!(b, Boot::FirmwareDisk { .. }))) + .unwrap_or_else(|| die("no control-supported boot protocol in manifest")); + + let disk = m + .disks + .iter() + .find(|d| d.id == proto.disk()) + .unwrap_or_else(|| die(format!("boot entry references unknown disk '{}'", proto.disk()))); + let disk_path = dir.join(&disk.path); + + let ssh_port = m + .access + .iter() + .find_map(|a| match a { + Access::Ssh { port } => Some(*port), + }) + .unwrap_or(22); + + let qa = qemu_arch(&m.machine.arch); + let mem_mib = m.machine.ram.default / (1024 * 1024); + + let mut cmd = Command::new(format!("qemu-system-{qa}")); + cmd.args(cpu_opts(&m.machine.arch)); + cmd.args([ + "-pidfile".into(), + format!("/tmp/qemu-{port}.id"), + "-m".into(), + mem_mib.to_string(), + "-smp".into(), + format!("cpus={}", m.machine.cpu.default), + "-net".into(), + "nic,model=virtio".into(), + "-net".into(), + format!("user,hostfwd=tcp:127.0.0.1:{port}-:{ssh_port}"), + "-display".into(), + "none".into(), + "-device".into(), + "virtio-rng-pci".into(), + "-device".into(), + "virtio-balloon".into(), + ]); + + // Ephemeral: snapshot=on discards guest writes. + match proto { + Boot::Linux { + kernel, + initrd, + cmdline, + .. + } => { + // Direct kernel boot: sidecar kernel/initrd + canonical cmdline. + cmd.args([ + "-drive".into(), + format!("file={},media=disk,snapshot=on,id=root,if=none", disk_path.display()), + "-device".into(), + "virtio-blk-pci,drive=root".into(), + "-kernel".into(), + dir.join(&kernel.path).display().to_string(), + "-initrd".into(), + dir.join(&initrd.path).display().to_string(), + "-append".into(), + cmdline.clone(), + ]); + } + Boot::FirmwareDisk { .. } => { + // Self-bootable disk, qemu's firmware boots it. + cmd.args([ + "-drive".into(), + format!("file={},media=disk,snapshot=on,if=virtio", disk_path.display()), + ]); + } + } + + eprintln!( + "hule: booting {} via {} (ssh: localhost:{port})", + dir.display(), + proto.id() + ); + let status = cmd + .status() + .unwrap_or_else(|e| die(format!("failed to launch qemu: {e}"))); + exit(status.code().unwrap_or(1)); +} + +/// Hule machine arch -> qemu-system-<arch> suffix. +fn qemu_arch(arch: &str) -> &'static str { + match arch { + "amd64" => "x86_64", + "arm64" => "aarch64", + "ppc64el" => "ppc64le", + other => die(format!("unsupported arch '{other}'")), + } +} + +/// Machine/accel flags for an arch (KVM when the host matches, else TCG). +fn cpu_opts(arch: &str) -> Vec<String> { + let qa = qemu_arch(arch); + let kvm = std::env::consts::ARCH == qa && Path::new("/dev/kvm").exists(); + let mut v: Vec<String> = Vec::new(); + match qa { + "aarch64" => v.extend(["-M".into(), "virt".into()]), + "ppc64le" => v.extend(["-machine".into(), "pseries".into()]), + _ => {} + } + if kvm { + v.extend(["-cpu".into(), "host".into(), "-enable-kvm".into()]); + } else { + match qa { + "aarch64" => v.extend(["-cpu".into(), "cortex-a53".into()]), + "x86_64" => v.extend(["-cpu".into(), "qemu64".into()]), + "ppc64le" => v.extend(["-cpu".into(), "power9".into()]), + _ => {} + } + } + v } diff --git a/images/alpine/functions b/images/alpine/functions deleted file mode 100644 --- a/images/alpine/functions +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/sh -# SPDX-FileCopyrightText: 2017-2026 Drew DeVault -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-only - -poweroff_cmd="sudo poweroff" -default_arch=x86_64 - -boot() { - if [ "$arch" != "x86_64" ] - then - echo "Unsupported architecture $arch" >&2 - exit 1 - fi - _boot $(cpu_opts x86_64) -} - -install() { - port=$1 - shift 1 - guest_ssh -p $port build@localhost sudo apk upgrade -U - guest_ssh -p $port build@localhost sudo apk add "$@" -} - -sanity_check() { - echo "Booting..." - cmd_boot x86_64 8022 qemu & - trap 'cmd_cleanup 8022' EXIT - _wait_boot 8022 - echo "Testing sudo..." - guest_ssh -p 8022 build@localhost sudo ls -a - echo "Testing networking..." - guest_ssh -p 8022 build@localhost curl https://builds.sr.ht - echo "Testing apk..." - # repo cache doesn't work in this context - guest_ssh -p 8022 build@localhost \ - sudo sed -i /etc/apk/repositories -e 's/repo-cache.local/dl-2.alpinelinux.org/g' - guest_ssh -p 8022 build@localhost sudo apk update - guest_ssh -p 8022 build@localhost sudo apk upgrade - guest_ssh -p 8022 build@localhost sudo apk add htop - echo "Testing git..." - guest_ssh -p 8022 build@localhost git --version - echo "Testing mercurial..." - guest_ssh -p 8022 build@localhost hg --version - echo "Everything works!" - guest_ssh -p 8022 build@localhost sudo poweroff || true -} diff --git a/images/alpine/genimg b/images/alpine/genimg index 908132a..e6b1ab2 100755 --- a/images/alpine/genimg +++ b/images/alpine/genimg @@ -11,16 +11,16 @@ arch="${2:-x86_64}" linux="${linux:-linux-lts}" case $version in - 3.20|3.21|3.22|3.23|3.24) - release="v$version" - ;; - edge) - release="edge" - ;; - *) - echo "usage: ${0##*/} <3.20|3.21|3.22|3.23|3.24|edge> [arch]" >&2 - exit 1 - ;; +3.20 | 3.21 | 3.22 | 3.23 | 3.24) + release="v$version" + ;; +edge) + release="edge" + ;; +*) + echo "usage: ${0##*/} <3.20|3.21|3.22|3.23|3.24|edge> [arch]" >&2 + exit 1 + ;; esac out="$version/$arch" @@ -35,24 +35,27 @@ cleanup() { umount -f /mnt/run 2>/dev/null || true umount -f /mnt/sys 2>/dev/null || true umount -f /mnt/boot 2>/dev/null || true - umount -f /mnt 2>/dev/null || true + umount -f /mnt 2>/dev/null || true swapoff /dev/nbd0p2 || true qemu-nbd --disconnect /dev/nbd0 || true } +size_gib=24 mkdir -p "$out" -qemu-img create -f qcow2 "$out"/root.img.qcow2 24G +# .hmi (Hule Machine Image): plain qcow2 restricted to a feature subset that +# materializes losslessly to raw (no internal snapshots/bitmaps/encryption). +# Base image = chain root, so no backing. Never `qemu-img snapshot` it. +qemu-img create -f qcow2 -o compat=1.1 "$out"/root.hmi "${size_gib}G" modprobe nbd max_part=16 -qemu-nbd --connect=/dev/nbd0 "$out"/root.img.qcow2 +qemu-nbd --connect=/dev/nbd0 "$out"/root.hmi for i in $(seq 1 5); do sleep 0.$i partprobe /dev/nbd0 && break done trap cleanup EXIT -if [ "$arch" == "x86_64" ] || [ "$arch" == "i686" ] -then +if [ "$arch" == "x86_64" ] || [ "$arch" == "i686" ]; then dd if=/usr/share/syslinux/mbr.bin of=/dev/nbd0 bs=1 count=440 fi sfdisk --no-reread /dev/nbd0 <<EOF @@ -109,8 +112,7 @@ iface eth0 inet static gateway 10.0.2.2 EOF run_root setup-dns -d example.org 8.8.8.8 9.9.9.9 1.1.1.1 -if [ "$release" = "edge" ] -then +if [ "$release" = "edge" ]; then run_root setup-timezone UTC cat >/mnt/etc/apk/repositories <<EOF http://dl-cdn.alpinelinux.org/alpine/$release/main @@ -127,20 +129,16 @@ fi extlinux -i /mnt/boot -for i in ntpd sshd crond haveged -do +for i in ntpd sshd crond haveged; do run_root rc-update add $i default done -for i in hwclock modules sysctl hostname bootmisc loadkmap networking seedrng syslog swap -do +for i in hwclock modules sysctl hostname bootmisc loadkmap networking seedrng syslog swap; do run_root rc-update add $i boot done -for i in devfs dmesg hwdrivers mdev -do +for i in devfs dmesg hwdrivers mdev; do run_root rc-update add $i sysinit done -for i in mount-ro killprocs savecache -do +for i in mount-ro killprocs savecache; do run_root rc-update add $i shutdown done @@ -153,22 +151,29 @@ run_root adduser build kvm run_root adduser build abuild run_root passwd -u build -printf '%s\n' "%wheel ALL=(ALL) NOPASSWD: ALL" >> /mnt/etc/sudoers +printf '%s\n' "%wheel ALL=(ALL) NOPASSWD: ALL" >>/mnt/etc/sudoers rm -f /mnt/etc/motd -printf 'permit nopass :wheel\n' >> /mnt/etc/doas.d/doas.conf +printf 'permit nopass :wheel\n' >>/mnt/etc/doas.d/doas.conf + +# Reference partitions by PARTUUID so boot survives whatever the VMM names the +# disk. cmdline is canonical (extlinux APPEND == manifest boot.cmdline). +boot_partuuid=$(blkid -s PARTUUID -o value /dev/nbd0p1) +swap_partuuid=$(blkid -s PARTUUID -o value /dev/nbd0p2) +root_partuuid=$(blkid -s PARTUUID -o value /dev/nbd0p3) +cmdline="root=PARTUUID=$root_partuuid rw modules=sd-mod,usb-storage,ext4 quiet rootfstype=ext4" cat >/mnt/boot/extlinux.conf <<EOF DEFAULT linux LABEL linux LINUX vmlinuz-$(echo "$linux" | cut -d- -f2-) INITRD initramfs-$(echo "$linux" | cut -d- -f2-) - APPEND root=/dev/vda3 rw modules=sd-mod,usb-storage,ext4 quiet rootfstype=ext4 + APPEND $cmdline EOF cat >>/mnt/etc/fstab <<EOF -/dev/vda1 /boot ext4 rw,relatime,data=ordered 0 0 -/dev/vda2 swap swap defaults 0 0 -/dev/vda3 / ext4 rw,relatime,data=ordered 0 0 +PARTUUID=$boot_partuuid /boot ext4 rw,relatime,data=ordered 0 0 +PARTUUID=$swap_partuuid swap swap defaults 0 0 +PARTUUID=$root_partuuid / ext4 rw,relatime,data=ordered 0 0 EOF mkdir -p /mnt/etc/docker @@ -186,3 +191,62 @@ pkg_version() { run_root apk add $linux=$(pkg_version $linux) sync + +# Detach the disk so root.hmi is fully flushed before hashing; disarm cleanup. +cleanup +trap : EXIT + +# --- Hule Machine Manifest (.hmm) -- docs/boot-protocol.md 10b --------------- +case "$arch" in +x86_64) march=amd64 ;; +aarch64) march=arm64 ;; +*) march=$arch ;; +esac +disk_sha=$(sha256sum "$out/root.hmi" | cut -d' ' -f1) +disk_bytes=$(stat -c%s "$out/root.hmi") +virtual_bytes=$((size_gib * 1024 * 1024 * 1024)) +mem_min=$((256 * 1024 * 1024)) +mem_def=$((1024 * 1024 * 1024)) + +cat >"$out/image.hmm" <<EOF +{ + "schemaVersion": 1, + "kind": "MachineImage", + + "system": { + "family": "linux", + "distro": "alpine", + "release": "$version" + }, + + "machine": { + "arch": "$march", + "cpu": { "minimum": 1, "default": 2 }, + "ram": { "minimum": $mem_min, "default": $mem_def } + }, + + "disks": [ + { + "id": "root", + "format": "qcow2", + "path": "root.hmi", + "checksum": "sha256:$disk_sha", + "virtualSize": $virtual_bytes, + "physicalSize": $disk_bytes + } + ], + + "boot": [ + { + "id": "firmware-disk/bios", + "disk": "root" + } + ], + + "access": [ + { "type": "ssh", "port": 22, "user": "build", "auth": "empty-password" } + ], + + "network": { "mode": "static", "address": "10.0.2.15/24", "gateway": "10.0.2.2" } +} +EOF diff --git a/images/control b/images/control deleted file mode 100755 --- a/images/control +++ /dev/null @@ -1,142 +0,0 @@ -#!/bin/sh -eu -# SPDX-FileCopyrightText: 2017-2026 Drew DeVault -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-only - -self=$(readlink -f $0) -self=$(dirname "$self") - -base=$1 -cmd=$2 -shift 2 - -if [ ! -d $self/$base ]; then - printf "Image '%s' does not exist\n" "$base" >&2 - exit 1 -fi - -ssh_opts="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" -guestport=22 - -guest_ssh() { - ssh $ssh_opts "$@" -} - -cpu_opts() { - if [ "$(uname -m)" = "$1" ] && [ -e /dev/kvm ] - then - printf "%s" "-cpu host -enable-kvm" - return - fi - case "$1" in - aarch64) - printf "%s" "-M virt -cpu cortex-a53" - ;; - x86_64) - printf "%s" "-cpu qemu64" - ;; - ppc64le) - printf "%s" "-cpu power9 -machine pseries" - ;; - *) - printf "Unsupported architecture '%s'\n" "$arch" >&2 - exit 1 - ;; - esac -} - -_boot() { - ${qemu:-qemu-system-$arch} \ - -pidfile /tmp/qemu-$port.id \ - -m ${MEMORY:-4096} \ - -smp cpus=2 \ - -net nic,model=virtio -net user,hostfwd=tcp:127.0.0.1:$port-:$guestport \ - -display none \ - -device virtio-rng-pci \ - -device virtio-balloon \ - -drive file="$wd/$arch/root.img.qcow2",media=disk,snapshot=on,${driveopts:-if=virtio} \ - "$@" & -} - -cmd_boot() { - arch=$1 - shift - - if [ "$arch" = "default" ]; then - arch="$default_arch" - fi - - if [ ! -e "$self/$base/$arch/root.img.qcow2" ]; then - printf "Image '%s' is not available for arch '%s'\n" "$base" "$arch" >&2 - exit 1 - fi - - port=$1 - wd="$self/$base" - - boot -} - -_wait_boot() { - port=$1 - attempts=0 - echo "Waiting for VM to come up..." - while ! guest_ssh -p "$port" build@localhost echo Hello world 2>&1 >/dev/null - do - sleep 5 - attempts=$((attempts + 1)) - if [ "$attempts" -eq 20 ] - then - echo "Giving up." - cmd_cleanup "$port" - exit 1 - fi - echo "Attempt $attempts..." - done -} - -cmd_cleanup() { - port=$1 - # Power off - if [ "$#" -eq 1 ] - then - if [ -e /tmp/qemu-$port.id ] - then - cid=$(cat /tmp/qemu-$port.id) - guest_ssh -p $port build@localhost $poweroff_cmd || true - sleep 2 - kill $cid || true - rm -f /tmp/qemu-$port.id - fi - fi -} - -if [ -f "$self/$base/functions" ]; then - . "$self/$base/functions" -elif [ -f "$self/$(dirname "$base")/functions" ]; then - . "$self/$(dirname "$base")/functions" -else - printf "Missing base image functions '%s'\n" "$base" >&2 - exit 1 -fi - -case "$cmd" in - boot) - cmd_boot "$@" - ;; - cleanup) - cmd_cleanup "$@" - ;; - sanity-check) - sanity_check "$@" - ;; - ssh) - port=$1 - shift - guest_ssh -p "$port" build@localhost "$@" - ;; - *) - printf "Unknown command '%s'\n" "$cmd" >&2 - exit 1 - ;; -esac diff --git a/images/debian/functions b/images/debian/functions deleted file mode 100644 --- a/images/debian/functions +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/sh -# SPDX-FileCopyrightText: 2017-2026 Drew DeVault -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-only - -poweroff_cmd="sudo systemctl poweroff" -default_arch=amd64 - -boot() { - case "$arch" in - amd64) - qemu=qemu-system-x86_64 - _boot $(cpu_opts x86_64) - ;; - arm64) - driveopts="id=root,if=none" - qemu=qemu-system-aarch64 - _boot \ - $(cpu_opts aarch64) \ - -device virtio-blk-pci,drive=root \ - -kernel "$wd/$arch/vmlinuz" \ - -initrd "$wd/$arch/initrd.img" \ - -append "root=/dev/vda3" - ;; - ppc64el) - driveopts="id=root,if=none" - qemu=qemu-system-ppc64 - _boot \ - $(cpu_opts ppc64le) \ - -device virtio-blk-pci,drive=root \ - -kernel "$wd/$arch/vmlinux" \ - -initrd "$wd/$arch/initrd.img" \ - -append "root=/dev/vda3" - ;; - *) - echo "Unsupported architecture $arch" >&2 - exit 1 - ;; - esac -} - -install() { - port=$1 - shift 1 - guest_ssh -p $port build@localhost sudo env DEBIAN_FRONTEND=noninteractive \ - apt-get update -y - guest_ssh -p $port build@localhost sudo env DEBIAN_FRONTEND=noninteractive \ - apt-get install -y "$@" -} - -sanity_check() { - arch=$1 - shift - - echo "Booting..." - cmd_boot "$arch" 8022 qemu & - trap 'cmd_cleanup 8022' EXIT - _wait_boot 8022 - echo "Testing sudo..." - guest_ssh -p 8022 build@localhost sudo ls -a - echo "Testing apt..." - guest_ssh -p 8022 build@localhost sudo apt-get update - install 8022 curl - echo "Testing networking..." - guest_ssh -p 8022 build@localhost curl https://builds.sr.ht - echo "Testing git..." - guest_ssh -p 8022 build@localhost git --version - echo "Testing mercurial..." - guest_ssh -p 8022 build@localhost hg --version - echo "Everything works!" - guest_ssh -p 8022 build@localhost sudo systemctl poweroff || true -} diff --git a/images/debian/genimg b/images/debian/genimg index 7c98cd3..eafe118 100755 --- a/images/debian/genimg +++ b/images/debian/genimg @@ -10,33 +10,33 @@ release=${1:-} arch=${2:-amd64} case $release in - bookworm|forky|sid|trixie) ;; - *) - echo "usage: ${0##*/} <bookworm|forky|sid|trixie> [arch]" >&2 - exit 1 - ;; +bookworm | forky | sid | trixie) ;; +*) + echo "usage: ${0##*/} <bookworm|forky|sid|trixie> [arch]" >&2 + exit 1 + ;; esac case $arch in - amd64) - iface=ens3 - qarch=x86_64 - kpkg=linux-image-amd64 - ;; - arm64) - iface=enp0s1 - qarch=aarch64 - kpkg=linux-image-arm64 - ;; - ppc64el) - iface=enp0s0 - qarch=ppc64le - kpkg=linux-image-powerpc64le - ;; - *) - echo "unsupported architecture $arch" - exit 1 - ;; +amd64) + iface=ens3 + qarch=x86_64 + kpkg=linux-image-amd64 + ;; +arm64) + iface=enp0s1 + qarch=aarch64 + kpkg=linux-image-arm64 + ;; +ppc64el) + iface=enp0s0 + qarch=ppc64le + kpkg=linux-image-powerpc64le + ;; +*) + echo "unsupported architecture $arch" + exit 1 + ;; esac cleanup() { @@ -48,25 +48,28 @@ cleanup() { umount /mnt/proc 2>/dev/null || true umount /mnt/run 2>/dev/null || true umount /mnt/sys 2>/dev/null || true - umount /mnt/boot 2>/dev/null || true - umount /mnt 2>/dev/null || true + umount /mnt/boot 2>/dev/null || true + umount /mnt 2>/dev/null || true qemu-nbd --disconnect /dev/nbd0 || true } out="$release/$arch" +size_gib=32 mkdir -p "$out" -qemu-img create -f qcow2 "$out/root.img.qcow2" 32G +# .hmi (Hule Machine Image): plain qcow2 restricted to a feature subset that +# materializes losslessly to raw (no internal snapshots/bitmaps/encryption). +# Base image = chain root, so no backing. Never `qemu-img snapshot` it. +qemu-img create -f qcow2 -o compat=1.1 "$out/root.hmi" "${size_gib}G" modprobe nbd max_part=16 -qemu-nbd --connect=/dev/nbd0 "$out/root.img.qcow2" +qemu-nbd --connect=/dev/nbd0 "$out/root.hmi" for i in $(seq 1 5); do sleep 0.$i partprobe /dev/nbd0 && break done trap cleanup EXIT -if [ "$arch" = "amd64" ] -then +if [ "$arch" = "amd64" ]; then dd if=/usr/lib/syslinux/mbr/mbr.bin of=/dev/nbd0 bs=1 count=440 fi @@ -84,8 +87,7 @@ mount /dev/nbd0p3 /mnt mkdir /mnt/boot mount /dev/nbd0p1 /mnt/boot -if [ "$arch" = "amd64" ] -then +if [ "$arch" = "amd64" ]; then debootstrap --include=gnupg2 --arch=$arch $release /mnt else ./qemu-debootstrap --include=gnupg2 --arch=$arch $release /mnt @@ -107,7 +109,7 @@ run_root() { echo 'nameserver 8.8.8.8' >/mnt/etc/resolv.conf echo 'nameserver 9.9.9.9' >>/mnt/etc/resolv.conf echo 'nameserver 1.1.1.1' >>/mnt/etc/resolv.conf -cat > /mnt/etc/network/interfaces <<EOF +cat >/mnt/etc/network/interfaces <<EOF auto lo iface lo inet loopback @@ -118,12 +120,12 @@ iface $iface inet static netmask 255.255.255.0 gateway 10.0.2.2 EOF -echo build > /mnt/etc/hostname -cat > /mnt/etc/hosts <<EOF +echo build >/mnt/etc/hostname +cat >/mnt/etc/hosts <<EOF 127.0.0.1 build localhost EOF -echo 'APT::Install-Recommends "False";' > /mnt/etc/apt/apt.conf.d/60recommends +echo 'APT::Install-Recommends "False";' >/mnt/etc/apt/apt.conf.d/60recommends run_root apt-get update run_root apt-get -y install locales @@ -137,10 +139,10 @@ run_root systemctl enable systemd-timesyncd.service run_root useradd -mG sudo build run_root passwd -d build -echo '%sudo ALL=(ALL) NOPASSWD: ALL' >> /mnt/etc/sudoers +echo '%sudo ALL=(ALL) NOPASSWD: ALL' >>/mnt/etc/sudoers -echo "PermitEmptyPasswords yes" >> /mnt/etc/ssh/sshd_config -echo ssh >> /mnt/etc/securetty +echo "PermitEmptyPasswords yes" >>/mnt/etc/ssh/sshd_config +echo ssh >>/mnt/etc/securetty run_root systemctl enable ssh # Prevent docker from mucking up networking @@ -155,34 +157,138 @@ run_root update-initramfs -u linuxver=$(ls /mnt/boot | grep vmlinuz | cut -d- -f2-) +# Reference partitions by PARTUUID so boot survives whatever the VMM names the +# disk. cmdline is canonical: identical for every boot protocol (extlinux append +# here, -append on the direct path) -- see docs/boot-protocol.md. +boot_partuuid=$(blkid -s PARTUUID -o value /dev/nbd0p1) +swap_partuuid=$(blkid -s PARTUUID -o value /dev/nbd0p2) +root_partuuid=$(blkid -s PARTUUID -o value /dev/nbd0p3) +cmdline="root=PARTUUID=$root_partuuid rw quiet" + cat >>/mnt/etc/fstab <<EOF -/dev/vda1 /boot ext4 rw,relatime,data=ordered 0 0 -/dev/vda2 swap swap defaults 0 0 -/dev/vda3 / ext4 rw,relatime,data=ordered 0 0 +PARTUUID=$boot_partuuid /boot ext4 rw,relatime,data=ordered 0 0 +PARTUUID=$swap_partuuid swap swap defaults 0 0 +PARTUUID=$root_partuuid / ext4 rw,relatime,data=ordered 0 0 EOF -# Boot setup -case "$arch" in - amd64) - run_root apt-get -y install extlinux - extlinux -i /mnt/boot +# Boot setup: amd64 self-boots via extlinux (firmware-disk/bios); other arches +# have no in-image bootloader, the VMM boots the extracted kernel directly. +case "$arch" in +amd64) + run_root apt-get -y install extlinux + extlinux -i /mnt/boot - cat >/mnt/boot/extlinux.conf <<-EOF + cat >/mnt/boot/extlinux.conf <<-EOF default debian label debian linux vmlinuz-$linuxver initrd initrd.img-$linuxver - append root=/dev/vda3 rw quiet - EOF - ;; - arm64) - cp /mnt/boot/vmlinuz-* "$out/vmlinuz" - cp /mnt/boot/initrd.img-* "$out/initrd.img" - ;; - ppc64el) - cp /mnt/boot/vmlinux-* "$out/vmlinux" - cp /mnt/boot/initrd.img-* "$out/initrd.img" - ;; + append $cmdline + EOF + ;; +arm64) + cp /mnt/boot/vmlinuz-* "$out/vmlinuz" + cp /mnt/boot/initrd.img-* "$out/initrd" + ;; +ppc64el) + cp /mnt/boot/vmlinux-* "$out/vmlinux" + cp /mnt/boot/initrd.img-* "$out/initrd" + ;; esac sync + +# Detach the disk so root.hmi is fully flushed before hashing; disarm cleanup. +cleanup +trap : EXIT + +# --- Hule Machine Manifest (.hmm) -- docs/boot-protocol.md 10b --------------- +disk_sha=$(sha256sum "$out/root.hmi" | cut -d' ' -f1) +disk_bytes=$(stat -c%s "$out/root.hmi") +virtual_bytes=$((size_gib * 1024 * 1024 * 1024)) +mem_min=$((256 * 1024 * 1024)) +mem_def=$((1024 * 1024 * 1024)) + +case "$arch" in +amd64) + # Legacy BIOS: extlinux in an MBR disk, booted by the VMM's BIOS firmware. + protocols=$( + cat <<JSON + { + "id": "firmware-disk/bios", + "disk": "root" + } +JSON + ) + ;; +arm64) + kernel_sha=$(sha256sum "$out/vmlinuz" | cut -d' ' -f1) + initrd_sha=$(sha256sum "$out/initrd" | cut -d' ' -f1) + protocols=$( + cat <<JSON + { + "id": "linux/arm64-image", + "disk": "root", + "kernel": { "path": "vmlinuz", "checksum": "sha256:$kernel_sha" }, + "initrd": { "path": "initrd", "checksum": "sha256:$initrd_sha" }, + "cmdline": "$cmdline" + } +JSON + ) + ;; +ppc64el) + kernel_sha=$(sha256sum "$out/vmlinux" | cut -d' ' -f1) + initrd_sha=$(sha256sum "$out/initrd" | cut -d' ' -f1) + protocols=$( + cat <<JSON + { + "id": "linux/ppc64le-elf", + "disk": "root", + "kernel": { "path": "vmlinux", "checksum": "sha256:$kernel_sha" }, + "initrd": { "path": "initrd", "checksum": "sha256:$initrd_sha" }, + "cmdline": "$cmdline" + } +JSON + ) + ;; +esac + +cat >"$out/image.hmm" <<EOF +{ + "schemaVersion": 1, + "kind": "MachineImage", + + "system": { + "family": "linux", + "distro": "debian", + "release": "$release" + }, + + "machine": { + "arch": "$arch", + "cpu": { "minimum": 1, "default": 2 }, + "ram": { "minimum": $mem_min, "default": $mem_def } + }, + + "disks": [ + { + "id": "root", + "format": "qcow2", + "path": "root.hmi", + "checksum": "sha256:$disk_sha", + "virtualSize": $virtual_bytes, + "physicalSize": $disk_bytes + } + ], + + "boot": [ +$protocols + ], + + "access": [ + { "type": "ssh", "port": 22, "user": "build", "auth": "empty-password" } + ], + + "network": { "mode": "static", "address": "10.0.2.15/24", "gateway": "10.0.2.2" } +} +EOF diff --git a/images/fedora/functions b/images/fedora/functions deleted file mode 100644 --- a/images/fedora/functions +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/sh -# SPDX-FileCopyrightText: 2017-2026 Drew DeVault -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-only - -poweroff_cmd="sudo systemctl poweroff" -default_arch=x86_64 - -boot() { - if [ "$arch" != "x86_64" ] - then - echo "Unsupported architecture $arch" >&2 - exit 1 - fi - _boot $(cpu_opts x86_64) -} - -install() { - port=$1 - shift 1 - guest_ssh -p $port build@localhost sudo dnf -y install "$@" -} - -sanity_check() { - echo "Booting..." - cmd_boot x86_64 8022 qemu & - trap 'cmd_cleanup 8022' EXIT - _wait_boot 8022 - echo "Testing sudo..." - guest_ssh -p 8022 build@localhost sudo ls -a - echo "Testing networking..." - guest_ssh -p 8022 build@localhost curl https://builds.sr.ht - echo "Testing dnf..." - guest_ssh -p 8022 build@localhost sudo dnf -y upgrade - guest_ssh -p 8022 build@localhost sudo dnf -y install htop - echo "Testing git..." - guest_ssh -p 8022 build@localhost git --version - echo "Testing mercurial..." - guest_ssh -p 8022 build@localhost hg --version - echo "Everything works!" - guest_ssh -p 8022 build@localhost sudo systemctl poweroff || true -} diff --git a/images/fedora/genimg b/images/fedora/genimg index 5ba53e7..3e4e668 100755 --- a/images/fedora/genimg +++ b/images/fedora/genimg @@ -13,21 +13,21 @@ release=${1:-} arch=${2:-x86_64} case $release in - 42|43|44) ;; - *) - echo "usage: ${0##*/} <42|43|44> [arch]" >&2 - exit 1 - ;; +42 | 43 | 44) ;; +*) + echo "usage: ${0##*/} <42|43|44> [arch]" >&2 + exit 1 + ;; esac case $arch in - x86_64) - iface=ens3 - ;; - *) - echo "unsupported architecture $arch" - exit 1 - ;; +x86_64) + iface=ens3 + ;; +*) + echo "unsupported architecture $arch" + exit 1 + ;; esac cleanup() { @@ -50,11 +50,15 @@ run_root() { } out="$release/$arch" +size_gib=24 mkdir -p "$out" -qemu-img create -f qcow2 "$out/root.img.qcow2" 24G +# .hmi (Hule Machine Image): plain qcow2 restricted to a feature subset that +# materializes losslessly to raw (no internal snapshots/bitmaps/encryption). +# Base image = chain root, so no backing. Never `qemu-img snapshot` it. +qemu-img create -f qcow2 -o compat=1.1 "$out/root.hmi" "${size_gib}G" modprobe nbd max_part=16 -qemu-nbd --connect=$NBD_DEVICE "$out/root.img.qcow2" +qemu-nbd --connect=$NBD_DEVICE "$out/root.hmi" trap cleanup EXIT # Looks like qemu-nbd is non-blocking. Wait one second to make sure $NBD_DEVICE @@ -112,8 +116,8 @@ Address=10.0.2.15/24 Gateway=10.0.2.2 EOF -echo build > /mnt/etc/hostname -cat > /mnt/etc/hosts <<EOF +echo build >/mnt/etc/hostname +cat >/mnt/etc/hosts <<EOF 127.0.0.1 localhost 127.0.0.1 build EOF @@ -125,13 +129,13 @@ run_root systemctl enable systemd-timesyncd.service run_root dnf -y \ --releasever="$release" \ - install \ - @development-tools git mercurial openssh-server sudo kernel grub2 + install \ + @development-tools git mercurial openssh-server sudo kernel grub2 run_root dnf clean all # Add support for virtio block devices at boot time. -cat > /mnt/etc/dracut.conf.d/virtio-blk.conf <<EOF +cat >/mnt/etc/dracut.conf.d/virtio-blk.conf <<EOF add_drivers="virtio-blk" EOF kernel_version=$(ls /mnt/boot | grep "vmlinuz.*.$arch" | cut -d- -f2-) @@ -162,3 +166,63 @@ UUID=$root_uuid / ext4 rw,relatime,data=ordered 0 0 EOF sync + +# Detach the disk so root.hmi is fully flushed before hashing; disarm cleanup. +cleanup +trap : EXIT + +# --- Hule Machine Manifest (.hmm) -- docs/boot-protocol.md 10b --------------- +# x86_64 only, self-booting via grub2 (BIOS). No boot.cmdline: grub owns it and +# firmware-disk boot takes no cmdline from the VMM. +case "$arch" in +x86_64) march=amd64 ;; +*) march=$arch ;; +esac +disk_sha=$(sha256sum "$out/root.hmi" | cut -d' ' -f1) +disk_bytes=$(stat -c%s "$out/root.hmi") +virtual_bytes=$((size_gib * 1024 * 1024 * 1024)) +mem_min=$((256 * 1024 * 1024)) +mem_def=$((1024 * 1024 * 1024)) + +cat >"$out/image.hmm" <<EOF +{ + "schemaVersion": 1, + "kind": "MachineImage", + + "system": { + "family": "linux", + "distro": "fedora", + "release": "$release" + }, + + "machine": { + "arch": "$march", + "cpu": { "minimum": 1, "default": 2 }, + "ram": { "minimum": $mem_min, "default": $mem_def } + }, + + "disks": [ + { + "id": "root", + "format": "qcow2", + "path": "root.hmi", + "checksum": "sha256:$disk_sha", + "virtualSize": $virtual_bytes, + "physicalSize": $disk_bytes + } + ], + + "boot": [ + { + "id": "firmware-disk/bios", + "disk": "root" + } + ], + + "access": [ + { "type": "ssh", "port": 22, "user": "build", "auth": "empty-password" } + ], + + "network": { "mode": "static", "address": "10.0.2.15/24", "gateway": "10.0.2.2" } +} +EOF diff --git a/images/freebsd/functions b/images/freebsd/functions deleted file mode 100644 --- a/images/freebsd/functions +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/sh -# SPDX-FileCopyrightText: 2017-2026 Drew DeVault -# SPDX-License-Identifier: AGPL-3.0-only - -poweroff_cmd="sudo poweroff" -default_arch=amd64 - -boot() { - if [ "$arch" != "amd64" ] - then - echo "Unsupported architecture $arch" >&2 - exit 1 - fi - qemu=qemu-system-x86_64 - _boot $(cpu_opts x86_64) -} - -install() { - port=$1 - shift 1 - guest_ssh -p $port build@localhost \ - "sudo pkg update && sudo pkg upgrade -y && sudo pkg install -y $@" -} - -sanity_check() { - echo "Booting..." - cmd_boot amd64 8022 qemu & - trap 'cmd_cleanup 8022' EXIT - _wait_boot 8022 - sleep 20 - echo "Testing sudo..." - guest_ssh -p 8022 build@localhost sudo ls -a - echo "Testing networking..." - guest_ssh -p 8022 build@localhost curl http://builds.sr.ht - echo "Testing pkg..." - guest_ssh -p 8022 build@localhost sudo pkg update - guest_ssh -p 8022 build@localhost sudo pkg upgrade -y - guest_ssh -p 8022 build@localhost sudo pkg install -y htop - echo "Testing git..." - guest_ssh -p 8022 build@localhost git --version - echo "Everything works!" - guest_ssh -p 8022 build@localhost sudo poweroff || true -} diff --git a/images/freebsd/genimg b/images/freebsd/genimg index 521bb50..8b23ed0 100755 --- a/images/freebsd/genimg +++ b/images/freebsd/genimg @@ -144,7 +144,6 @@ pkg -C /dev/null -R "$reposdir" -r "$rootfs" install -y \ pkg git-lite mercurial sudo curl pkg -C /dev/null -R "$reposdir" -r "$rootfs" clean -ay - if [ "$with_ports" = true ]; then git clone https://github.com/freebsd/freebsd-ports "$rootfs"/usr/ports \ --depth=1 --single-branch @@ -185,13 +184,69 @@ echo "%sudo ALL=(ALL) NOPASSWD: ALL" >>"$rootfs"/usr/local/etc/sudoers # bootable GPT qcow2 -- no md(4), mount or raw->qcow2 conversion needed. makefs -s 8g -B little -o label=rootfs -o version=2 -o softupdates=1 disk.img "$rootfs" mkdir -p "$out" +# .hmi (Hule Machine Image): mkimg emits a plain qcow2; keep it snapshot-free so +# it materializes losslessly to raw. Base image = chain root, no backing. +size_gib=16 mkimg -s gpt \ -b "$rootfs"/boot/pmbr \ - -C 16G \ + -C "${size_gib}G" \ -p freebsd-boot:="$rootfs"/boot/gptboot \ -p freebsd-ufs/rootfs:=disk.img \ -f qcow2 \ - -o "$out"/root.img.qcow2 + -o "$out"/root.hmi cleanup trap : EXIT + +# --- Hule Machine Manifest (.hmm) -- docs/boot-protocol.md 10b --------------- +# GPT + freebsd-boot (gptboot/pmbr), booted by the VMM's BIOS firmware. No +# boot.cmdline (the FreeBSD loader owns boot; firmware-disk takes none from the +# VMM). FreeBSD host tools: sha256(1) and stat -f, not GNU coreutils. +disk_sha=$(sha256 -q "$out/root.hmi") +disk_bytes=$(stat -f%z "$out/root.hmi") +virtual_bytes=$((size_gib * 1024 * 1024 * 1024)) +mem_min=$((256 * 1024 * 1024)) +mem_def=$((1024 * 1024 * 1024)) + +cat >"$out/image.hmm" <<HMM +{ + "schemaVersion": 1, + "kind": "MachineImage", + + "system": { + "family": "freebsd", + "distro": "freebsd", + "release": "$version" + }, + + "machine": { + "arch": "$arch", + "cpu": { "minimum": 1, "default": 2 }, + "ram": { "minimum": $mem_min, "default": $mem_def } + }, + + "disks": [ + { + "id": "root", + "format": "qcow2", + "path": "root.hmi", + "checksum": "sha256:$disk_sha", + "virtualSize": $virtual_bytes, + "physicalSize": $disk_bytes + } + ], + + "boot": [ + { + "id": "firmware-disk/bios", + "disk": "root" + } + ], + + "access": [ + { "type": "ssh", "port": 22, "user": "build", "auth": "empty-password" } + ], + + "network": { "mode": "static", "address": "10.0.2.15/24", "gateway": "10.0.2.2" } +} +HMM diff --git a/images/ubuntu/functions b/images/ubuntu/functions deleted file mode 100644 --- a/images/ubuntu/functions +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/sh -# SPDX-FileCopyrightText: 2017-2026 Drew DeVault -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-only - -poweroff_cmd="sudo systemctl poweroff" -default_arch=amd64 - -boot() { - case "$arch" in - amd64) - qemu=qemu-system-x86_64 - _boot $(cpu_opts x86_64) - ;; - arm64) - driveopts="id=root,if=none" - _boot \ - $(cpu_opts aarch64) \ - -device virtio-blk-pci,drive=root \ - -kernel "$wd/$arch/vmlinuz" \ - -initrd "$wd/$arch/initrd.img" \ - -append "root=/dev/vda3" - ;; - *) - echo "Unsupported architecture $arch" >&2 - exit 1 - ;; - esac -} - -install() { - port=$1 - shift 1 - guest_ssh -p $port build@localhost sudo env DEBIAN_FRONTEND=noninteractive \ - apt-get update -y - guest_ssh -p $port build@localhost sudo env DEBIAN_FRONTEND=noninteractive \ - apt-get install -y -q "$@" -} - -sanity_check() { - echo "Booting..." - cmd_boot amd64 8022 qemu & - trap 'cmd_cleanup 8022' EXIT - _wait_boot 8022 - - echo "Testing sudo..." - guest_ssh -p 8022 build@localhost sudo ls -a - - echo "Testing apt..." - guest_ssh -p 8022 build@localhost sudo apt-get update - install 8022 curl - - echo "Testing networking..." - guest_ssh -p 8022 build@localhost curl https://builds.sr.ht - - echo "Testing git..." - guest_ssh -p 8022 build@localhost git --version - - echo "Testing mercurial..." - guest_ssh -p 8022 build@localhost hg --version - - echo "Everything works!" - guest_ssh -p 8022 build@localhost sudo systemctl poweroff || true -} diff --git a/images/ubuntu/genimg b/images/ubuntu/genimg index 4b519be..d34f67b 100755 --- a/images/ubuntu/genimg +++ b/images/ubuntu/genimg @@ -10,26 +10,26 @@ release=${1:-} arch=${2:-amd64} case $release in - jammy|noble|resolute) ;; - *) - echo "usage: ${0##*/} <jammy|noble|resolute> [arch]" >&2 - exit 1 - ;; +jammy | noble | resolute) ;; +*) + echo "usage: ${0##*/} <jammy|noble|resolute> [arch]" >&2 + exit 1 + ;; esac case $arch in - amd64) - iface=ens3 - qarch=x86_64 - ;; - arm64) - iface=enp0s1 - qarch=aarch64 - ;; - *) - echo "unsupported architecture $arch" - exit 1 - ;; +amd64) + iface=ens3 + qarch=x86_64 + ;; +arm64) + iface=enp0s1 + qarch=aarch64 + ;; +*) + echo "unsupported architecture $arch" + exit 1 + ;; esac cleanup() { @@ -42,24 +42,30 @@ cleanup() { umount /mnt/run 2>/dev/null || true umount /mnt/sys 2>/dev/null || true umount /mnt/boot 2>/dev/null || true - umount /mnt 2>/dev/null || true + umount /mnt 2>/dev/null || true qemu-nbd --disconnect /dev/nbd0 || true } out="$release/$arch" +size_gib=32 mkdir -p "$out" -qemu-img create -f qcow2 "$out/root.img.qcow2" 32G +# .hmi (Hule Machine Image) is a qcow2 restricted to a feature subset that +# materializes losslessly to raw (no internal snapshots, no dirty bitmaps, no +# encryption). Backing files are allowed in general -- that is how Hule layers +# images over OCI -- but this is a base image, i.e. a chain root, so it has no +# backing. It is the *resolved chain* that must round-trip to raw for raw-only +# VMMs (e.g. Firecracker). Never `qemu-img snapshot` it. +qemu-img create -f qcow2 -o compat=1.1 "$out/root.hmi" "${size_gib}G" modprobe nbd max_part=16 -qemu-nbd --connect=/dev/nbd0 "$out/root.img.qcow2" +qemu-nbd --connect=/dev/nbd0 "$out/root.hmi" trap cleanup EXIT # Looks like qemu-nbd is non-blocking. Wait one second to make sure $NBD_DEVICE # is available. sleep 1 -if [ "$arch" = "amd64" ] -then +if [ "$arch" = "amd64" ]; then dd if=/usr/lib/syslinux/mbr/mbr.bin of=/dev/nbd0 bs=1 count=440 fi @@ -91,8 +97,7 @@ mount --bind /proc /mnt/proc mount --bind /run /mnt/run mount --bind /sys /mnt/sys -if [ "$arch" != "amd64" ] -then +if [ "$arch" != "amd64" ]; then cp /usr/bin/qemu-$qarch-static /mnt/usr/bin fi @@ -106,13 +111,19 @@ rm -f /mnt/etc/resolv.conf echo 'nameserver 8.8.8.8' >/mnt/etc/resolv.conf echo 'nameserver 9.9.9.9' >>/mnt/etc/resolv.conf echo 'nameserver 1.1.1.1' >>/mnt/etc/resolv.conf +# Network config baked into the guest; the manifest declares it (mode + params) +# so consumers know how the image is reachable. Single source, so the two stay +# in sync. +net_mode=static +net_address=10.0.2.15/24 +net_gateway=10.0.2.2 cat >/mnt/etc/systemd/network/25-ens3.network <<EOF [Match] Name=ens3 [Network] -Address=10.0.2.15/24 -Gateway=10.0.2.2 +Address=$net_address +Gateway=$net_gateway EOF run_root systemctl enable systemd-networkd.service @@ -139,8 +150,7 @@ run_root apt-get -y install linux-image-generic run_root apt-get -y install \ build-essential git mercurial ssh sudo dirmngr curl ca-certificates -if [ "$arch" = "amd64" ] -then +if [ "$arch" = "amd64" ]; then extlinux -i /mnt/boot fi @@ -150,10 +160,10 @@ run_root systemctl enable systemd-timesyncd.service || run_root useradd -mG sudo build run_root passwd -d build -echo '%sudo ALL=(ALL) NOPASSWD: ALL' >> /mnt/etc/sudoers +echo '%sudo ALL=(ALL) NOPASSWD: ALL' >>/mnt/etc/sudoers -echo "PermitEmptyPasswords yes" >> /mnt/etc/ssh/sshd_config -echo ssh >> /mnt/etc/securetty +echo "PermitEmptyPasswords yes" >>/mnt/etc/ssh/sshd_config +echo ssh >>/mnt/etc/securetty run_root systemctl enable ssh # Prevent docker from mucking up networking @@ -168,24 +178,127 @@ run_root update-initramfs -u linuxver=$(ls /mnt/boot | grep 'vmlinuz-[0-9].*' | cut -d- -f2-) +# Reference partitions by PARTUUID so boot works regardless of how the VMM names +# the disk (vda/sda/nvme...). cmdline is canonical: identical for every boot +# protocol (extlinux here, and -append on the direct path) -- see +# docs/boot-protocol.md. +boot_partuuid=$(blkid -s PARTUUID -o value /dev/nbd0p1) +swap_partuuid=$(blkid -s PARTUUID -o value /dev/nbd0p2) +root_partuuid=$(blkid -s PARTUUID -o value /dev/nbd0p3) +cmdline="root=PARTUUID=$root_partuuid rw quiet" + cat >/mnt/boot/extlinux.conf <<EOF default ubuntu label ubuntu linux vmlinuz-$linuxver initrd initrd.img-$linuxver - append root=/dev/vda3 rw quiet + append $cmdline EOF cat >>/mnt/etc/fstab <<EOF -/dev/vda1 /boot ext4 rw,relatime,data=ordered 0 0 -/dev/vda2 swap swap defaults 0 0 -/dev/vda3 / ext4 rw,relatime,data=ordered 0 0 +PARTUUID=$boot_partuuid /boot ext4 rw,relatime,data=ordered 0 0 +PARTUUID=$swap_partuuid swap swap defaults 0 0 +PARTUUID=$root_partuuid / ext4 rw,relatime,data=ordered 0 0 EOF -if [ "$arch" != "amd64" ] -then +if [ "$arch" != "amd64" ]; then + # Sidecar kernel/initrd for the linux-boot protocol: there is no + # in-image bootloader on non-amd64, the VMM boots these directly. cp /mnt/boot/vmlinuz "$out/vmlinuz" - cp /mnt/boot/initrd.img "$out/initrd.img" + cp /mnt/boot/initrd.img "$out/initrd" fi sync + +# Detach the disk so root.hmi is fully flushed before we hash it, then stop +# the cleanup trap from running a second time on exit. +cleanup +trap : EXIT + +# --- Hule Machine Manifest (.hmm) ------------------------------------------- +# JSON description of the image: the .hmi disk(s), the boot protocols this image +# can honestly be booted with, and the surfaces it exposes. The declaration is a +# promise (see docs/boot-protocol.md); only list what this build produces. +# Self-contained: paths + checksums, no dependency on OCI (layers are transport +# only). Sizes are integer bytes, keys are camelCase, disks referenced by id. +disk_sha=$(sha256sum "$out/root.hmi" | cut -d' ' -f1) +disk_bytes=$(stat -c%s "$out/root.hmi") +virtual_bytes=$((size_gib * 1024 * 1024 * 1024)) +mem_min=$((256 * 1024 * 1024)) +mem_def=$((1024 * 1024 * 1024)) + +case "$arch" in +amd64) + # Legacy BIOS: extlinux inside an MBR-partitioned disk, booted by the + # VMM's BIOS firmware (e.g. SeaBIOS). Self-bootable, no external inputs. + protocols=$( + cat <<JSON + { + "id": "firmware-disk/bios", + "disk": "root" + } +JSON + ) + ;; +*) + # No in-image bootloader; the VMM boots the extracted kernel/initrd + # directly and mounts the root disk. cmdline is canonical (boot.cmdline). + kernel_sha=$(sha256sum "$out/vmlinuz" | cut -d' ' -f1) + initrd_sha=$(sha256sum "$out/initrd" | cut -d' ' -f1) + protocols=$( + cat <<JSON + { + "id": "linux/arm64-image", + "disk": "root", + "kernel": { "path": "vmlinuz", "checksum": "sha256:$kernel_sha" }, + "initrd": { "path": "initrd", "checksum": "sha256:$initrd_sha" }, + "cmdline": "$cmdline" + } +JSON + ) + ;; +esac + +cat >"$out/image.hmm" <<EOF +{ + "schemaVersion": 1, + "kind": "MachineImage", + + "system": { + "family": "linux", + "distro": "ubuntu", + "release": "$release" + }, + + "machine": { + "arch": "$arch", + "cpu": { "minimum": 1, "default": 2 }, + "ram": { "minimum": $mem_min, "default": $mem_def } + }, + + "disks": [ + { + "id": "root", + "format": "qcow2", + "path": "root.hmi", + "checksum": "sha256:$disk_sha", + "virtualSize": $virtual_bytes, + "physicalSize": $disk_bytes + } + ], + + "boot": [ +$protocols + ], + + "access": [ + { "type": "ssh", "port": 22, "user": "build", "auth": "empty-password" } + ], + + "network": { + "mode": "$net_mode", + "address": "$net_address", + "gateway": "$net_gateway" + } +} +EOF |
