Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions crates/integration-tests/src/tests/libvirt_verb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <log> 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 file='{}' append='on'/>", log_path);
assert!(domain_xml.contains(&expected_log));

Ok(())
}
integration_test!(test_libvirt_run_console_log);
71 changes: 68 additions & 3 deletions crates/kit/src/libvirt/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub struct DomainBuilder {
nvram_template: Option<String>, // Custom NVRAM template with enrolled keys
nvram_format: Option<String>, // Format of NVRAM template (raw, qcow2)
firmware_log: Option<FirmwareLogOutput>, // OVMF debug log output via isa-debugcon
virtio_console_log: Option<String>, // Virtio console log file path (hvc0 — OS/journald)
serial_console_log: Option<String>, // Serial console log file path (ttyS0 — UEFI/bootloader)
fw_cfg_entries: Vec<(String, String)>, // fw_cfg entries (name, file_path)
ignition_disk_path: Option<String>, // Path to Ignition config for virtio-blk injection
}
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")?;

Expand Down Expand Up @@ -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 <target type="serial"
assert_eq!(
xml.matches(r#"<log file="/var/log/serial.log" append="on"/>"#)
.count(),
1,
"expected exactly one serial log element in:\n{xml}"
);
let serial_log_pos = xml.find(r#"<log file="/var/log/serial.log""#).unwrap();
let serial_target_pos = xml.find(r#"<target type="serial""#).unwrap();
assert!(
serial_log_pos < serial_target_pos,
"serial log must precede serial target"
);

// Virtio log appears before <target type="virtio"
assert_eq!(
xml.matches(r#"<log file="/var/log/virtio.log" append="on"/>"#)
.count(),
1,
"expected exactly one virtio log element in:\n{xml}"
);
let virtio_log_pos = xml.find(r#"<log file="/var/log/virtio.log""#).unwrap();
let virtio_target_pos = xml.find(r#"<target type="virtio""#).unwrap();
assert!(
virtio_log_pos < virtio_target_pos,
"virtio log must precede virtio target"
);
}
}
62 changes: 62 additions & 0 deletions crates/kit/src/libvirt/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,14 @@ pub struct LibvirtRunOpts {
#[clap(long = "ignition")]
pub ignition_config: Option<Utf8PathBuf>,

/// Log virtio console (OS/journald on hvc0) to this file (created if absent)
#[clap(long = "console-log")]
pub console_log: Option<Utf8PathBuf>,

/// Log platform console (UEFI/bootloader on ttyS0) to this file (created if absent)
#[clap(long = "platform-console-log")]
pub platform_console_log: Option<Utf8PathBuf>,

/// Additional metadata key-value pairs (used internally, not exposed via CLI)
#[clap(skip)]
pub metadata: std::collections::HashMap<String, String>,
Expand Down Expand Up @@ -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<Utf8PathBuf> {
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()
Expand Down
25 changes: 25 additions & 0 deletions docs/src/man/bcvk-libvirt-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<!-- END GENERATED OPTIONS -->

# EXAMPLES
Expand Down Expand Up @@ -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
Expand Down
Loading