diff options
| author | tv <tv@krebsco.de> | 2026-10-24 15:30:04 +0200 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-24 15:30:04 +0200 |
| commit | feb349ee09aa2151c421840bec521719c6149dcb (patch) | |
| tree | cd4a2709dfd3ee1fde3827be6c4daadff1f9faa8 | |
| parent | e5d590f4e927a18fbed334a3d90650015ed75106 (diff) | |
add API and CLI to print effective config
| -rw-r--r-- | src/api/mod.rs | 10 | ||||
| -rw-r--r-- | src/config/mod.rs | 90 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | src/state.rs | 3 |
4 files changed, 106 insertions, 2 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index 3233b16..ce1f6ae 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -9,7 +9,8 @@ mod stats; mod store; use axum::{ - Router, + Json, Router, + extract::State, http::{ HeaderValue, StatusCode, header::{CONTENT_TYPE, ETAG}, @@ -46,6 +47,7 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> ) .route("/admin/stats", get(stats::get_stats)) .route("/admin/document", get(document::get_document)) + .route("/admin/config", get(get_config)) .merge(crate::remake::router::<AppState>()); if debug_api { @@ -63,6 +65,12 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> .with_state(state) } +/// `GET /admin/config`: the effective configuration with each value's source; +/// imported values are left out. +async fn get_config(State(state): State<AppState>) -> Json<JsonValue> { + Json((*state.config).clone()) +} + // --- shared helpers ---------------------------------------------------------- pub(crate) fn store_status(error: StoreError, context: &str) -> StatusCode { diff --git a/src/config/mod.rs b/src/config/mod.rs index f042fb8..e62b2b6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -436,6 +436,7 @@ fn culprit(merged: &Layer) -> Option<(&String, &Entry)> { // --- command line -------------------------------------------------------------- const CONFIG: &str = "config"; +const PRINT_CONFIG: &str = "print-config"; fn command() -> Command { let mut command = Command::new("continuum") @@ -453,6 +454,12 @@ fn command() -> Command { .value_name("PATH") .action(ArgAction::Append) .help("Configuration file (.toml or .json); repeatable, later files win."), + ) + .arg( + Arg::new(PRINT_CONFIG) + .long(PRINT_CONFIG) + .action(ArgAction::SetTrue) + .help("Print the effective configuration as JSON (with each value's source) and exit."), ); for spec in OPTIONS { let help = match spec.default { @@ -529,12 +536,69 @@ pub(crate) struct Loaded { pub(crate) settings: Settings, pub(crate) origins: BTreeMap<String, Origin>, pub(crate) files: Vec<PathBuf>, + /// `--print-config`: print [`Loaded::report`] and exit. + pub(crate) print_only: bool, +} + +impl Loaded { + /// The effective configuration with each value's source, for `--print-config` and + /// `GET /admin/config`. Imported values are left out (they may be secret); their + /// importers are listed instead. + pub(crate) fn report(&self) -> JsonValue { + let values = match serde_json::to_value(&self.settings) { + Ok(JsonValue::Object(values)) => values, + _ => serde_json::Map::new(), + }; + let options: serde_json::Map<String, JsonValue> = OPTIONS + .iter() + .filter(|spec| !spec.hidden || self.origins.contains_key(spec.name)) + .map(|spec| { + let mut entry = serde_json::Map::new(); + match self.origins.get(spec.name) { + None => { + entry.insert("source".into(), "default".into()); + } + Some(origin) => { + match &origin.provenance { + Provenance::File(path) => { + entry.insert("source".into(), "file".into()); + entry.insert("file".into(), path.display().to_string().into()); + } + Provenance::Argument => { + entry.insert("source".into(), "argument".into()); + } + } + if origin.is_imported() { + entry.insert("imported".into(), origin.imports.clone().into()); + } + } + } + let imported = self.origins.get(spec.name).is_some_and(Origin::is_imported); + if !imported { + entry.insert( + "value".into(), + values.get(spec.name).cloned().unwrap_or(JsonValue::Null), + ); + } + (spec.name.to_owned(), JsonValue::Object(entry)) + }) + .collect(); + serde_json::json!({ + "files": self.files.iter().map(|path| path.display().to_string()).collect::<Vec<_>>(), + "options": options, + }) + } } /// Reads the command line and the configuration files it names. Exits on `--help`, /// `--version` and command-line syntax errors, like any command. pub(crate) fn load() -> Result<Loaded> { let matches = command().get_matches(); + let print_only = matches.get_flag(PRINT_CONFIG); + if print_only { + // Logs go to stdout too; the report says where each value came from anyway. + let _ = crate::logging::set_filter("off"); + } let files: Vec<PathBuf> = matches .get_many::<String>(CONFIG) .into_iter() @@ -568,7 +632,9 @@ pub(crate) fn load() -> Result<Loaded> { } merged.insert(name, entry); } - resolve(merged, files) + let mut loaded = resolve(merged, files)?; + loaded.print_only = print_only; + Ok(loaded) } fn resolve(merged: Layer, files: Vec<PathBuf>) -> Result<Loaded> { @@ -595,6 +661,7 @@ fn resolve(merged: Layer, files: Vec<PathBuf>) -> Result<Loaded> { .map(|(name, entry)| (name, entry.origin)) .collect(), files, + print_only: false, }) } @@ -651,6 +718,27 @@ mod tests { } #[test] + fn report_hides_imported_values() { + let mut values = layer(json!({ "htpasswd": "users", "alias": "secret-ish" })); + values.get_mut("alias").unwrap().origin.imports = vec!["env:HOSTNAME".into()]; + let report = resolve(values, Vec::new()).unwrap().report(); + let options = &report["options"]; + assert_eq!( + options["htpasswd"], + json!({ "source": "argument", "value": "users" }) + ); + assert_eq!( + options["alias"], + json!({ "source": "argument", "imported": ["env:HOSTNAME"] }) + ); + assert_eq!( + options["neighbours"], + json!({ "source": "default", "value": 12 }) + ); + assert!(options.get("debug-epoch").is_none()); + } + + #[test] fn text_values() { assert_eq!(from_text(Kind::Integer, " 30 ").unwrap(), json!(30)); assert!(from_text(Kind::Integer, "-1").is_err()); diff --git a/src/main.rs b/src/main.rs index f89cd13..5337f90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,10 @@ async fn main() -> Result<()> { logging::init(); let config = config::load()?; + if config.print_only { + println!("{}", serde_json::to_string_pretty(&config.report())?); + return Ok(()); + } let args = &config.settings; // Read before anything starts, so a bad key file fails right away. @@ -135,6 +139,7 @@ async fn main() -> Result<()> { neighbours_k: args.neighbours, alias: args.alias.clone(), electorate_window: Duration::from_secs(args.electorate_window_days * 24 * 3600), + config: Arc::new(config.report()), neighbours: Default::default(), pulling: Default::default(), rtt: Default::default(), diff --git a/src/state.rs b/src/state.rs index a47211f..df053a6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -597,6 +597,9 @@ pub(crate) struct AppState { pub(crate) alias: Option<String>, /// Members alive within this window vote on remakes (`--electorate-window-days`). pub(crate) electorate_window: Duration, + /// The effective configuration with sources, imported values left out + /// (`config::Loaded::report`), for `GET /admin/config`. + pub(crate) config: Arc<serde_json::Value>, /// Our own connectivity record and stability tier (`swarm::stability`). pub(crate) stability: Arc<std::sync::Mutex<crate::swarm::stability::Stability>>, /// Round-trip times to peers (`swarm::rtt`). |
