From 24b317b5431ea26219f12f74714488c836d123d0 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sat, 9 May 2026 17:15:08 -0400 Subject: [PATCH] libvirt: add --console-log and --platform-console-log options The motivation is making debugging easier for automated testing. The console situation is messy - in bootc's CI we always inject `hvc0` but that's not yet a standard across distros/OSes which maeks things messy. We often end up with both hvc0 and a platform-specific console (which may be better supported by the bootloader), so support logging both to a file. Assisted-by: OpenCode (Claude Sonnet 4.6) Signed-off-by: Colin Walters --- .../src/tests/libvirt_verb.rs | 36 ++++++++++ crates/kit/src/libvirt/domain.rs | 71 ++++++++++++++++++- crates/kit/src/libvirt/run.rs | 62 ++++++++++++++++ docs/src/man/bcvk-libvirt-run.md | 25 +++++++ 4 files changed, 191 insertions(+), 3 deletions(-) diff --git a/crates/integration-tests/src/tests/libvirt_verb.rs b/crates/integration-tests/src/tests/libvirt_verb.rs index 2d188d0c1..50d6af80a 100644 --- a/crates/integration-tests/src/tests/libvirt_verb.rs +++ b/crates/integration-tests/src/tests/libvirt_verb.rs @@ -1122,3 +1122,39 @@ fn test_libvirt_run_bind_mounts() -> TestResult { Ok(()) } integration_test!(test_libvirt_run_bind_mounts); + +/// Test --console-log: boots a VM with a log path, then verifies the file is +/// non-empty and the domain XML contains the expected element. +fn test_libvirt_run_console_log() -> TestResult { + let sh = shell()?; + let bck = get_bck_command()?; + let test_image = get_test_image(); + let label = LIBVIRT_INTEGRATION_TEST_LABEL; + + let domain_name = format!("test-console-log-{}", random_suffix()); + let log_file = tempfile::NamedTempFile::new()?; + let log_path = log_file.path().to_str().expect("log path is not UTF-8"); + + cleanup_domain(&domain_name); + defer! { cleanup_domain(&domain_name); } + + cmd!( + sh, + "{bck} libvirt run --name {domain_name} --label {label} --filesystem ext4 --ssh-wait --karg=console=hvc0 --karg=systemd.journald.forward_to_console=1 --console-log {log_path} {test_image}" + ) + .run()?; + + // console=hvc0 makes /dev/console point to hvc0; forward_to_console=1 + // then routes journald output there. "systemd" appears in every boot. + let log_content = std::fs::read_to_string(log_file.path())?; + assert!(log_content.contains("systemd")); + + // virsh dumpxml uses single-quoted attributes: append='on' + let sh = shell()?; + let domain_xml = cmd!(sh, "virsh dumpxml {domain_name}").read()?; + let expected_log = format!("", log_path); + assert!(domain_xml.contains(&expected_log)); + + Ok(()) +} +integration_test!(test_libvirt_run_console_log); diff --git a/crates/kit/src/libvirt/domain.rs b/crates/kit/src/libvirt/domain.rs index 1d6909bde..59bce1af4 100644 --- a/crates/kit/src/libvirt/domain.rs +++ b/crates/kit/src/libvirt/domain.rs @@ -55,6 +55,8 @@ pub struct DomainBuilder { nvram_template: Option, // Custom NVRAM template with enrolled keys nvram_format: Option, // Format of NVRAM template (raw, qcow2) firmware_log: Option, // OVMF debug log output via isa-debugcon + virtio_console_log: Option, // Virtio console log file path (hvc0 — OS/journald) + serial_console_log: Option, // Serial console log file path (ttyS0 — UEFI/bootloader) fw_cfg_entries: Vec<(String, String)>, // fw_cfg entries (name, file_path) ignition_disk_path: Option, // Path to Ignition config for virtio-blk injection } @@ -88,6 +90,8 @@ impl DomainBuilder { nvram_template: None, nvram_format: None, firmware_log: None, + virtio_console_log: None, + serial_console_log: None, fw_cfg_entries: Vec::new(), ignition_disk_path: None, } @@ -208,6 +212,18 @@ impl DomainBuilder { self } + /// Log virtio console output (OS/journald on hvc0) to the given host file. + pub fn with_virtio_console_log(mut self, path: &str) -> Self { + self.virtio_console_log = Some(path.to_string()); + self + } + + /// Log serial console output (UEFI/bootloader on ttyS0) to the given host file. + pub fn with_serial_console_log(mut self, path: &str) -> Self { + self.serial_console_log = Some(path.to_string()); + self + } + /// Add a fw_cfg entry for passing config files to the guest /// /// This is used for Ignition config injection on x86_64/aarch64. @@ -441,13 +457,21 @@ impl DomainBuilder { } } - // Serial console, see https://libvirt.org/formatdomain.html#relationship-between-serial-ports-and-consoles - // We allocate a platform-specific default for early console stuff like bootloaders, - // and a platform-independent `hvc0` that can be referenced independently. + // Serial console (ttyS0) — platform firmware, bootloader, early kernel. + // Virtio console (hvc0) — platform-independent; OS and journald write here. + // Each chardev opens its logfile independently; giving both the same path + // causes QEMU to return EBUSY on the second open. writer.start_element("console", &[("type", "pty")])?; + if let Some(ref log_path) = self.serial_console_log { + writer.write_empty_element("log", &[("file", log_path.as_str()), ("append", "on")])?; + } writer.write_empty_element("target", &[("type", "serial")])?; writer.end_element("console")?; + writer.start_element("console", &[("type", "pty")])?; + if let Some(ref log_path) = self.virtio_console_log { + writer.write_empty_element("log", &[("file", log_path.as_str()), ("append", "on")])?; + } writer.write_empty_element("target", &[("type", "virtio")])?; writer.end_element("console")?; @@ -819,4 +843,45 @@ mod tests { assert!(xml_ro.contains("source dir=\"/host/storage\"")); assert!(xml_ro.contains("target dir=\"hoststorage\"")); } + + #[test] + fn test_domain_xml_console_log() { + let xml = DomainBuilder::new() + .with_name("test-console-log") + .with_memory(2048) + .with_vcpus(2) + .with_disk("/tmp/disk.raw") + .with_virtio_console_log("/var/log/virtio.log") + .with_serial_console_log("/var/log/serial.log") + .build_xml() + .unwrap(); + + // Serial log appears before "#) + .count(), + 1, + "expected exactly one serial log element in:\n{xml}" + ); + let serial_log_pos = xml.find(r#""#) + .count(), + 1, + "expected exactly one virtio log element in:\n{xml}" + ); + let virtio_log_pos = xml.find(r#", + /// Log virtio console (OS/journald on hvc0) to this file (created if absent) + #[clap(long = "console-log")] + pub console_log: Option, + + /// Log platform console (UEFI/bootloader on ttyS0) to this file (created if absent) + #[clap(long = "platform-console-log")] + pub platform_console_log: Option, + /// Additional metadata key-value pairs (used internally, not exposed via CLI) #[clap(skip)] pub metadata: std::collections::HashMap, @@ -1462,6 +1470,60 @@ fn create_libvirt_domain_from_disk( qemu_args.push("-device".to_string()); qemu_args.push("virtio-net-pci,netdev=ssh0,addr=0x3".to_string()); + // Helper closure: resolve to absolute path, guard against directory, pre-create. + // QEMU's chardev logfile= requires the file to exist before the domain starts. + let resolve_log_path = |log_path: &Utf8Path, flag: &str| -> Result { + let raw_parent = log_path.parent().unwrap_or(Utf8Path::new(".")); + let effective_parent = if raw_parent.as_str().is_empty() { + Utf8Path::new(".") + } else { + raw_parent + }; + let parent = effective_parent + .canonicalize_utf8() + .with_context(|| format!("{flag} parent directory not found: {log_path}"))?; + let abs = parent.join(log_path.file_name().unwrap_or(log_path.as_str())); + if abs.is_dir() { + eyre::bail!("{flag} path is a directory: {abs}"); + } + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&abs) + .with_context(|| format!("create {flag} log file {abs}"))?; + Ok(abs) + }; + + let virtio_log = opts + .console_log + .as_deref() + .map(|p| resolve_log_path(p, "--console-log")) + .transpose()?; + + let serial_log = opts + .platform_console_log + .as_deref() + .map(|p| resolve_log_path(p, "--platform-console-log")) + .transpose()?; + + if let (Some(v), Some(s)) = (&virtio_log, &serial_log) { + if v == s { + eyre::bail!( + "--console-log and --platform-console-log cannot point to the same file \ + (QEMU opens each chardev logfile independently and returns EBUSY if both \ + paths are identical)" + ); + } + } + + if let Some(p) = &virtio_log { + domain_builder = domain_builder.with_virtio_console_log(p.as_str()); + } + if let Some(p) = &serial_log { + domain_builder = domain_builder.with_serial_console_log(p.as_str()); + } + let domain_xml = domain_builder .with_qemu_args(qemu_args) .build_xml() diff --git a/docs/src/man/bcvk-libvirt-run.md b/docs/src/man/bcvk-libvirt-run.md index 1b833838f..736ea27c0 100644 --- a/docs/src/man/bcvk-libvirt-run.md +++ b/docs/src/man/bcvk-libvirt-run.md @@ -158,6 +158,14 @@ Run a bootable container as a persistent VM Path to Ignition config file (JSON format) for first-boot provisioning +**--console-log**=*CONSOLE_LOG* + + Log virtio console (OS/journald on hvc0) to this file (created if absent) + +**--platform-console-log**=*PLATFORM_CONSOLE_LOG* + + Log platform console (UEFI/bootloader on ttyS0) to this file (created if absent) + # EXAMPLES @@ -186,6 +194,23 @@ Create a VM with access to host container storage for bootc upgrade: bcvk libvirt run --name upgrade-test --bind-storage-ro quay.io/fedora/fedora-bootc:42 +Capture the virtio console (OS/journald output) to a log file. The +`console=hvc0` kernel argument is required so that the kernel maps +`/dev/console` to `hvc0`; without it journald's `forward_to_console` +output goes to the serial console (`ttyS0`) instead: + + bcvk libvirt run --name testvm \ + --karg=console=hvc0 \ + --karg=systemd.journald.forward_to_console=1 \ + --console-log /var/home/user/vm-console.log \ + quay.io/fedora/fedora-bootc:42 + +Capture the platform console (UEFI/GRUB/serial) separately: + + bcvk libvirt run --name testvm \ + --platform-console-log /var/home/user/vm-serial.log \ + quay.io/fedora/fedora-bootc:42 + Server management workflow: # Create a persistent server VM