summaryrefslogtreecommitdiffstats
path: root/src/config
diff options
context:
space:
mode:
Diffstat (limited to 'src/config')
-rw-r--r--src/config/import.rs113
-rw-r--r--src/config/mod.rs100
2 files changed, 204 insertions, 9 deletions
diff --git a/src/config/import.rs b/src/config/import.rs
index 895204a..4602705 100644
--- a/src/config/import.rs
+++ b/src/config/import.rs
@@ -3,7 +3,10 @@
//!
//! * `env:NAME`: an environment variable;
//! * `file:PATH`: a file's contents;
-//! * `cmd:COMMAND`: the standard output of a shell command (`sh -c`).
+//! * `cmd:COMMAND`: the standard output of a shell command (`sh -c`);
+//! * `template:TEXT`: text with importers in it, `${env:NAME}`, `${file:PATH}` or
+//! `${cmd:COMMAND}`, each replaced by its content (`$$` is a `$`; a placeholder ends
+//! at the first `}`), e.g. `template:node-${env:HOSTNAME}`.
//!
//! The content is text (trailing line breaks removed), converted like a value written
//! on the command line; with `+json` (`env+json:NAME`, …) it is parsed as JSON instead.
@@ -11,8 +14,9 @@
//! On the command line, a value with one of these prefixes is imported; `literal:`
//! passes the rest as is. In configuration files, an importer is a table:
//! `{ env = "NAME" }`, `{ file = "PATH" }`, `{ command = "COMMAND" }` (or an argument
-//! list, run without a shell), each with an optional `format = "json"`. Relative paths
-//! are relative to the file, and commands run in its directory.
+//! list, run without a shell), `{ template = "TEXT" }`, each with an optional
+//! `format = "json"`. Relative paths are relative to the file, and commands run in its
+//! directory, also inside a template.
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -38,6 +42,10 @@ pub(crate) enum Source {
argv: Vec<String>,
dir: Option<PathBuf>,
},
+ Template {
+ template: String,
+ dir: Option<PathBuf>,
+ },
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -73,6 +81,10 @@ pub(crate) fn parse_argument(raw: &str) -> Raw<'_> {
command: rest.to_owned(),
dir: None,
},
+ "template" => Source::Template {
+ template: rest.to_owned(),
+ dir: None,
+ },
_ => return Raw::Literal(raw),
};
Raw::Import(Importer { source, format })
@@ -83,7 +95,7 @@ pub(crate) fn parse_table(
object: &JsonMap<String, JsonValue>,
dir: &Path,
) -> Result<Option<Importer>> {
- let sources = ["env", "file", "command"]
+ let sources = ["env", "file", "command", "template"]
.into_iter()
.filter(|key| object.contains_key(*key))
.count();
@@ -91,11 +103,11 @@ pub(crate) fn parse_table(
return Ok(None);
}
if sources > 1 {
- bail!("an importer takes exactly one of env, file and command");
+ bail!("an importer takes exactly one of env, file, command and template");
}
if let Some(unknown) = object
.keys()
- .find(|key| !["env", "file", "command", "format"].contains(&key.as_str()))
+ .find(|key| !["env", "file", "command", "template", "format"].contains(&key.as_str()))
{
bail!("unknown importer key {unknown:?}");
}
@@ -115,6 +127,11 @@ pub(crate) fn parse_table(
Source::Env(text("env")?)
} else if object.contains_key("file") {
Source::File(dir.join(text("file")?))
+ } else if object.contains_key("template") {
+ Source::Template {
+ template: text("template")?,
+ dir: Some(dir.to_owned()),
+ }
} else {
match &object["command"] {
JsonValue::String(command) => Source::Shell {
@@ -153,6 +170,7 @@ impl Importer {
Source::File(path) => format!("file{format}:{}", path.display()),
Source::Shell { command, .. } => format!("cmd{format}:{command}"),
Source::Argv { argv, .. } => format!("cmd{format}:{}", argv.join(" ")),
+ Source::Template { template, .. } => format!("template{format}:{template}"),
}
}
@@ -174,6 +192,7 @@ impl Importer {
process.args(&argv[1..]);
run(process, dir.as_deref(), &argv.join(" "))?
}
+ Source::Template { template, dir } => expand(template, dir.as_deref())?,
};
let text = text.trim_end_matches(['\n', '\r']);
Ok(match self.format {
@@ -192,6 +211,55 @@ pub(crate) enum Imported {
Json(JsonValue),
}
+/// A template with its placeholders replaced by their content.
+fn expand(template: &str, dir: Option<&Path>) -> Result<String> {
+ let mut out = String::new();
+ let mut rest = template;
+ while let Some(at) = rest.find('$') {
+ out.push_str(&rest[..at]);
+ let after = &rest[at + 1..];
+ if let Some(after) = after.strip_prefix('$') {
+ out.push('$');
+ rest = after;
+ } else if let Some(after) = after.strip_prefix('{') {
+ let end = after
+ .find('}')
+ .ok_or_else(|| anyhow!("unterminated placeholder in template {template:?}"))?;
+ let placeholder = &after[..end];
+ let Raw::Import(mut importer) = parse_argument(placeholder) else {
+ bail!(
+ "placeholder {placeholder:?} in template {template:?} isn't env:, file: or cmd:"
+ );
+ };
+ match &mut importer.source {
+ Source::File(path) => {
+ if let Some(dir) = dir {
+ *path = dir.join(&*path);
+ }
+ }
+ Source::Shell { dir: run_in, .. } => *run_in = dir.map(Path::to_owned),
+ Source::Template { .. } => bail!("templates don't nest: {placeholder:?}"),
+ Source::Env(_) | Source::Argv { .. } => {}
+ }
+ if importer.format != Format::Text {
+ bail!(
+ "placeholder {placeholder:?} must be text; +json applies to the whole template"
+ );
+ }
+ match importer.fetch()? {
+ Imported::Text(text) => out.push_str(&text),
+ Imported::Json(_) => unreachable!("text importer"),
+ }
+ rest = &after[end + 1..];
+ } else {
+ out.push('$');
+ rest = after;
+ }
+ }
+ out.push_str(rest);
+ Ok(out)
+}
+
fn run(mut process: Command, dir: Option<&Path>, shown: &str) -> Result<String> {
if let Some(dir) = dir {
process.current_dir(dir);
@@ -240,6 +308,39 @@ mod tests {
})
);
assert_eq!(parse_argument("env+yaml:X"), Raw::Literal("env+yaml:X"));
+ assert_eq!(
+ parse_argument("template:node-${env:HOST}"),
+ Raw::Import(Importer {
+ source: Source::Template {
+ template: "node-${env:HOST}".into(),
+ dir: None
+ },
+ format: Format::Text
+ })
+ );
+ }
+
+ #[test]
+ fn templates() {
+ let expanded = |template: &str| expand(template, None);
+ assert_eq!(expanded("a-${cmd:echo b}-c $$5 $x").unwrap(), "a-b-c $5 $x");
+ assert_eq!(
+ expanded("${cmd:printf one}${cmd:printf two}").unwrap(),
+ "onetwo"
+ );
+ assert!(expanded("${cmd:echo b").is_err());
+ assert!(expanded("${plain}").is_err());
+ assert!(expanded("${env+json:X}").is_err());
+ assert!(expanded("${template:x}").is_err());
+
+ let json = Importer {
+ source: Source::Template {
+ template: "[\"${cmd:printf a}\", 1]".into(),
+ dir: None,
+ },
+ format: Format::Json,
+ };
+ assert_eq!(json.fetch().unwrap(), Imported::Json(json!(["a", 1])));
}
#[test]
diff --git a/src/config/mod.rs b/src/config/mod.rs
index ef28542..538689e 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -65,6 +65,9 @@ const fn option(
}
}
+/// What `--force` may override at start (`force`).
+pub(crate) const FORCE: &[&str] = &["swarmId", "badDocumentFile", "replaceDocumentFile"];
+
/// Every option. Names are the long option names and the configuration file keys.
pub(crate) const OPTIONS: &[OptionSpec] = &[
option(
@@ -86,7 +89,7 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
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.",
+ "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 and the swarm ID are never writable.",
),
option(
"debug-api",
@@ -100,14 +103,70 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
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).",
+ "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. A node keeps trying until it has joined.",
+ ),
+ option(
+ "genesis",
+ Kind::Flag,
+ "BOOL",
+ Some("false"),
+ "Start a new swarm, as its genesis node, instead of joining one through seeds. Every start with it starts another new swarm: drop it once the swarm exists, and give the node seeds instead. Can't be combined with seeds. With a --state-file that remembers a swarm, it starts that swarm again instead (empty; nodes of it still running merge with this one).",
+ ),
+ option(
+ "force",
+ Kind::List,
+ "NAME,[NAME...]",
+ None,
+ "Override checks that otherwise refuse to start: true for all, or names: swarmId (a document to start on is of another swarm than the state file remembers), badDocumentFile (an unusable document file: moved aside, start without it), replaceDocumentFile (recover-from while the document file holds a document).",
+ ),
+ option(
+ "recover-from",
+ Kind::Text,
+ "PATH",
+ None,
+ "Start the swarm again from a backup (as GET /admin/document saves it): this node loads it, on the swarm and epoch it names, and is ready at once; other nodes join it. Members of the swarm still running merge with it, or it rejoins them if they moved on to a newer epoch (the backup is then dropped).",
+ ),
+ option(
+ "remake",
+ Kind::Text,
+ "KIND[:PATH]",
+ None,
+ "With --genesis: make the new swarm's document as a remake of this kind would, instead of starting it empty: import:PATH (an Automerge document, e.g. a backup, without its history and protocol data) or replace:PATH (a JSON document, the store root); compact changes nothing.",
+ ),
+ option(
+ "document-file",
+ Kind::Text,
+ "PATH",
+ None,
+ "This node's document on disk. Read at start: the node resumes with it, ready at once. Kept up to date while ready: changes are appended every --document-append-secs, and the file is rewritten after a switch, a rejoin or a restart, and when the appended changes outgrew it.",
+ ),
+ option(
+ "document-append-secs",
+ Kind::Integer,
+ "SECONDS",
+ Some("5"),
+ "How often changes are appended to the document file.",
+ ),
+ option(
+ "document-rewrite-secs",
+ Kind::Integer,
+ "SECONDS",
+ Some("3600"),
+ "Shortest time between rewrites of the document file because its appended changes outgrew its full save (a new epoch or a restart rewrites it at once).",
+ ),
+ option(
+ "state-file",
+ Kind::Text,
+ "PATH",
+ None,
+ "File where this node remembers its swarm across restarts: the swarm ID and some members to rejoin through (not the document). Written once ready, then rarely. Without it, a restarted node knows only its configured seeds.",
),
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, anyone who knows the swarm ID can.",
+ "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",
@@ -213,6 +272,18 @@ pub(crate) struct Settings {
pub(crate) debug_api: bool,
#[serde(default)]
pub(crate) seeds: Vec<String>,
+ #[serde(default)]
+ pub(crate) genesis: bool,
+ pub(crate) state_file: Option<PathBuf>,
+ pub(crate) document_file: Option<PathBuf>,
+ #[serde(default = "default_document_append_secs")]
+ pub(crate) document_append_secs: u64,
+ #[serde(default = "default_document_rewrite_secs")]
+ pub(crate) document_rewrite_secs: u64,
+ pub(crate) recover_from: Option<PathBuf>,
+ pub(crate) remake: Option<String>,
+ #[serde(default)]
+ pub(crate) force: Vec<String>,
pub(crate) swarm_key_file: Option<PathBuf>,
pub(crate) alias: Option<String>,
#[serde(default = "default_keepalive_secs")]
@@ -236,6 +307,12 @@ pub(crate) struct Settings {
fn default_bind_addr() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], 3000))
}
+fn default_document_append_secs() -> u64 {
+ 5
+}
+fn default_document_rewrite_secs() -> u64 {
+ 3600
+}
fn default_keepalive_secs() -> u64 {
60
}
@@ -286,12 +363,29 @@ impl Settings {
);
}
}
+ crate::force::Force::parse(self.force.iter().map(String::as_str), FORCE)
+ .map_err(|reason| anyhow!("force: {reason}"))?;
+ // Contradictions, not overrides: an error wherever the options come from.
+ if self.genesis && !self.seeds.is_empty() {
+ bail!(
+ "genesis starts a new swarm, so there are no seeds to join through; unset seeds (--unset seeds) or genesis"
+ );
+ }
+ if self.remake.is_some() && !self.genesis {
+ bail!("remake makes a new swarm's document; it needs genesis");
+ }
+ if self.genesis && self.recover_from.is_some() {
+ bail!("recover-from starts the swarm from a backup; it can't be combined with genesis");
+ }
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.document_append_secs == 0 || self.document_rewrite_secs == 0 {
+ bail!("document-append-secs and document-rewrite-secs must be at least 1");
+ }
if self.keepalive_secs == 0 {
bail!("keepalive-secs must be at least 1");
}