aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--Cargo.lock50+50 −0
-rw-r--r--crates/hule-image/src/lib.rs19+18 −1
-rw-r--r--crates/hule-vmm/Cargo.toml4+4 −0
-rw-r--r--crates/hule-vmm/src/backend/mod.rs4+4 −0
-rw-r--r--crates/hule-vmm/src/backend/qemu/cli.rs300+300 −0
-rw-r--r--crates/hule-vmm/src/backend/qemu/mod.rs375+375 −0
-rw-r--r--crates/hule-vmm/src/backend/qemu/supervise.rs140+140 −0
-rw-r--r--crates/hule-vmm/src/hypervisor.rs52+52 −0
-rw-r--r--crates/hule-vmm/src/lib.rs79+78 −1
-rw-r--r--crates/hule-vmm/src/machine.rs98+98 −0
-rw-r--r--crates/hule-vmm/src/monitor.rs109+109 −0
-rw-r--r--crates/hule/Cargo.toml1+1 −0
-rw-r--r--crates/hule/src/main.rs195+39 −156
13 files changed, 1268 insertions, 158 deletions
diff --git a/Cargo.lock b/Cargo.lock
index ec39167..88fde04 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -21,6 +21,17 @@ dependencies = [
]
[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -351,6 +362,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -557,6 +578,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"tokio",
+ "uuid",
"zstd",
]
@@ -575,6 +597,12 @@ version = "0.1.0"
[[package]]
name = "hule-vmm"
version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "hule-image",
+ "tokio",
+ "uuid",
+]
[[package]]
name = "hybrid-array"
@@ -1472,6 +1500,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1640,6 +1678,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
+ "signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
@@ -1826,6 +1865,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
+name = "uuid"
+version = "1.23.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/crates/hule-image/src/lib.rs b/crates/hule-image/src/lib.rs
index a50c920..6974882 100644
--- a/crates/hule-image/src/lib.rs
+++ b/crates/hule-image/src/lib.rs
@@ -255,6 +255,21 @@ pub enum Architecture {
S390x,
}
+impl Architecture {
+ /// The architecture this process is running on, if it's one we know.
+ pub fn host() -> Option<Self> {
+ match std::env::consts::ARCH {
+ "x86_64" => Some(Self::Amd64),
+ "aarch64" => Some(Self::Arm64),
+ "loongarch64" => Some(Self::Loong64),
+ "riscv64" => Some(Self::Riscv64),
+ "s390x" => Some(Self::S390x),
+ "powerpc64" if cfg!(target_endian = "little") => Some(Self::Ppc64le),
+ _ => None,
+ }
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct System {
@@ -351,7 +366,9 @@ impl Access {
fn validate_into(&self, field: &str, validation: &mut Validation) {
match self {
- Self::Ssh { port, user, auth, .. } => {
+ Self::Ssh {
+ port, user, auth, ..
+ } => {
if *port == 0 {
validation.error(format!("{field}.port must be greater than zero"));
}
diff --git a/crates/hule-vmm/Cargo.toml b/crates/hule-vmm/Cargo.toml
index 42c8561..9e2a649 100644
--- a/crates/hule-vmm/Cargo.toml
+++ b/crates/hule-vmm/Cargo.toml
@@ -13,3 +13,7 @@ authors.workspace = true
repository.workspace = true
[dependencies]
+hule-image.workspace = true
+uuid = { version = "1", features = ["v7"] }
+tokio = { version = "1", features = ["process", "fs", "io-util", "sync"] }
+async-trait = "0.1"
diff --git a/crates/hule-vmm/src/backend/mod.rs b/crates/hule-vmm/src/backend/mod.rs
new file mode 100644
--- /dev/null
+++ b/crates/hule-vmm/src/backend/mod.rs
@@ -0,0 +1,4 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: Apache-2.0
+
+pub mod qemu;
diff --git a/crates/hule-vmm/src/backend/qemu/cli.rs b/crates/hule-vmm/src/backend/qemu/cli.rs
new file mode 100644
--- /dev/null
+++ b/crates/hule-vmm/src/backend/qemu/cli.rs
@@ -0,0 +1,300 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: Apache-2.0
+
+//! Typed wrapper over the `qemu-system-*` command line. One qemu concept
+//! per type/field; knows nothing about Hule's image/machine model.
+
+use std::path::PathBuf;
+
+/// One of the architectures qemu ships a `qemu-system-*` binary for.
+/// Distinct from `hule_image::Architecture`: this is qemu's own set of
+/// per-arch conventions (binary name, machine type, TCG cpu model), not
+/// Hule's.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Arch {
+ Amd64,
+ Arm64,
+ Loong64,
+ Ppc64le,
+ Riscv64,
+ S390x,
+}
+
+impl Arch {
+ /// The `qemu-system-*` binary for this target.
+ pub fn binary(&self) -> String {
+ let suffix = match self {
+ Self::Amd64 => "x86_64",
+ Self::Arm64 => "aarch64",
+ Self::Loong64 => "loongarch64",
+ Self::Ppc64le => "ppc64le",
+ Self::Riscv64 => "riscv64",
+ Self::S390x => "s390x",
+ };
+ format!("qemu-system-{suffix}")
+ }
+
+ /// `-machine`/`-M` value this arch needs, if any (the default machine
+ /// type is fine for the others).
+ pub fn machine_type(&self) -> Option<&'static str> {
+ match self {
+ Self::Arm64 | Self::Riscv64 => Some("virt"),
+ Self::Ppc64le => Some("pseries"),
+ _ => None,
+ }
+ }
+
+ /// `-cpu` model to emulate when KVM isn't usable.
+ pub fn tcg_cpu_model(&self) -> &'static str {
+ match self {
+ Self::Amd64 => "qemu64",
+ Self::Arm64 => "cortex-a53",
+ Self::Loong64 => "la464",
+ Self::Ppc64le => "power9",
+ Self::Riscv64 => "rv64",
+ Self::S390x => "max",
+ }
+ }
+}
+
+pub enum Accel {
+ Tcg,
+ Kvm,
+}
+
+pub enum NetBackend {
+ Nic { model: String },
+ User { hostfwd: Vec<(u16, u16)> },
+}
+
+impl NetBackend {
+ fn to_arg(&self) -> String {
+ match self {
+ Self::Nic { model } => format!("nic,model={model}"),
+ Self::User { hostfwd } => {
+ let mut s = String::from("user");
+ for (host, guest) in hostfwd {
+ s.push_str(&format!(",hostfwd=tcp:127.0.0.1:{host}-:{guest}"));
+ }
+ s
+ }
+ }
+ }
+}
+
+pub enum DriveInterface {
+ /// `if=virtio`: the disk is a bootable device in its own right.
+ Virtio,
+ /// `if=none,id=<id>`: unattached, paired with a `Device::VirtioBlkPci`.
+ None { id: String },
+}
+
+pub struct Drive {
+ pub file: PathBuf,
+ /// `snapshot=on`: writes go to a throwaway overlay, never to `file`.
+ pub snapshot: bool,
+ pub interface: DriveInterface,
+}
+
+impl Drive {
+ fn to_arg(&self) -> String {
+ let mut s = format!("file={},media=disk", self.file.display());
+ if self.snapshot {
+ s.push_str(",snapshot=on");
+ }
+ match &self.interface {
+ DriveInterface::Virtio => s.push_str(",if=virtio"),
+ DriveInterface::None { id } => s.push_str(&format!(",id={id},if=none")),
+ }
+ s
+ }
+}
+
+pub enum Device {
+ VirtioBlkPci { drive: String },
+ VirtioRngPci,
+ VirtioBalloon,
+}
+
+impl Device {
+ fn to_arg(&self) -> String {
+ match self {
+ Self::VirtioBlkPci { drive } => format!("virtio-blk-pci,drive={drive}"),
+ Self::VirtioRngPci => "virtio-rng-pci".into(),
+ Self::VirtioBalloon => "virtio-balloon".into(),
+ }
+ }
+}
+
+/// One `qemu-system-*` invocation. `to_argv()` is pure -- no process/IO --
+/// so it's testable without spawning anything.
+#[derive(Default)]
+pub struct Command {
+ pub binary: String,
+ /// `-machine`/`-M`; qemu treats them as synonyms, so only one field.
+ pub machine: Option<String>,
+ pub cpu: Option<String>,
+ pub accel: Option<Accel>,
+ pub memory_mib: u64,
+ pub smp: u32,
+ pub display_none: bool,
+ pub nets: Vec<NetBackend>,
+ pub drives: Vec<Drive>,
+ pub devices: Vec<Device>,
+ pub kernel: Option<PathBuf>,
+ pub initrd: Option<PathBuf>,
+ pub append: Option<String>,
+ /// `-pidfile`: qemu writes its own pid here. The only reliable way to
+ /// signal/check it later from a process that isn't its parent.
+ pub pidfile: Option<PathBuf>,
+}
+
+impl Command {
+ pub fn to_argv(&self) -> Vec<String> {
+ let mut argv = vec![self.binary.clone()];
+ if let Some(machine) = &self.machine {
+ argv.extend(["-machine".into(), machine.clone()]);
+ }
+ if let Some(cpu) = &self.cpu {
+ argv.extend(["-cpu".into(), cpu.clone()]);
+ }
+ if matches!(self.accel, Some(Accel::Kvm)) {
+ argv.push("-enable-kvm".into());
+ }
+ argv.extend(["-m".into(), self.memory_mib.to_string()]);
+ argv.extend(["-smp".into(), format!("cpus={}", self.smp)]);
+ for net in &self.nets {
+ argv.extend(["-net".into(), net.to_arg()]);
+ }
+ for drive in &self.drives {
+ argv.extend(["-drive".into(), drive.to_arg()]);
+ }
+ for device in &self.devices {
+ argv.extend(["-device".into(), device.to_arg()]);
+ }
+ if self.display_none {
+ argv.extend(["-display".into(), "none".into()]);
+ }
+ if let Some(kernel) = &self.kernel {
+ argv.extend(["-kernel".into(), kernel.display().to_string()]);
+ }
+ if let Some(initrd) = &self.initrd {
+ argv.extend(["-initrd".into(), initrd.display().to_string()]);
+ }
+ if let Some(append) = &self.append {
+ argv.extend(["-append".into(), append.clone()]);
+ }
+ if let Some(pidfile) = &self.pidfile {
+ argv.extend(["-pidfile".into(), pidfile.display().to_string()]);
+ }
+ argv
+ }
+}
+
+/// Whether `/dev/kvm` is actually usable, not just present -- the node can
+/// exist but be unopenable (e.g. group `kvm` without membership), which
+/// qemu only reports once it's already mid-boot.
+pub async fn kvm_available() -> bool {
+ tokio::fs::OpenOptions::new()
+ .read(true)
+ .write(true)
+ .open("/dev/kvm")
+ .await
+ .is_ok()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn renders_flags_in_a_stable_order() {
+ let cmd = Command {
+ binary: "qemu-system-x86_64".into(),
+ cpu: Some("qemu64".into()),
+ memory_mib: 1024,
+ smp: 2,
+ display_none: true,
+ nets: vec![
+ NetBackend::Nic {
+ model: "virtio".into(),
+ },
+ NetBackend::User {
+ hostfwd: vec![(8022, 22)],
+ },
+ ],
+ drives: vec![Drive {
+ file: "root.hmi".into(),
+ snapshot: true,
+ interface: DriveInterface::Virtio,
+ }],
+ devices: vec![Device::VirtioRngPci],
+ ..Default::default()
+ };
+ assert_eq!(
+ cmd.to_argv(),
+ vec![
+ "qemu-system-x86_64",
+ "-cpu",
+ "qemu64",
+ "-m",
+ "1024",
+ "-smp",
+ "cpus=2",
+ "-net",
+ "nic,model=virtio",
+ "-net",
+ "user,hostfwd=tcp:127.0.0.1:8022-:22",
+ "-drive",
+ "file=root.hmi,media=disk,snapshot=on,if=virtio",
+ "-device",
+ "virtio-rng-pci",
+ "-display",
+ "none",
+ ]
+ );
+ }
+
+ #[test]
+ fn kvm_accel_adds_enable_kvm_flag() {
+ let cmd = Command {
+ binary: "qemu-system-x86_64".into(),
+ cpu: Some("host".into()),
+ accel: Some(Accel::Kvm),
+ ..Default::default()
+ };
+ assert!(cmd.to_argv().contains(&"-enable-kvm".to_string()));
+ }
+
+ #[test]
+ fn detached_drive_pairs_with_a_virtio_blk_device() {
+ let cmd = Command {
+ binary: "qemu-system-x86_64".into(),
+ drives: vec![Drive {
+ file: "root.hmi".into(),
+ snapshot: true,
+ interface: DriveInterface::None { id: "root".into() },
+ }],
+ devices: vec![Device::VirtioBlkPci {
+ drive: "root".into(),
+ }],
+ kernel: Some("vmlinuz".into()),
+ initrd: Some("initrd.img".into()),
+ append: Some("console=ttyS0".into()),
+ ..Default::default()
+ };
+ let argv = cmd.to_argv();
+ assert!(argv.windows(2).any(|w| w == ["-kernel", "vmlinuz"]));
+ assert!(argv.windows(2).any(|w| w == ["-initrd", "initrd.img"]));
+ assert!(argv.windows(2).any(|w| w == ["-append", "console=ttyS0"]));
+ assert!(argv.windows(2).any(|w| w
+ == [
+ "-drive",
+ "file=root.hmi,media=disk,snapshot=on,id=root,if=none"
+ ]));
+ assert!(
+ argv.windows(2)
+ .any(|w| w == ["-device", "virtio-blk-pci,drive=root"])
+ );
+ }
+}
diff --git a/crates/hule-vmm/src/backend/qemu/mod.rs b/crates/hule-vmm/src/backend/qemu/mod.rs
new file mode 100644
--- /dev/null
+++ b/crates/hule-vmm/src/backend/qemu/mod.rs
@@ -0,0 +1,375 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: Apache-2.0
+
+//! qemu backend. Basic extraction from the old `hule` binary's boot code:
+//! `start`/`kill`/`wait`/`state`/`delete` work, everything needing QMP or a
+//! console channel (`stop`, `restart`, `pause`, `exec`, `attach`, ...) is
+//! `todo!()` -- QMP is opportunistic, for operations signals can't do, not
+//! load-bearing for the base lifecycle.
+//!
+//! `cli` is the typed qemu CLI wrapper; `supervise` gets an exit code out of
+//! a process even if we stop being its parent; this module only translates
+//! Hule's image/machine types into both.
+
+mod cli;
+mod supervise;
+
+use std::path::{Path, PathBuf};
+use std::process::{ExitStatus, Output};
+use std::time::Duration;
+
+use async_trait::async_trait;
+use hule_image::{Architecture, Boot, BootLinux, Disk, MachineImage};
+use tokio::process::Child;
+use tokio::sync::Mutex;
+
+use crate::{
+ Console, Error, Hypervisor, HypervisorId, Machine, MachineId, R, Settings, State, Stats,
+};
+
+const QEMU_HYPERVISOR_ID: HypervisorId = HypervisorId(1);
+
+enum QemuBoot {
+ // No BootBios fields are needed here: `disk` is already resolved into
+ // `QemuMachine::disk`, and `protocol` isn't used past `supports()`.
+ Bios,
+ Linux(BootLinux),
+}
+
+/// The wrapper (`sh`) process, not qemu itself -- once qemu has a pid on
+/// disk (see `QemuMachine::pid_path`), that's the source of truth for
+/// liveness/signaling, not this.
+enum ChildState {
+ NotStarted,
+ Running(Child),
+ Exited(ExitStatus),
+}
+
+pub struct QemuMachine {
+ id: MachineId,
+ name: Option<String>,
+ dir: PathBuf,
+ arch: Architecture,
+ settings: Settings,
+ disk: Disk,
+ boot: QemuBoot,
+ child: Mutex<ChildState>,
+}
+
+impl QemuMachine {
+ /// Where qemu's own `-pidfile` lands. Provisional: once a persisted
+ /// `run/<id>/` directory exists (separate from `dir`, the materialized
+ /// image files), this moves there instead of living inside `dir`.
+ fn pid_path(&self) -> PathBuf {
+ self.dir.join("qemu.pid")
+ }
+
+ fn exit_code_path(&self) -> PathBuf {
+ self.dir.join("qemu.exit-code")
+ }
+
+ /// Reads qemu's pid, waiting briefly for `-pidfile` to actually appear
+ /// (there's a short window between spawn and qemu writing it).
+ async fn qemu_pid(&self) -> R<supervise::Pid> {
+ for _ in 0..50 {
+ if let Ok(data) = tokio::fs::read_to_string(self.pid_path()).await
+ && let Ok(pid) = data.trim().parse()
+ {
+ return Ok(supervise::Pid(pid));
+ }
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ }
+ Err(Error::InvalidState(
+ "qemu did not write its pidfile in time".into(),
+ ))
+ }
+
+ async fn read_exit_code(&self) -> Option<ExitStatus> {
+ let data = tokio::fs::read_to_string(self.exit_code_path())
+ .await
+ .ok()?;
+ let code: i32 = data.trim().parse().ok()?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::process::ExitStatusExt;
+ // `$?` is already qemu's plain exit code; shift it into the
+ // wait()-status shape ExitStatus expects (bits 8-15 == exit
+ // code, low byte 0 == not signaled). Loses signal detail, but
+ // `.code()` -- all we ever read -- comes back right.
+ Some(ExitStatus::from_raw(code << 8))
+ }
+ #[cfg(not(unix))]
+ {
+ None
+ }
+ }
+}
+
+#[async_trait]
+impl Machine for QemuMachine {
+ fn id(&self) -> MachineId {
+ self.id
+ }
+
+ fn hid(&self) -> HypervisorId {
+ QEMU_HYPERVISOR_ID
+ }
+
+ fn name(&self) -> Option<&str> {
+ self.name.as_deref()
+ }
+
+ fn settings(&self) -> Settings {
+ self.settings.clone()
+ }
+
+ async fn state(&self) -> State {
+ if let Some(status) = self.read_exit_code().await {
+ return State::Exited(status.code().unwrap_or(-1));
+ }
+ match self.qemu_pid().await {
+ Ok(pid) if supervise::pid_alive(pid).await => State::Running,
+ Ok(_) => State::Dead,
+ Err(_) => match &*self.child.lock().await {
+ ChildState::NotStarted => State::Created,
+ ChildState::Exited(status) => State::Exited(status.code().unwrap_or(-1)),
+ ChildState::Running(_) => State::Running,
+ },
+ }
+ }
+
+ async fn stats(&self) -> R<Stats> {
+ todo!("needs /proc or QMP query-blockstats wiring")
+ }
+
+ async fn logs(&self) -> R<Vec<u8>> {
+ todo!("needs a captured serial console, not wired up yet")
+ }
+
+ async fn start(&mut self) -> R<()> {
+ let mut child = self.child.lock().await;
+ if matches!(*child, ChildState::Running(_)) {
+ return Err(Error::InvalidState("already started".into()));
+ }
+ let _ = tokio::fs::remove_file(self.pid_path()).await;
+ let _ = tokio::fs::remove_file(self.exit_code_path()).await;
+
+ let kvm = Architecture::host() == Some(self.arch) && cli::kvm_available().await;
+ let sys_arch = match self.arch {
+ Architecture::Amd64 => cli::Arch::Amd64,
+ Architecture::Arm64 => cli::Arch::Arm64,
+ Architecture::Loong64 => cli::Arch::Loong64,
+ Architecture::Ppc64le => cli::Arch::Ppc64le,
+ Architecture::Riscv64 => cli::Arch::Riscv64,
+ Architecture::S390x => cli::Arch::S390x,
+ };
+ let mut cmd = cli::Command {
+ binary: sys_arch.binary(),
+ machine: sys_arch.machine_type().map(String::from),
+ memory_mib: self.settings.ram,
+ smp: self.settings.cpu,
+ display_none: true,
+ nets: vec![
+ cli::NetBackend::Nic {
+ model: "virtio".into(),
+ },
+ cli::NetBackend::User {
+ hostfwd: self.settings.port_forwards.clone(),
+ },
+ ],
+ devices: vec![cli::Device::VirtioRngPci, cli::Device::VirtioBalloon],
+ ..Default::default()
+ };
+
+ if kvm {
+ cmd.cpu = Some("host".into());
+ cmd.accel = Some(cli::Accel::Kvm);
+ } else {
+ cmd.cpu = Some(sys_arch.tcg_cpu_model().into());
+ cmd.accel = Some(cli::Accel::Tcg);
+ }
+
+ let disk_path = self.dir.join(&self.disk.path);
+ match &self.boot {
+ QemuBoot::Bios => {
+ cmd.drives.push(cli::Drive {
+ file: disk_path,
+ snapshot: true,
+ interface: cli::DriveInterface::Virtio,
+ });
+ }
+ QemuBoot::Linux(linux) => {
+ cmd.drives.push(cli::Drive {
+ file: disk_path,
+ snapshot: true,
+ interface: cli::DriveInterface::None { id: "root".into() },
+ });
+ cmd.devices.push(cli::Device::VirtioBlkPci {
+ drive: "root".into(),
+ });
+ cmd.kernel = Some(self.dir.join(&linux.kernel.path));
+ cmd.initrd = Some(self.dir.join(&linux.initrd.path));
+ cmd.append = Some(linux.cmdline.clone());
+ }
+ }
+ cmd.pidfile = Some(self.pid_path());
+ let argv = cmd.to_argv();
+
+ let wrapper = supervise::spawn(&argv, &self.exit_code_path())?;
+ *child = ChildState::Running(wrapper);
+ Ok(())
+ }
+
+ async fn stop(&mut self) -> R<()> {
+ todo!("needs QMP system_powerdown, not wired up yet")
+ }
+
+ async fn kill(&mut self) -> R<()> {
+ let pid = self.qemu_pid().await?;
+ supervise::kill(pid).await?;
+ // Reap the wrapper so its resources are released; its own exit
+ // status is uninteresting, qemu's (now on disk) is what matters.
+ let mut child = self.child.lock().await;
+ if let ChildState::Running(c) = &mut *child {
+ let _ = c.wait().await;
+ }
+ if let Some(status) = self.read_exit_code().await {
+ *child = ChildState::Exited(status);
+ }
+ Ok(())
+ }
+
+ async fn restart(&mut self) -> R<()> {
+ todo!("needs QMP system_reset, not wired up yet")
+ }
+
+ async fn delete(&mut self) -> R<()> {
+ if self.state().await == State::Running {
+ self.kill().await?;
+ }
+ let _ = tokio::fs::remove_file(self.pid_path()).await;
+ let _ = tokio::fs::remove_file(self.exit_code_path()).await;
+ Ok(())
+ }
+
+ async fn pause(&mut self) -> R<()> {
+ todo!("needs QMP stop, not wired up yet")
+ }
+
+ async fn unpause(&mut self) -> R<()> {
+ todo!("needs QMP cont, not wired up yet")
+ }
+
+ async fn update(&mut self, _settings: Settings) -> R<()> {
+ todo!("needs a diff against current settings and QMP/hotplug to apply it")
+ }
+
+ async fn rename(&mut self, name: Option<&str>) -> R<()> {
+ self.name = name.map(String::from);
+ Ok(())
+ }
+
+ async fn wait(&mut self) -> R<ExitStatus> {
+ loop {
+ if let Some(status) = self.read_exit_code().await {
+ *self.child.lock().await = ChildState::Exited(status);
+ return Ok(status);
+ }
+ let mut child = self.child.lock().await;
+ match &mut *child {
+ ChildState::Running(c) => {
+ // The wrapper only exits after qemu does and the exit
+ // code is flushed to disk, so looping back re-reads it.
+ c.wait().await?;
+ }
+ ChildState::Exited(status) => return Ok(*status),
+ ChildState::NotStarted => {
+ return Err(Error::InvalidState("not started".into()));
+ }
+ }
+ }
+ }
+
+ async fn exec(&self, _cmd: &[String]) -> R<Output> {
+ todo!("needs the access channel (ssh today) wired up here")
+ }
+
+ async fn attach(&self) -> R<Box<dyn Console>> {
+ todo!("needs a serial console channel, not wired up yet")
+ }
+
+ async fn resize(&self, _cols: u16, _rows: u16) -> R<()> {
+ todo!("depends on attach")
+ }
+}
+
+pub struct QemuHypervisor;
+
+#[async_trait]
+impl Hypervisor for QemuHypervisor {
+ fn id(&self) -> HypervisorId {
+ QEMU_HYPERVISOR_ID
+ }
+
+ fn name(&self) -> &'static str {
+ "qemu"
+ }
+
+ fn supports(&self, boot: &Boot) -> bool {
+ matches!(boot, Boot::Bios(_) | Boot::Linux(_))
+ }
+
+ async fn create(
+ &self,
+ id: MachineId,
+ name: Option<&str>,
+ image: &MachineImage,
+ dir: &Path,
+ boot: &Boot,
+ settings: &Settings,
+ ) -> R<Box<dyn Machine>> {
+ let disk = image
+ .disks
+ .iter()
+ .find(|d| d.id == boot.disk())
+ .cloned()
+ .ok_or_else(|| {
+ Error::InvalidImage(format!(
+ "boot entry references unknown disk '{}'",
+ boot.disk()
+ ))
+ })?;
+
+ let qemu_boot = match boot {
+ Boot::Bios(_) => QemuBoot::Bios,
+ Boot::Linux(b) => QemuBoot::Linux(b.clone()),
+ Boot::Uefi(_) | Boot::Unknown(_) => {
+ return Err(Error::Unsupported(format!(
+ "qemu backend cannot boot protocol '{}'",
+ boot.protocol()
+ )));
+ }
+ };
+
+ Ok(Box::new(QemuMachine {
+ id,
+ name: name.map(String::from),
+ dir: dir.to_path_buf(),
+ arch: image.system.architecture,
+ settings: settings.clone(),
+ disk,
+ boot: qemu_boot,
+ child: Mutex::new(ChildState::NotStarted),
+ }))
+ }
+
+ async fn reattach(
+ &self,
+ _id: MachineId,
+ _name: Option<&str>,
+ _dir: &Path,
+ _settings: &Settings,
+ ) -> R<Box<dyn Machine>> {
+ todo!("no persisted qemu run-state to reattach to yet")
+ }
+}
diff --git a/crates/hule-vmm/src/backend/qemu/supervise.rs b/crates/hule-vmm/src/backend/qemu/supervise.rs
new file mode 100644
--- /dev/null
+++ b/crates/hule-vmm/src/backend/qemu/supervise.rs
@@ -0,0 +1,140 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: Apache-2.0
+
+//! Runs a command so its exit code survives even once nobody is left to
+//! `wait()` on it as its real OS parent -- podman does this with `conmon`;
+//! here the host's own shell does the same job. Host-specific by nature
+//! (`sh` on POSIX, PowerShell on Windows), which is fine: unlike guests,
+//! the set of hosts we run on is small and known, so a wrapper per host is
+//! not a burden. Each function has one signature; the OS split lives inside
+//! as `#[cfg]`-gated blocks.
+
+use std::fmt;
+use std::io;
+use std::path::Path;
+
+use tokio::process::{Child, Command};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Pid(pub u32);
+
+impl fmt::Display for Pid {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+/// Fixed PowerShell wrapper: `$Bin`/`$ExitFile`/`$Rest` all arrive as real
+/// process arguments (via `-File`, which binds them like any `param()`
+/// block), never spliced into script text.
+#[cfg(windows)]
+const SUPERVISE_PS1: &str = r#"
+param(
+ [Parameter(Mandatory=$true, Position=0)] [string]$Bin,
+ [Parameter(Mandatory=$true, Position=1)] [string]$ExitFile,
+ [Parameter(ValueFromRemainingArguments=$true)] [string[]]$Rest
+)
+& $Bin @Rest
+Set-Content -Path $ExitFile -Value $LASTEXITCODE -NoNewline
+"#;
+
+/// Spawns `argv` (`argv[0]` is the binary) wrapped so that once it exits,
+/// its exit code is written to `exit_code_file` -- readable later even by a
+/// process that isn't its parent and so can't `wait()` on it directly.
+///
+/// All dynamic values travel as separate argv entries to the wrapper, never
+/// interpolated into script text, so nothing needs escaping.
+pub fn spawn(argv: &[String], exit_code_file: &Path) -> io::Result<Child> {
+ #[cfg(unix)]
+ {
+ Command::new("sh")
+ .arg("-c")
+ .arg(r#"bin="$1"; shift; exitfile="$1"; shift; "$bin" "$@"; echo $? > "$exitfile""#)
+ .arg("sh") // conventional $0 filler, unused
+ .arg(&argv[0])
+ .arg(exit_code_file)
+ .args(&argv[1..])
+ .spawn()
+ }
+ #[cfg(windows)]
+ {
+ // Content is constant, only the path is dynamic -- written next to
+ // the machine's own files, same lifetime as the pidfile/exit-code
+ // file.
+ let script = exit_code_file
+ .parent()
+ .unwrap_or_else(|| Path::new("."))
+ .join("supervise.ps1");
+ std::fs::write(&script, SUPERVISE_PS1)?;
+
+ Command::new("powershell")
+ .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
+ .arg(&script)
+ .arg(&argv[0])
+ .arg(exit_code_file)
+ .args(&argv[1..])
+ .spawn()
+ }
+}
+
+/// Whether `pid` is still alive. Works for any pid, not just our own
+/// children -- `Child::try_wait` only works on the latter.
+pub async fn pid_alive(pid: Pid) -> bool {
+ #[cfg(target_os = "linux")]
+ {
+ tokio::fs::try_exists(format!("/proc/{pid}"))
+ .await
+ .unwrap_or(false)
+ }
+ #[cfg(all(unix, not(target_os = "linux")))]
+ {
+ Command::new("kill")
+ .arg("-0")
+ .arg(pid.to_string())
+ .status()
+ .await
+ .map(|s| s.success())
+ .unwrap_or(false)
+ }
+ #[cfg(windows)]
+ {
+ let Ok(out) = Command::new("tasklist")
+ .args(["/FI", &format!("PID eq {pid}"), "/NH"])
+ .output()
+ .await
+ else {
+ return false;
+ };
+ String::from_utf8_lossy(&out.stdout).contains(&pid.to_string())
+ }
+}
+
+/// Force-terminates `pid`. No parent relationship required, unlike
+/// `Child::kill()`, which is exactly the point.
+pub async fn kill(pid: Pid) -> io::Result<()> {
+ #[cfg(unix)]
+ {
+ let status = Command::new("kill")
+ .arg("-KILL")
+ .arg(pid.to_string())
+ .status()
+ .await?;
+ if status.success() {
+ Ok(())
+ } else {
+ Err(io::Error::other(format!("kill -KILL {pid} failed")))
+ }
+ }
+ #[cfg(windows)]
+ {
+ let status = Command::new("taskkill")
+ .args(["/PID", &pid.to_string(), "/F"])
+ .status()
+ .await?;
+ if status.success() {
+ Ok(())
+ } else {
+ Err(io::Error::other(format!("taskkill /PID {pid} /F failed")))
+ }
+ }
+}
diff --git a/crates/hule-vmm/src/hypervisor.rs b/crates/hule-vmm/src/hypervisor.rs
new file mode 100644
--- /dev/null
+++ b/crates/hule-vmm/src/hypervisor.rs
@@ -0,0 +1,52 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: Apache-2.0
+
+use std::fmt;
+use std::path::Path;
+
+use async_trait::async_trait;
+use hule_image::{Boot, MachineImage};
+
+use crate::{Machine, MachineId, R, Settings};
+
+/// Self-assigned backend identity, unique only within one `Monitor`
+/// (checked in [`crate::Monitor::new`]) -- plugins can't coordinate globally.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct HypervisorId(pub u128);
+
+impl fmt::Display for HypervisorId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+/// A single hypervisor backend (qemu, firecracker, ...).
+#[async_trait]
+pub trait Hypervisor: Send + Sync {
+ fn id(&self) -> HypervisorId;
+ fn name(&self) -> &'static str;
+
+ /// Whether this backend can boot the given protocol.
+ fn supports(&self, boot: &Boot) -> bool;
+
+ /// Create a machine bound to `dir`. Does not start it.
+ async fn create(
+ &self,
+ id: MachineId,
+ name: Option<&str>,
+ image: &MachineImage,
+ dir: &Path,
+ boot: &Boot,
+ settings: &Settings,
+ ) -> R<Box<dyn Machine>>;
+
+ /// Reconnect to a machine this backend previously created, e.g. after a
+ /// restart. `id`/`name`/`settings` come from the monitor's own record.
+ async fn reattach(
+ &self,
+ id: MachineId,
+ name: Option<&str>,
+ dir: &Path,
+ settings: &Settings,
+ ) -> R<Box<dyn Machine>>;
+}
diff --git a/crates/hule-vmm/src/lib.rs b/crates/hule-vmm/src/lib.rs
index d508953..924193f 100644
--- a/crates/hule-vmm/src/lib.rs
+++ b/crates/hule-vmm/src/lib.rs
@@ -1,4 +1,81 @@
// SPDX-FileCopyrightText: 2026 Nikolay Govorov
// SPDX-License-Identifier: Apache-2.0
-pub const NAME: &str = "I'm a vmm library";
+//! Machine lifecycle: hypervisor plugins, machine handles, and the monitor
+//! that tracks a fleet of them.
+//!
+//! No OCI, registry, or storage concerns -- callers hand in an
+//! already-resolved [`hule_image::MachineImage`] plus a directory of its
+//! files.
+//!
+//! `Machine`/`Monitor` mirror Docker's container API term-for-term.
+//! Excluded: `changes`/`export`/`archive` (no host-visible VM filesystem
+//! without guest cooperation), `top` (guest internals are opaque to the
+//! host by construction -- use `exec`).
+
+pub mod backend;
+mod hypervisor;
+mod machine;
+mod monitor;
+
+use std::fmt;
+
+pub use hypervisor::{Hypervisor, HypervisorId};
+pub use machine::{Console, Machine, MachineId, Settings, State, Stats};
+pub use monitor::Monitor;
+
+#[derive(Debug)]
+pub enum Error {
+ /// No registered hypervisor supports any boot protocol the image declares.
+ Unsupported(String),
+
+ /// The image is not resolvable in this context (e.g. `boot` references an unknown disk id).
+ InvalidImage(String),
+
+ /// The machine isn't in the state the operation requires.
+ InvalidState(String),
+
+ /// No machine matches the given id.
+ NotFound(MachineId),
+
+ /// Two registered hypervisors share a `HypervisorId`.
+ DuplicateHypervisor(HypervisorId),
+
+ /// Underlying OS/process failure.
+ Io(std::io::Error),
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Unsupported(msg) => write!(f, "unsupported: {msg}"),
+ Self::InvalidImage(msg) => write!(f, "invalid image: {msg}"),
+ Self::InvalidState(msg) => write!(f, "invalid state: {msg}"),
+ Self::NotFound(id) => write!(f, "no machine '{id}'"),
+ Self::DuplicateHypervisor(id) => {
+ write!(
+ f,
+ "duplicate hypervisor id {id} registered with this monitor"
+ )
+ }
+ Self::Io(err) => write!(f, "{err}"),
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Io(err) => Some(err),
+ _ => None,
+ }
+ }
+}
+
+impl From<std::io::Error> for Error {
+ fn from(err: std::io::Error) -> Self {
+ Self::Io(err)
+ }
+}
+
+pub type R<T> = std::result::Result<T, Error>;
diff --git a/crates/hule-vmm/src/machine.rs b/crates/hule-vmm/src/machine.rs
new file mode 100644
--- /dev/null
+++ b/crates/hule-vmm/src/machine.rs
@@ -0,0 +1,98 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: Apache-2.0
+
+use std::fmt;
+use std::process::{ExitStatus, Output};
+
+use async_trait::async_trait;
+use tokio::io::{AsyncRead, AsyncWrite};
+use uuid::Uuid;
+
+use crate::{HypervisorId, R};
+
+/// Always a UUIDv7 from [`crate::Monitor::create`], so ids sort by creation
+/// time. An optional, non-unique name can ride alongside -- see
+/// [`Machine::name`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct MachineId(pub Uuid);
+
+impl fmt::Display for MachineId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+/// Coarse machine state, mirroring Docker's container states.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum State {
+ Created,
+ Running,
+ Paused,
+ Restarting,
+ Exited(i32),
+ Dead,
+}
+
+/// A live duplex connection to a machine's interactive console.
+pub trait Console: AsyncRead + AsyncWrite + Send + Unpin {}
+impl<T: AsyncRead + AsyncWrite + Send + Unpin> Console for T {}
+
+/// Deployment-time config of one instance -- distinct from
+/// [`hule_image::MachineImage`], which only declares a cpu/ram *range* and
+/// knows nothing about e.g. host ports. Always concrete: callers resolve
+/// image defaults before constructing this.
+#[derive(Debug, Clone)]
+pub struct Settings {
+ pub cpu: u32,
+ pub ram: u64, // megabytes
+
+ /// (host, guest) TCP port pairs forwarded to the guest's access channel.
+ pub port_forwards: Vec<(u16, u16)>,
+}
+
+/// A snapshot of resource usage, as of the call to `Machine::stats`.
+#[derive(Debug, Clone, Copy)]
+pub struct Stats {
+ pub cpu_time_ns: u64,
+ pub memory_bytes: u64,
+}
+
+/// A handle to one virtual machine. Method names follow the Docker Engine
+/// API's container operations (see the crate docs for the two exclusions).
+///
+/// Everything that can touch disk or a socket is `async` -- this crate runs
+/// on tokio throughout, no blocking calls.
+#[async_trait]
+pub trait Machine: Send + Sync {
+ fn id(&self) -> MachineId;
+ fn hid(&self) -> HypervisorId;
+
+ /// Optional, caller-chosen, not required to be unique.
+ fn name(&self) -> Option<&str>;
+
+ /// In-memory, not the persisted record -- see `update`/`rename` for why
+ /// those are `async` while this isn't.
+ fn settings(&self) -> Settings;
+
+ async fn state(&self) -> State;
+ async fn stats(&self) -> R<Stats>;
+ async fn logs(&self) -> R<Vec<u8>>;
+
+ async fn start(&mut self) -> R<()>;
+ async fn stop(&mut self) -> R<()>;
+ async fn kill(&mut self) -> R<()>;
+ async fn restart(&mut self) -> R<()>;
+ async fn delete(&mut self) -> R<()>;
+
+ async fn pause(&mut self) -> R<()>;
+ async fn unpause(&mut self) -> R<()>;
+
+ async fn update(&mut self, settings: Settings) -> R<()>;
+ async fn rename(&mut self, name: Option<&str>) -> R<()>;
+
+ async fn wait(&mut self) -> R<ExitStatus>;
+ async fn exec(&self, cmd: &[String]) -> R<Output>;
+
+ async fn attach(&self) -> R<Box<dyn Console>>;
+ async fn resize(&self, cols: u16, rows: u16) -> R<()>;
+}
diff --git a/crates/hule-vmm/src/monitor.rs b/crates/hule-vmm/src/monitor.rs
new file mode 100644
--- /dev/null
+++ b/crates/hule-vmm/src/monitor.rs
@@ -0,0 +1,109 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: Apache-2.0
+
+use std::path::{Path, PathBuf};
+
+use hule_image::MachineImage;
+use tokio::sync::mpsc::Receiver;
+use uuid::Uuid;
+
+use crate::{Error, Hypervisor, Machine, MachineId, R, Settings, State};
+
+/// A fleet-wide state change, as delivered by [`Monitor::events`].
+#[derive(Debug, Clone)]
+pub struct Event {
+ pub machine: MachineId,
+ pub kind: EventKind,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum EventKind {
+ Created,
+ Started,
+ Stopped,
+ Killed,
+ Restarted,
+ Paused,
+ Unpaused,
+ Removed,
+}
+
+/// Tracks a fleet of machines, possibly across different hypervisors.
+pub struct Monitor {
+ #[allow(dead_code)] // read by list()/get() once persisted state lands
+ run_dir: PathBuf,
+
+ hypervisors: Vec<Box<dyn Hypervisor>>,
+}
+
+impl Monitor {
+ pub fn new(run_dir: PathBuf, hypervisors: Vec<Box<dyn Hypervisor>>) -> R<Self> {
+ let mut seen = std::collections::HashSet::new();
+ for hv in &hypervisors {
+ if !seen.insert(hv.id()) {
+ return Err(Error::DuplicateHypervisor(hv.id()));
+ }
+ }
+ Ok(Self {
+ run_dir,
+ hypervisors,
+ })
+ }
+
+ /// Picks the first (hypervisor, boot protocol) pair the hypervisor
+ /// supports and creates through it. Manifest boot order isn't significant.
+ pub async fn create(
+ &self,
+ image: &MachineImage,
+ dir: &Path,
+ name: Option<&str>,
+ settings: &Settings,
+ ) -> R<Box<dyn Machine>> {
+ let id = MachineId(Uuid::now_v7());
+ let (hv, boot) = image
+ .machine
+ .boot
+ .iter()
+ .filter(|b| b.is_recognized())
+ .find_map(|b| {
+ self.hypervisors
+ .iter()
+ .find(|hv| hv.supports(b))
+ .map(|hv| (hv, b))
+ })
+ .ok_or_else(|| {
+ Error::Unsupported(
+ "no registered hypervisor supports any declared boot protocol".into(),
+ )
+ })?;
+ hv.create(id, name, image, dir, boot, settings).await
+ }
+
+ /// All machines known to this monitor, running or not.
+ pub async fn list(&self) -> R<Vec<Box<dyn Machine>>> {
+ todo!("needs a persisted run-state format -- see open design question")
+ }
+
+ pub async fn get(&self, _id: MachineId) -> R<Box<dyn Machine>> {
+ todo!("needs a persisted run-state format -- see open design question")
+ }
+
+ /// Live stream of fleet-wide state changes. Needs a publish path from
+ /// `Machine` impls -- same open question as `list`/`get`.
+ pub async fn events(&self) -> R<Receiver<Event>> {
+ todo!("needs a publish path from Machine implementations")
+ }
+
+ /// Deletes every non-running machine (`list` + `delete`; no hypervisor
+ /// has a native bulk op). Returns the count deleted.
+ pub async fn prune(&self) -> R<usize> {
+ let mut deleted = 0;
+ for mut machine in self.list().await? {
+ if machine.state().await != State::Running {
+ machine.delete().await?;
+ deleted += 1;
+ }
+ }
+ Ok(deleted)
+ }
+}
diff --git a/crates/hule/Cargo.toml b/crates/hule/Cargo.toml
index 41a87ef..e3fe2b3 100644
--- a/crates/hule/Cargo.toml
+++ b/crates/hule/Cargo.toml
@@ -22,3 +22,4 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs"] }
futures-util = "0.3"
zstd = "0.13"
sha2 = "0.10"
+uuid = { version = "1", features = ["v7"] }
diff --git a/crates/hule/src/main.rs b/crates/hule/src/main.rs
index fdff3a8..58ffd34 100644
--- a/crates/hule/src/main.rs
+++ b/crates/hule/src/main.rs
@@ -3,10 +3,10 @@
//! Manifest-driven VM harness and OCI image lifecycle.
//!
-//! Reads a Hule machine configuration (`config.json`) 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.
+//! Reads a Hule machine configuration (`config.json`) from an image
+//! directory and boots the guest via `hule-vmm`'s qemu backend according to
+//! the boot protocol it declares. No per-distro logic lives here --
+//! everything comes from the manifest.
//!
//! Images live in a local content-addressed store (`$HOME/.hule/store`, a
//! plain OCI Image Layout) and move in and out of OCI registries via
@@ -16,9 +16,11 @@
use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::{Path, PathBuf};
-use std::process::{Command, exit};
+use std::process::exit;
-use hule_image::{Access, Architecture, BOOT_FIRMWARE_DISK_BIOS, Boot, MachineImage};
+use hule_image::{Access, MachineImage};
+use hule_vmm::backend::qemu::QemuHypervisor;
+use hule_vmm::{Hypervisor, MachineId, Settings};
use oci_client::annotations::{ORG_OPENCONTAINERS_IMAGE_REF_NAME, ORG_OPENCONTAINERS_IMAGE_TITLE};
use oci_client::client::{ClientConfig, ClientProtocol, Config, ImageLayer};
use oci_client::manifest::{
@@ -26,6 +28,7 @@ use oci_client::manifest::{
};
use oci_client::secrets::RegistryAuth;
use oci_client::{Client, Reference};
+use uuid::Uuid;
type R<T> = std::result::Result<T, String>;
@@ -483,53 +486,21 @@ async fn cmd_run(reference_str: &str, port: u16) -> R<()> {
}
}
- launch_qemu(&scratch, port);
-}
-
-// ---- qemu launch (unchanged logic, factored out of the old boot-only main) --
-
-/// Reads `dir/config.json` and execs qemu according to its boot protocol.
-/// Never returns.
-fn launch_qemu(dir: &Path, port: u16) -> ! {
- let manifest_path = dir.join("config.json");
- let data = std::fs::read_to_string(&manifest_path)
- .unwrap_or_else(|e| die(format!("cannot read {}: {e}", manifest_path.display())));
- let m = MachineImage::from_json(data.as_bytes()).unwrap_or_else(|e| {
- die(format!(
- "invalid machine configuration {}: {e}",
- manifest_path.display()
- ))
- });
+ let image = MachineImage::from_json(&config_bytes).map_err(|e| e.to_string())?;
+ let hv = QemuHypervisor;
- // Negotiation: qemu handles every protocol we emit. Prefer direct-kernel
- // (skips firmware+bootloader), else firmware-disk. Manifest order is not
- // significant.
- let proto = m
+ // Negotiation: prefer direct-kernel (skips firmware+bootloader) over
+ // firmware-disk when both are declared and supported; no Monitor yet,
+ // so this stays hand-rolled instead of `Monitor::create`.
+ let boot = image
.machine
.boot
.iter()
- .find(|b| matches!(b, Boot::Linux(_)))
- .or_else(|| {
- m.machine
- .boot
- .iter()
- .find(|b| matches!(b, Boot::Bios(_)) && b.protocol() == BOOT_FIRMWARE_DISK_BIOS)
- })
- .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);
+ .find(|b| matches!(b, hule_image::Boot::Linux(_)) && hv.supports(b))
+ .or_else(|| image.machine.boot.iter().find(|b| hv.supports(b)))
+ .ok_or("no boot protocol in manifest is supported by the qemu backend")?;
- let ssh_port = m
+ let guest_port = image
.machine
.access
.iter()
@@ -538,122 +509,34 @@ fn launch_qemu(dir: &Path, port: u16) -> ! {
Access::Unknown(_) => None,
})
.unwrap_or(22);
+ let settings = Settings {
+ cpu: image.machine.cpu.default as u32,
+ ram: image.machine.ram.default / (1024 * 1024),
+ port_forwards: vec![(port, guest_port)],
+ };
- let qa = qemu_arch(&m.system.architecture);
- let mem_mib = m.machine.ram.default / (1024 * 1024);
-
- let mut cmd = Command::new(format!("qemu-system-{qa}"));
- cmd.args(cpu_opts(&m.system.architecture));
- 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(boot) => {
- // 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(&boot.kernel.path).display().to_string(),
- "-initrd".into(),
- dir.join(&boot.initrd.path).display().to_string(),
- "-append".into(),
- boot.cmdline.clone(),
- ]);
- }
- Boot::Bios(_) => {
- // Self-bootable disk, qemu's firmware boots it.
- cmd.args([
- "-drive".into(),
- format!(
- "file={},media=disk,snapshot=on,if=virtio",
- disk_path.display()
- ),
- ]);
- }
- Boot::Uefi(_) => die("UEFI boot is not supported by the QEMU backend yet"),
- Boot::Unknown(_) => die("unreachable: negotiation never selects an unrecognized boot protocol"),
- }
+ let mut machine = hv
+ .create(
+ MachineId(Uuid::now_v7()),
+ None,
+ &image,
+ &scratch,
+ boot,
+ &settings,
+ )
+ .await
+ .map_err(|e| e.to_string())?;
eprintln!(
"hule: booting {} via {} (ssh: localhost:{port})",
- dir.display(),
- proto.protocol()
+ scratch.display(),
+ boot.protocol()
);
- let status = cmd
- .status()
- .unwrap_or_else(|e| die(format!("failed to launch qemu: {e}")));
+ machine.start().await.map_err(|e| e.to_string())?;
+ let status = machine.wait().await.map_err(|e| e.to_string())?;
exit(status.code().unwrap_or(1));
}
-/// Hule machine arch -> qemu-system-<arch> suffix.
-fn qemu_arch(arch: &Architecture) -> &'static str {
- match arch {
- Architecture::Amd64 => "x86_64",
- Architecture::Arm64 => "aarch64",
- Architecture::Loong64 => "loongarch64",
- Architecture::Ppc64le => "ppc64le",
- Architecture::Riscv64 => "riscv64",
- Architecture::S390x => "s390x",
- }
-}
-
-/// Machine/accel flags for an arch (KVM when the host matches, else TCG).
-fn cpu_opts(arch: &Architecture) -> Vec<String> {
- let host_matches = match arch {
- Architecture::Amd64 => std::env::consts::ARCH == "x86_64",
- Architecture::Arm64 => std::env::consts::ARCH == "aarch64",
- Architecture::Loong64 => std::env::consts::ARCH == "loongarch64",
- Architecture::Ppc64le => {
- std::env::consts::ARCH == "powerpc64" && cfg!(target_endian = "little")
- }
- Architecture::Riscv64 => std::env::consts::ARCH == "riscv64",
- Architecture::S390x => std::env::consts::ARCH == "s390x",
- };
- let kvm = host_matches && Path::new("/dev/kvm").exists();
- let mut v: Vec<String> = Vec::new();
- match arch {
- Architecture::Arm64 | Architecture::Riscv64 => v.extend(["-M".into(), "virt".into()]),
- Architecture::Ppc64le => v.extend(["-machine".into(), "pseries".into()]),
- _ => {}
- }
- if kvm {
- v.extend(["-cpu".into(), "host".into(), "-enable-kvm".into()]);
- } else {
- match arch {
- Architecture::Amd64 => v.extend(["-cpu".into(), "qemu64".into()]),
- Architecture::Arm64 => v.extend(["-cpu".into(), "cortex-a53".into()]),
- Architecture::Loong64 => v.extend(["-cpu".into(), "la464".into()]),
- Architecture::Ppc64le => v.extend(["-cpu".into(), "power9".into()]),
- Architecture::Riscv64 => v.extend(["-cpu".into(), "rv64".into()]),
- Architecture::S390x => v.extend(["-cpu".into(), "max".into()]),
- }
- }
- v
-}
-
// ---- CLI dispatch -------------------------------------------------------
#[tokio::main]