From 1bfc2fbd3c1fbc5f895e3e04a3e11d89b6305f99 Mon Sep 17 00:00:00 2001 From: BatmanByte <300328404+BatmanByte@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:48:14 +0800 Subject: [PATCH 1/7] feat(capture): arm durable init log capture before the container starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in capture of the container init process's output needs one thing to be true before the workload can produce a byte: a durable marker saying capture was active for this run. Without it a reader cannot tell "capture never started" from "capture started and its records were lost", and a caller who asked for logs would only discover the failure after their workload had already run. The guest writes `begin` to shared/containers/{cid}/output.log and fsyncs it inside Container.Init, so any failure — a symlinked path, a read-only share, a malformed run id — fails Init instead of surfacing later as a missing log. The file sits beside #988's exit.json rather than under the box's logs/, which holds host-written diagnostics a high-privilege guest must not be able to touch, and outside {root}/rootfs, so the workload cannot reach its own log. Nothing streams payload yet; that arrives with the capture sink. The workspace version moves to 0.9.8 because the guest version gates require it. Three of them now read (0, 9, 8) while the guest reports CARGO_PKG_VERSION, so on a 0.9.7 tree capabilities, nested virtualization, and capture all fail their own gate. That went unnoticed because the nested-virt test is opt-in and no integration test sets a non-empty capability set. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 25 +-- Cargo.toml | 14 +- sdks/node/src/options.rs | 2 + .../src/litebox/init/tasks/guest_init.rs | 15 ++ .../src/portal/interfaces/container.rs | 48 ++++- src/boxlite/src/runtime/options.rs | 53 +++++ src/boxlite/tests/log_capture.rs | 114 ++++++++++ src/guest/Cargo.toml | 1 + src/guest/src/capture.rs | 196 ++++++++++++++++++ src/guest/src/main.rs | 2 + src/guest/src/service/container.rs | 35 ++++ src/shared/proto/boxlite/v1/service.proto | 13 ++ src/shared/src/layout.rs | 29 +++ 13 files changed, 526 insertions(+), 21 deletions(-) create mode 100644 src/boxlite/tests/log_capture.rs create mode 100644 src/guest/src/capture.rs diff --git a/Cargo.lock b/Cargo.lock index 3efe35c96..f565e24c0 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,13 +659,14 @@ dependencies = [ [[package]] name = "boxlite-guest" -version = "0.9.7" +version = "0.9.8" dependencies = [ "async-stream", "async-trait", "base64 0.22.1", "boxlite-shared", "bytes", + "chrono", "clap", "futures", "libcontainer", @@ -691,7 +692,7 @@ dependencies = [ [[package]] name = "boxlite-node" -version = "0.9.7" +version = "0.9.8" dependencies = [ "boxlite", "boxlite-shared", @@ -705,7 +706,7 @@ dependencies = [ [[package]] name = "boxlite-python" -version = "0.9.7" +version = "0.9.8" dependencies = [ "boxlite", "futures", @@ -718,7 +719,7 @@ dependencies = [ [[package]] name = "boxlite-shared" -version = "0.9.7" +version = "0.9.8" dependencies = [ "proptest", "prost", @@ -734,7 +735,7 @@ dependencies = [ [[package]] name = "boxlite-shim" -version = "0.9.7" +version = "0.9.8" dependencies = [ "boxlite", "boxlite-shared", @@ -783,7 +784,7 @@ dependencies = [ [[package]] name = "bubblewrap-sys" -version = "0.9.7" +version = "0.9.8" dependencies = [ "num_cpus", ] @@ -1593,7 +1594,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "e2fsprogs-sys" -version = "0.9.7" +version = "0.9.8" dependencies = [ "num_cpus", ] @@ -2999,14 +3000,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/runtime/options.rs b/src/boxlite/src/runtime/options.rs index aa2531c0a..a7cd46f9e 100644 --- a/src/boxlite/src/runtime/options.rs +++ b/src/boxlite/src/runtime/options.rs @@ -366,6 +366,14 @@ pub struct BoxOptions { #[serde(default)] pub auto_delete: Option, + /// Capture the container init process's stdout and stderr to a durable log + /// the host can read after the box stops. + /// + /// 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 +545,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 +578,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 +590,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 +1580,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/Cargo.toml b/src/guest/Cargo.toml index 445e28e64..50e5cf4b4 100644 --- a/src/guest/Cargo.toml +++ b/src/guest/Cargo.toml @@ -17,6 +17,7 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "io-ut serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } bytes = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/guest/src/capture.rs b/src/guest/src/capture.rs new file mode 100644 index 000000000..d7067eb4d --- /dev/null +++ b/src/guest/src/capture.rs @@ -0,0 +1,196 @@ +//! 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 boxlite_shared::errors::{BoxliteError, BoxliteResult}; +use boxlite_shared::LogCapture; +use chrono::{SecondsFormat, Utc}; +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. + /// + /// The fsync 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. + /// + /// `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", error))?; + file.write_all(self.begin_record()?.as_bytes()) + .map_err(|error| self.io_error("write", error))?; + file.sync_all() + .map_err(|error| self.io_error("fsync", 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", + Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true) + )) + } + + fn io_error(&self, action: &str, error: std::io::Error) -> BoxliteError { + BoxliteError::Internal(format!( + "failed to {action} capture log {}: {error}", + self.log_path.display() + )) + } +} + +/// 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\"}}") + ); + } + + /// 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")); + } + + #[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] From 877e63501f091567a9c6f512b74f24b00899f745 Mon Sep 17 00:00:00 2001 From: BatmanByte <300328404+BatmanByte@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:03:29 +0800 Subject: [PATCH 2/7] fix(capture): fsync the log's parent directory, not just the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Syncing output.log persists its contents but not the directory entry naming it, and the barrier creates that entry on a box's first captured run. A host crash in the window after write_begin returned could therefore drop the whole file, leaving a log with no `begin` for a run that did have capture armed — the one ambiguity the fsync was there to rule out. The new test covers sync_parent's own error handling: both ways the directory sync can fail must surface rather than let the barrier report itself armed. It calls sync_parent 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. Nothing guards the call site itself, and whether the entry survives a crash needs fault injection to observe; neither is claimed here. io_error takes the path it is reporting on, so the directory failure reuses it instead of repeating its shape. Co-Authored-By: Claude Opus 5 --- src/guest/src/capture.rs | 58 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/guest/src/capture.rs b/src/guest/src/capture.rs index d7067eb4d..048655f49 100644 --- a/src/guest/src/capture.rs +++ b/src/guest/src/capture.rs @@ -62,11 +62,29 @@ impl Capture { .append(true) .custom_flags(nix::libc::O_NOFOLLOW) .open(&self.log_path) - .map_err(|error| self.io_error("open", error))?; + .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", error))?; + .map_err(|error| Self::io_error("write", &self.log_path, error))?; file.sync_all() - .map_err(|error| self.io_error("fsync", error)) + .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 { @@ -80,10 +98,10 @@ impl Capture { )) } - fn io_error(&self, action: &str, error: std::io::Error) -> BoxliteError { + fn io_error(action: &str, path: &std::path::Path, error: std::io::Error) -> BoxliteError { BoxliteError::Internal(format!( "failed to {action} capture log {}: {error}", - self.log_path.display() + path.display() )) } } @@ -170,6 +188,36 @@ mod tests { 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(); From d9836c95b19e09044c69334d44ca3f52e7a552ca Mon Sep 17 00:00:00 2001 From: BatmanByte <300328404+BatmanByte@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:27:12 +0800 Subject: [PATCH 3/7] fix(capture): refuse capture_logs on remote runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateBoxRequest carries no capture field, so a REST runtime accepted capture_logs=true, created the box with capture off, and returned success. The caller then learned its output was never recorded only after the workload had exited — the same failure the startup barrier exists to prevent, one layer up from the guest. Refused in validate_remote_box_options beside the existing local-only checks, so it fails before any network I/O rather than after a run. REST propagation is a later slice; until it lands, refusing beats silently dropping. Co-Authored-By: Claude Opus 5 --- src/boxlite/src/rest/runtime.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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(); From e524deb4561ede18bd49b1eaa7bafcbb460eaa2d Mon Sep 17 00:00:00 2001 From: BatmanByte <300328404+BatmanByte@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:36:47 +0800 Subject: [PATCH 4/7] docs(capture): name both syncs in write_begin's contract --- src/guest/src/capture.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/guest/src/capture.rs b/src/guest/src/capture.rs index 048655f49..f71b659c8 100644 --- a/src/guest/src/capture.rs +++ b/src/guest/src/capture.rs @@ -50,9 +50,10 @@ impl Capture { /// Put `begin` on disk, durably, before the container is allowed to run. /// - /// The fsync 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. + /// 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. From 569bdc3e9b71d558d7cd723ee56ddbbb1bff3579 Mon Sep 17 00:00:00 2001 From: BatmanByte <300328404+BatmanByte@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:01:36 +0800 Subject: [PATCH 5/7] docs(capture): state that capture_logs writes only begin so far The field promised stdout and stderr capture while this slice writes only the begin record, so a caller reading the doc would enable it and expect output that no code produces yet. The contract now says what it does. --- src/boxlite/src/runtime/options.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/boxlite/src/runtime/options.rs b/src/boxlite/src/runtime/options.rs index a7cd46f9e..7c42aa652 100644 --- a/src/boxlite/src/runtime/options.rs +++ b/src/boxlite/src/runtime/options.rs @@ -366,8 +366,13 @@ pub struct BoxOptions { #[serde(default)] pub auto_delete: Option, - /// Capture the container init process's stdout and stderr to a durable log - /// the host can read after the box stops. + /// 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. From 688f3b69c0dbd2bd1aa15e0d8badd7d354d1e8aa Mon Sep 17 00:00:00 2001 From: BatmanByte <300328404+BatmanByte@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:05:54 +0800 Subject: [PATCH 6/7] fix(capture): format timestamps without a date crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's Linux clippy lints the guest inside the workspace with --all-features, which overrides the narrow feature set the chrono dependency declared. That run began failing clippy::result_large_err on three pre-existing signatures in exec/output.rs and one in ssh/sftp.rs — files this branch never touched, and green on main minutes earlier. The macOS path lints the guest in isolation, so it never saw this. The guest needed a date crate for exactly one format! call, and it ships inside every VM image, so the timestamp is now built from SystemTime and Hinnant's civil_from_days. Six vectors pin it against values computed independently: epoch, leap days in both a leap and a non-leap century, a fraction that must keep its leading zeros, the last representable second, and a pre-epoch instant that has to borrow one. --- src/guest/Cargo.toml | 1 - src/guest/src/capture.rs | 94 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/guest/Cargo.toml b/src/guest/Cargo.toml index 50e5cf4b4..445e28e64 100644 --- a/src/guest/Cargo.toml +++ b/src/guest/Cargo.toml @@ -17,7 +17,6 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "io-ut serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" -chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } bytes = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/guest/src/capture.rs b/src/guest/src/capture.rs index f71b659c8..2c6843166 100644 --- a/src/guest/src/capture.rs +++ b/src/guest/src/capture.rs @@ -9,9 +9,10 @@ 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 chrono::{SecondsFormat, Utc}; use uuid::Uuid; /// Metadata rides a private stream name rather than `stdout`/`stderr` so a @@ -95,7 +96,7 @@ impl Capture { })?; Ok(format!( "{} {METADATA_STREAM} F {payload}\n", - Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true) + rfc3339_nanos(SystemTime::now()) )) } @@ -107,6 +108,66 @@ impl Capture { } } +/// 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)] @@ -166,6 +227,35 @@ mod tests { ); } + /// 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] From 794edb53a20725dd584ceb3c35f67bb0e2b4a55c Mon Sep 17 00:00:00 2001 From: BatmanByte <300328404+BatmanByte@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:16:59 +0800 Subject: [PATCH 7/7] fix(guest): drop chrono from the lockfile too The commit that removed the dependency from the manifest left it in Cargo.lock, so a --locked build would reject the tree as out of date. --- Cargo.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f565e24c0..2e706e27a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -666,7 +666,6 @@ dependencies = [ "base64 0.22.1", "boxlite-shared", "bytes", - "chrono", "clap", "futures", "libcontainer",