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
88 changes: 87 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 @@ -1122,3 +1122,89 @@ 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);

/// 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);
51 changes: 51 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,54 @@ 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.
///
/// The journal file lives under $HOME, not /var/tmp. On bootc/ostree systems /var is a
/// btrfs subvolume that rootless podman bind-mounts directly from the block device, so
/// host overlay-layer writes and container writes don't share the same directory.
/// $HOME (also btrfs but accessed the same way by both host and container) works correctly.
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;

// Use $HOME so the path is shared correctly between the host and the rootless
// podman container across the bind-mount chain.
let home = std::env::var("HOME").expect("HOME not set");
let journal_file = tempfile::Builder::new()
.suffix(".json")
.tempfile_in(&home)?;
let journal_path = journal_file.path().to_str().unwrap().to_owned();
// Keep the tempfile handle alive; bcvk will overwrite/append to the same path.
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);
6 changes: 6 additions & 0 deletions crates/kit/scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ BWRAP_ARGS=(
--bind /run/inner-shared /run/inner-shared
)

# Bind-mount the journal output file's parent directory into bwrap so the inner
# bcvk process can write journal data to a path on the host filesystem.
if [ -n "${BCVK_JOURNAL_BIND_DIR:-}" ]; then
BWRAP_ARGS+=(--bind "$BCVK_JOURNAL_BIND_DIR" "$BCVK_JOURNAL_BIND_DIR")
fi

# Pass ALL arguments to container-entrypoint
# Default to "run-ephemeral" if no args
if [[ $# -eq 0 ]]; then
Expand Down
30 changes: 29 additions & 1 deletion crates/kit/src/libvirt/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ pub struct DomainBuilder {
firmware_log: Option<FirmwareLogOutput>, // OVMF debug log output via isa-debugcon
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 @@ -87,9 +88,10 @@ impl DomainBuilder {
ovmf_code_format: None,
nvram_template: None,
nvram_format: None,
firmware_log: None,
firmware_log: Some(FirmwareLogOutput::Console), // Default to pty for virsh console access
fw_cfg_entries: Vec::new(),
ignition_disk_path: None,
journal_channel_file: None,
}
}

Expand Down Expand Up @@ -223,6 +225,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 @@ -451,6 +462,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 @@ -312,6 +312,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 @@ -408,6 +416,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 @@ -1413,6 +1430,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 @@ -1437,6 +1486,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