Skip to content

Commit e953294

Browse files
committed
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 <walters@verbum.org>
1 parent f661aad commit e953294

4 files changed

Lines changed: 191 additions & 3 deletions

File tree

crates/integration-tests/src/tests/libvirt_verb.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,3 +1122,39 @@ fn test_libvirt_run_bind_mounts() -> TestResult {
11221122
Ok(())
11231123
}
11241124
integration_test!(test_libvirt_run_bind_mounts);
1125+
1126+
/// Test --console-log: boots a VM with a log path, then verifies the file is
1127+
/// non-empty and the domain XML contains the expected <log> element.
1128+
fn test_libvirt_run_console_log() -> TestResult {
1129+
let sh = shell()?;
1130+
let bck = get_bck_command()?;
1131+
let test_image = get_test_image();
1132+
let label = LIBVIRT_INTEGRATION_TEST_LABEL;
1133+
1134+
let domain_name = format!("test-console-log-{}", random_suffix());
1135+
let log_file = tempfile::NamedTempFile::new()?;
1136+
let log_path = log_file.path().to_str().expect("log path is not UTF-8");
1137+
1138+
cleanup_domain(&domain_name);
1139+
defer! { cleanup_domain(&domain_name); }
1140+
1141+
cmd!(
1142+
sh,
1143+
"{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}"
1144+
)
1145+
.run()?;
1146+
1147+
// console=hvc0 makes /dev/console point to hvc0; forward_to_console=1
1148+
// then routes journald output there. "systemd" appears in every boot.
1149+
let log_content = std::fs::read_to_string(log_file.path())?;
1150+
assert!(log_content.contains("systemd"));
1151+
1152+
// virsh dumpxml uses single-quoted attributes: append='on'
1153+
let sh = shell()?;
1154+
let domain_xml = cmd!(sh, "virsh dumpxml {domain_name}").read()?;
1155+
let expected_log = format!("<log file='{}' append='on'/>", log_path);
1156+
assert!(domain_xml.contains(&expected_log));
1157+
1158+
Ok(())
1159+
}
1160+
integration_test!(test_libvirt_run_console_log);

crates/kit/src/libvirt/domain.rs

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ pub struct DomainBuilder {
5555
nvram_template: Option<String>, // Custom NVRAM template with enrolled keys
5656
nvram_format: Option<String>, // Format of NVRAM template (raw, qcow2)
5757
firmware_log: Option<FirmwareLogOutput>, // OVMF debug log output via isa-debugcon
58+
virtio_console_log: Option<String>, // Virtio console log file path (hvc0 — OS/journald)
59+
serial_console_log: Option<String>, // Serial console log file path (ttyS0 — UEFI/bootloader)
5860
fw_cfg_entries: Vec<(String, String)>, // fw_cfg entries (name, file_path)
5961
ignition_disk_path: Option<String>, // Path to Ignition config for virtio-blk injection
6062
}
@@ -88,6 +90,8 @@ impl DomainBuilder {
8890
nvram_template: None,
8991
nvram_format: None,
9092
firmware_log: None,
93+
virtio_console_log: None,
94+
serial_console_log: None,
9195
fw_cfg_entries: Vec::new(),
9296
ignition_disk_path: None,
9397
}
@@ -208,6 +212,18 @@ impl DomainBuilder {
208212
self
209213
}
210214

215+
/// Log virtio console output (OS/journald on hvc0) to the given host file.
216+
pub fn with_virtio_console_log(mut self, path: &str) -> Self {
217+
self.virtio_console_log = Some(path.to_string());
218+
self
219+
}
220+
221+
/// Log serial console output (UEFI/bootloader on ttyS0) to the given host file.
222+
pub fn with_serial_console_log(mut self, path: &str) -> Self {
223+
self.serial_console_log = Some(path.to_string());
224+
self
225+
}
226+
211227
/// Add a fw_cfg entry for passing config files to the guest
212228
///
213229
/// This is used for Ignition config injection on x86_64/aarch64.
@@ -441,13 +457,21 @@ impl DomainBuilder {
441457
}
442458
}
443459

444-
// Serial console, see https://libvirt.org/formatdomain.html#relationship-between-serial-ports-and-consoles
445-
// We allocate a platform-specific default for early console stuff like bootloaders,
446-
// and a platform-independent `hvc0` that can be referenced independently.
460+
// Serial console (ttyS0) — platform firmware, bootloader, early kernel.
461+
// Virtio console (hvc0) — platform-independent; OS and journald write here.
462+
// Each chardev opens its logfile independently; giving both the same path
463+
// causes QEMU to return EBUSY on the second open.
447464
writer.start_element("console", &[("type", "pty")])?;
465+
if let Some(ref log_path) = self.serial_console_log {
466+
writer.write_empty_element("log", &[("file", log_path.as_str()), ("append", "on")])?;
467+
}
448468
writer.write_empty_element("target", &[("type", "serial")])?;
449469
writer.end_element("console")?;
470+
450471
writer.start_element("console", &[("type", "pty")])?;
472+
if let Some(ref log_path) = self.virtio_console_log {
473+
writer.write_empty_element("log", &[("file", log_path.as_str()), ("append", "on")])?;
474+
}
451475
writer.write_empty_element("target", &[("type", "virtio")])?;
452476
writer.end_element("console")?;
453477

@@ -819,4 +843,45 @@ mod tests {
819843
assert!(xml_ro.contains("source dir=\"/host/storage\""));
820844
assert!(xml_ro.contains("target dir=\"hoststorage\""));
821845
}
846+
847+
#[test]
848+
fn test_domain_xml_console_log() {
849+
let xml = DomainBuilder::new()
850+
.with_name("test-console-log")
851+
.with_memory(2048)
852+
.with_vcpus(2)
853+
.with_disk("/tmp/disk.raw")
854+
.with_virtio_console_log("/var/log/virtio.log")
855+
.with_serial_console_log("/var/log/serial.log")
856+
.build_xml()
857+
.unwrap();
858+
859+
// Serial log appears before <target type="serial"
860+
assert_eq!(
861+
xml.matches(r#"<log file="/var/log/serial.log" append="on"/>"#)
862+
.count(),
863+
1,
864+
"expected exactly one serial log element in:\n{xml}"
865+
);
866+
let serial_log_pos = xml.find(r#"<log file="/var/log/serial.log""#).unwrap();
867+
let serial_target_pos = xml.find(r#"<target type="serial""#).unwrap();
868+
assert!(
869+
serial_log_pos < serial_target_pos,
870+
"serial log must precede serial target"
871+
);
872+
873+
// Virtio log appears before <target type="virtio"
874+
assert_eq!(
875+
xml.matches(r#"<log file="/var/log/virtio.log" append="on"/>"#)
876+
.count(),
877+
1,
878+
"expected exactly one virtio log element in:\n{xml}"
879+
);
880+
let virtio_log_pos = xml.find(r#"<log file="/var/log/virtio.log""#).unwrap();
881+
let virtio_target_pos = xml.find(r#"<target type="virtio""#).unwrap();
882+
assert!(
883+
virtio_log_pos < virtio_target_pos,
884+
"virtio log must precede virtio target"
885+
);
886+
}
822887
}

crates/kit/src/libvirt/run.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,14 @@ pub struct LibvirtRunOpts {
305305
#[clap(long = "ignition")]
306306
pub ignition_config: Option<Utf8PathBuf>,
307307

308+
/// Log virtio console (OS/journald on hvc0) to this file (created if absent)
309+
#[clap(long = "console-log")]
310+
pub console_log: Option<Utf8PathBuf>,
311+
312+
/// Log platform console (UEFI/bootloader on ttyS0) to this file (created if absent)
313+
#[clap(long = "platform-console-log")]
314+
pub platform_console_log: Option<Utf8PathBuf>,
315+
308316
/// Additional metadata key-value pairs (used internally, not exposed via CLI)
309317
#[clap(skip)]
310318
pub metadata: std::collections::HashMap<String, String>,
@@ -1462,6 +1470,60 @@ fn create_libvirt_domain_from_disk(
14621470
qemu_args.push("-device".to_string());
14631471
qemu_args.push("virtio-net-pci,netdev=ssh0,addr=0x3".to_string());
14641472

1473+
// Helper closure: resolve to absolute path, guard against directory, pre-create.
1474+
// QEMU's chardev logfile= requires the file to exist before the domain starts.
1475+
let resolve_log_path = |log_path: &Utf8Path, flag: &str| -> Result<Utf8PathBuf> {
1476+
let raw_parent = log_path.parent().unwrap_or(Utf8Path::new("."));
1477+
let effective_parent = if raw_parent.as_str().is_empty() {
1478+
Utf8Path::new(".")
1479+
} else {
1480+
raw_parent
1481+
};
1482+
let parent = effective_parent
1483+
.canonicalize_utf8()
1484+
.with_context(|| format!("{flag} parent directory not found: {log_path}"))?;
1485+
let abs = parent.join(log_path.file_name().unwrap_or(log_path.as_str()));
1486+
if abs.is_dir() {
1487+
eyre::bail!("{flag} path is a directory: {abs}");
1488+
}
1489+
std::fs::OpenOptions::new()
1490+
.write(true)
1491+
.create(true)
1492+
.truncate(false)
1493+
.open(&abs)
1494+
.with_context(|| format!("create {flag} log file {abs}"))?;
1495+
Ok(abs)
1496+
};
1497+
1498+
let virtio_log = opts
1499+
.console_log
1500+
.as_deref()
1501+
.map(|p| resolve_log_path(p, "--console-log"))
1502+
.transpose()?;
1503+
1504+
let serial_log = opts
1505+
.platform_console_log
1506+
.as_deref()
1507+
.map(|p| resolve_log_path(p, "--platform-console-log"))
1508+
.transpose()?;
1509+
1510+
if let (Some(v), Some(s)) = (&virtio_log, &serial_log) {
1511+
if v == s {
1512+
eyre::bail!(
1513+
"--console-log and --platform-console-log cannot point to the same file \
1514+
(QEMU opens each chardev logfile independently and returns EBUSY if both \
1515+
paths are identical)"
1516+
);
1517+
}
1518+
}
1519+
1520+
if let Some(p) = &virtio_log {
1521+
domain_builder = domain_builder.with_virtio_console_log(p.as_str());
1522+
}
1523+
if let Some(p) = &serial_log {
1524+
domain_builder = domain_builder.with_serial_console_log(p.as_str());
1525+
}
1526+
14651527
let domain_xml = domain_builder
14661528
.with_qemu_args(qemu_args)
14671529
.build_xml()

docs/src/man/bcvk-libvirt-run.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@ Run a bootable container as a persistent VM
158158

159159
Path to Ignition config file (JSON format) for first-boot provisioning
160160

161+
**--console-log**=*CONSOLE_LOG*
162+
163+
Log virtio console (OS/journald on hvc0) to this file (created if absent)
164+
165+
**--platform-console-log**=*PLATFORM_CONSOLE_LOG*
166+
167+
Log platform console (UEFI/bootloader on ttyS0) to this file (created if absent)
168+
161169
<!-- END GENERATED OPTIONS -->
162170

163171
# EXAMPLES
@@ -186,6 +194,23 @@ Create a VM with access to host container storage for bootc upgrade:
186194

187195
bcvk libvirt run --name upgrade-test --bind-storage-ro quay.io/fedora/fedora-bootc:42
188196

197+
Capture the virtio console (OS/journald output) to a log file. The
198+
`console=hvc0` kernel argument is required so that the kernel maps
199+
`/dev/console` to `hvc0`; without it journald's `forward_to_console`
200+
output goes to the serial console (`ttyS0`) instead:
201+
202+
bcvk libvirt run --name testvm \
203+
--karg=console=hvc0 \
204+
--karg=systemd.journald.forward_to_console=1 \
205+
--console-log /var/home/user/vm-console.log \
206+
quay.io/fedora/fedora-bootc:42
207+
208+
Capture the platform console (UEFI/GRUB/serial) separately:
209+
210+
bcvk libvirt run --name testvm \
211+
--platform-console-log /var/home/user/vm-serial.log \
212+
quay.io/fedora/fedora-bootc:42
213+
189214
Server management workflow:
190215

191216
# Create a persistent server VM

0 commit comments

Comments
 (0)