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

/// 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
51 changes: 50 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,7 @@ use itest::TestResult;
use scopeguard::defer;
use xshell::cmd;

use crate::{get_bck_command, get_test_image, shell, LIBVIRT_INTEGRATION_TEST_LABEL};
use crate::{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 +1158,52 @@ fn test_libvirt_run_console_log() -> TestResult {
Ok(())
}
integration_test!(test_libvirt_run_console_log);
/// Test `--journal-output-file` for libvirt VMs.
///
/// Boots a VM with `--journal-output-file`, waits for SSH (proving the VM has reached
/// multi-user.target and is actively writing the journal), then polls the output file
/// until a structured JSON entry with a MESSAGE_ID appears. Output is always JSON.
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_file = tempfile::NamedTempFile::new()?;
let log_path = log_file
.path()
.to_str()
.expect("log 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 --journal-output-file {log_path} {test_image}"
)
.run()?;

// SSH is up but bcvk-journal-stream may not have flushed its first batch yet.
// Poll until a structured JSON entry with a MESSAGE_ID appears.
let log_path_buf = log_file.path().to_owned();
poll_until(
"MESSAGE_ID entry in journal JSON output",
std::time::Duration::from_secs(60),
std::time::Duration::from_millis(500),
|| {
let content = std::fs::read_to_string(&log_path_buf)?;
Ok(content.lines().any(|line| {
serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|v| v.get("MESSAGE_ID").cloned())
.is_some()
}))
},
)?;

Ok(())
}
integration_test!(test_libvirt_run_journal_output);
46 changes: 46 additions & 0 deletions crates/integration-tests/src/tests/run_ephemeral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -542,3 +542,49 @@ fn test_run_ephemeral_detect_ordering_cycle() -> TestResult {
Ok(())
}
integration_test!(test_run_ephemeral_detect_ordering_cycle);

/// Test that `--journal-output-file` writes JSON journal lines to a file.
///
/// Boots the VM with --execute poweroff (clean shutdown after reaching multi-user.target),
/// waits for bcvk to exit, then parses the JSON file and verifies well-known MESSAGE_IDs
/// are present. Format is always JSON when writing to a file.
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;

// The journal file can live anywhere on the host: bcvk opens it before
// launching the container and passes the fd through, so no bind-mount of
// the parent directory is needed.
let journal_file = tempfile::Builder::new()
.suffix(".json")
.tempfile_in("/var/tmp")?;
let journal_path = journal_file.path().to_str().unwrap().to_owned();
// Keep the tempfile handle alive; bcvk will write to the same fd.
let _ = &journal_file;

cmd!(
sh,
"{bck} ephemeral run --rm --label {label} --execute poweroff --journal-output-file {journal_path} {image}"
)
.run()?;

// Parse the JSON file and verify we got structured journal output.
let content = std::fs::read_to_string(&journal_path)?;
let mut found_message_id = false;
for line in content.lines() {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
if v.get("MESSAGE_ID").is_some() {
found_message_id = true;
break;
}
}
}
assert!(
found_message_id,
"no MESSAGE_ID found in journal JSON output"
);
Ok(())
}
integration_test!(test_run_ephemeral_journal_output);
7 changes: 7 additions & 0 deletions crates/kit/scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ BWRAP_ARGS=(
--bind /run/inner-shared /run/inner-shared
)

# Pass the journal output file descriptor into the bwrap sandbox.
# The fd was opened on the host and passed through podman --preserve-fd;
# bwrap closes all fds >= 3 by default, so we must explicitly allow it.
if [ -n "${BCVK_JOURNAL_FD:-}" ]; then
BWRAP_ARGS+=(--pass-fd "$BCVK_JOURNAL_FD")
fi

# Pass ALL arguments to container-entrypoint
# Default to "run-ephemeral" if no args
if [[ $# -eq 0 ]]; then
Expand Down
28 changes: 28 additions & 0 deletions crates/kit/src/libvirt/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ 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)
}

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

Expand Down Expand Up @@ -238,6 +240,15 @@ 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
}

/// 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 +485,23 @@ 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")?;
}

// 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
61 changes: 61 additions & 0 deletions crates/kit/src/libvirt/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,14 @@ pub struct LibvirtRunOpts {
/// Additional SMBIOS credentials to inject (used internally, not exposed via CLI)
#[clap(skip)]
pub extra_smbios_credentials: Vec<String>,

/// Stream the VM's systemd journal to a file on the host.
/// Stream the VM's systemd journal as JSON to the given file.
///
/// The path must be absolute. Output is always JSON (unlike `--output journal`
/// on ephemeral run, which streams plain text to stdout).
#[clap(long, value_name = "PATH")]
pub journal_output_file: Option<std::path::PathBuf>,
}

impl LibvirtRunOpts {
Expand Down Expand Up @@ -420,6 +428,15 @@ pub fn run(global_opts: &crate::libvirt::LibvirtOptions, mut opts: LibvirtRunOpt
// Validate labels don't contain commas
opts.validate_labels()?;

// Validate --journal-output-file early (before any expensive work).
if let Some(ref path) = opts.journal_output_file {
if !path.is_absolute() {
return Err(color_eyre::eyre::eyre!(
"--journal-output-file path must be absolute: {path:?}"
));
}
}

let connect_uri = global_opts.connect.as_deref();
let lister = match global_opts.connect.as_ref() {
Some(uri) => DomainLister::with_connection(uri.clone()),
Expand Down Expand Up @@ -1428,6 +1445,38 @@ fn create_libvirt_domain_from_disk(
smbios_creds.push(dropin_cred);
}

// Validate and record the journal output file path if requested.
// For libvirt we only support target=file:, not stdout (there is no persistent
// bcvk process to relay the stream; QEMU writes directly to the file via chardev).
// Journal output is always JSON when writing to a file (libvirt only supports file).
let journal_file_path: Option<std::path::PathBuf> = if let Some(ref path) =
opts.journal_output_file
{
// Validate parent directory exists before handing off to libvirt.
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(color_eyre::eyre::eyre!(
"--journal-output-file parent directory does not exist: {parent:?}"
));
}
}
// Inject SMBIOS credentials to set up the guest streaming unit (always JSON).
let encoded_unit =
data_encoding::BASE64.encode(crate::run_ephemeral::JOURNAL_STREAM_UNIT.as_bytes());
smbios_creds.push(format!(
"io.systemd.credential.binary:systemd.extra-unit.bcvk-journal-stream.service={encoded_unit}"
));
let journal_dropin = "[Unit]\nWants=bcvk-journal-stream.service\n";
let encoded_dropin = data_encoding::BASE64.encode(journal_dropin.as_bytes());
smbios_creds.push(format!(
"io.systemd.credential.binary:systemd.unit-dropin.sysinit.target~bcvk-journal={encoded_dropin}"
));
debug!("Injected journal streaming unit credentials (json=true, file={path:?})");
Some(path.clone())
} else {
None
};

let mut qemu_args = Vec::new();

// Build QEMU args with all SMBIOS credentials
Expand All @@ -1452,6 +1501,18 @@ fn create_libvirt_domain_from_disk(
qemu_args.push(format!("type=11,value={}", extra_cred));
}

// If journal output was requested, configure the DomainBuilder to emit a
// <channel type='file'> element. Libvirt attaches it to the existing
// virtio-serial controller (created implicitly for the virtio console), so
// no extra QEMU args are needed.
if let Some(ref jpath) = journal_file_path {
let path_str = jpath
.to_str()
.ok_or_else(|| color_eyre::eyre::eyre!("journal file path is not valid UTF-8"))?;
domain_builder = domain_builder.with_journal_channel_file(path_str);
debug!("Added journal channel file → {path_str}");
}

// Build netdev user mode networking with port forwarding
let mut hostfwd_args = vec![format!("tcp::{}-:22", ssh_port)];

Expand Down
Loading
Loading