From a0167823ba254acdda4c29c64fbbad1ca2f8a1af Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Sun, 12 Jul 2026 22:52:18 +0100 Subject: Add qemu-guest-agent to golden images --- crates/hule-image/src/lib.rs | 41 ++++++++++++++++++++++++++++++++---- crates/hule/src/main.rs | 2 +- images/alpine/genimg | 20 +++++++++++++++--- images/debian/genimg | 6 ++++-- images/fedora/genimg | 7 ++++-- images/freebsd/genimg | 6 ++++-- images/ubuntu/genimg | 7 ++++-- 7 files changed, 73 insertions(+), 16 deletions(-) diff --git a/crates/hule-image/src/lib.rs b/crates/hule-image/src/lib.rs index 6974882..6165294 100644 --- a/crates/hule-image/src/lib.rs +++ b/crates/hule-image/src/lib.rs @@ -326,6 +326,12 @@ pub enum Access { user: String, auth: String, }, + /// qemu-guest-agent. The hypervisor backend chooses and configures a + /// compatible transport; the image only promises that the agent runs. + Qga { + #[serde(rename = "type")] + kind: String, + }, /// Unrecognized access surface, preserved verbatim. Unknown(serde_json::Value), } @@ -353,6 +359,7 @@ impl<'de> Deserialize<'de> for Access { auth: raw.auth, }) } + "qga" => Ok(Self::Qga { kind: "qga".into() }), _ => Ok(Self::Unknown(value)), } } @@ -375,7 +382,7 @@ impl Access { validation.required(user, &format!("{field}.user")); validation.required(auth, &format!("{field}.auth")); } - Self::Unknown(_) => {} + Self::Qga { .. } | Self::Unknown(_) => {} } } } @@ -413,6 +420,7 @@ pub struct Machine { pub cpu: ResourceRange, pub ram: ResourceRange, pub boot: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub access: Vec, pub network: Network, } @@ -429,10 +437,9 @@ impl Machine { for (index, boot) in self.boot.iter().enumerate() { boot.validate_into(&format!("{field}.boot[{index}]"), validation); } - if !self.access.iter().any(Access::is_recognized) { + if !self.access.is_empty() && !self.access.iter().any(Access::is_recognized) { validation.error(format!( - "{field}.access must contain at least one supported access method \ - (a running machine must be reachable by something, if only to shut it down)" + "{field}.access must contain at least one supported access method" )); } for (index, access) in self.access.iter().enumerate() { @@ -760,6 +767,32 @@ mod tests { ); } + #[test] + fn parses_qga_access() { + let json = String::from_utf8(valid_json()).unwrap().replace( + r#"[{"type":"ssh","port":22,"user":"build","auth":"empty-password"}]"#, + r#"[{"type":"ssh","port":22,"user":"build","auth":"empty-password"},{"type":"qga"}]"#, + ); + let image = MachineImage::from_json(json.as_bytes()).unwrap(); + assert_eq!(image.machine.access.len(), 2); + assert!(matches!(image.machine.access[1], Access::Qga { .. })); + } + + #[test] + fn access_may_be_omitted_for_a_black_box_image() { + let json = String::from_utf8(valid_json()).unwrap().replace( + r#","access":[{"type":"ssh","port":22,"user":"build","auth":"empty-password"}]"#, + "", + ); + let image = MachineImage::from_json(json.as_bytes()).unwrap(); + assert!(image.machine.access.is_empty()); + assert!( + serde_json::to_value(image).unwrap()["machine"] + .get("access") + .is_none() + ); + } + #[test] fn parses_dhcp_network_without_static_parameters() { let json = String::from_utf8(valid_json()).unwrap().replace( diff --git a/crates/hule/src/main.rs b/crates/hule/src/main.rs index 58ffd34..82ff039 100644 --- a/crates/hule/src/main.rs +++ b/crates/hule/src/main.rs @@ -506,7 +506,7 @@ async fn cmd_run(reference_str: &str, port: u16) -> R<()> { .iter() .find_map(|a| match a { Access::Ssh { port, .. } => Some(*port), - Access::Unknown(_) => None, + Access::Qga { .. } | Access::Unknown(_) => None, }) .unwrap_or(22); let settings = Settings { diff --git a/images/alpine/genimg b/images/alpine/genimg index 3bf3e2d..76405a4 100755 --- a/images/alpine/genimg +++ b/images/alpine/genimg @@ -90,7 +90,7 @@ apk add -U \ --initdb \ acct alpine-base alpine-conf alpine-sdk linux-firmware-none $linux \ git mercurial openssh sudo syslinux tzdata gnupg haveged bash curl \ - doas + doas qemu-guest-agent mount --bind /dev /mnt/dev mount --bind /dev/pts /mnt/dev/pts @@ -135,7 +135,20 @@ fi extlinux -i /mnt/boot -for i in ntpd sshd crond haveged; do +# Alpine uses mdev rather than udev, so create the stable, named virtio-port +# symlink expected by qemu-ga without depending on controller/port numbers. +mkdir -p /mnt/etc/mdev +cat >/mnt/etc/mdev/virtio-port <<'EOF' +#!/bin/sh +name=$(cat "/sys/class/virtio-ports/$MDEV/name") || exit +[ "$name" = org.qemu.guest_agent.0 ] || exit +mkdir -p /dev/virtio-ports +ln -sf "/dev/$MDEV" "/dev/virtio-ports/$name" +EOF +chmod +x /mnt/etc/mdev/virtio-port +echo 'vport.* root:root 0600 @/etc/mdev/virtio-port' >>/mnt/etc/mdev.conf + +for i in ntpd sshd crond haveged qemu-guest-agent; do run_root rc-update add $i default done for i in hwclock modules sysctl hostname bootmisc loadkmap networking seedrng syslog swap; do @@ -228,7 +241,8 @@ cat >"$out/config.json" <>/mnt/etc/sudoers echo "PermitEmptyPasswords yes" >>/mnt/etc/ssh/sshd_config echo ssh >>/mnt/etc/securetty run_root systemctl enable ssh +run_root systemctl enable qemu-guest-agent # Prevent docker from mucking up networking mkdir -p /mnt/etc/docker @@ -275,7 +276,8 @@ cat >"$out/config.json" <>/mnt/etc/sudoers echo "PermitEmptyPasswords yes" >>/mnt/etc/ssh/sshd_config echo ssh >>/mnt/etc/securetty run_root systemctl enable sshd +run_root systemctl enable qemu-guest-agent boot_uuid=$(blkid --match-tag UUID --output value ${NBD_DEVICE}p1) swap_uuid=$(blkid --match-tag UUID --output value ${NBD_DEVICE}p2) @@ -213,7 +215,8 @@ cat >"$out/config.json" <>"$rootfs"/etc/rc.conf <"$out/config.json" <>/mnt/etc/sudoers echo "PermitEmptyPasswords yes" >>/mnt/etc/ssh/sshd_config echo ssh >>/mnt/etc/securetty run_root systemctl enable ssh +run_root systemctl enable qemu-guest-agent # Prevent docker from mucking up networking mkdir -p /mnt/etc/docker @@ -272,7 +274,8 @@ cat >"$out/config.json" <