diff --git a/crates/integration-tests/src/main.rs b/crates/integration-tests/src/main.rs index 3d64015a2..e8c9a5169 100644 --- a/crates/integration-tests/src/main.rs +++ b/crates/integration-tests/src/main.rs @@ -83,6 +83,32 @@ pub(crate) fn get_all_test_images() -> Vec { } } +/// 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, +) -> 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"); diff --git a/crates/integration-tests/src/tests/libvirt_verb.rs b/crates/integration-tests/src/tests/libvirt_verb.rs index 2d188d0c1..5b1ba2484 100644 --- a/crates/integration-tests/src/tests/libvirt_verb.rs +++ b/crates/integration-tests/src/tests/libvirt_verb.rs @@ -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 @@ -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 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_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::(line) + .ok() + .and_then(|v| v.get("MESSAGE_ID").cloned()) + .is_some() + })) + }, + )?; + + Ok(()) +} +integration_test!(test_libvirt_run_journal_output); diff --git a/crates/integration-tests/src/tests/run_ephemeral.rs b/crates/integration-tests/src/tests/run_ephemeral.rs index 234eeea3e..0cf22fe3b 100644 --- a/crates/integration-tests/src/tests/run_ephemeral.rs +++ b/crates/integration-tests/src/tests/run_ephemeral.rs @@ -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::(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); diff --git a/crates/kit/scripts/entrypoint.sh b/crates/kit/scripts/entrypoint.sh index 06474ff2f..f4ab5bce8 100644 --- a/crates/kit/scripts/entrypoint.sh +++ b/crates/kit/scripts/entrypoint.sh @@ -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 diff --git a/crates/kit/src/libvirt/domain.rs b/crates/kit/src/libvirt/domain.rs index 1d6909bde..31dc0b917 100644 --- a/crates/kit/src/libvirt/domain.rs +++ b/crates/kit/src/libvirt/domain.rs @@ -57,6 +57,7 @@ pub struct DomainBuilder { firmware_log: Option, // OVMF debug log output via isa-debugcon fw_cfg_entries: Vec<(String, String)>, // fw_cfg entries (name, file_path) ignition_disk_path: Option, // Path to Ignition config for virtio-blk injection + journal_channel_file: Option, // virtserialport "org.bcvk.journal" → host file (append) } impl Default for DomainBuilder { @@ -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, } } @@ -223,6 +225,15 @@ impl DomainBuilder { self } + /// Stream the guest's `org.bcvk.journal` virtserialport to a host file (append mode). + /// + /// Emits a `` 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 { let name = self.name.ok_or_else(|| eyre!("Domain name is required"))?; @@ -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 serial0 diff --git a/crates/kit/src/libvirt/run.rs b/crates/kit/src/libvirt/run.rs index 5dd412fdf..342f49a53 100644 --- a/crates/kit/src/libvirt/run.rs +++ b/crates/kit/src/libvirt/run.rs @@ -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, + + /// 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, } impl LibvirtRunOpts { @@ -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()), @@ -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 = 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 @@ -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 + // 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)]; diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 0d0fdf3b8..b8db648a2 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -129,6 +129,72 @@ const IGNITION_SERIAL_NAME: &str = "ignition"; /// Mount path for Ignition config inside the container const IGNITION_CONFIG_MOUNT_PATH: &str = "/run/ignition-config.json"; +// --------------------------------------------------------------------------- +// Journal / output mode types +// --------------------------------------------------------------------------- + +/// Controls where the VM's systemd journal is streamed. +/// +/// - `Console` (default): no journal capture; the VM's hvc0 console is connected +/// to the container's stdio as usual. +/// - `Journal`: stream the journal as plain text to stdout. +/// +/// For writing the journal to a file (always JSON), use `--journal-output-file`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)] +#[serde(rename_all = "kebab-case")] +pub enum OutputMode { + #[default] + Console, + Journal, +} + +/// The guest-side systemd unit that streams the journal as JSON over virtio-serial. +/// Always uses JSON format; the host converts to plain text for stdout as needed. +pub(crate) const JOURNAL_STREAM_UNIT: &str = include_str!("units/bcvk-journal-stream.service"); + +/// Convert a single JSON journal line (as produced by `journalctl -o json`) into +/// a human-readable text line suitable for printing to stdout. +/// +/// Returns `None` if the JSON has no `MESSAGE` field or if parsing fails. +/// +/// The prefix mirrors systemd's `with-unit` output mode (`journalctl -o with-unit`): +/// - Unit: `_SYSTEMD_UNIT[/_SYSTEMD_USER_UNIT]`, falling back to +/// `SYSLOG_IDENTIFIER` → `_COMM` → `"unknown"` +/// - PID: `[_PID]` or `[SYSLOG_PID]` if present +/// - Then `: MESSAGE` +pub(crate) fn journal_json_to_text(line: &str) -> Option { + let obj = serde_json::from_str::(line).ok()?; + let message = obj.get("MESSAGE").and_then(|v| v.as_str())?; + + let str_field = |key: &str| obj.get(key).and_then(|v| v.as_str()); + + // Build the unit/identifier prefix, matching systemd's OUTPUT_WITH_UNIT logic. + let unit = str_field("_SYSTEMD_UNIT"); + let user_unit = str_field("_SYSTEMD_USER_UNIT"); + let prefix = if unit.is_some() || user_unit.is_some() { + match (unit, user_unit) { + (Some(u), Some(uu)) => format!("{u}/{uu}"), + (Some(u), None) => u.to_owned(), + (None, Some(uu)) => uu.to_owned(), + (None, None) => unreachable!(), + } + } else if let Some(id) = str_field("SYSLOG_IDENTIFIER") { + id.to_owned() + } else if let Some(comm) = str_field("_COMM") { + comm.to_owned() + } else { + "unknown".to_owned() + }; + + // Append [PID] when available, preferring the trusted _PID field. + let pid_suffix = str_field("_PID") + .or_else(|| str_field("SYSLOG_PID")) + .map(|p| format!("[{p}]")) + .unwrap_or_default(); + + Some(format!("{prefix}{pid_suffix}: {message}\n")) +} + /// Common container lifecycle options for podman commands. #[derive(Parser, Debug, Clone, Default, Serialize, Deserialize)] pub struct CommonPodmanOptions { @@ -218,6 +284,23 @@ pub struct CommonVmOpts { help = "Generate SSH keypair and inject via systemd credentials" )] pub ssh_keygen: bool, + + /// Select how VM output is presented. + /// + /// `console` (default): the VM's hvc0 console is forwarded to stdio. + /// `journal`: the systemd journal is streamed as plain text to stdout. + /// + /// To capture the journal as JSON to a file, use `--journal-output-file`. + #[clap(long, value_enum, default_value = "console")] + pub output: OutputMode, + + /// Stream the VM's systemd journal as JSON to the given file. + /// + /// The path must be absolute. Can be combined with `--output journal` + /// to get both stdout (plain text, decoded from JSON by the host) and + /// file (JSON) simultaneously. + #[clap(long, value_name = "PATH")] + pub journal_output_file: Option, } impl CommonVmOpts { @@ -666,6 +749,25 @@ fn prepare_run_command_with_temp( let config = serde_json::to_string(&opts_with_dns).unwrap(); cmd.args(["-e", &format!("BCK_CONFIG={config}")]); + // If --journal-output targets a file, bind-mount its parent directory into the + // container so the inner bcvk can write to the host filesystem path. + // We also pass BCVK_JOURNAL_BIND_DIR so entrypoint.sh can add a --bind to bwrap + // (bwrap's own --bind /var/tmp /var/tmp only covers that one path; other parents + // need an explicit bind). + // If --journal-output-file targets a file, bind-mount its parent directory into + // the container so the inner bcvk can write to the host filesystem path. + if let Some(path) = &opts.common.journal_output_file { + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(std::path::Path::new("/")); + let parent_str = parent.to_str().ok_or_else(|| { + color_eyre::eyre::eyre!("journal file parent path is not valid UTF-8") + })?; + cmd.args(["-v", &format!("{parent_str}:{parent_str}")]); + cmd.args(["-e", &format!("BCVK_JOURNAL_BIND_DIR={parent_str}")]); + } + // Handle --execute output files and virtio-serial devices let mut all_serial_devices = opts.common.virtio_serial_out.clone(); if !opts.common.execute.is_empty() { @@ -1222,39 +1324,36 @@ pub(crate) async fn run_impl(opts: RunEphemeralOpts) -> Result<()> { // Handle --execute: pipes will be created when adding to qemu_config later // No need to create files anymore as we're using pipes - // Create systemd unit to stream journal to virtio-serial device via SMBIOS credential - let journal_stream_unit = r#"[Unit] -Description=Stream systemd journal to host via virtio-serial -DefaultDependencies=no -After=systemd-journald.service dev-virtio\x2dports-org.bcvk.journal.device -Requires=systemd-journald.service dev-virtio\x2dports-org.bcvk.journal.device + // Inject the journal streaming unit when either --output=journal or + // --journal-output-file is requested. The guest always streams JSON; the host + // converts JSON→plain-text for stdout as needed. + let wants_journal_stream = + opts.common.output == OutputMode::Journal || opts.common.journal_output_file.is_some(); + if wants_journal_stream { + let encoded_journal = data_encoding::BASE64.encode(JOURNAL_STREAM_UNIT.as_bytes()); + mount_unit_smbios_creds.push(format!( + "io.systemd.credential.binary:systemd.extra-unit.bcvk-journal-stream.service={encoded_journal}" + )); + debug!("Injected SMBIOS credential for journal streaming unit"); -[Service] -Type=simple -ExecStart=/usr/bin/journalctl -f -o short-precise --no-pager -StandardOutput=file:/dev/virtio-ports/org.bcvk.journal -StandardError=file:/dev/virtio-ports/org.bcvk.journal -Restart=always -RestartSec=1s - -[Install] -WantedBy=sysinit.target -"#; - let encoded_journal = data_encoding::BASE64.encode(journal_stream_unit.as_bytes()); - let journal_cred = format!( - "io.systemd.credential.binary:systemd.extra-unit.bcvk-journal-stream.service={encoded_journal}" - ); - mount_unit_smbios_creds.push(journal_cred); - debug!("Generated SMBIOS credential for journal streaming unit"); - - // Create dropin for sysinit.target to enable journal streaming - let journal_dropin = "[Unit]\nWants=bcvk-journal-stream.service\n"; - let encoded_dropin = data_encoding::BASE64.encode(journal_dropin.as_bytes()); - let dropin_cred = format!( - "io.systemd.credential.binary:systemd.unit-dropin.sysinit.target~bcvk-journal={encoded_dropin}" - ); - mount_unit_smbios_creds.push(dropin_cred); - debug!("Created sysinit.target dropin to enable journal streaming unit"); + let journal_dropin = "[Unit]\nWants=bcvk-journal-stream.service\n"; + let encoded_dropin = data_encoding::BASE64.encode(journal_dropin.as_bytes()); + mount_unit_smbios_creds.push(format!( + "io.systemd.credential.binary:systemd.unit-dropin.sysinit.target~bcvk-journal={encoded_dropin}" + )); + debug!("Created sysinit.target dropin to enable journal streaming unit"); + + // Validate file parent exists before QEMU launch. + if let Some(ref path) = opts.common.journal_output_file { + 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:?}" + )); + } + } + } + } // Create execute units via SMBIOS credentials if needed match opts.common.execute.as_slice() { @@ -1541,9 +1640,67 @@ Options= qemu_config.set_console(opts.common.console); - // Add virtio-serial device for journal streaming - qemu_config.add_virtio_serial_out("org.bcvk.journal", "/run/journal.log".to_string(), false); - debug!("Added virtio-serial device for journal streaming to /run/journal.log"); + // Optionally set up virtio-serial device for journal streaming using an in-process pipe. + // Set up virtio-serial pipe for journal streaming when requested. + // add_virtio_serial_pipe() creates a pipe, passes the write end to QEMU via + // --add-fd/fdset, and returns the read end. Spawn an async task to drain it; + // we await the task after QEMU exits to flush all data. + // + // The guest always streams JSON. The host decodes each JSON line to plain text + // for stdout (when --output=journal) and writes raw JSON lines to the file + // (when --journal-output-file), handling both sinks independently. + let journal_copy_task: Option> = if wants_journal_stream { + let read_file: std::fs::File = qemu_config + .add_virtio_serial_pipe("org.bcvk.journal")? + .into(); + debug!("Added virtio-serial pipe for journal streaming"); + let reader = tokio::fs::File::from_std(read_file); + + let stdout_wants_journal = opts.common.output == OutputMode::Journal; + let file_writer: Option = + if let Some(ref path) = opts.common.journal_output_file { + let f = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .await + .with_context(|| format!("Opening journal output file {path:?}"))?; + Some(f) + } else { + None + }; + + Some(tokio::task::spawn(async move { + use tokio::io::AsyncBufReadExt as _; + use tokio::io::AsyncWriteExt as _; + let mut file_writer = file_writer; + let mut stdout = tokio::io::stdout(); + let mut lines = tokio::io::BufReader::new(reader).lines(); + loop { + match lines.next_line().await { + Ok(None) | Err(_) => break, + Ok(Some(line)) => { + if let Some(ref mut fw) = file_writer { + if let Err(e) = fw.write_all(format!("{line}\n").as_bytes()).await { + tracing::warn!("Failed to write journal JSON to file: {e}"); + file_writer = None; + } + } + if stdout_wants_journal { + if let Some(text) = journal_json_to_text(&line) { + if let Err(e) = stdout.write_all(text.as_bytes()).await { + tracing::warn!("Failed to write journal text to stdout: {e}"); + } + } + } + } + } + } + tracing::debug!("journal copy task done"); + })) + } else { + None + }; // DNS is configured via podman --dns flags (see prepare_run_command_with_temp) // This fixes DNS resolution issues when QEMU runs inside containers. @@ -1662,6 +1819,11 @@ Options= tracing::debug!("qemu exit status: {qemu:?}"); tracing::debug!("output copy: {output_copier:?}"); + // Drain any remaining journal output (pipe write end closed when QEMU exits) + if let Some(t) = journal_copy_task { + let _ = t.await; + } + // Parse exit code from systemd service status let exit_code = parse_service_exit_code(&status)?; if exit_code != 0 { @@ -1677,6 +1839,10 @@ Options= if !exit_status.success() { return Err(eyre!("QEMU exited with non-zero status: {}", exit_status)); } + // Drain any remaining journal output (pipe write end closed when QEMU exits) + if let Some(t) = journal_copy_task { + let _ = t.await; + } } drop(tmp_swapfile); @@ -1691,6 +1857,49 @@ Options= mod tests { use super::*; + #[test] + fn test_journal_json_to_text() { + // _SYSTEMD_UNIT takes priority over SYSLOG_IDENTIFIER, with PID + let line = r#"{"MESSAGE":"started","_SYSTEMD_UNIT":"foo.service","SYSLOG_IDENTIFIER":"foo","_PID":"42"}"#; + assert_eq!( + journal_json_to_text(line), + Some("foo.service[42]: started\n".into()) + ); + + // _SYSTEMD_UNIT + _SYSTEMD_USER_UNIT joined with / + let line = + r#"{"MESSAGE":"hi","_SYSTEMD_UNIT":"app.service","_SYSTEMD_USER_UNIT":"user.service"}"#; + assert_eq!( + journal_json_to_text(line), + Some("app.service/user.service: hi\n".into()) + ); + + // Falls back to SYSLOG_IDENTIFIER when no unit fields; SYSLOG_PID used + let line = r#"{"MESSAGE":"hello","SYSLOG_IDENTIFIER":"myapp","SYSLOG_PID":"99"}"#; + assert_eq!( + journal_json_to_text(line), + Some("myapp[99]: hello\n".into()) + ); + + // Falls back to _COMM when no unit or identifier + let line = r#"{"MESSAGE":"from comm","_COMM":"bash"}"#; + assert_eq!(journal_json_to_text(line), Some("bash: from comm\n".into())); + + // Falls back to "unknown" with no identifying fields + let line = r#"{"MESSAGE":"bare message"}"#; + assert_eq!( + journal_json_to_text(line), + Some("unknown: bare message\n".into()) + ); + + // No MESSAGE → None + let line = r#"{"SYSLOG_IDENTIFIER":"foo"}"#; + assert_eq!(journal_json_to_text(line), None); + + // Invalid JSON → None + assert_eq!(journal_json_to_text("not json at all"), None); + } + #[test] fn test_parse_resolv_conf() { let cases = vec![ diff --git a/crates/kit/src/units/bcvk-journal-stream.service b/crates/kit/src/units/bcvk-journal-stream.service index 5acd5cea9..7f435c7f5 100644 --- a/crates/kit/src/units/bcvk-journal-stream.service +++ b/crates/kit/src/units/bcvk-journal-stream.service @@ -8,7 +8,7 @@ ConditionPathExists=!/etc/initrd-release [Service] Type=simple -ExecStart=/usr/bin/journalctl -f -o short-precise --no-pager +ExecStart=/usr/bin/journalctl -f -o json --no-pager StandardOutput=file:/dev/virtio-ports/org.bcvk.journal StandardError=file:/dev/virtio-ports/org.bcvk.journal Restart=always diff --git a/docs/src/man/bcvk-ephemeral-run-ssh.md b/docs/src/man/bcvk-ephemeral-run-ssh.md index 6bebe0280..afcb5a8f2 100644 --- a/docs/src/man/bcvk-ephemeral-run-ssh.md +++ b/docs/src/man/bcvk-ephemeral-run-ssh.md @@ -82,6 +82,20 @@ For longer-running VMs where you need to reconnect multiple times, use Generate SSH keypair and inject via systemd credentials +**--output**=*OUTPUT* + + Select how VM output is presented + + Possible values: + - console + - journal + + Default: console + +**--journal-output-file**=*PATH* + + Stream the VM's systemd journal as JSON to the given file + **-t**, **--tty** Allocate a pseudo-TTY for container diff --git a/docs/src/man/bcvk-ephemeral-run.md b/docs/src/man/bcvk-ephemeral-run.md index 72ba5ae47..99a1b26f5 100644 --- a/docs/src/man/bcvk-ephemeral-run.md +++ b/docs/src/man/bcvk-ephemeral-run.md @@ -84,6 +84,20 @@ This design allows bcvk to provide VM-like isolation and boot behavior while lev Generate SSH keypair and inject via systemd credentials +**--output**=*OUTPUT* + + Select how VM output is presented + + Possible values: + - console + - journal + + Default: console + +**--journal-output-file**=*PATH* + + Stream the VM's systemd journal as JSON to the given file + **-t**, **--tty** Allocate a pseudo-TTY for container diff --git a/docs/src/man/bcvk-libvirt-run.md b/docs/src/man/bcvk-libvirt-run.md index 1b833838f..720bc8fa3 100644 --- a/docs/src/man/bcvk-libvirt-run.md +++ b/docs/src/man/bcvk-libvirt-run.md @@ -158,6 +158,10 @@ Run a bootable container as a persistent VM Path to Ignition config file (JSON format) for first-boot provisioning +**--journal-output-file**=*PATH* + + Stream the VM's systemd journal to a file on the host. Stream the VM's systemd journal as JSON to the given file + # EXAMPLES diff --git a/docs/src/man/bcvk-to-disk.md b/docs/src/man/bcvk-to-disk.md index e824acf26..6cb2ae7cf 100644 --- a/docs/src/man/bcvk-to-disk.md +++ b/docs/src/man/bcvk-to-disk.md @@ -115,6 +115,20 @@ The installation process: Generate SSH keypair and inject via systemd credentials +**--output**=*OUTPUT* + + Select how VM output is presented + + Possible values: + - console + - journal + + Default: console + +**--journal-output-file**=*PATH* + + Stream the VM's systemd journal as JSON to the given file + **--install-log**=*INSTALL_LOG* Configure logging for `bootc install` by setting the `RUST_LOG` environment variable