Skip to content

Commit d7f6a62

Browse files
committed
lib: Add --verbose flag to the CLI for providing a debug option
Signed-off-by: mabulgu <[email protected]>
1 parent 859bf9e commit d7f6a62

File tree

5 files changed

+226
-32
lines changed

5 files changed

+226
-32
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ bootc.tar.zst
55

66
# Added by cargo
77
/target
8+
9+
# IDEs / Editors
10+
.idea/

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ uuid = { version = "1.8.0", features = ["v4"] }
5454
tini = "1.3.0"
5555
comfy-table = "7.1.1"
5656
thiserror = { workspace = true }
57+
nix = "0.27.1"
5758

5859
[dev-dependencies]
5960
similar-asserts = { workspace = true }

lib/src/cli.rs

Lines changed: 180 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ impl TryFrom<ProgressOptions> for ProgressWriter {
5555
}
5656
}
5757

58+
/// Global args that apply to all subcommands
59+
#[derive(clap::Args, Debug, Clone, Copy, Default)]
60+
#[command(about = None, long_about = None)]
61+
pub(crate) struct GlobalArgs {
62+
/// Increase logging verbosity
63+
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
64+
pub(crate) verbose: u8, // Custom verbosity, counts occurrences of -v
65+
}
66+
5867
/// Perform an upgrade operation
5968
#[derive(Debug, Parser, PartialEq, Eq)]
6069
pub(crate) struct UpgradeOpts {
@@ -460,10 +469,19 @@ impl InternalsOpts {
460469
/// whether directly via `bootc install` (executed as part of a container)
461470
/// or via another mechanism such as an OS installer tool, further
462471
/// updates can be pulled and `bootc upgrade`.
463-
#[derive(Debug, Parser, PartialEq, Eq)]
472+
#[derive(Debug, Parser)]
464473
#[clap(name = "bootc")]
465474
#[clap(rename_all = "kebab-case")]
466475
#[clap(version,long_version=clap::crate_version!())]
476+
pub(crate) struct Cli {
477+
#[clap(flatten)]
478+
pub(crate) global_args: GlobalArgs,
479+
480+
#[clap(subcommand)]
481+
pub(crate) opt: Opt, // Wrap Opt inside Cli
482+
}
483+
484+
#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
467485
#[allow(clippy::large_enum_variant)]
468486
pub(crate) enum Opt {
469487
/// Download and queue an updated container image to apply.
@@ -988,7 +1006,7 @@ where
9881006
I: IntoIterator,
9891007
I::Item: Into<OsString> + Clone,
9901008
{
991-
run_from_opt(Opt::parse_including_static(args)).await
1009+
run_from_opt(Cli::parse_including_static(args).opt).await
9921010
}
9931011

9941012
/// Find the base binary name from argv0 (without a full path). The empty string
@@ -1003,7 +1021,7 @@ fn callname_from_argv0(argv0: &OsStr) -> &str {
10031021
.unwrap_or(default)
10041022
}
10051023

1006-
impl Opt {
1024+
impl Cli {
10071025
/// In some cases (e.g. systemd generator) we dispatch specifically on argv0. This
10081026
/// requires some special handling in clap.
10091027
fn parse_including_static<I>(args: I) -> Self
@@ -1027,13 +1045,26 @@ impl Opt {
10271045
};
10281046
if let Some(base_args) = mapped {
10291047
let base_args = base_args.iter().map(OsString::from);
1030-
return Opt::parse_from(base_args.chain(args.map(|i| i.into())));
1048+
return Cli::parse_from(base_args.chain(args.map(|i| i.into())));
10311049
}
10321050
Some(first)
10331051
} else {
10341052
None
10351053
};
1036-
Opt::parse_from(first.into_iter().chain(args.map(|i| i.into())))
1054+
// Parse CLI to extract verbosity level
1055+
let cli = Cli::parse_from(first.into_iter().chain(args.map(|i| i.into())));
1056+
1057+
// Set log level based on `-v` occurrences
1058+
let log_level = match cli.global_args.verbose {
1059+
0 => tracing::Level::WARN, // Default (no -v)
1060+
1 => tracing::Level::INFO, // -v
1061+
2 => tracing::Level::DEBUG, // -vv
1062+
_ => tracing::Level::TRACE, // -vvv or more
1063+
};
1064+
1065+
bootc_utils::update_tracing(log_level);
1066+
1067+
cli
10371068
}
10381069
}
10391070

@@ -1240,14 +1271,15 @@ mod tests {
12401271
#[test]
12411272
fn test_parse_install_args() {
12421273
// Verify we still process the legacy --target-no-signature-verification
1243-
let o = Opt::try_parse_from([
1274+
let o = Cli::try_parse_from([
12441275
"bootc",
12451276
"install",
12461277
"to-filesystem",
12471278
"--target-no-signature-verification",
12481279
"/target",
12491280
])
1250-
.unwrap();
1281+
.unwrap()
1282+
.opt;
12511283
let o = match o {
12521284
Opt::Install(InstallOpts::ToFilesystem(fsopts)) => fsopts,
12531285
o => panic!("Expected filesystem opts, not {o:?}"),
@@ -1264,7 +1296,7 @@ mod tests {
12641296
#[test]
12651297
fn test_parse_opts() {
12661298
assert!(matches!(
1267-
Opt::parse_including_static(["bootc", "status"]),
1299+
Cli::parse_including_static(["bootc", "status"]).opt,
12681300
Opt::Status(StatusOpts {
12691301
json: false,
12701302
format: None,
@@ -1273,7 +1305,7 @@ mod tests {
12731305
})
12741306
));
12751307
assert!(matches!(
1276-
Opt::parse_including_static(["bootc", "status", "--format-version=0"]),
1308+
Cli::parse_including_static(["bootc", "status", "--format-version=0"]).opt,
12771309
Opt::Status(StatusOpts {
12781310
format_version: Some(0),
12791311
..
@@ -1284,18 +1316,18 @@ mod tests {
12841316
#[test]
12851317
fn test_parse_generator() {
12861318
assert!(matches!(
1287-
Opt::parse_including_static([
1319+
Cli::parse_including_static([
12881320
"/usr/lib/systemd/system/bootc-systemd-generator",
12891321
"/run/systemd/system"
1290-
]),
1322+
]).opt,
12911323
Opt::Internals(InternalsOpts::SystemdGenerator { normal_dir, .. }) if normal_dir == "/run/systemd/system"
12921324
));
12931325
}
12941326

12951327
#[test]
12961328
fn test_parse_ostree_ext() {
12971329
assert!(matches!(
1298-
Opt::parse_including_static(["bootc", "internals", "ostree-container"]),
1330+
Cli::parse_including_static(["bootc", "internals", "ostree-container"]).opt,
12991331
Opt::Internals(InternalsOpts::OstreeContainer { .. })
13001332
));
13011333

@@ -1305,25 +1337,147 @@ mod tests {
13051337
o => panic!("unexpected {o:?}"),
13061338
}
13071339
}
1308-
let args = peel(Opt::parse_including_static([
1309-
"/usr/libexec/libostree/ext/ostree-ima-sign",
1310-
"ima-sign",
1311-
"--repo=foo",
1312-
"foo",
1313-
"bar",
1314-
"baz",
1315-
]));
1340+
let args = peel(
1341+
Cli::parse_including_static([
1342+
"/usr/libexec/libostree/ext/ostree-ima-sign",
1343+
"ima-sign",
1344+
"--repo=foo",
1345+
"foo",
1346+
"bar",
1347+
"baz",
1348+
])
1349+
.opt,
1350+
);
13161351
assert_eq!(
13171352
args.as_slice(),
13181353
["ima-sign", "--repo=foo", "foo", "bar", "baz"]
13191354
);
13201355

1321-
let args = peel(Opt::parse_including_static([
1322-
"/usr/libexec/libostree/ext/ostree-container",
1323-
"container",
1324-
"image",
1325-
"pull",
1326-
]));
1356+
let args = peel(
1357+
Cli::parse_including_static([
1358+
"/usr/libexec/libostree/ext/ostree-container",
1359+
"container",
1360+
"image",
1361+
"pull",
1362+
])
1363+
.opt,
1364+
);
13271365
assert_eq!(args.as_slice(), ["container", "image", "pull"]);
13281366
}
13291367
}
1368+
1369+
#[cfg(test)]
1370+
mod tracing_tests {
1371+
#![allow(unsafe_code)]
1372+
1373+
use bootc_utils::{initialize_tracing, update_tracing};
1374+
use nix::unistd::{close, dup, dup2, pipe};
1375+
use std::fs::File;
1376+
use std::io::{self, Read};
1377+
use std::os::unix::io::{AsRawFd, FromRawFd};
1378+
use std::sync::Once;
1379+
1380+
// Ensure logging is initialized once to prevent conflicts across tests
1381+
static INIT: Once = Once::new();
1382+
1383+
/// Helper function to initialize tracing for tests
1384+
fn init_tracing_for_tests() {
1385+
INIT.call_once(|| {
1386+
std::env::remove_var("RUST_LOG");
1387+
initialize_tracing();
1388+
});
1389+
}
1390+
1391+
/// Captures `stderr` output using a pipe
1392+
fn capture_stderr<F: FnOnce()>(test_fn: F) -> String {
1393+
let (read_fd, write_fd) = pipe().expect("Failed to create pipe");
1394+
1395+
// Duplicate original stderr
1396+
let original_stderr = dup(io::stderr().as_raw_fd()).expect("Failed to duplicate stderr");
1397+
1398+
// Redirect stderr to the write end of the pipe
1399+
dup2(write_fd, io::stderr().as_raw_fd()).expect("Failed to redirect stderr");
1400+
1401+
// Close the write end in the parent to prevent deadlocks
1402+
close(write_fd).expect("Failed to close write end");
1403+
1404+
// Run the test function that produces logs
1405+
test_fn();
1406+
1407+
// Restore original stderr
1408+
dup2(original_stderr, io::stderr().as_raw_fd()).expect("Failed to restore stderr");
1409+
close(original_stderr).expect("Failed to close original stderr");
1410+
1411+
// Read from the pipe
1412+
let mut buffer = String::new();
1413+
// File::from_raw_fd() is considered unsafe in Rust, as it takes ownership of a raw file descriptor.
1414+
// However, in this case, it's safe because we're using a valid file descriptor obtained from pipe().
1415+
let mut file = unsafe { File::from_raw_fd(read_fd) };
1416+
file.read_to_string(&mut buffer)
1417+
.expect("Failed to read from pipe");
1418+
1419+
buffer
1420+
}
1421+
1422+
#[test]
1423+
fn test_default_tracing() {
1424+
init_tracing_for_tests();
1425+
1426+
let output = capture_stderr(|| {
1427+
tracing::warn!("Test log message to stderr");
1428+
});
1429+
1430+
assert!(
1431+
output.contains("Test log message to stderr"),
1432+
"Expected log message not found in stderr"
1433+
);
1434+
}
1435+
1436+
#[test]
1437+
fn test_update_tracing() {
1438+
init_tracing_for_tests();
1439+
std::env::remove_var("RUST_LOG");
1440+
update_tracing(tracing::Level::TRACE);
1441+
1442+
let output = capture_stderr(|| {
1443+
tracing::info!("Info message to stderr");
1444+
tracing::debug!("Debug message to stderr");
1445+
tracing::trace!("Trace message to stderr");
1446+
});
1447+
1448+
assert!(
1449+
output.contains("Info message to stderr"),
1450+
"Expected INFO message not found"
1451+
);
1452+
assert!(
1453+
output.contains("Debug message to stderr"),
1454+
"Expected DEBUG message not found"
1455+
);
1456+
assert!(
1457+
output.contains("Trace message to stderr"),
1458+
"Expected TRACE message not found"
1459+
);
1460+
}
1461+
1462+
#[test]
1463+
fn test_update_tracing_respects_rust_log() {
1464+
init_tracing_for_tests();
1465+
// Set RUST_LOG before initializing(not possible in this test) or after updating tracing
1466+
std::env::set_var("RUST_LOG", "info");
1467+
update_tracing(tracing::Level::DEBUG);
1468+
1469+
let output = capture_stderr(|| {
1470+
tracing::info!("Info message to stderr");
1471+
tracing::debug!("Debug message to stderr");
1472+
});
1473+
1474+
assert!(
1475+
output.contains("Info message to stderr"),
1476+
"Expected INFO message not found"
1477+
);
1478+
assert!(
1479+
!output.contains("Debug message to stderr"),
1480+
"Expected DEBUG message found"
1481+
);
1482+
}
1483+
}

utils/src/tracing_util.rs

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,54 @@
11
//! Helpers related to tracing, used by main entrypoints
22
3+
use std::sync::{Arc, OnceLock};
4+
use tracing::Level;
5+
use tracing_subscriber::{
6+
filter::LevelFilter, fmt, layer::SubscriberExt, reload, EnvFilter, Registry,
7+
};
8+
9+
/// Global reload handle for dynamically updating log levels
10+
static TRACING_RELOAD_HANDLE: OnceLock<Arc<reload::Handle<EnvFilter, Registry>>> = OnceLock::new();
11+
312
/// Initialize tracing with the default configuration.
413
pub fn initialize_tracing() {
14+
// Create a reloadable EnvFilter: Use `RUST_LOG` if available, otherwise default to WARN
15+
let env_filter = EnvFilter::try_from_default_env()
16+
.unwrap_or_else(|_| EnvFilter::new(LevelFilter::WARN.to_string()));
17+
let (filter, reload_handle) = reload::Layer::new(env_filter);
18+
519
// Don't include timestamps and such because they're not really useful and
620
// too verbose, and plus several log targets such as journald will already
721
// include timestamps.
822
let format = tracing_subscriber::fmt::format()
923
.without_time()
1024
.with_target(false)
1125
.compact();
26+
27+
// Create a subscriber with a reloadable filter and formatted output
1228
// Log to stderr by default
13-
tracing_subscriber::fmt()
14-
.event_format(format)
15-
.with_writer(std::io::stderr)
16-
.with_max_level(tracing::Level::WARN)
17-
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
18-
.init();
29+
let subscriber = Registry::default().with(filter).with(
30+
fmt::layer()
31+
.event_format(format)
32+
.with_writer(std::io::stderr),
33+
);
34+
35+
// Set the subscriber globally
36+
tracing::subscriber::set_global_default(subscriber).expect("Failed to set tracing subscriber");
37+
38+
// Store the reload handle in a global OnceLock
39+
TRACING_RELOAD_HANDLE.set(Arc::new(reload_handle)).ok();
40+
}
41+
42+
/// Update tracing log level dynamically.
43+
pub fn update_tracing(log_level: Level) {
44+
if let Some(handle) = TRACING_RELOAD_HANDLE.get() {
45+
// Create new filter. Use `RUST_LOG` if available
46+
let new_filter = EnvFilter::try_from_default_env()
47+
.unwrap_or_else(|_| EnvFilter::new(log_level.to_string()));
48+
if let Err(e) = handle.modify(|filter| *filter = new_filter) {
49+
eprintln!("Failed to update log level: {}", e);
50+
}
51+
} else {
52+
eprintln!("Logging system not initialized yet.");
53+
}
1954
}

0 commit comments

Comments
 (0)