refactor(vm): the engine owns a LogLevel, not UI booleans

RunOptions carried quiet/verbose, which are flags, not engine state.
They are now a LogLevel enum (Quiet/Normal/Verbose) mapped onto
libkrun's filter; the CLI maps its flags onto it. The forked child's
last-gasp boot diagnostics also stop going straight to stderr: they
land in the console file the parent streams, with stderr kept for the
case where the console was never configured. lib.rs now states the
crate contract: no UI in the library, progress through the sink,
logging through the facade, typed errors out.
This commit is contained in:
Valentin Haudiquet committed 2026-10-28 00:43:56 +01:00
1 parent a27879dbe7
commit a91c1b6b51
4 files changed
+60 -23

No files matched your search

+11 -3
View File
@@ -14,9 +14,17 @@ keep whatever crossed the workspace share, throw the machine away.
The project is library-first like ecr: `crates/ecrou` is the library The project is library-first like ecr: `crates/ecrou` is the library
(package `ecrou`), `crates/ecrou-cli` is the thin front-end (binary (package `ecrou`), `crates/ecrou-cli` is the thin front-end (binary
`ecrou`) that parses flags, maps them onto the library API, and `ecrou`) that parses flags, maps them onto the library API, and
propagates exit codes. The library never writes to the terminal: propagates exit codes. The split is enforced by the dependency edges:
diagnostics go through the `log` facade, long operations report through the library links no clap and no rendering crate. It never writes to
the `Progress` sink, all output is the CLI's. the terminal: diagnostics go through the `log` facade, filtered by
`RunOptions::log_level` (an engine-owned enum, not UI booleans), long
operations report through the `Progress` sink, all output is the
CLI's. In-process failures come back as typed errors; the one
exception is the forked machine child's last-gasp diagnostics, which
land in the console file the parent streams. The CLI owns clap, the
indicatif rendering of progress events, the log sink, and exit codes.
A consumer embeds the library exactly the way the CLI does: a
`RootfsCache` to prepare a rootfs, then `run` to boot it.
The first consumer is rhema's sandbox seam, which shells out to the The first consumer is rhema's sandbox seam, which shells out to the
binary the way it shells out to `docker` and `lxc` today. The library binary the way it shells out to `docker` and `lxc` today. The library
+8 -3
View File
@@ -8,7 +8,7 @@ mod progress;
use clap::Parser; use clap::Parser;
use ecrou::vm::RunOptions; use ecrou::vm::RunOptions;
use ecrou::{NoProgress, Progress}; use ecrou::{LogLevel, NoProgress, Progress};
use cli::{parse_env, parse_share, Cli, Command, RunArgs}; use cli::{parse_env, parse_share, Cli, Command, RunArgs};
use progress::TermProgress; use progress::TermProgress;
@@ -28,8 +28,13 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
command: args.command.clone(), command: args.command.clone(),
memory: args.memory.clone(), memory: args.memory.clone(),
workdir: args.workdir.clone(), workdir: args.workdir.clone(),
quiet: args.quiet, log_level: if args.quiet {
verbose: cli_verbose, LogLevel::Quiet
} else if cli_verbose {
LogLevel::Verbose
} else {
LogLevel::Normal
},
no_cache: args.no_cache, no_cache: args.no_cache,
..RunOptions::default() ..RunOptions::default()
}; };
+11 -1
View File
@@ -20,6 +20,16 @@
//! ([`Error::KvmUnavailable`], [`Error::ArchNotAvailable`]) versus //! ([`Error::KvmUnavailable`], [`Error::ArchNotAvailable`]) versus
//! ordinary failures ([`Error::Other`], which preserves the full context //! ordinary failures ([`Error::Other`], which preserves the full context
//! chain). //! chain).
//!
//! ## Crate split
//!
//! `ecrou` is the library and carries no UI: progress flows through
//! the [`Progress`] sink, engine logging through the `log` facade
//! filtered by [`LogLevel`], and results come back as typed errors.
//! `ecrou-cli` owns everything a human sees: flags, the indicatif
//! rendering of progress events, the log sink, exit codes. A consumer
//! embeds the library exactly the way the CLI does - a [`RootfsCache`]
//! to prepare a rootfs, then [`run`] to boot it.
pub mod config; pub mod config;
pub mod distro; pub mod distro;
@@ -36,4 +46,4 @@ pub mod vm;
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use progress::{NoProgress, Progress, Unit}; pub use progress::{NoProgress, Progress, Unit};
pub use rootfs::{PrepareRequest, PreparedRootfs, RootfsCache}; pub use rootfs::{PrepareRequest, PreparedRootfs, RootfsCache};
pub use vm::{run, RunOptions, Share}; pub use vm::{run, LogLevel, RunOptions, Share};
+30 -16
View File
@@ -42,12 +42,25 @@ pub struct RunOptions {
pub workdir: String, pub workdir: String,
pub env: Vec<(String, String)>, pub env: Vec<(String, String)>,
pub hostname: String, pub hostname: String,
pub quiet: bool, /// How much of the engine's own logging reaches stderr
/// More library diagnostics on stderr pub log_level: LogLevel,
pub verbose: bool,
pub no_cache: bool, pub no_cache: bool,
} }
/// How much of the engine's own logging reaches stderr. The engine
/// logs through the `log` facade, so the consumer's sink decides where
/// lines land; this only picks the filter.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LogLevel {
/// Nothing from the engine; the consumer owns all output.
Quiet,
/// Warnings and errors. The default.
#[default]
Normal,
/// Engine info as well: lifecycle lines, boot chatter.
Verbose,
}
impl Default for RunOptions { impl Default for RunOptions {
fn default() -> Self { fn default() -> Self {
RunOptions { RunOptions {
@@ -60,18 +73,12 @@ impl Default for RunOptions {
workdir: "/".to_string(), workdir: "/".to_string(),
env: Vec::new(), env: Vec::new(),
hostname: String::new(), hostname: String::new(),
quiet: false, log_level: LogLevel::Normal,
verbose: false,
no_cache: false, no_cache: false,
} }
} }
} }
/// libkrun log levels (0 off, 1 error, 2 warn, 3 info, 4 debug).
const LOG_LEVEL_WARN: u32 = 2;
const LOG_LEVEL_INFO: u32 = 3;
const LOG_LEVEL_QUIET: u32 = 0;
/// Run one machine to completion and return the payload's exit code. /// Run one machine to completion and return the payload's exit code.
pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> { pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
if options.image.is_empty() { if options.image.is_empty() {
@@ -122,12 +129,10 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
); );
let _ = std::fs::remove_file(&exit_file_host); let _ = std::fs::remove_file(&exit_file_host);
krun.set_log_level(if options.quiet { krun.set_log_level(match options.log_level {
LOG_LEVEL_QUIET LogLevel::Quiet => 0, // off
} else if options.verbose { LogLevel::Normal => 2, // warn
LOG_LEVEL_INFO LogLevel::Verbose => 3, // info
} else {
LOG_LEVEL_WARN
})?; })?;
let ctx = krun.create_ctx()?; let ctx = krun.create_ctx()?;
krun.set_vm_config(ctx, vcpus, ram_mib)?; krun.set_vm_config(ctx, vcpus, ram_mib)?;
@@ -165,6 +170,15 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
// Safety: after fork the child runs no threads of its own; the // Safety: after fork the child runs no threads of its own; the
// prctl/exit pair has no unwinding preconditions. // prctl/exit pair has no unwinding preconditions.
let bail = |msg: String, code: i32| -> ! { let bail = |msg: String, code: i32| -> ! {
// The console file is the channel the parent streams: a
// boot failure belongs in it, next to whatever the guest
// printed. stderr carries the last-gasp case where the
// console was never configured; the child is process
// teardown, not library logging.
use std::io::Write as _;
if let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(&console_path) {
let _ = f.write_all(format!("ecrou: {msg}\n").as_bytes());
}
eprintln!("Error: {msg}"); eprintln!("Error: {msg}");
unsafe { libc::_exit(code) } unsafe { libc::_exit(code) }
}; };