Add safe configuration commands
This commit is contained in:
parent
c20cc47914
commit
443602a30b
7 changed files with 432 additions and 3 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -10,4 +10,5 @@ directories.workspace = true
|
|||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
toml_edit.workspace = true
|
||||
tempfile.workspace = true
|
||||
url.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
pub const DEFAULT_CONFIG_TOML: &str = r#"# geth local node config
|
||||
# Remote node-to-node communication is Iroh-only.
|
||||
|
|
@ -124,6 +126,117 @@ pub enum ConfigError {
|
|||
InvalidLiveSyncEnabled(String),
|
||||
#[error("invalid sync live_sync_interval_ms value: {0}")]
|
||||
InvalidLiveSyncInterval(String),
|
||||
#[error(
|
||||
"unsupported config key `{0}`; supported keys: iroh.relay_mode, iroh.relay_map, iroh.local_discovery, sync.live_sync_enabled, sync.live_sync_interval_ms"
|
||||
)]
|
||||
UnsupportedKey(String),
|
||||
#[error("invalid value `{value}` for {key}; expected {expected}")]
|
||||
InvalidSetting {
|
||||
key: &'static str,
|
||||
value: String,
|
||||
expected: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ConfigKey {
|
||||
IrohRelayMode,
|
||||
IrohRelayMap,
|
||||
IrohLocalDiscovery,
|
||||
SyncLiveSyncEnabled,
|
||||
SyncLiveSyncIntervalMs,
|
||||
}
|
||||
|
||||
impl ConfigKey {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::IrohRelayMode => "iroh.relay_mode",
|
||||
Self::IrohRelayMap => "iroh.relay_map",
|
||||
Self::IrohLocalDiscovery => "iroh.local_discovery",
|
||||
Self::SyncLiveSyncEnabled => "sync.live_sync_enabled",
|
||||
Self::SyncLiveSyncIntervalMs => "sync.live_sync_interval_ms",
|
||||
}
|
||||
}
|
||||
|
||||
fn set(
|
||||
self,
|
||||
document: &mut toml_edit::DocumentMut,
|
||||
raw_value: &str,
|
||||
) -> Result<(), ConfigError> {
|
||||
match self {
|
||||
Self::IrohRelayMode => {
|
||||
if !matches!(raw_value, "default" | "staging" | "disabled" | "custom") {
|
||||
return Err(invalid_setting(
|
||||
self,
|
||||
raw_value,
|
||||
"default, staging, disabled, or custom",
|
||||
));
|
||||
}
|
||||
document["iroh"]["relay_mode"] = toml_edit::value(raw_value);
|
||||
}
|
||||
Self::IrohRelayMap => {
|
||||
if raw_value.trim().is_empty() {
|
||||
return Err(invalid_setting(
|
||||
self,
|
||||
raw_value,
|
||||
"a non-empty relay-map name",
|
||||
));
|
||||
}
|
||||
document["iroh"]["relay_map"] = toml_edit::value(raw_value);
|
||||
}
|
||||
Self::IrohLocalDiscovery => {
|
||||
document["iroh"]["local_discovery"] =
|
||||
toml_edit::value(parse_bool_setting(self, raw_value)?);
|
||||
}
|
||||
Self::SyncLiveSyncEnabled => {
|
||||
document["sync"]["live_sync_enabled"] =
|
||||
toml_edit::value(parse_bool_setting(self, raw_value)?);
|
||||
}
|
||||
Self::SyncLiveSyncIntervalMs => {
|
||||
let interval = raw_value
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.filter(|interval| *interval >= 100)
|
||||
.ok_or_else(|| {
|
||||
invalid_setting(self, raw_value, "an integer of at least 100")
|
||||
})?;
|
||||
document["sync"]["live_sync_interval_ms"] = toml_edit::value(interval);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ConfigKey {
|
||||
type Err = ConfigError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"iroh.relay_mode" => Ok(Self::IrohRelayMode),
|
||||
"iroh.relay_map" => Ok(Self::IrohRelayMap),
|
||||
"iroh.local_discovery" => Ok(Self::IrohLocalDiscovery),
|
||||
"sync.live_sync_enabled" => Ok(Self::SyncLiveSyncEnabled),
|
||||
"sync.live_sync_interval_ms" => Ok(Self::SyncLiveSyncIntervalMs),
|
||||
other => Err(ConfigError::UnsupportedKey(other.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_setting(key: ConfigKey, value: &str, expected: &'static str) -> ConfigError {
|
||||
ConfigError::InvalidSetting {
|
||||
key: key.as_str(),
|
||||
value: value.to_owned(),
|
||||
expected,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bool_setting(key: ConfigKey, value: &str) -> Result<bool, ConfigError> {
|
||||
match value {
|
||||
"true" => Ok(true),
|
||||
"false" => Ok(false),
|
||||
_ => Err(invalid_setting(key, value, "true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
|
|
@ -169,6 +282,28 @@ impl GethConfig {
|
|||
sync,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set(path: &Path, key: ConfigKey, value: &str) -> Result<Self, ConfigError> {
|
||||
let text = if path.exists() {
|
||||
std::fs::read_to_string(path)?
|
||||
} else {
|
||||
Self::default_toml().to_owned()
|
||||
};
|
||||
let mut document = text.parse::<toml_edit::DocumentMut>()?;
|
||||
key.set(&mut document, value)?;
|
||||
let updated = document.to_string();
|
||||
let config = Self::parse(&updated)?;
|
||||
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
|
||||
temporary.write_all(updated.as_bytes())?;
|
||||
temporary.as_file().sync_all()?;
|
||||
temporary
|
||||
.persist(path)
|
||||
.map_err(|error| ConfigError::Io(error.error))?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -467,4 +602,50 @@ mod tests {
|
|||
let error = GethConfig::parse("[iroh]\nlocal_discovery = \"yes\"\n").expect_err("error");
|
||||
assert!(matches!(error, ConfigError::InvalidLocalDiscovery(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_set_preserves_comments_and_validates_the_result() {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
let path = home.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"# operator note\n[iroh]\nrelay_mode = \"default\" # keep relays\n\n[sync]\nlive_sync_enabled = true\nlive_sync_interval_ms = 30000\n",
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let config = GethConfig::set(&path, ConfigKey::SyncLiveSyncIntervalMs, "750")
|
||||
.expect("update config");
|
||||
assert_eq!(config.sync.live_sync_interval_ms, 750);
|
||||
let updated = std::fs::read_to_string(&path).expect("read config");
|
||||
assert!(updated.contains("# operator note"));
|
||||
assert!(updated.contains("relay_mode = \"default\" # keep relays"));
|
||||
assert!(updated.contains("live_sync_interval_ms = 750"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_set_does_not_replace_a_valid_file_with_an_invalid_update() {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
let path = home.path().join("config.toml");
|
||||
let original = GethConfig::default_toml();
|
||||
std::fs::write(&path, original).expect("write config");
|
||||
|
||||
let error = GethConfig::set(&path, ConfigKey::IrohRelayMode, "custom")
|
||||
.expect_err("custom mode needs an existing map");
|
||||
assert!(matches!(error, ConfigError::MissingRelayMapSelection));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(path).expect("read config"),
|
||||
original
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_set_creates_a_default_config_when_missing() {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
let path = home.path().join("nested/config.toml");
|
||||
|
||||
let config =
|
||||
GethConfig::set(&path, ConfigKey::IrohLocalDiscovery, "false").expect("create config");
|
||||
assert!(!config.iroh.local_discovery);
|
||||
assert!(path.is_file());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue