diff options
Diffstat
| -rw-r--r-- | Cargo.lock | 50 | +50 −0 |
| -rw-r--r-- | crates/hule-image/src/lib.rs | 19 | +18 −1 |
| -rw-r--r-- | crates/hule-vmm/Cargo.toml | 4 | +4 −0 |
| -rw-r--r-- | crates/hule-vmm/src/backend/mod.rs | 4 | +4 −0 |
| -rw-r--r-- | crates/hule-vmm/src/backend/qemu/cli.rs | 300 | +300 −0 |
| -rw-r--r-- | crates/hule-vmm/src/backend/qemu/mod.rs | 375 | +375 −0 |
| -rw-r--r-- | crates/hule-vmm/src/backend/qemu/supervise.rs | 140 | +140 −0 |
| -rw-r--r-- | crates/hule-vmm/src/hypervisor.rs | 52 | +52 −0 |
| -rw-r--r-- | crates/hule-vmm/src/lib.rs | 79 | +78 −1 |
| -rw-r--r-- | crates/hule-vmm/src/machine.rs | 98 | +98 −0 |
| -rw-r--r-- | crates/hule-vmm/src/monitor.rs | 109 | +109 −0 |
| -rw-r--r-- | crates/hule/Cargo.toml | 1 | +1 −0 |
| -rw-r--r-- | crates/hule/src/main.rs | 195 | +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] |
