Add safe configuration commands
This commit is contained in:
parent
c20cc47914
commit
443602a30b
7 changed files with 432 additions and 3 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1573,6 +1573,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"serde",
|
"serde",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"toml_edit",
|
"toml_edit",
|
||||||
"url",
|
"url",
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -305,8 +305,10 @@ listing and later approval/signing workflows. The current log is materialized
|
||||||
from signed certificate requests, signed certificate imports, and signed
|
from signed certificate requests, signed certificate imports, and signed
|
||||||
revocation records, then reduced locally; it is not a mutable remote ACL blob.
|
revocation records, then reduced locally; it is not a mutable remote ACL blob.
|
||||||
While the daemon is running, it also performs a background live-sync tick for
|
While the daemon is running, it also performs a background live-sync tick for
|
||||||
known peers. The default interval is 30 seconds and can be changed in
|
known peers. The default interval is 30 seconds and can be changed with the
|
||||||
`config.toml` with `[sync] live_sync_enabled` and `live_sync_interval_ms`.
|
validated configuration commands, for example
|
||||||
|
`geth config set sync.live_sync_interval_ms 10000` or
|
||||||
|
`geth config set sync.live_sync_enabled false`.
|
||||||
Live-sync stores per-peer high-water cursors in local metadata so repeated ticks
|
Live-sync stores per-peer high-water cursors in local metadata so repeated ticks
|
||||||
request only newer SSH certificate-flow and revocation log entries. Sync import
|
request only newer SSH certificate-flow and revocation log entries. Sync import
|
||||||
preserves local metadata by rejecting conflicting records with ids that already
|
preserves local metadata by rejecting conflicting records with ids that already
|
||||||
|
|
@ -430,6 +432,22 @@ grants, and bearer-secret access without scraping prose.
|
||||||
|
|
||||||
## Local State
|
## Local State
|
||||||
|
|
||||||
|
Use `geth config path` to find the selected configuration file, `geth config
|
||||||
|
show` to inspect either the file or the effective built-in defaults, and `geth
|
||||||
|
config validate` before restarting a daemon after manual edits. The safe setter
|
||||||
|
supports these dotted keys:
|
||||||
|
|
||||||
|
- `iroh.relay_mode` (`default`, `staging`, `disabled`, or `custom`)
|
||||||
|
- `iroh.relay_map`
|
||||||
|
- `iroh.local_discovery` (`true` or `false`)
|
||||||
|
- `sync.live_sync_enabled` (`true` or `false`)
|
||||||
|
- `sync.live_sync_interval_ms` (at least `100`)
|
||||||
|
|
||||||
|
`geth config set <key> <value>` preserves unrelated TOML and comments, validates
|
||||||
|
the complete prospective config, and reports that the daemon must be restarted.
|
||||||
|
Custom relay-map URL tables remain a deliberate manual TOML edit; validate them
|
||||||
|
with `geth config validate`.
|
||||||
|
|
||||||
If `GETH_HOME` is set, geth uses it. Otherwise it uses an OS-specific data
|
If `GETH_HOME` is set, geth uses it. Otherwise it uses an OS-specific data
|
||||||
directory. The bootstrap layout is:
|
directory. The bootstrap layout is:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use anyhow::{Context, Result, bail};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
|
use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
|
||||||
use clap_complete::{Shell, generate};
|
use clap_complete::{Shell, generate};
|
||||||
use geth_config::GethPaths;
|
use geth_config::{ConfigKey, GethConfig, GethPaths};
|
||||||
use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus};
|
use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus};
|
||||||
use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport};
|
use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport};
|
||||||
use std::io::{Read, Write, stdout};
|
use std::io::{Read, Write, stdout};
|
||||||
|
|
@ -422,6 +422,11 @@ pub enum Command {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: DaemonCommand,
|
command: DaemonCommand,
|
||||||
},
|
},
|
||||||
|
/// Inspect, validate, and safely edit daemon configuration
|
||||||
|
Config {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: ConfigCommand,
|
||||||
|
},
|
||||||
/// Show daemon, storage, Iroh, and backend health
|
/// Show daemon, storage, Iroh, and backend health
|
||||||
Status,
|
Status,
|
||||||
/// Inspect or trigger peer synchronization
|
/// Inspect or trigger peer synchronization
|
||||||
|
|
@ -526,6 +531,48 @@ pub enum GuideTopic {
|
||||||
SmokeTest,
|
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)]
|
#[derive(Debug, Subcommand)]
|
||||||
#[command(after_long_help = DAEMON_AFTER_HELP)]
|
#[command(after_long_help = DAEMON_AFTER_HELP)]
|
||||||
pub enum DaemonCommand {
|
pub enum DaemonCommand {
|
||||||
|
|
@ -1541,6 +1588,8 @@ fn argument_help(path: &str, id: &str) -> Option<&'static str> {
|
||||||
("geth cas conflict resolve", "resolution") => {
|
("geth cas conflict resolve", "resolution") => {
|
||||||
Some("Resolution: keep-local, accept-remote, keep-both, or manual")
|
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") => {
|
("geth node enroll request", "capabilities") => {
|
||||||
Some("Requested RESOURCE=CAPABILITY pair; repeat to request more than one")
|
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 } => {
|
Command::Guide { topic } => {
|
||||||
print_guide(topic, cli.json || cli.jsonl)?;
|
print_guide(topic, cli.json || cli.jsonl)?;
|
||||||
}
|
}
|
||||||
|
Command::Config { command } => {
|
||||||
|
run_config_command(&paths, command, cli.json || cli.jsonl)?;
|
||||||
|
}
|
||||||
Command::Init {
|
Command::Init {
|
||||||
admin_key,
|
admin_key,
|
||||||
signing_key,
|
signing_key,
|
||||||
|
|
@ -1840,6 +1892,124 @@ async fn run_inner(cli: Cli) -> Result<()> {
|
||||||
Ok(())
|
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<()> {
|
async fn run_ephemeral_daemon(json: bool) -> Result<()> {
|
||||||
let (home, paths, node) = create_ephemeral_node()?;
|
let (home, paths, node) = create_ephemeral_node()?;
|
||||||
if json {
|
if json {
|
||||||
|
|
@ -2724,6 +2894,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Command::Guide { .. }
|
Command::Guide { .. }
|
||||||
|
| Command::Config { .. }
|
||||||
| Command::Init { .. }
|
| Command::Init { .. }
|
||||||
| Command::Daemon { .. }
|
| Command::Daemon { .. }
|
||||||
| Command::Backup { .. }
|
| 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]
|
#[tokio::test]
|
||||||
async fn ephemeral_daemon_rejects_an_explicit_persistent_home() {
|
async fn ephemeral_daemon_rejects_an_explicit_persistent_home() {
|
||||||
let parsed = Cli::try_parse_from([
|
let parsed = Cli::try_parse_from([
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,5 @@ directories.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
toml_edit.workspace = true
|
toml_edit.workspace = true
|
||||||
|
tempfile.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
pub const DEFAULT_CONFIG_TOML: &str = r#"# geth local node config
|
pub const DEFAULT_CONFIG_TOML: &str = r#"# geth local node config
|
||||||
# Remote node-to-node communication is Iroh-only.
|
# Remote node-to-node communication is Iroh-only.
|
||||||
|
|
@ -124,6 +126,117 @@ pub enum ConfigError {
|
||||||
InvalidLiveSyncEnabled(String),
|
InvalidLiveSyncEnabled(String),
|
||||||
#[error("invalid sync live_sync_interval_ms value: {0}")]
|
#[error("invalid sync live_sync_interval_ms value: {0}")]
|
||||||
InvalidLiveSyncInterval(String),
|
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)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
|
@ -169,6 +282,28 @@ impl GethConfig {
|
||||||
sync,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
|
@ -467,4 +602,50 @@ mod tests {
|
||||||
let error = GethConfig::parse("[iroh]\nlocal_discovery = \"yes\"\n").expect_err("error");
|
let error = GethConfig::parse("[iroh]\nlocal_discovery = \"yes\"\n").expect_err("error");
|
||||||
assert!(matches!(error, ConfigError::InvalidLocalDiscovery(_)));
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,17 @@ For deployment-readiness work that cuts across feature areas, see
|
||||||
- `[x]` A recursive CLI test fails when a future argument is added without a
|
- `[x]` A recursive CLI test fails when a future argument is added without a
|
||||||
description.
|
description.
|
||||||
|
|
||||||
|
- `[x]` Add a safe configuration workflow.
|
||||||
|
Acceptance criteria:
|
||||||
|
- `[x]` `geth config path/show/validate` identify the selected file, expose
|
||||||
|
built-in defaults when it is absent, and report parse or semantic errors.
|
||||||
|
- `[x]` `geth config set` supports the common Iroh and live-sync settings,
|
||||||
|
preserves unrelated TOML and comments, and validates before replacement.
|
||||||
|
- `[x]` Updates use a same-directory temporary file and clearly report that a
|
||||||
|
daemon restart is required.
|
||||||
|
- `[x]` Tests cover comment preservation, invalid-update rollback, and
|
||||||
|
creation from defaults in an isolated temporary home.
|
||||||
|
|
||||||
- `[x]` Document task-oriented user stories.
|
- `[x]` Document task-oriented user stories.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[x]` Workflows cover disposable evaluation, persistent background use,
|
- `[x]` Workflows cover disposable evaluation, persistent background use,
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,27 @@ Set `RUST_LOG=geth_node=debug` when more daemon diagnostics are useful. Use
|
||||||
`geth --home <dir> ...` to operate an isolated home without exporting an
|
`geth --home <dir> ...` to operate an isolated home without exporting an
|
||||||
environment variable.
|
environment variable.
|
||||||
|
|
||||||
|
## Inspect And Change Configuration
|
||||||
|
|
||||||
|
User story: as an operator, I want to find and validate the exact configuration
|
||||||
|
used by one geth home without guessing an OS-specific path or hand-editing
|
||||||
|
common boolean and interval settings.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
geth config path
|
||||||
|
geth config show
|
||||||
|
geth config set sync.live_sync_interval_ms 10000
|
||||||
|
geth config validate
|
||||||
|
geth daemon stop
|
||||||
|
geth daemon start
|
||||||
|
```
|
||||||
|
|
||||||
|
`config show` reports built-in defaults when the file has not been created yet.
|
||||||
|
`config set` preserves unrelated TOML and comments and refuses to replace the
|
||||||
|
file if the complete prospective configuration is invalid. Success means
|
||||||
|
`config validate` identifies the selected path as valid. Configuration is
|
||||||
|
loaded at daemon startup, so restart the user service after changes.
|
||||||
|
|
||||||
## Establish An Owner Trust Root
|
## Establish An Owner Trust Root
|
||||||
|
|
||||||
User story: as the mesh owner, I want my first node rooted in an existing SSH
|
User story: as the mesh owner, I want my first node rooted in an existing SSH
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue