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
83 changes: 83 additions & 0 deletions crates/integration-tests/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,89 @@ pub(crate) fn get_all_test_images() -> Vec<String> {
}
}

/// Verify journal coverage for a `--log-dir` directory.
///
/// Checks that:
/// - `journal.json` contains structured entries (with `MESSAGE_ID`), confirming
/// the real-root journal stream ran and reached multi-user.target.
/// - `journal-initrd.json` contains seqnum 1, confirming the initrd journal
/// stream replayed from the very start of boot (kernel messages included).
pub(crate) fn check_journal_coverage(log_dir: &std::path::Path) -> anyhow::Result<()> {
// Check journal.json for structured entries.
let journal_path = log_dir.join("journal.json");
let content = std::fs::read_to_string(&journal_path)
.with_context(|| format!("reading {journal_path:?}"))?;
anyhow::ensure!(!content.is_empty(), "journal.json is empty");

let mut found_message_id = false;
for line in content.lines() {
// Skip lines that don't parse — journalctl may leave a partial line at
// the end of the file if we read it while it is still being written.
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if v.get("MESSAGE_ID").is_some() {
found_message_id = true;
break;
}
}
anyhow::ensure!(
found_message_id,
"no MESSAGE_ID found in journal.json — structured journal entries missing"
);

// Check journal-initrd.json contains seqnum 1, confirming that
// journalctl --since=@0 successfully replayed from the very start of boot.
let initrd_path = log_dir.join("journal-initrd.json");
let initrd_content = std::fs::read_to_string(&initrd_path)
.with_context(|| format!("reading {initrd_path:?}"))?;
anyhow::ensure!(
!initrd_content.is_empty(),
"journal-initrd.json is empty — initrd journal stream did not produce output"
);
let has_seqnum_one = initrd_content
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.any(|v| {
v.get("__SEQNUM")
.and_then(|s| s.as_str())
.map(|s| s == "1")
.unwrap_or(false)
});
anyhow::ensure!(
has_seqnum_one,
"journal-initrd.json does not contain __SEQNUM=1 — early boot messages were not captured"
);

Ok(())
}

/// Poll `condition` every `interval` until it returns `true` or `timeout` elapses.
///
/// Returns `Ok(())` as soon as the condition holds, or an error describing what was
/// being waited for if the deadline is reached.
pub(crate) fn poll_until(
what: &str,
timeout: std::time::Duration,
interval: std::time::Duration,
mut condition: impl FnMut() -> anyhow::Result<bool>,
) -> anyhow::Result<()> {
let deadline = std::time::Instant::now() + timeout;
loop {
if condition()? {
return Ok(());
}
if std::time::Instant::now() >= deadline {
return Err(anyhow::anyhow!(
"timed out after {}s waiting for: {}",
timeout.as_secs(),
what
));
}
std::thread::sleep(interval);
}
}

fn test_images_list() -> itest::TestResult {
println!("Running test: bcvk images list --json");

Expand Down
45 changes: 44 additions & 1 deletion crates/integration-tests/src/tests/libvirt_verb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ use itest::TestResult;
use scopeguard::defer;
use xshell::cmd;

use crate::{get_bck_command, get_test_image, shell, LIBVIRT_INTEGRATION_TEST_LABEL};
use crate::{
check_journal_coverage, get_bck_command, get_test_image, poll_until, shell,
LIBVIRT_INTEGRATION_TEST_LABEL,
};
use bcvk::xml_utils::parse_xml_dom;

/// Generate a random alphanumeric suffix for VM names to avoid collisions
Expand Down Expand Up @@ -1158,3 +1161,43 @@ fn test_libvirt_run_console_log() -> TestResult {
Ok(())
}
integration_test!(test_libvirt_run_console_log);
/// Test `--log-dir=journal=DIR` for libvirt VMs.
///
/// Boots a VM with `--log-dir=journal=DIR`, waits for SSH (proving the VM has reached
/// multi-user.target), then polls `journal.json` until it contains early-boot entries,
/// confirming initrd journal capture is working.
fn test_libvirt_run_journal_output() -> 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-journal-out-{}", random_suffix());
let log_dir = tempfile::tempdir()?;
let log_dir_path = log_dir
.path()
.to_str()
.expect("log dir path is not UTF-8")
.to_owned();

cleanup_domain(&domain_name);
defer! { cleanup_domain(&domain_name); }

cmd!(
sh,
"{bck} libvirt run --name {domain_name} --label {label} --filesystem ext4 --ssh-wait --log-dir=journal={log_dir_path} {test_image}"
)
.run()?;

// --ssh-wait guarantees multi-user.target was reached, but bcvk-journal-stream
// may still be flushing. Poll until check_journal_coverage passes.
poll_until(
"journal coverage (journal.json + journal-initrd.json)",
std::time::Duration::from_secs(60),
std::time::Duration::from_millis(500),
|| Ok(check_journal_coverage(log_dir.path()).is_ok()),
)?;

Ok(())
}
integration_test!(test_libvirt_run_journal_output);
67 changes: 66 additions & 1 deletion crates/integration-tests/src/tests/run_ephemeral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ use tempfile::TempDir;
use camino::Utf8Path;
use tracing::debug;

use crate::{get_bck_command, get_test_image, shell, INTEGRATION_TEST_LABEL};
use crate::{
check_journal_coverage, get_bck_command, get_test_image, poll_until, shell,
INTEGRATION_TEST_LABEL,
};

pub fn get_container_kernel_version(image: &str) -> String {
// Run container to get its kernel version
Expand Down Expand Up @@ -542,3 +545,65 @@ fn test_run_ephemeral_detect_ordering_cycle() -> TestResult {
Ok(())
}
integration_test!(test_run_ephemeral_detect_ordering_cycle);

/// Test that `--log-dir=journal=DIR` writes JSON journal lines to `journal.json`,
/// including entries from the initrd (early boot).
///
/// Boots the VM detached, polls journal.json until multi-user.target is reached
/// (proving the system fully booted), then terminates the VM and verifies coverage.
fn test_run_ephemeral_journal_output() -> TestResult {
let sh = shell()?;
let bck = get_bck_command()?;
let image = get_test_image();
let label = INTEGRATION_TEST_LABEL;
let container_name = format!(
"bcvk-journal-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
);

let log_dir = tempfile::tempdir_in("/var/tmp")?;
let log_dir_path = log_dir.path().to_str().unwrap().to_owned();

cmd!(
sh,
"{bck} ephemeral run --detach --name {container_name} --label {label} --log-dir=journal={log_dir_path} {image}"
)
.run()?;

let journal_path = log_dir.path().join("journal.json");

// Wait until multi-user.target is reached, then stop the VM.
let stop_result = poll_until(
"multi-user.target reached in journal",
std::time::Duration::from_secs(120),
std::time::Duration::from_millis(500),
|| {
let content = std::fs::read_to_string(&journal_path).unwrap_or_default();
Ok(content.lines().any(|line| {
serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|v| {
v.get("MESSAGE")
.and_then(|m| m.as_str())
.map(|s| s.to_owned())
})
.map(|msg| msg.contains("multi-user.target") && msg.contains("Reached target"))
.unwrap_or(false)
}))
},
);

// Always stop the container, even if polling failed.
let _ = cmd!(sh, "podman stop {container_name}")
.ignore_status()
.quiet()
.run();

stop_result?;
check_journal_coverage(log_dir.path())?;
Ok(())
}
integration_test!(test_run_ephemeral_journal_output);
50 changes: 50 additions & 0 deletions crates/kit/src/libvirt/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ pub struct DomainBuilder {
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
journal_channel_file: Option<String>, // virtserialport "org.bcvk.journal" → host file (append)
journal_initrd_channel_file: Option<String>, // virtserialport "org.bcvk.journal.initrd" → host file (append)
}

impl Default for DomainBuilder {
Expand Down Expand Up @@ -94,6 +96,8 @@ impl DomainBuilder {
serial_console_log: None,
fw_cfg_entries: Vec::new(),
ignition_disk_path: None,
journal_channel_file: None,
journal_initrd_channel_file: None,
}
}

Expand Down Expand Up @@ -238,6 +242,22 @@ impl DomainBuilder {
self
}

/// Stream the guest's `org.bcvk.journal` virtserialport to a host file (append mode).
///
/// Emits a `<channel type='file'>` element in the domain XML, which libvirt attaches
/// to the existing virtio-serial controller. No extra QEMU args are needed.
pub fn with_journal_channel_file(mut self, path: &str) -> Self {
self.journal_channel_file = Some(path.to_string());
self
}

/// Stream the guest's `org.bcvk.journal.initrd` virtserialport to a host file (append mode).
/// Captures journal output from the initrd phase.
pub fn with_journal_initrd_channel_file(mut self, path: &str) -> Self {
self.journal_initrd_channel_file = Some(path.to_string());
self
}

/// Build the domain XML
pub fn build_xml(self) -> Result<String> {
let name = self.name.ok_or_else(|| eyre!("Domain name is required"))?;
Expand Down Expand Up @@ -474,6 +494,36 @@ impl DomainBuilder {
writer.write_empty_element("target", &[("type", "virtio")])?;
writer.end_element("console")?;

// Journal streaming channel: virtserialport named "org.bcvk.journal" backed by a
// host-side file in append mode. Libvirt attaches this to the existing
// virtio-serial controller that it creates for the virtio console above.
if let Some(ref journal_path) = self.journal_channel_file {
writer.start_element("channel", &[("type", "file")])?;
writer.write_empty_element(
"source",
&[("path", journal_path.as_str()), ("append", "on")],
)?;
writer.start_element(
"target",
&[("type", "virtio"), ("name", "org.bcvk.journal")],
)?;
writer.end_element("target")?;
writer.end_element("channel")?;
}
if let Some(ref journal_initrd_path) = self.journal_initrd_channel_file {
writer.start_element("channel", &[("type", "file")])?;
writer.write_empty_element(
"source",
&[("path", journal_initrd_path.as_str()), ("append", "on")],
)?;
writer.start_element(
"target",
&[("type", "virtio"), ("name", "org.bcvk.journal.initrd")],
)?;
writer.end_element("target")?;
writer.end_element("channel")?;
}

// Firmware debug log via isa-debugcon (x86_64 only)
// This captures OVMF/EDK2 DEBUG() output on IO port 0x402, useful for
// debugging Secure Boot failures. Access via: virsh console <domain> serial0
Expand Down
Loading
Loading