diff --git a/Cargo.lock b/Cargo.lock index 3efe35c96..2e706e27a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "boxlite" -version = "0.9.7" +version = "0.9.8" dependencies = [ "anyhow", "async-stream", @@ -601,7 +601,7 @@ dependencies = [ [[package]] name = "boxlite-c" -version = "0.9.7" +version = "0.9.8" dependencies = [ "boxlite", "cbindgen", @@ -612,7 +612,7 @@ dependencies = [ [[package]] name = "boxlite-cli" -version = "0.9.7" +version = "0.9.8" dependencies = [ "anyhow", "assert_cmd", @@ -659,7 +659,7 @@ dependencies = [ [[package]] name = "boxlite-guest" -version = "0.9.7" +version = "0.9.8" dependencies = [ "async-stream", "async-trait", @@ -691,7 +691,7 @@ dependencies = [ [[package]] name = "boxlite-node" -version = "0.9.7" +version = "0.9.8" dependencies = [ "boxlite", "boxlite-shared", @@ -705,7 +705,7 @@ dependencies = [ [[package]] name = "boxlite-python" -version = "0.9.7" +version = "0.9.8" dependencies = [ "boxlite", "futures", @@ -718,7 +718,7 @@ dependencies = [ [[package]] name = "boxlite-shared" -version = "0.9.7" +version = "0.9.8" dependencies = [ "proptest", "prost", @@ -734,7 +734,7 @@ dependencies = [ [[package]] name = "boxlite-shim" -version = "0.9.7" +version = "0.9.8" dependencies = [ "boxlite", "boxlite-shared", @@ -783,7 +783,7 @@ dependencies = [ [[package]] name = "bubblewrap-sys" -version = "0.9.7" +version = "0.9.8" dependencies = [ "num_cpus", ] @@ -1593,7 +1593,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "e2fsprogs-sys" -version = "0.9.7" +version = "0.9.8" dependencies = [ "num_cpus", ] @@ -2999,14 +2999,14 @@ dependencies = [ [[package]] name = "libgvproxy-sys" -version = "0.9.7" +version = "0.9.8" dependencies = [ "libc", ] [[package]] name = "libkrun-sys" -version = "0.9.7" +version = "0.9.8" dependencies = [ "libc", "num_cpus", diff --git a/Cargo.toml b/Cargo.toml index 5d0c45b9a..bbd6e9a1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,12 +19,12 @@ exclude = ["build/tmp", "target", ".venv", "examples/*/.venv"] resolver = "2" [workspace.dependencies] -boxlite = { path = "src/boxlite", version = "0.9.7" } -boxlite-shared = { path = "src/shared", version = "0.9.7" } -bubblewrap-sys = { path = "src/deps/bubblewrap-sys", version = "0.9.7" } -e2fsprogs-sys = { path = "src/deps/e2fsprogs-sys", version = "0.9.7" } -libgvproxy-sys = { path = "src/deps/libgvproxy-sys", version = "0.9.7" } -libkrun-sys = { path = "src/deps/libkrun-sys", version = "0.9.7" } +boxlite = { path = "src/boxlite", version = "0.9.8" } +boxlite-shared = { path = "src/shared", version = "0.9.8" } +bubblewrap-sys = { path = "src/deps/bubblewrap-sys", version = "0.9.8" } +e2fsprogs-sys = { path = "src/deps/e2fsprogs-sys", version = "0.9.8" } +libgvproxy-sys = { path = "src/deps/libgvproxy-sys", version = "0.9.8" } +libkrun-sys = { path = "src/deps/libkrun-sys", version = "0.9.8" } [patch.crates-io] # Pull in youki PR #3504 (split intermediate/init readiness channels) which @@ -33,7 +33,7 @@ libkrun-sys = { path = "src/deps/libkrun-sys", version = "0.9.7" } libcontainer = { git = "https://github.com/youki-dev/youki", rev = "4b2f0e00a4a11107f3a338c21c17407d2f664ec9" } [workspace.package] -version = "0.9.7" +version = "0.9.8" edition = "2024" authors = ["Dorian Zheng "] license = "Apache-2.0" diff --git a/sdks/node/src/options.rs b/sdks/node/src/options.rs index a1438c666..41dc77040 100644 --- a/sdks/node/src/options.rs +++ b/sdks/node/src/options.rs @@ -503,6 +503,8 @@ impl TryFrom for BoxOptions { // client that attaches to the main command, which the SDKs cannot // do until they grow `attach()` (see sdk-run-semantics-api.md). tty: false, + // Capture stays off until the SDKs can also read the log back. + capture_logs: false, secrets, }) } diff --git a/src/boxlite/src/litebox/init/tasks/guest_init.rs b/src/boxlite/src/litebox/init/tasks/guest_init.rs index 53f191809..3ca5ce35f 100644 --- a/src/boxlite/src/litebox/init/tasks/guest_init.rs +++ b/src/boxlite/src/litebox/init/tasks/guest_init.rs @@ -35,6 +35,11 @@ const MIN_PRIVILEGED_CONTAINER_GUEST_VERSION: crate::portal::interfaces::guest:: /// with no `/dev/kvm` while the caller believes nesting was granted. const MIN_DEVICE_GUEST_VERSION: crate::portal::interfaces::guest::GuestVersion = (0, 9, 8); +/// Oldest guest release that honors `log_capture` on `Container.Init`. Same trap +/// again, and the one the startup barrier cannot cover: an unaware guest never +/// writes `begin`, so the caller would find no log and no failure either. +const MIN_LOG_CAPTURE_GUEST_VERSION: crate::portal::interfaces::guest::GuestVersion = (0, 9, 8); + pub struct GuestInitTask; struct GuestBootstrapConfig { @@ -97,6 +102,11 @@ impl PipelineTask for GuestInitTask { } else { Vec::new() }, + log_capture: ctx + .config + .options + .capture_logs + .then(|| uuid::Uuid::new_v4().to_string()), advanced: advanced.into(), }, }; @@ -157,6 +167,11 @@ async fn run_guest_init( .require_min_version(MIN_DEVICE_GUEST_VERSION) .await?; } + if bootstrap.container.log_capture.is_some() { + guest_interface + .require_min_version(MIN_LOG_CAPTURE_GUEST_VERSION) + .await?; + } guest_interface.init(bootstrap.guest).await?; tracing::info!("Guest initialized successfully"); diff --git a/src/boxlite/src/portal/interfaces/container.rs b/src/boxlite/src/portal/interfaces/container.rs index 6c9f96d24..b49140428 100644 --- a/src/boxlite/src/portal/interfaces/container.rs +++ b/src/boxlite/src/portal/interfaces/container.rs @@ -5,8 +5,8 @@ use boxlite_shared::{ ContainerAdvancedOptions as ProtoContainerAdvancedOptions, ContainerCapabilities as ProtoContainerCapabilities, ContainerClient, ContainerConfig as ProtoContainerConfig, ContainerDevice, ContainerInitErrorKind, - ContainerInitRequest, DiskRootfs, LinuxOptions, MergedRootfs, MountOptions, OverlayRootfs, - RootfsInit, container_init_response, + ContainerInitRequest, DiskRootfs, LinuxOptions, LogCapture, MergedRootfs, MountOptions, + OverlayRootfs, RootfsInit, container_init_response, }; use tonic::transport::Channel; @@ -90,6 +90,8 @@ pub struct ContainerInitConfig { pub tty: bool, /// Guest device nodes to reproduce inside the OCI workload. pub devices: Vec, + /// Run id when durable output capture is enabled; `None` disables capture. + pub log_capture: Option, pub advanced: ContainerAdvancedConfig, } @@ -136,6 +138,7 @@ impl ContainerInterface { ca_certs, tty, devices, + log_capture, advanced, } = config; @@ -191,6 +194,7 @@ impl ContainerInterface { rootfs = ?rootfs, mounts_count = proto_mounts.len(), device_count = devices.len(), + capture_logs = log_capture.is_some(), "Container configuration" ); @@ -205,6 +209,7 @@ impl ContainerInterface { // it sent, instead of both sides separately hard-coding it. execution_id: container_id.clone(), devices, + log_capture: log_capture.map(|run_id| LogCapture { run_id }), }; let response = self @@ -468,6 +473,7 @@ mod tests { destination: "/dev/kvm".to_string(), file_mode: Some(0o666), }], + log_capture: None, advanced: ContainerAdvancedConfig { capabilities: crate::runtime::advanced_options::ContainerCapabilities { add: vec!["ALL".into()], @@ -519,5 +525,43 @@ mod tests { assert_eq!(mount.source, "/sys"); assert_eq!(mount.destination, "/sys"); assert!(!mount.options.contains(&"rro".to_string())); + assert!( + request.log_capture.is_none(), + "capture must stay off unless asked for" + ); + } + + /// The run id is what tells one run's records from another's in a log file + /// that outlives the VM, so it has to survive the crossing verbatim. + #[tokio::test] + async fn container_init_forwards_the_capture_run_id() { + let seen = Arc::new(Mutex::new(None)); + let mut iface = interface_recording(StartReply::Success, Arc::clone(&seen)).await; + let run_id = "b3f1c0a4-7d2e-4a91-8c55-0e6f2ab41d90"; + + iface + .init(ContainerInitConfig { + container_id: "container-1".to_string(), + image: crate::images::ContainerImageConfig::default(), + rootfs: ContainerRootfsInitConfig::Merged, + mounts: Vec::new(), + ca_certs: Vec::new(), + tty: false, + devices: Vec::new(), + log_capture: Some(run_id.to_string()), + advanced: ContainerAdvancedConfig { + capabilities: Default::default(), + linux: Default::default(), + mount: Default::default(), + }, + }) + .await + .unwrap(); + + let request = seen.lock().unwrap().take().expect("guest saw Init"); + assert_eq!( + request.log_capture.expect("capture requested").run_id, + run_id + ); } } diff --git a/src/boxlite/src/rest/runtime.rs b/src/boxlite/src/rest/runtime.rs index a3db5574f..8df728931 100644 --- a/src/boxlite/src/rest/runtime.rs +++ b/src/boxlite/src/rest/runtime.rs @@ -32,6 +32,18 @@ impl RestRuntime { } fn validate_remote_box_options(options: &BoxOptions) -> BoxliteResult<()> { + // The create request carries no capture field yet, so a server would accept + // this and run the workload with capture off. Refusing here keeps the + // promise the option makes: a caller learns capture is unavailable before + // their workload runs, not after it has already produced the output. + if options.capture_logs { + return Err(BoxliteError::Unsupported( + "capture_logs is local-only for now: remote runtimes cannot yet carry it, \ + and a silently uncaptured run is worse than a refused one." + .to_string(), + )); + } + if options.ports.is_empty() { return Ok(()); } @@ -504,6 +516,27 @@ mod tests { assert!(error.to_string().contains("local runtime")); } + /// The request has no capture field, so accepting this would hand back a box + /// that runs the workload with capture off and reports success — the failure + /// the startup barrier exists to make impossible, one layer up. + #[tokio::test] + async fn create_rejects_capture_logs_in_rest_mode() { + let options = BoxliteRestOptions::new("http://localhost:1"); + let runtime = RestRuntime::new(&options).expect("failed to create REST runtime"); + let box_options = BoxOptions { + capture_logs: true, + ..Default::default() + }; + + let error = RuntimeBackend::create(&runtime, box_options, None) + .await + .err() + .expect("REST capture_logs must be rejected before network I/O"); + + assert!(matches!(error, BoxliteError::Unsupported(_))); + assert!(error.to_string().contains("capture_logs"), "{error}"); + } + #[tokio::test] async fn get_or_create_rejects_custom_kernel_for_rest_runtime() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/boxlite/src/runtime/options.rs b/src/boxlite/src/runtime/options.rs index aa2531c0a..7c42aa652 100644 --- a/src/boxlite/src/runtime/options.rs +++ b/src/boxlite/src/runtime/options.rs @@ -366,6 +366,19 @@ pub struct BoxOptions { #[serde(default)] pub auto_delete: Option, + /// Capture the container init process's output to a durable log the host + /// can read after the box stops. + /// + /// Only the `begin` record is written so far: enough to prove capture was + /// armed for a run, and to fail `Container.Init` when it cannot be, but not + /// the output itself. Enabling this today yields a log a reader classifies + /// as interrupted, holding no stdout or stderr. + /// + /// Incompatible with remove-on-stop: removal deletes the box directory the + /// log lives in. + #[serde(default)] + pub capture_logs: bool, + /// Whether the box should automatically resume when accessed after AutoStop. /// `None` lets the runtime/server pick its default (typically `true`). #[serde(default)] @@ -537,6 +550,7 @@ impl Default for BoxOptions { auto_remove: default_auto_remove(), auto_stop: None, auto_delete: None, + capture_logs: false, auto_resume: None, detach: default_detach(), advanced: AdvancedBoxOptions::default(), @@ -569,6 +583,7 @@ impl BoxOptions { /// Validates option combinations: /// - effective remove-on-stop (`auto_delete>0`, or deprecated `auto_remove`) /// with `detach=true` is invalid + /// - `capture_logs=true` with effective remove-on-stop is invalid /// - `advanced.isolate_mounts=true` is only supported on Linux /// - `advanced.capabilities` contains well-formed Linux capability names pub(crate) fn sanitize_common(&self) -> BoxliteResult<()> { @@ -580,6 +595,14 @@ impl BoxOptions { )); } + if self.capture_logs && self.removes_on_stop() { + return Err(boxlite_shared::errors::BoxliteError::Config( + "capture_logs is incompatible with remove-on-stop: removing the box deletes the \ + captured log. Use auto_delete=0 (or deprecated auto_remove=false) to keep it." + .to_string(), + )); + } + #[cfg(not(target_os = "linux"))] if self.advanced.isolate_mounts { return Err(boxlite_shared::errors::BoxliteError::Unsupported( @@ -1562,6 +1585,41 @@ mod tests { assert!(err_msg.contains("incompatible")); } + /// Removal deletes the box directory the captured log lives in, so honoring + /// both would mean silently dropping one of them. + #[test] + fn capture_logs_is_rejected_with_remove_on_stop() { + let opts = BoxOptions { + capture_logs: true, + auto_delete: Some(1), + ..Default::default() + }; + let err_msg = opts.sanitize().unwrap_err().to_string(); + assert!(err_msg.contains("capture_logs"), "{err_msg}"); + assert!(err_msg.contains("incompatible"), "{err_msg}"); + + let kept = BoxOptions { + capture_logs: true, + auto_delete: Some(0), + ..Default::default() + }; + assert!(kept.sanitize().is_ok()); + } + + /// The deprecated flag defaults to removing on stop, so capture must trip on + /// it too rather than only on explicit `auto_delete`. + #[test] + #[allow(deprecated)] + fn capture_logs_is_rejected_with_deprecated_auto_remove() { + let opts = BoxOptions { + capture_logs: true, + auto_remove: true, + auto_delete: None, + ..Default::default() + }; + assert!(opts.sanitize().is_err()); + } + #[test] fn test_sanitize_valid_combinations() { let remove = BoxOptions { diff --git a/src/boxlite/tests/log_capture.rs b/src/boxlite/tests/log_capture.rs new file mode 100644 index 000000000..c6e466ad8 --- /dev/null +++ b/src/boxlite/tests/log_capture.rs @@ -0,0 +1,114 @@ +//! Integration tests for the durable-capture startup barrier. +//! +//! The barrier is the part of capture that must hold before the workload runs: +//! `begin` on disk, fsynced, or `Container.Init` fails. These tests exercise it +//! against a real guest rather than a mock, because the record's path is derived +//! independently on both sides of the VM boundary. + +mod common; + +use boxlite::BoxliteRuntime; +use boxlite::runtime::options::{BoxOptions, BoxliteOptions}; +use std::path::{Path, PathBuf}; + +/// Locate the captured log without asking the host for the container id: the +/// guest derives this path from its own mount, so a test that reconstructs it +/// from host-side knowledge would pass even if the two sides disagreed. +fn captured_log(home_dir: &Path, box_id: &str) -> Option { + let containers = home_dir + .join("boxes") + .join(box_id) + .join("shared") + .join("containers"); + for entry in std::fs::read_dir(containers).ok()?.flatten() { + let candidate = entry.path().join("output.log"); + if candidate.exists() { + return Some(candidate); + } + } + None +} + +#[tokio::test] +async fn capture_arms_a_begin_record_the_host_can_read() { + let home = boxlite_test_utils::home::PerTestBoxHome::new(); + let runtime = BoxliteRuntime::new(BoxliteOptions { + home_dir: home.path.clone(), + image_registries: common::test_registries(), + }) + .expect("create runtime"); + + let handle = runtime + .create( + BoxOptions { + capture_logs: true, + cmd: Some(vec!["sleep".into(), "300".into()]), + ..common::alpine_opts() + }, + None, + ) + .await + .unwrap(); + + // Starting is what reaches `Container.Init`, and the barrier runs there. + handle.start().await.expect("start box"); + + let log = captured_log(&home.path, handle.id().as_str()) + .expect("capture must create output.log beside the container's exit file"); + let contents = std::fs::read_to_string(&log).expect("read captured log"); + + // One line and nothing more: this slice arms capture, it does not yet stream + // payload, so anything else here means the writer landed early or `begin` + // was emitted twice. + let lines: Vec<&str> = contents.lines().collect(); + assert_eq!(lines.len(), 1, "expected only begin, got {contents:?}"); + + let (timestamp, rest) = lines[0].split_once(' ').expect("timestamped record"); + assert!( + timestamp.ends_with('Z') && timestamp.contains('.'), + "not RFC3339 with fractional seconds: {timestamp:?}" + ); + let payload = rest + .strip_prefix("boxlite F ") + .expect("metadata rides the private boxlite stream, full frame"); + let record: serde_json::Value = serde_json::from_str(payload).expect("metadata is JSON"); + assert_eq!(record["event"], "begin"); + assert!( + uuid::Uuid::parse_str(record["run"].as_str().expect("run id is a string")).is_ok(), + "run id must be the host's UUID: {record:?}" + ); + + runtime.remove(handle.id().as_str(), true).await.unwrap(); +} + +/// Capture off must leave nothing behind — the file's absence is what tells a +/// reader "never captured" apart from "captured and lost". +#[tokio::test] +async fn no_log_is_created_when_capture_is_off() { + let home = boxlite_test_utils::home::PerTestBoxHome::new(); + let runtime = BoxliteRuntime::new(BoxliteOptions { + home_dir: home.path.clone(), + image_registries: common::test_registries(), + }) + .expect("create runtime"); + + let handle = runtime + .create( + BoxOptions { + cmd: Some(vec!["sleep".into(), "300".into()]), + ..common::alpine_opts() + }, + None, + ) + .await + .unwrap(); + + handle.start().await.expect("start box"); + + assert!( + captured_log(&home.path, handle.id().as_str()).is_none(), + "capture was not requested, so no log may exist" + ); + + runtime.remove(handle.id().as_str(), true).await.unwrap(); +} diff --git a/src/guest/src/capture.rs b/src/guest/src/capture.rs new file mode 100644 index 000000000..2c6843166 --- /dev/null +++ b/src/guest/src/capture.rs @@ -0,0 +1,335 @@ +//! Durable capture of the container init process's output. +//! +//! Only the startup barrier lives here so far: the `begin` record that must be +//! on disk before the workload can produce a byte. Nothing yet streams payload +//! into the same file. + +use std::fs::OpenOptions; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::PathBuf; + +use std::time::{SystemTime, UNIX_EPOCH}; + +use boxlite_shared::errors::{BoxliteError, BoxliteResult}; +use boxlite_shared::LogCapture; +use uuid::Uuid; + +/// Metadata rides a private stream name rather than `stdout`/`stderr` so a +/// workload cannot forge it by printing matching text. +const METADATA_STREAM: &str = "boxlite"; + +/// Capture state for one `Container.Init` attempt. +#[derive(Debug)] +pub(crate) struct Capture { + run_id: Uuid, + log_path: PathBuf, +} + +impl Capture { + /// Parse the host's capture request, rejecting a malformed run id here at + /// the gRPC boundary rather than carrying an unvalidated string inward. + pub(crate) fn from_request( + log_capture: Option, + log_path: PathBuf, + ) -> BoxliteResult> { + let Some(log_capture) = log_capture else { + return Ok(None); + }; + let run_id = Uuid::parse_str(&log_capture.run_id).map_err(|error| { + BoxliteError::Config(format!( + "log_capture.run_id must be a UUID, got {:?}: {error}", + log_capture.run_id + )) + })?; + Ok(Some(Self { run_id, log_path })) + } + + pub(crate) fn run_id(&self) -> Uuid { + self.run_id + } + + /// Put `begin` on disk, durably, before the container is allowed to run. + /// + /// Syncing is what lets a reader tell "capture never started" from "capture + /// started and its record was lost": once this returns, a file without + /// `begin` can only mean the former. Both syncs are needed for that — the + /// file's for the record, the parent directory's for the entry naming it. + /// + /// `O_NOFOLLOW` applies to the final component, so a symlink planted at the + /// log path fails the call instead of redirecting the write. + pub(crate) fn write_begin(&self) -> BoxliteResult<()> { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(&self.log_path) + .map_err(|error| Self::io_error("open", &self.log_path, error))?; + file.write_all(self.begin_record()?.as_bytes()) + .map_err(|error| Self::io_error("write", &self.log_path, error))?; + file.sync_all() + .map_err(|error| Self::io_error("fsync", &self.log_path, error))?; + self.sync_parent() + } + + /// Syncing the file persists its contents, not the directory entry naming + /// it. On a first run `output.log` is newly created, so without this a host + /// crash can drop the whole file after `write_begin` returned success — + /// producing exactly the ambiguity the barrier exists to rule out, a log + /// with no `begin` that did have capture armed. + fn sync_parent(&self) -> BoxliteResult<()> { + let parent = self.log_path.parent().ok_or_else(|| { + BoxliteError::Internal(format!( + "capture log path has no parent directory: {}", + self.log_path.display() + )) + })?; + std::fs::File::open(parent) + .and_then(|dir| dir.sync_all()) + .map_err(|error| Self::io_error("fsync directory", parent, error)) + } + + fn begin_record(&self) -> BoxliteResult { + let payload = serde_json::to_string(&MetadataRecord { + run: self.run_id.to_string(), + event: "begin", + })?; + Ok(format!( + "{} {METADATA_STREAM} F {payload}\n", + rfc3339_nanos(SystemTime::now()) + )) + } + + fn io_error(action: &str, path: &std::path::Path, error: std::io::Error) -> BoxliteError { + BoxliteError::Internal(format!( + "failed to {action} capture log {}: {error}", + path.display() + )) + } +} + +/// Format a UTC instant as RFC3339 with nine fractional digits, the shape CRI +/// readers expect. +/// +/// Hand-rolled rather than pulled from a date crate: this is the guest binary, +/// which ships inside every VM image, and one `format!` plus the civil-date +/// arithmetic below is the whole requirement. +fn rfc3339_nanos(at: SystemTime) -> String { + // A clock before the epoch means a broken VM, but emitting a wrong + // timestamp into a durability record is worse than carrying four lines to + // handle it: borrow a second so the fraction stays positive, the same + // representation `Duration` uses going forward. + let (secs, nanos) = match at.duration_since(UNIX_EPOCH) { + Ok(since) => (since.as_secs() as i64, since.subsec_nanos()), + Err(before) => { + let ago = before.duration(); + match ago.subsec_nanos() { + 0 => (-(ago.as_secs() as i64), 0), + frac => (-(ago.as_secs() as i64) - 1, 1_000_000_000 - frac), + } + } + }; + + let days = secs.div_euclid(86_400); + let time_of_day = secs.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{nanos:09}Z", + time_of_day / 3_600, + (time_of_day % 3_600) / 60, + time_of_day % 60, + ) +} + +/// Days since 1970-01-01 to a proleptic Gregorian date. +/// +/// Hinnant's `civil_from_days`: shifting the era to start in March puts the +/// leap day last, which is what lets the month-length sequence be arithmetic +/// instead of a table. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let shifted = days + 719_468; + let era = if shifted >= 0 { + shifted + } else { + shifted - 146_096 + } / 146_097; + let day_of_era = shifted - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_shifted = (5 * day_of_year + 2) / 153; + let day = (day_of_year - (153 * month_shifted + 2) / 5 + 1) as u32; + let month = if month_shifted < 10 { + month_shifted + 3 + } else { + month_shifted - 9 + } as u32; + let year = year_of_era + era * 400 + i64::from(month <= 2); + (year, month, day) +} + +/// Serialized rather than built with `json!` so field order is the struct's, +/// matching the order the format documents. +#[derive(serde::Serialize)] +struct MetadataRecord<'a> { + run: String, + event: &'a str, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(run_id: &str) -> Option { + Some(LogCapture { + run_id: run_id.to_string(), + }) + } + + #[test] + fn absent_log_capture_disables_capture() { + let capture = Capture::from_request(None, PathBuf::from("/nonexistent")).unwrap(); + assert!(capture.is_none()); + } + + #[test] + fn malformed_run_id_is_rejected() { + for bad in ["", "not-a-uuid", "b3f1c0a4-7d2e-4a91-8c55"] { + let error = Capture::from_request(request(bad), PathBuf::from("/nonexistent")) + .expect_err("a non-UUID run id must fail Init"); + assert!( + matches!(error, BoxliteError::Config(_)), + "expected Config error for {bad:?}, got {error:?}" + ); + } + } + + #[test] + fn begin_record_carries_the_run_id_on_the_private_stream() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("output.log"); + let run_id = "b3f1c0a4-7d2e-4a91-8c55-0e6f2ab41d90"; + let capture = Capture::from_request(request(run_id), path.clone()) + .unwrap() + .unwrap(); + + capture.write_begin().unwrap(); + + let written = std::fs::read_to_string(&path).unwrap(); + let (timestamp, rest) = written.trim_end().split_once(' ').unwrap(); + assert!( + timestamp.ends_with('Z') && timestamp.contains('.'), + "timestamp must be RFC3339 with fractional seconds, got {timestamp:?}" + ); + assert_eq!( + rest, + format!("boxlite F {{\"run\":\"{run_id}\",\"event\":\"begin\"}}") + ); + } + + /// Vectors verified against an independent implementation rather than + /// against this code: epoch, a leap day in a century that is a leap year + /// and one that is not, a fraction that must keep its leading zeros, and a + /// pre-epoch instant that has to borrow a second. + #[test] + fn rfc3339_matches_known_instants() { + for (secs, nanos, expected) in [ + (0i64, 0u32, "1970-01-01T00:00:00.000000000Z"), + (1_700_000_000, 123_456_789, "2023-11-14T22:13:20.123456789Z"), + (1_709_164_800, 0, "2024-02-29T00:00:00.000000000Z"), + (951_782_400, 1, "2000-02-29T00:00:00.000000001Z"), + ( + 253_402_300_799, + 999_999_999, + "9999-12-31T23:59:59.999999999Z", + ), + (-1, 500_000_000, "1969-12-31T23:59:59.500000000Z"), + ] { + let at = if secs >= 0 { + UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos) + } else { + let ago = (-secs) as u64 - u64::from(nanos > 0); + let frac = if nanos > 0 { 1_000_000_000 - nanos } else { 0 }; + UNIX_EPOCH - std::time::Duration::new(ago, frac) + }; + assert_eq!(rfc3339_nanos(at), expected, "secs={secs} nanos={nanos}"); + } + } + + /// The same file spans VM restarts, so a second run must add its own `begin` + /// rather than replace the first one's. + #[test] + fn a_second_run_appends_instead_of_truncating() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("output.log"); + for run in [ + "b3f1c0a4-7d2e-4a91-8c55-0e6f2ab41d90", + "c4e2d1b5-8e3f-4b02-9d66-1f7a3bc52ea1", + ] { + Capture::from_request(request(run), path.clone()) + .unwrap() + .unwrap() + .write_begin() + .unwrap(); + } + + let written = std::fs::read_to_string(&path).unwrap(); + assert_eq!(written.lines().count(), 2); + assert!(written.contains("b3f1c0a4-7d2e-4a91-8c55-0e6f2ab41d90")); + assert!(written.contains("c4e2d1b5-8e3f-4b02-9d66-1f7a3bc52ea1")); + } + + /// Whether the entry survives a crash needs fault injection to observe, so + /// this covers what is observable: both ways the directory sync can fail must + /// surface as errors rather than let the barrier report itself armed. Called + /// directly because reaching it through `write_begin` is impossible — a parent + /// that cannot be opened cannot be traversed either, so the log's own `open` + /// fails first and the sync never runs. + #[test] + fn a_failed_directory_sync_is_reported() { + for (log_path, why) in [ + ("/", "root has no parent to sync"), + ( + "/nonexistent-boxlite-capture-dir/output.log", + "an absent parent cannot be synced", + ), + ] { + let capture = Capture::from_request( + request("b3f1c0a4-7d2e-4a91-8c55-0e6f2ab41d90"), + PathBuf::from(log_path), + ) + .unwrap() + .unwrap(); + + let error = capture.sync_parent().expect_err(why); + assert!( + matches!(error, BoxliteError::Internal(_)), + "{why}: {error:?}" + ); + } + } + + #[test] + fn a_symlinked_log_path_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("target.log"); + let link = dir.path().join("output.log"); + std::fs::write(&target, b"").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let error = Capture::from_request( + request("b3f1c0a4-7d2e-4a91-8c55-0e6f2ab41d90"), + link.clone(), + ) + .unwrap() + .unwrap() + .write_begin() + .expect_err("O_NOFOLLOW must refuse a symlinked log path"); + + assert!(matches!(error, BoxliteError::Internal(_)), "{error:?}"); + assert!( + std::fs::read(&target).unwrap().is_empty(), + "the symlink target must not have been written through" + ); + } +} diff --git a/src/guest/src/main.rs b/src/guest/src/main.rs index e3e0ffb5f..82b6b7db8 100644 --- a/src/guest/src/main.rs +++ b/src/guest/src/main.rs @@ -4,6 +4,8 @@ compile_error!("BoxLite guest is Linux-only; build with a Linux target"); #[cfg(target_os = "linux")] +mod capture; + mod ca_trust; #[cfg(target_os = "linux")] mod container; diff --git a/src/guest/src/service/container.rs b/src/guest/src/service/container.rs index d2a070ccb..efb33aae6 100644 --- a/src/guest/src/service/container.rs +++ b/src/guest/src/service/container.rs @@ -17,6 +17,7 @@ use nix::mount::{mount, MsFlags}; use tonic::{Request, Response, Status}; use tracing::{debug, error, info, warn}; +use crate::capture::Capture; use crate::container::{ validate_mount_override, CapabilitySet, Container, ContainerDevices, MountOverride, UserMount, }; @@ -171,6 +172,24 @@ impl ContainerService for GuestServer { } }; + // Same reason as devices: a bad run id is caller input, so reject it + // before anything on disk changes. + let capture = match Capture::from_request( + init_req.log_capture, + self.layout.shared().container(&container_id).output_log(), + ) { + Ok(capture) => capture, + Err(error) => { + error!("Invalid log capture request: {error}"); + return Ok(Response::new(ContainerInitResponse { + result: Some(container_init_response::Result::Error(init_error( + "Invalid log capture request", + &error, + ))), + })); + } + }; + // Extract container config let config = init_req .container_config @@ -336,6 +355,22 @@ impl ContainerService for GuestServer { } } + // The barrier: `begin` is on disk and fsynced before the container is + // created, so a caller who asked for capture learns here that it is + // impossible, rather than after the workload has already run. + if let Some(capture) = &capture { + if let Err(error) = capture.write_begin() { + error!(run_id = %capture.run_id(), "Failed to arm log capture: {error}"); + return Ok(Response::new(ContainerInitResponse { + result: Some(container_init_response::Result::Error(init_error( + "Failed to arm log capture", + &error, + ))), + })); + } + info!(run_id = %capture.run_id(), "Log capture armed"); + } + // Start container using OCI bundle rootfs. Init is the box's main // command (docker semantics) and may exit on its own; its stdio is // pipe-based so the session registered below can stream it, and the diff --git a/src/shared/proto/boxlite/v1/service.proto b/src/shared/proto/boxlite/v1/service.proto index a366c7438..5296fe9c9 100644 --- a/src/shared/proto/boxlite/v1/service.proto +++ b/src/shared/proto/boxlite/v1/service.proto @@ -269,6 +269,17 @@ message ContainerDevice { optional uint32 file_mode = 3; } +// Opt-in durable capture of the container init process's stdout and stderr. +// +// Presence enables capture; an absent message disables it. The guest derives +// the log path from its own shared mount, so no path crosses this boundary. +message LogCapture { + // Host-generated UUID identifying this Container.Init attempt. The log file + // outlives VM restarts, so runs are told apart by this id rather than by any + // guest-side counter. + string run_id = 1; +} + message ContainerInitRequest { // Container ID (generated by host, used for paths and libcontainer state) string container_id = 1; @@ -291,6 +302,8 @@ message ContainerInitRequest { // Device nodes to reproduce from the guest VM inside the OCI workload. repeated ContainerDevice devices = 7; + + optional LogCapture log_capture = 8; } message ContainerStartRequest { diff --git a/src/shared/src/layout.rs b/src/shared/src/layout.rs index ba73be39f..11799a741 100644 --- a/src/shared/src/layout.rs +++ b/src/shared/src/layout.rs @@ -135,6 +135,20 @@ impl SharedContainerLayout { self.root.join("exit.json") } + /// This container's captured init output: {root}/output.log — written by + /// the guest, read by the host after the box stops. + /// + /// Beside [`Self::exit_file`] rather than under the box's `logs/`, which + /// holds host-written diagnostics the guest must not be able to touch. The + /// container's rootfs is `{root}/rootfs`, so this file is outside the + /// workload's own filesystem view. + /// + /// Unlike `exit.json` this is *not* scoped to one run: the file survives VM + /// restarts and each run identifies its records by run id. + pub fn output_log(&self) -> PathBuf { + self.root.join("output.log") + } + /// Overlayfs directory: {root}/overlayfs pub fn overlayfs_dir(&self) -> PathBuf { self.root.join(dirs::OVERLAYFS) @@ -295,6 +309,21 @@ mod tests { assert_eq!(parsed, ExitRecord { exit_code: 137 }); } + /// The guest writes this path and the host reads it, so both sides must + /// derive the same one from the container root — and it must stay outside + /// `rootfs/`, which the workload itself can reach. + #[test] + fn output_log_sits_beside_the_exit_file_and_outside_the_rootfs() { + let layout = SharedContainerLayout::new("/run/boxlite/shared/containers/cid"); + + assert_eq!( + layout.output_log(), + std::path::Path::new("/run/boxlite/shared/containers/cid/output.log") + ); + assert_eq!(layout.output_log().parent(), layout.exit_file().parent()); + assert!(!layout.output_log().starts_with(layout.rootfs_dir())); + } + /// Absent file is the "still running" signal, and must not be confused /// with a container that exited — hence `Option`, not a default. #[test]