Add safe configuration commands

This commit is contained in:
Eric Wendland 2026-07-18 16:15:13 +02:00
commit 443602a30b
7 changed files with 432 additions and 3 deletions

View file

@ -2,7 +2,7 @@ use anyhow::{Context, Result, bail};
use base64::Engine;
use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate};
use geth_config::GethPaths;
use geth_config::{ConfigKey, GethConfig, GethPaths};
use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus};
use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport};
use std::io::{Read, Write, stdout};
@ -422,6 +422,11 @@ pub enum Command {
#[command(subcommand)]
command: DaemonCommand,
},
/// Inspect, validate, and safely edit daemon configuration
Config {
#[command(subcommand)]
command: ConfigCommand,
},
/// Show daemon, storage, Iroh, and backend health
Status,
/// Inspect or trigger peer synchronization
@ -526,6 +531,48 @@ pub enum GuideTopic {
SmokeTest,
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
/// Print the selected config.toml path
Path,
/// Print the selected config and its effective values
Show,
/// Parse and validate the complete selected config
Validate,
/// Safely update one supported setting and validate the result
Set {
#[arg(value_enum)]
key: ConfigKeyArg,
value: String,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum ConfigKeyArg {
#[value(name = "iroh.relay_mode")]
IrohRelayMode,
#[value(name = "iroh.relay_map")]
IrohRelayMap,
#[value(name = "iroh.local_discovery")]
IrohLocalDiscovery,
#[value(name = "sync.live_sync_enabled")]
SyncLiveSyncEnabled,
#[value(name = "sync.live_sync_interval_ms")]
SyncLiveSyncIntervalMs,
}
impl From<ConfigKeyArg> for ConfigKey {
fn from(value: ConfigKeyArg) -> Self {
match value {
ConfigKeyArg::IrohRelayMode => Self::IrohRelayMode,
ConfigKeyArg::IrohRelayMap => Self::IrohRelayMap,
ConfigKeyArg::IrohLocalDiscovery => Self::IrohLocalDiscovery,
ConfigKeyArg::SyncLiveSyncEnabled => Self::SyncLiveSyncEnabled,
ConfigKeyArg::SyncLiveSyncIntervalMs => Self::SyncLiveSyncIntervalMs,
}
}
}
#[derive(Debug, Subcommand)]
#[command(after_long_help = DAEMON_AFTER_HELP)]
pub enum DaemonCommand {
@ -1541,6 +1588,8 @@ fn argument_help(path: &str, id: &str) -> Option<&'static str> {
("geth cas conflict resolve", "resolution") => {
Some("Resolution: keep-local, accept-remote, keep-both, or manual")
}
("geth config set", "key") => Some("Supported dotted configuration key"),
("geth config set", "value") => Some("New value for the selected setting"),
("geth node enroll request", "capabilities") => {
Some("Requested RESOURCE=CAPABILITY pair; repeat to request more than one")
}
@ -1678,6 +1727,9 @@ async fn run_inner(cli: Cli) -> Result<()> {
Command::Guide { topic } => {
print_guide(topic, cli.json || cli.jsonl)?;
}
Command::Config { command } => {
run_config_command(&paths, command, cli.json || cli.jsonl)?;
}
Command::Init {
admin_key,
signing_key,
@ -1840,6 +1892,124 @@ async fn run_inner(cli: Cli) -> Result<()> {
Ok(())
}
fn run_config_command(paths: &GethPaths, command: ConfigCommand, json: bool) -> Result<()> {
let path = paths.config_file();
match command {
ConfigCommand::Path => {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "config-path",
"path": path,
"exists": path.exists(),
}))?
);
} else {
println!("{}", path.display());
}
}
ConfigCommand::Show => {
let exists = path.exists();
let text = if exists {
std::fs::read_to_string(&path)
.with_context(|| format!("read config at {}", path.display()))?
} else {
GethConfig::default_toml().to_owned()
};
let config = GethConfig::parse(&text)
.with_context(|| format!("validate config at {}", path.display()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "config",
"path": path,
"source": if exists { "file" } else { "built-in-defaults" },
"raw": text,
"effective": config_json(&config),
}))?
);
} else {
println!("# path: {}", path.display());
if !exists {
println!("# source: built-in defaults (file does not exist yet)");
}
print!("{text}");
if !text.ends_with('\n') {
println!();
}
}
}
ConfigCommand::Validate => {
let exists = path.exists();
let config = GethConfig::load(&path)
.with_context(|| format!("validate config at {}", path.display()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "config-validation",
"path": path,
"valid": true,
"source": if exists { "file" } else { "built-in-defaults" },
"effective": config_json(&config),
}))?
);
} else if exists {
println!("valid config: {}", path.display());
} else {
println!(
"valid built-in defaults; config file does not exist yet: {}",
path.display()
);
}
}
ConfigCommand::Set { key, value } => {
let key = ConfigKey::from(key);
let config = GethConfig::set(&path, key, &value)
.with_context(|| format!("set {} in {}", key.as_str(), path.display()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "config-updated",
"path": path,
"key": key.as_str(),
"value": value,
"restart_required": true,
"effective": config_json(&config),
}))?
);
} else {
println!("updated {} in {}", key.as_str(), path.display());
println!("restart the daemon for the change to take effect");
}
}
}
Ok(())
}
fn config_json(config: &GethConfig) -> serde_json::Value {
let relay_maps = config
.iroh
.relay_maps
.iter()
.map(|(name, map)| (name.clone(), map.urls.clone()))
.collect::<std::collections::BTreeMap<_, _>>();
serde_json::json!({
"iroh": {
"relay_mode": config.iroh.relay_mode.label(),
"relay_maps": relay_maps,
"local_discovery": config.iroh.local_discovery,
},
"sync": {
"live_sync_enabled": config.sync.live_sync_enabled,
"live_sync_interval_ms": config.sync.live_sync_interval_ms,
},
})
}
async fn run_ephemeral_daemon(json: bool) -> Result<()> {
let (home, paths, node) = create_ephemeral_node()?;
if json {
@ -2724,6 +2894,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
},
},
Command::Guide { .. }
| Command::Config { .. }
| Command::Init { .. }
| Command::Daemon { .. }
| Command::Backup { .. }
@ -4853,6 +5024,31 @@ mod tests {
));
}
#[test]
fn configuration_commands_parse_and_update_an_isolated_home() {
for command in ["path", "show", "validate"] {
let parsed = Cli::try_parse_from(["geth", "config", command]);
assert!(parsed.is_ok(), "config {command} should parse: {parsed:?}");
}
let parsed =
Cli::try_parse_from(["geth", "config", "set", "sync.live_sync_interval_ms", "500"]);
assert!(parsed.is_ok(), "config set should parse: {parsed:?}");
let home = tempfile::tempdir().expect("temporary geth home");
let paths = GethPaths::from_home(home.path());
run_config_command(
&paths,
ConfigCommand::Set {
key: ConfigKeyArg::SyncLiveSyncIntervalMs,
value: "500".to_owned(),
},
false,
)
.expect("set config");
let config = GethConfig::load(&paths.config_file()).expect("load config");
assert_eq!(config.sync.live_sync_interval_ms, 500);
}
#[tokio::test]
async fn ephemeral_daemon_rejects_an_explicit_persistent_home() {
let parsed = Cli::try_parse_from([