summaryrefslogtreecommitdiffstats
path: root/src/config/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/config/mod.rs')
-rw-r--r--src/config/mod.rs1031
1 files changed, 1031 insertions, 0 deletions
diff --git a/src/config/mod.rs b/src/config/mod.rs
new file mode 100644
index 0000000..0fee76e
--- /dev/null
+++ b/src/config/mod.rs
@@ -0,0 +1,1031 @@
+//! Configuration: built-in defaults, then configuration files (`--config`, in order),
+//! then command-line arguments. Every option can be set in each.
+//!
+//! An argument that replaces a value from a file is logged (info), as is a file that
+//! replaces one from another file (debug; warning between two includes of one file).
+//! Values can be imported instead of written out (`import`): imported values are never
+//! logged, only where they come from.
+//!
+//! All options are described once, in [`OPTIONS`]: the command line, the file keys and
+//! the conversion of text values all derive from it. The merged values are then read
+//! into [`Settings`] and checked.
+
+mod file;
+mod import;
+
+use std::collections::BTreeMap;
+use std::net::SocketAddr;
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result, anyhow, bail};
+use clap::parser::ValueSource;
+use clap::{Arg, ArgAction, ArgMatches, Command};
+use serde::{Deserialize, Serialize};
+use serde_json::Value as JsonValue;
+use tracing::{debug, info, warn};
+
+use import::{Imported, Raw};
+
+/// How an option's value is written as text (command line, text imports).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Kind {
+ /// `true`/`false`; on the command line, present or not.
+ Flag,
+ Text,
+ Integer,
+ /// Comma- or line-separated; repeatable on the command line.
+ List,
+}
+
+#[derive(Debug)]
+pub(crate) struct OptionSpec {
+ pub(crate) name: &'static str,
+ pub(crate) kind: Kind,
+ pub(crate) value_name: &'static str,
+ /// For `--help` only; the effective defaults are in [`Settings`].
+ pub(crate) default: Option<&'static str>,
+ pub(crate) help: &'static str,
+ pub(crate) hidden: bool,
+}
+
+const fn option(
+ name: &'static str,
+ kind: Kind,
+ value_name: &'static str,
+ default: Option<&'static str>,
+ help: &'static str,
+) -> OptionSpec {
+ OptionSpec {
+ name,
+ kind,
+ value_name,
+ default,
+ help,
+ hidden: false,
+ }
+}
+
+/// Every option. Names are the long option names and the configuration file keys.
+pub(crate) const OPTIONS: &[OptionSpec] = &[
+ option(
+ "bind-addr",
+ Kind::Text,
+ "HOSTNAME:PORT",
+ Some("127.0.0.1:3000"),
+ "Address of the HTTP API.",
+ ),
+ option(
+ "htpasswd",
+ Kind::Text,
+ "PATH",
+ None,
+ "Users of the HTTP API (htpasswd file with Argon2 hashes). Required.",
+ ),
+ option(
+ "protocol-writers",
+ Kind::List,
+ "USER,[USER...]",
+ None,
+ "HTTP API users (from htpasswd) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake. Such writes act on the whole swarm and are logged. Epoch metadata is never writable.",
+ ),
+ option(
+ "debug-api",
+ Kind::Flag,
+ "BOOL",
+ Some("false"),
+ "Enable the /debug endpoints (tests only).",
+ ),
+ option(
+ "seeds",
+ Kind::List,
+ "STRING,[STRING...]",
+ None,
+ "How to reach a node of the swarm to join: a Mycelium public key, a Mycelium IPv6 address, or a host name with an AAAA record for one. Without seeds, the node starts a new swarm (genesis).",
+ ),
+ option(
+ "swarm-key-file",
+ Kind::Text,
+ "PATH",
+ None,
+ "Pre-shared swarm key: only nodes that know it may join (raw bytes, at least 16; e.g. `head -c 32 /dev/urandom`). Without it, any node that can reach a member may join.",
+ ),
+ option(
+ "alias",
+ Kind::Text,
+ "NAME",
+ None,
+ "A name for this node, shown next to its public key (/peers, metrics). Published in the swarm's member list; not unique, never an identity.",
+ ),
+ option(
+ "keepalive-secs",
+ Kind::Integer,
+ "SECONDS",
+ Some("60"),
+ "Keepalive interval; dormancy, resync and probe timings derive from it.",
+ ),
+ option(
+ "neighbours",
+ Kind::Integer,
+ "COUNT",
+ Some("12"),
+ "Neighbours each node keeps (at least 2). Swarms of up to this many + 1 nodes are a full mesh; larger ones sync over several hops.",
+ ),
+ option(
+ "electorate-window-days",
+ Kind::Integer,
+ "DAYS",
+ Some("7"),
+ "Members that renewed their alive stamp within this many days vote on remakes (a majority of them must agree). Matters on the node that coordinates a remake.",
+ ),
+ option(
+ "blob-chunk-kib",
+ Kind::Integer,
+ "KIB",
+ Some("64"),
+ "Chunk size when serving large transfers to other nodes (e.g. an epoch's snapshot to a rejoining node), in KiB (1 to 4096).",
+ ),
+ option(
+ "mycelium-rest-url",
+ Kind::Text,
+ "SCHEME://HOSTNAME:PORT",
+ None,
+ "Use a Mycelium daemon running elsewhere (its REST API) instead of supervising one. Excludes the other mycelium options except mycelium-peers.",
+ ),
+ option(
+ "mycelium-key-file",
+ Kind::Text,
+ "PATH",
+ None,
+ "Private key of the supervised Mycelium daemon (its identity); a new one each start if absent.",
+ ),
+ option(
+ "mycelium-network-key-file",
+ Kind::Text,
+ "PATH",
+ None,
+ "Key of a private Mycelium network for the supervised daemon.",
+ ),
+ option(
+ "mycelium-network-name",
+ Kind::Text,
+ "NAME",
+ None,
+ "Name of that private Mycelium network.",
+ ),
+ option(
+ "mycelium-tcp-listen-port",
+ Kind::Integer,
+ "PORT",
+ None,
+ "TCP port the supervised Mycelium daemon listens on for overlay peers; a free port if absent.",
+ ),
+ option(
+ "mycelium-peers",
+ Kind::List,
+ "URL,[URL...]",
+ Some("tls://127.0.0.1:9651"),
+ "Peers of the supervised Mycelium daemon (overlay links such as `tls://host:9651`), not continuum peers.",
+ ),
+ OptionSpec {
+ name: "debug-epoch",
+ kind: Kind::Integer,
+ value_name: "GENERATION",
+ default: None,
+ help: "Test hook: start on a non-genesis epoch of this generation (with a fixed dummy id). Needs debug-api.",
+ hidden: true,
+ },
+];
+
+pub(crate) fn spec(name: &str) -> Option<&'static OptionSpec> {
+ OPTIONS.iter().find(|spec| spec.name == name)
+}
+
+/// The effective configuration.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+pub(crate) struct Settings {
+ #[serde(default = "default_bind_addr")]
+ pub(crate) bind_addr: SocketAddr,
+ pub(crate) htpasswd: String,
+ #[serde(default)]
+ pub(crate) protocol_writers: Vec<String>,
+ #[serde(default)]
+ pub(crate) debug_api: bool,
+ #[serde(default)]
+ pub(crate) seeds: Vec<String>,
+ pub(crate) swarm_key_file: Option<PathBuf>,
+ pub(crate) alias: Option<String>,
+ #[serde(default = "default_keepalive_secs")]
+ pub(crate) keepalive_secs: u64,
+ #[serde(default = "default_neighbours")]
+ pub(crate) neighbours: usize,
+ #[serde(default = "default_electorate_window_days")]
+ pub(crate) electorate_window_days: u64,
+ #[serde(default = "default_blob_chunk_kib")]
+ pub(crate) blob_chunk_kib: u32,
+ pub(crate) mycelium_rest_url: Option<String>,
+ pub(crate) mycelium_key_file: Option<PathBuf>,
+ pub(crate) mycelium_network_key_file: Option<PathBuf>,
+ pub(crate) mycelium_network_name: Option<String>,
+ pub(crate) mycelium_tcp_listen_port: Option<u16>,
+ #[serde(default = "default_mycelium_peers")]
+ pub(crate) mycelium_peers: Vec<String>,
+ pub(crate) debug_epoch: Option<u64>,
+}
+
+fn default_bind_addr() -> SocketAddr {
+ SocketAddr::from(([127, 0, 0, 1], 3000))
+}
+fn default_keepalive_secs() -> u64 {
+ 60
+}
+fn default_neighbours() -> usize {
+ 12
+}
+fn default_electorate_window_days() -> u64 {
+ 7
+}
+fn default_blob_chunk_kib() -> u32 {
+ 64
+}
+fn default_mycelium_peers() -> Vec<String> {
+ vec!["tls://127.0.0.1:9651".to_owned()]
+}
+
+impl Settings {
+ /// `mycelium-rest-url`, parsed (checked when loading).
+ pub(crate) fn mycelium_rest_url(&self) -> Option<url::Url> {
+ self.mycelium_rest_url
+ .as_deref()
+ .and_then(|url| url::Url::parse(url).ok())
+ }
+
+ fn check(&self) -> Result<()> {
+ if let Some(url) = &self.mycelium_rest_url {
+ url::Url::parse(url).with_context(|| format!("mycelium-rest-url {url:?}"))?;
+ }
+ if self.mycelium_rest_url.is_some() {
+ let supervised_only = [
+ ("mycelium-key-file", self.mycelium_key_file.is_some()),
+ (
+ "mycelium-network-key-file",
+ self.mycelium_network_key_file.is_some(),
+ ),
+ (
+ "mycelium-network-name",
+ self.mycelium_network_name.is_some(),
+ ),
+ (
+ "mycelium-tcp-listen-port",
+ self.mycelium_tcp_listen_port.is_some(),
+ ),
+ ];
+ if let Some((name, _)) = supervised_only.iter().find(|(_, set)| *set) {
+ bail!(
+ "{name} is for a supervised Mycelium daemon and can't be combined with mycelium-rest-url"
+ );
+ }
+ }
+ if self.debug_epoch.is_some() && !self.debug_api {
+ bail!("debug-epoch needs debug-api");
+ }
+ if self.neighbours < 2 {
+ bail!("neighbours must be at least 2");
+ }
+ if self.keepalive_secs == 0 {
+ bail!("keepalive-secs must be at least 1");
+ }
+ if !(1..=4096).contains(&self.blob_chunk_kib) {
+ bail!("blob-chunk-kib must be between 1 and 4096");
+ }
+ if self.electorate_window_days == 0 {
+ bail!("electorate-window-days must be at least 1");
+ }
+ if let Some(alias) = &self.alias {
+ check_alias(alias)?;
+ }
+ Ok(())
+ }
+}
+
+/// Up to 64 characters, no control characters, not blank.
+fn check_alias(alias: &str) -> Result<()> {
+ if alias.trim().is_empty() {
+ bail!("alias must not be blank");
+ }
+ if alias.chars().count() > 64 {
+ bail!("alias must be at most 64 characters");
+ }
+ if alias.chars().any(char::is_control) {
+ bail!("alias must not contain control characters");
+ }
+ Ok(())
+}
+
+// --- values and where they came from -------------------------------------------
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum Provenance {
+ File(PathBuf),
+ Argument,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct Origin {
+ pub(crate) provenance: Provenance,
+ /// The option was unset here (back to its default): `--unset`, `unset = [...]` in a
+ /// file, or `null` in a JSON file.
+ pub(crate) unset: bool,
+ /// Importers the value (or some list elements) came from, e.g. `env:HOSTNAME`.
+ pub(crate) imports: Vec<String>,
+}
+
+impl Origin {
+ pub(crate) fn is_imported(&self) -> bool {
+ !self.imports.is_empty()
+ }
+}
+
+impl std::fmt::Display for Origin {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match &self.provenance {
+ Provenance::File(path) => write!(f, "{}", path.display())?,
+ Provenance::Argument => write!(f, "the command line")?,
+ }
+ if self.unset {
+ write!(f, " (unset)")?;
+ }
+ if self.is_imported() {
+ write!(f, " ({})", self.imports.join(", "))?;
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct Entry {
+ pub(crate) value: JsonValue,
+ pub(crate) origin: Origin,
+}
+
+impl Entry {
+ /// The value for logs: imported values are never shown.
+ pub(crate) fn shown(&self) -> String {
+ if self.origin.unset {
+ "<default>".to_owned()
+ } else if self.origin.is_imported() {
+ "<imported>".to_owned()
+ } else {
+ self.value.to_string()
+ }
+ }
+}
+
+/// Option name to value. A `null` value unsets the option (back to its default).
+pub(crate) type Layer = BTreeMap<String, Entry>;
+
+/// An unset of `name` from `provenance`.
+fn unset_entry(provenance: Provenance) -> Entry {
+ Entry {
+ value: JsonValue::Null,
+ origin: Origin {
+ provenance,
+ unset: true,
+ imports: Vec::new(),
+ },
+ }
+}
+
+/// Options that can't be combined. A source that sets one side drops the other side's
+/// values from earlier sources (lower precedence), so e.g. `--mycelium-rest-url` on the
+/// command line overrides `mycelium-key-file` from a file. Both set in one source is an
+/// error (`Settings::check`).
+const CONFLICTS: &[(&[&str], &[&str])] = &[(
+ &["mycelium-rest-url"],
+ &[
+ "mycelium-key-file",
+ "mycelium-network-key-file",
+ "mycelium-network-name",
+ "mycelium-tcp-listen-port",
+ ],
+)];
+
+/// The options `name` can't be combined with.
+fn conflicting(name: &str) -> impl Iterator<Item = &'static str> + '_ {
+ CONFLICTS.iter().flat_map(move |(a, b)| {
+ let other: &'static [&'static str] = if a.contains(&name) {
+ b
+ } else if b.contains(&name) {
+ a
+ } else {
+ &[]
+ };
+ other.iter().copied()
+ })
+}
+
+/// How a later source relates to the earlier ones it overrides (for logging).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Overlay {
+ /// A later include over an earlier include of the same file: usually a mistake.
+ IncludeOverInclude,
+ /// A file's own values over its includes, or a later `--config` over an earlier.
+ FileOverFile,
+ /// The command line over files.
+ ArgumentOverFile,
+}
+
+/// Puts `entry` for `name` into `layer`, which holds earlier sources, logging what it
+/// replaces and dropping earlier values it can't be combined with.
+pub(crate) fn overlay(layer: &mut Layer, name: String, entry: Entry, how: Overlay) {
+ let log = |message: &str, option: &str, previous: &Entry| match how {
+ Overlay::IncludeOverInclude => warn!(
+ option,
+ value = %entry.shown(),
+ from = %entry.origin,
+ overrides = %previous.shown(),
+ overridden_from = %previous.origin,
+ "{message}"
+ ),
+ Overlay::FileOverFile => debug!(
+ option,
+ from = %entry.origin,
+ overrides = %previous.origin,
+ "{message}"
+ ),
+ Overlay::ArgumentOverFile => info!(
+ option,
+ value = %entry.shown(),
+ overrides = %previous.shown(),
+ from = %previous.origin,
+ "{message}"
+ ),
+ };
+
+ if !entry.value.is_null() {
+ for other in conflicting(&name) {
+ let earlier = layer
+ .get(other)
+ .is_some_and(|previous| previous.origin.provenance != entry.origin.provenance);
+ if earlier {
+ if let Some(previous) = layer.remove(other) {
+ log(
+ &format!(
+ "{name} can't be combined with {other}; dropped the earlier value"
+ ),
+ other,
+ &previous,
+ );
+ }
+ }
+ }
+ }
+ if let Some(previous) = layer.get(&name) {
+ let message = match how {
+ Overlay::IncludeOverInclude => {
+ "Two included configuration files set the same option; the later one wins"
+ }
+ Overlay::FileOverFile => "Configuration file overrides an earlier one",
+ Overlay::ArgumentOverFile => "Command-line argument overrides the configuration file",
+ };
+ log(message, &name, previous);
+ }
+ layer.insert(name, entry);
+}
+
+/// The value of a text, by the option's kind.
+fn from_text(kind: Kind, text: &str) -> Result<JsonValue> {
+ Ok(match kind {
+ Kind::Text => JsonValue::String(text.to_owned()),
+ Kind::Integer => {
+ let number: u64 = text
+ .trim()
+ .parse()
+ .map_err(|_| anyhow!("{text:?} is not a non-negative integer"))?;
+ JsonValue::from(number)
+ }
+ Kind::Flag => match text.trim() {
+ "true" | "yes" | "1" => JsonValue::Bool(true),
+ "false" | "no" | "0" => JsonValue::Bool(false),
+ _ => bail!("{text:?} is not true or false"),
+ },
+ Kind::List => JsonValue::Array(
+ text.split([',', '\n'])
+ .map(str::trim)
+ .filter(|item| !item.is_empty())
+ .map(|item| JsonValue::String(item.to_owned()))
+ .collect(),
+ ),
+ })
+}
+
+fn from_import(kind: Kind, importer: &import::Importer) -> Result<JsonValue> {
+ match importer.fetch()? {
+ Imported::Text(text) => from_text(kind, &text),
+ Imported::Json(value) => Ok(value),
+ }
+}
+
+/// A value from a file in `dir`: importer tables are resolved, also as list elements.
+fn resolve_file_value(
+ spec: &OptionSpec,
+ value: JsonValue,
+ dir: &Path,
+) -> Result<(JsonValue, Vec<String>)> {
+ match value {
+ JsonValue::Object(table) => match import::parse_table(&table, dir)? {
+ Some(importer) => Ok((
+ from_import(spec.kind, &importer)?,
+ vec![importer.describe()],
+ )),
+ None => bail!("a table here must be an importer (env, file or command)"),
+ },
+ JsonValue::Array(items) if spec.kind == Kind::List => {
+ let mut imports = Vec::new();
+ let mut values = Vec::new();
+ for item in items {
+ match item {
+ JsonValue::Object(table) => {
+ let importer = import::parse_table(&table, dir)?.ok_or_else(|| {
+ anyhow!("a table here must be an importer (env, file or command)")
+ })?;
+ match from_import(Kind::List, &importer)? {
+ JsonValue::Array(more) => values.extend(more),
+ other => values.push(other),
+ }
+ imports.push(importer.describe());
+ }
+ other => values.push(other),
+ }
+ }
+ Ok((JsonValue::Array(values), imports))
+ }
+ other => Ok((other, Vec::new())),
+ }
+}
+
+/// The first option whose value alone doesn't fit (serde doesn't name it).
+fn culprit(merged: &Layer) -> Option<(&String, &Entry)> {
+ merged.iter().find(|(name, entry)| {
+ if entry.value.is_null() {
+ return false;
+ }
+ let mut alone = serde_json::Map::new();
+ alone.insert("htpasswd".to_owned(), JsonValue::String(String::new()));
+ alone.insert((*name).clone(), entry.value.clone());
+ serde_json::from_value::<Settings>(JsonValue::Object(alone)).is_err()
+ })
+}
+
+// --- command line --------------------------------------------------------------
+
+const CONFIG: &str = "config";
+const PRINT_CONFIG: &str = "print-config";
+const UNSET: &str = "unset";
+
+fn command() -> Command {
+ let mut command = Command::new("continuum")
+ .version(env!("CARGO_PKG_VERSION"))
+ .about("continuum: a replicated key-value store over Mycelium")
+ .after_help(
+ "Every option can also be set in a configuration file (--config); arguments win.\n\
+ A value can be imported instead of written out: env:NAME, file:PATH or cmd:COMMAND \
+ (as text), or env+json:, file+json:, cmd+json: (parsed as JSON). \
+ literal:VALUE passes a value that starts with one of these prefixes.",
+ )
+ .arg(
+ Arg::new(CONFIG)
+ .long(CONFIG)
+ .value_name("PATH")
+ .action(ArgAction::Append)
+ .help("Configuration file (.toml or .json); repeatable, later files win."),
+ )
+ .arg(
+ Arg::new(UNSET)
+ .long(UNSET)
+ .value_name("OPTION")
+ .action(ArgAction::Append)
+ .help("Unset an option set by a configuration file (back to its default); repeatable."),
+ )
+ .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 {
+ Some(default) => format!("{} [default: {default}]", spec.help),
+ None => spec.help.to_owned(),
+ };
+ let arg = Arg::new(spec.name)
+ .long(spec.name)
+ .help(help)
+ .hide(spec.hidden);
+ command = command.arg(match spec.kind {
+ Kind::Flag => arg.action(ArgAction::SetTrue),
+ Kind::List => arg.value_name(spec.value_name).action(ArgAction::Append),
+ Kind::Text | Kind::Integer => arg.value_name(spec.value_name).action(ArgAction::Set),
+ });
+ }
+ command
+}
+
+/// The values given as arguments (unsets first, so a value given too wins).
+fn arguments(matches: &ArgMatches) -> Result<Layer> {
+ let mut layer = Layer::new();
+ for name in matches.get_many::<String>(UNSET).into_iter().flatten() {
+ if spec(name).is_none() {
+ bail!("--unset {name}: no such option");
+ }
+ layer.insert(name.clone(), unset_entry(Provenance::Argument));
+ }
+ for spec in OPTIONS {
+ if matches.value_source(spec.name) != Some(ValueSource::CommandLine) {
+ continue;
+ }
+ let mut imports = Vec::new();
+ let value = if spec.kind == Kind::Flag {
+ JsonValue::Bool(true)
+ } else {
+ let mut values = Vec::new();
+ for raw in matches.get_many::<String>(spec.name).into_iter().flatten() {
+ let value = match import::parse_argument(raw) {
+ Raw::Literal(text) => from_text(spec.kind, text),
+ Raw::Import(importer) => {
+ imports.push(importer.describe());
+ from_import(spec.kind, &importer)
+ }
+ }
+ .with_context(|| format!("--{}", spec.name))?;
+ values.push(value);
+ }
+ if spec.kind == Kind::List {
+ JsonValue::Array(
+ values
+ .into_iter()
+ .flat_map(|value| match value {
+ JsonValue::Array(items) => items,
+ other => vec![other],
+ })
+ .collect(),
+ )
+ } else {
+ values.pop().unwrap_or(JsonValue::Null)
+ }
+ };
+ layer.insert(
+ spec.name.to_owned(),
+ Entry {
+ value,
+ origin: Origin {
+ provenance: Provenance::Argument,
+ unset: false,
+ imports,
+ },
+ },
+ );
+ }
+ Ok(layer)
+}
+
+/// The configuration, and where each value set came from (absent: default).
+#[derive(Debug, Clone)]
+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());
+ }
+ if origin.unset {
+ entry.insert("unset".into(), true.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()
+ .flatten()
+ .map(PathBuf::from)
+ .collect();
+
+ let mut merged = Layer::new();
+ for path in &files {
+ for (name, entry) in file::load(path)? {
+ overlay(&mut merged, name, entry, Overlay::FileOverFile);
+ }
+ }
+ for (name, entry) in arguments(&matches)? {
+ overlay(&mut merged, name, entry, Overlay::ArgumentOverFile);
+ }
+ let mut loaded = resolve(merged, files)?;
+ loaded.print_only = print_only;
+ Ok(loaded)
+}
+
+fn resolve(merged: Layer, files: Vec<PathBuf>) -> Result<Loaded> {
+ // Unset options take their defaults.
+ let object: serde_json::Map<String, JsonValue> = merged
+ .iter()
+ .filter(|(_, entry)| !entry.value.is_null())
+ .map(|(name, entry)| (name.clone(), entry.value.clone()))
+ .collect();
+ let mut settings: Settings =
+ serde_json::from_value(JsonValue::Object(object)).map_err(|error| {
+ match culprit(&merged) {
+ Some((name, entry)) => anyhow!(
+ "invalid configuration: option {name} (set in {}): {error}",
+ entry.origin
+ ),
+ None => anyhow!("invalid configuration: {error}"),
+ }
+ })?;
+ settings.alias = settings.alias.map(|alias| alias.trim().to_owned());
+ settings.check().context("invalid configuration")?;
+ Ok(Loaded {
+ settings,
+ origins: merged
+ .into_iter()
+ .map(|(name, entry)| (name, entry.origin))
+ .collect(),
+ files,
+ print_only: false,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn layer(values: JsonValue) -> Layer {
+ values
+ .as_object()
+ .unwrap()
+ .iter()
+ .map(|(name, value)| {
+ (
+ name.clone(),
+ Entry {
+ value: value.clone(),
+ origin: Origin {
+ provenance: Provenance::Argument,
+ unset: false,
+ imports: Vec::new(),
+ },
+ },
+ )
+ })
+ .collect()
+ }
+
+ #[test]
+ fn options_and_settings_agree() {
+ // Every option is a Settings field, and every field an option.
+ let loaded = resolve(layer(json!({ "htpasswd": "users" })), Vec::new()).unwrap();
+ let JsonValue::Object(fields) = serde_json::to_value(&loaded.settings).unwrap() else {
+ panic!()
+ };
+ let mut names: Vec<&str> = fields.keys().map(String::as_str).collect();
+ let mut options: Vec<&str> = OPTIONS.iter().map(|spec| spec.name).collect();
+ names.sort_unstable();
+ options.sort_unstable();
+ assert_eq!(names, options);
+
+ // Help defaults match the real ones.
+ for spec in OPTIONS {
+ if let Some(default) = spec.default {
+ assert_eq!(
+ from_text(spec.kind, default).unwrap(),
+ fields[spec.name],
+ "default of {}",
+ spec.name
+ );
+ }
+ }
+ command().debug_assert();
+ }
+
+ #[test]
+ fn later_sources_override_conflicts_and_unset() {
+ let from = |provenance: Provenance, value: JsonValue| Entry {
+ value,
+ origin: Origin {
+ provenance,
+ unset: false,
+ imports: Vec::new(),
+ },
+ };
+ let file = |value| from(Provenance::File("/etc/continuum.toml".into()), value);
+ let argument = |value| from(Provenance::Argument, value);
+ let url = json!("http://[::1]:8989");
+
+ let mut merged = Layer::new();
+ overlay(
+ &mut merged,
+ "htpasswd".into(),
+ file(json!("u")),
+ Overlay::FileOverFile,
+ );
+ overlay(
+ &mut merged,
+ "mycelium-key-file".into(),
+ file(json!("k")),
+ Overlay::FileOverFile,
+ );
+ overlay(
+ &mut merged,
+ "alias".into(),
+ file(json!("a")),
+ Overlay::FileOverFile,
+ );
+
+ // Both in one source: an error.
+ let mut same = merged.clone();
+ overlay(
+ &mut same,
+ "mycelium-rest-url".into(),
+ file(url.clone()),
+ Overlay::FileOverFile,
+ );
+ assert!(resolve(same, Vec::new()).is_err());
+
+ // The command line wins over the conflicting file value, and unsets.
+ overlay(
+ &mut merged,
+ "mycelium-rest-url".into(),
+ argument(url),
+ Overlay::ArgumentOverFile,
+ );
+ overlay(
+ &mut merged,
+ "alias".into(),
+ unset_entry(Provenance::Argument),
+ Overlay::ArgumentOverFile,
+ );
+ let loaded = resolve(merged, Vec::new()).unwrap();
+ assert!(loaded.settings.mycelium_key_file.is_none());
+ assert!(loaded.settings.mycelium_rest_url.is_some());
+ assert!(loaded.settings.alias.is_none());
+ assert_eq!(
+ loaded.report()["options"]["alias"],
+ json!({ "source": "argument", "unset": true, "value": null })
+ );
+ }
+
+ #[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());
+ assert_eq!(
+ from_text(Kind::List, "a, b\nc,").unwrap(),
+ json!(["a", "b", "c"])
+ );
+ assert_eq!(from_text(Kind::Flag, "yes").unwrap(), json!(true));
+ }
+
+ #[test]
+ fn checks() {
+ assert!(resolve(layer(json!({})), Vec::new()).is_err()); // htpasswd is required
+ assert!(
+ resolve(
+ layer(json!({ "htpasswd": "u", "neighbours": 1 })),
+ Vec::new()
+ )
+ .is_err()
+ );
+ assert!(
+ resolve(
+ layer(json!({ "htpasswd": "u", "debug-epoch": 3 })),
+ Vec::new()
+ )
+ .is_err()
+ );
+ assert!(
+ resolve(
+ layer(json!({
+ "htpasswd": "u",
+ "mycelium-rest-url": "http://[::1]:8989",
+ "mycelium-key-file": "k",
+ })),
+ Vec::new()
+ )
+ .is_err()
+ );
+ assert!(resolve(layer(json!({ "htpasswd": "u", "alias": " " })), Vec::new()).is_err());
+ assert!(
+ resolve(
+ layer(json!({ "htpasswd": "u", "keepalive-secs": "x" })),
+ Vec::new()
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn arguments_with_importers() {
+ // SAFETY: tests in this module don't read this variable concurrently.
+ unsafe { std::env::set_var("CONTINUUM_TEST_SEEDS", "[\"a\", \"b\"]") };
+ let matches = command()
+ .try_get_matches_from([
+ "continuum",
+ "--seeds",
+ "env+json:CONTINUUM_TEST_SEEDS",
+ "--seeds",
+ "c,d",
+ "--alias",
+ "literal:env:x",
+ "--debug-api",
+ ])
+ .unwrap();
+ let layer = arguments(&matches).unwrap();
+ assert_eq!(layer["seeds"].value, json!(["a", "b", "c", "d"]));
+ assert_eq!(
+ layer["seeds"].origin.imports,
+ ["env+json:CONTINUUM_TEST_SEEDS"]
+ );
+ assert_eq!(layer["alias"].value, json!("env:x"));
+ assert_eq!(layer["debug-api"].value, json!(true));
+ assert!(!layer.contains_key("neighbours"));
+ }
+}