aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNikolay Govorov <me@govorov.online>2026-07-09 04:29:20 +0100
committerNikolay Govorov <me@govorov.online>2026-07-09 04:29:20 +0100
commit67286b8141b032462d1510ea22b4873569dfe0f3 (patch)
tree92ab2397c565f592fc5ae6a56edb99ed63a4a965
parent522e424f4f6c008e946f3822052c3ce9e693374d (diff)
downloadtar
tar.gz
tar.bz2
tar.lz
tar.xz
tar.zst
zip
Rewrite image boot to Rust
Diffstat
-rw-r--r--.gitignore4+4 −0
-rw-r--r--Cargo.lock98+98 −0
-rw-r--r--Cargo.toml4+4 −0
-rw-r--r--crates/hule/Cargo.toml2+2 −0
-rw-r--r--crates/hule/src/main.rs286+282 −4
-rw-r--r--images/alpine/functions47+0 −47
-rwxr-xr-ximages/alpine/genimg126+95 −31
-rwxr-xr-ximages/control142+0 −142
-rw-r--r--images/debian/functions72+0 −72
-rwxr-xr-ximages/debian/genimg224+165 −59
-rw-r--r--images/fedora/functions42+0 −42
-rwxr-xr-ximages/fedora/genimg102+83 −19
-rw-r--r--images/freebsd/functions43+0 −43
-rwxr-xr-ximages/freebsd/genimg61+58 −3
-rw-r--r--images/ubuntu/functions64+0 −64
-rwxr-xr-ximages/ubuntu/genimg189+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