use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::Duration;
use yaml_rust2::{Yaml, YamlLoader};
type Error = String;
type Result<T> = std::result::Result<T, Error>;
type Env<'a> = &'a dyn Fn(&str) -> Option<String>;
#[derive(Debug, Clone)]
pub struct Config {
pub node: String,
pub grpc: SocketAddr,
pub http: SocketAddr,
pub data_dir: PathBuf,
pub retention: Duration,
pub offload: Option<String>,
pub max_request_bytes: usize,
pub queue: usize,
pub shards: usize,
pub wal: bool,
pub self_telemetry: bool,
pub telemetry_interval: Duration,
pub alerts: Option<PathBuf>,
pub replicas: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
node: "mira".into(),
grpc: "0.0.0.0:4317".parse().unwrap(),
http: "0.0.0.0:4318".parse().unwrap(),
data_dir: PathBuf::from("./mira-data"),
retention: Duration::from_secs(7 * 24 * 3600),
offload: None,
max_request_bytes: 16 << 20,
queue: 128,
shards: 0,
wal: true,
self_telemetry: false,
telemetry_interval: Duration::from_secs(15),
alerts: None,
replicas: Vec::new(),
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
Self::parse(&text).map_err(|e| format!("{}: {e}", path.display()))
}
pub fn parse(text: &str) -> Result<Self> {
Self::parse_with(text, &|k| std::env::var(k).ok())
}
fn parse_with(text: &str, env: Env) -> Result<Self> {
let docs = YamlLoader::load_from_str(text).map_err(|e| e.to_string())?;
let root = docs.into_iter().next().unwrap_or(Yaml::Null);
let mut cfg = Config::default();
if let Some(v) = get(&root, "node", env)? {
cfg.node = v;
}
if let Some(v) = get(&root, "listen.grpc", env)? {
cfg.grpc = v.parse().map_err(|e| format!("listen.grpc: {e}"))?;
}
if let Some(v) = get(&root, "listen.http", env)? {
cfg.http = v.parse().map_err(|e| format!("listen.http: {e}"))?;
}
if let Some(v) = get(&root, "storage.dir", env)? {
cfg.data_dir = PathBuf::from(v);
}
if let Some(v) = get(&root, "storage.retention", env)? {
cfg.retention = duration(&v).map_err(|e| format!("storage.retention: {e}"))?;
}
if let Some(v) = get(&root, "storage.offload", env)? {
cfg.offload = Some(v);
}
if let Some(v) = get(&root, "ingest.max_request_bytes", env)? {
cfg.max_request_bytes =
bytes(&v).map_err(|e| format!("ingest.max_request_bytes: {e}"))?;
}
if let Some(v) = get(&root, "ingest.queue", env)? {
cfg.queue = positive(&v).map_err(|e| format!("ingest.queue: {e}"))?;
}
if let Some(v) = get(&root, "ingest.shards", env)? {
cfg.shards = whole(&v).map_err(|e| format!("ingest.shards: {e}"))?;
}
if let Some(v) = get(&root, "ingest.wal", env)? {
cfg.wal = boolean(&v).map_err(|e| format!("ingest.wal: {e}"))?;
}
if let Some(v) = get(&root, "telemetry.self", env)? {
cfg.self_telemetry = boolean(&v).map_err(|e| format!("telemetry.self: {e}"))?;
}
if let Some(v) = get(&root, "telemetry.interval", env)? {
cfg.telemetry_interval =
duration(&v).map_err(|e| format!("telemetry.interval: {e}"))?;
}
if let Some(v) = get(&root, "alerts.rules", env)? {
cfg.alerts = Some(PathBuf::from(v));
}
if let Some(v) = get(&root, "proxy.replicas", env)? {
cfg.replicas = replicas(&v).map_err(|e| format!("proxy.replicas: {e}"))?;
}
check_keys(&root, "")?;
Ok(cfg)
}
}
const KNOWN: [&str; 14] = [
"node",
"listen.grpc",
"listen.http",
"storage.dir",
"storage.retention",
"storage.offload",
"ingest.max_request_bytes",
"ingest.queue",
"ingest.shards",
"ingest.wal",
"telemetry.self",
"telemetry.interval",
"alerts.rules",
"proxy.replicas",
];
fn check_keys(node: &Yaml, prefix: &str) -> Result<()> {
let Yaml::Hash(h) = node else { return Ok(()) };
for (k, v) in h {
let path = match (prefix, scalar(k)?.unwrap_or_default()) {
("", name) => name,
(p, name) => format!("{p}.{name}"),
};
let known = KNOWN.iter().any(|k| {
*k == path
|| k.strip_prefix(path.as_str())
.is_some_and(|r| r.starts_with('.'))
});
if !known {
return Err(format!(
"unknown key {path:?}. Mira reads exactly {}; see https://miradb.dev/config/",
KNOWN.join(", ")
));
}
let leaf = KNOWN.contains(&path.as_str());
match v {
Yaml::Hash(_) => {
check_keys(v, &path)?;
if leaf {
return Err(format!("{path}: expected a string, found a map"));
}
}
Yaml::Array(_) => return Err(format!("{path}: expected a string, found a list")),
Yaml::Null => {}
_ if !leaf => {
return Err(format!(
"{path}: expected a map of settings, found a value; see https://miradb.dev/config/"
));
}
_ => {}
}
}
Ok(())
}
fn lookup<'a>(root: &'a Yaml, path: &str) -> Option<&'a Yaml> {
let mut node = root;
for segment in path.split('.') {
node = match node {
Yaml::Hash(h) => h.get(&Yaml::String(segment.to_owned()))?,
_ => return None,
};
}
Some(node)
}
fn scalar(y: &Yaml) -> Result<Option<String>> {
match y {
Yaml::String(s) => Ok(Some(s.clone())),
Yaml::Integer(_) | Yaml::Real(_) | Yaml::Boolean(_) => Err(format!(
"YAML read this as a {}, not a string; quote it \
(KYAML quotes every string, and every value here is one)",
match y {
Yaml::Integer(_) => "number",
Yaml::Real(_) => "float",
_ => "boolean",
}
)),
_ => Ok(None),
}
}
fn get(root: &Yaml, path: &str, env: Env) -> Result<Option<String>> {
let found = match lookup(root, path) {
Some(y) => scalar(y).map_err(|e| format!("{path}: {e}"))?,
None => None,
};
match found {
None => Ok(None),
Some(raw) => resolve(root, &raw, &mut vec![path.to_owned()], env).map(Some),
}
}
fn resolve(root: &Yaml, raw: &str, stack: &mut Vec<String>, env: Env) -> Result<String> {
let mut out = String::with_capacity(raw.len());
let bytes = raw.as_bytes();
let mut i = 0;
while i < bytes.len() {
if raw[i..].starts_with("$${") {
out.push_str("${");
i += 3;
continue;
}
if !raw[i..].starts_with("${") {
let ch = raw[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
continue;
}
let rest = &raw[i + 2..];
let end = closing_brace(rest).ok_or_else(|| format!("unterminated `${{` in {raw:?}"))?;
out.push_str(&expand(root, &rest[..end], stack, env)?);
i += 2 + end + 1;
}
Ok(out)
}
fn closing_brace(s: &str) -> Option<usize> {
let b = s.as_bytes();
let (mut depth, mut i) = (0usize, 0);
while i < b.len() {
match b[i] {
b'$' if b.get(i + 1) == Some(&b'{') => {
depth += 1;
i += 1;
}
b'}' if depth == 0 => return Some(i),
b'}' => depth -= 1,
_ => {}
}
i += 1;
}
None
}
fn expand(root: &Yaml, expr: &str, stack: &mut Vec<String>, env: Env) -> Result<String> {
if let Some(rest) = expr.strip_prefix("env:") {
let (name, default) = match rest.split_once(',') {
Some((n, d)) => (n.trim(), Some(d)),
None => (rest.trim(), None),
};
return match (env(name), default) {
(Some(v), _) => Ok(v),
(None, Some(d)) => resolve(root, d, stack, env),
(None, None) => Err(format!(
"${{env:{name}}} is not set and has no default (write `${{env:{name},<default>}}`)"
)),
};
}
let path = expr.trim();
if stack.iter().any(|p| p == path) {
stack.push(path.to_owned());
return Err(format!("reference cycle: {}", stack.join(" -> ")));
}
let raw = match lookup(root, path) {
Some(y) => scalar(y).map_err(|e| format!("${{{path}}}: {e}"))?,
None => None,
}
.ok_or_else(|| format!("${{{path}}} does not name a scalar key"))?;
stack.push(path.to_owned());
let v = resolve(root, &raw, stack, env)?;
stack.pop();
Ok(v)
}
pub fn boolean(s: &str) -> Result<bool> {
match s.trim() {
"true" => Ok(true),
"false" => Ok(false),
other => Err(format!("{other:?} is not `true` or `false`")),
}
}
pub fn positive(s: &str) -> Result<usize> {
match whole(s)? {
0 => Err("must be at least 1".into()),
n => Ok(n),
}
}
pub fn replicas(s: &str) -> Result<Vec<String>> {
s.split(',')
.map(str::trim)
.filter(|u| !u.is_empty())
.map(|u| match u.strip_prefix("http://") {
Some(rest) if !rest.is_empty() => Ok(u.trim_end_matches('/').to_owned()),
_ => Err(format!(
"{u:?} is not a replica address; expected http://host:port"
)),
})
.collect()
}
pub fn whole(s: &str) -> Result<usize> {
s.trim()
.parse::<usize>()
.map_err(|_| format!("{s:?} is not a whole number"))
}
pub fn duration(s: &str) -> Result<Duration> {
let s = s.trim();
let split = s.len()
- s.chars()
.rev()
.take_while(|c| c.is_ascii_alphabetic())
.count();
let (n, unit) = s.split_at(split);
let n: u64 = n
.trim()
.parse()
.map_err(|_| format!("{s:?} is not a duration like `7d` or `500ms`"))?;
let scale = match unit {
"ms" => return Ok(Duration::from_millis(n)),
"" | "s" => 1,
"m" => 60,
"h" => 3600,
"d" => 86_400,
other => return Err(format!("unknown duration unit {other:?} in {s:?}")),
};
let secs = n
.checked_mul(scale)
.ok_or_else(|| format!("{s:?} is a longer duration than this system can represent"))?;
Ok(Duration::from_secs(secs))
}
pub fn bytes(s: &str) -> Result<usize> {
let s = s.trim();
let split = s.len()
- s.chars()
.rev()
.take_while(|c| c.is_ascii_alphabetic())
.count();
let (n, unit) = s.split_at(split);
let n: usize = n
.trim()
.parse()
.map_err(|_| format!("{s:?} is not a size like `4MiB` or `1048576`"))?;
let shift = match unit.to_ascii_lowercase().as_str() {
"" | "b" => 0,
"k" | "kb" | "kib" => 10,
"m" | "mb" | "mib" => 20,
"g" | "gb" | "gib" => 30,
other => return Err(format!("unknown size unit {other:?} in {s:?}")),
};
n.checked_shl(shift)
.filter(|v| v >> shift == n)
.ok_or_else(|| format!("{s:?} overflows a usize"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_duration_too_large_to_represent_is_a_parse_error_not_a_wrap() {
for s in [
"1000000000000000000d",
"18446744073709551615h",
"999999999999999d",
] {
assert!(duration(s).is_err(), "{s:?} must not wrap");
}
assert_eq!(
duration("106751991167d").unwrap().as_secs(),
9_223_372_036_828_800
);
assert_eq!(duration("7d").unwrap().as_secs(), 604_800);
}
fn env(k: &str) -> Option<String> {
match k {
"MIRA_TEST_HOST" => Some("node-7".to_owned()),
_ => None,
}
}
#[test]
fn interpolation_covers_env_reference_default_and_escape() {
let cfg = Config::parse_with(
r#"{
"node": "${env:MIRA_TEST_HOST}",
"listen": { "grpc": "0.0.0.0:5317", },
"storage": {
"dir": "/var/lib/${node}/${env:MIRA_TEST_MISSING,fallback}",
"retention": "36h",
"offload": "file:///cold/${node}",
},
"alerts": { "rules": "/etc/${node}/rules.yaml" },
}"#,
&env,
)
.unwrap();
assert_eq!(cfg.node, "node-7");
assert_eq!(cfg.grpc.port(), 5317);
assert_eq!(cfg.data_dir, PathBuf::from("/var/lib/node-7/fallback"));
assert_eq!(cfg.retention, Duration::from_secs(36 * 3600));
assert_eq!(cfg.offload.as_deref(), Some("file:///cold/node-7"));
assert_eq!(Config::default().offload, None, "off by default");
assert_eq!(
cfg.alerts,
Some(PathBuf::from("/etc/node-7/rules.yaml")),
"alerts.rules is read and interpolated"
);
assert_eq!(Config::default().alerts, None, "and is off by default");
assert_eq!(cfg.http.port(), 4318);
let esc = Config::parse_with(r#"{ "node": "$${env:NOPE}" }"#, &env).unwrap();
assert_eq!(esc.node, "${env:NOPE}");
let chain = r#"{ "node": "${env:MIRA_TEST_MISSING,${env:MIRA_TEST_HOST,last}}" }"#;
assert_eq!(Config::parse_with(chain, &env).unwrap().node, "node-7");
let all_unset =
r#"{ "node": "${env:MIRA_TEST_MISSING,${env:MIRA_TEST_ALSO_MISSING,last}}" }"#;
assert_eq!(Config::parse_with(all_unset, &env).unwrap().node, "last");
let outer = r#"{ "node": "${env:MIRA_TEST_HOST,${env:MIRA_TEST_MISSING,last}}" }"#;
assert_eq!(Config::parse_with(outer, &env).unwrap().node, "node-7");
}
#[test]
fn the_log_is_on_unless_it_is_spelled_false() {
assert!(Config::default().wal);
assert!(
Config::parse_with(r#"{ "ingest": { "wal": "true" } }"#, &env)
.unwrap()
.wal
);
assert!(
!Config::parse_with(r#"{ "ingest": { "wal": "false" } }"#, &env)
.unwrap()
.wal
);
for fuzzy in [r#""yes""#, r#""on""#, r#""1""#, r#""True""#] {
let doc = format!(r#"{{ "ingest": {{ "wal": {fuzzy} }} }}"#);
let e = Config::parse_with(&doc, &env).unwrap_err();
assert!(e.contains("ingest.wal"), "{fuzzy} was accepted: {e}");
}
}
#[test]
fn scalars_yaml_guessed_at_are_refused_rather_than_stringified_back() {
let e = Config::parse("node: 0x1f").unwrap_err();
assert!(e.contains("node:") && e.contains("quote"), "{e}");
let e = Config::parse("node: False").unwrap_err();
assert!(e.contains("boolean") && e.contains("quote"), "{e}");
let e = Config::parse("storage:\n dir: 1.10").unwrap_err();
assert!(e.contains("storage.dir:") && e.contains("quote"), "{e}");
assert_eq!(Config::parse(r#"{ "node": "0x1f" }"#).unwrap().node, "0x1f");
assert_eq!(Config::parse("node: no").unwrap().node, "no");
}
#[test]
fn bad_config_fails_at_boot_rather_than_silently() {
let e = Config::parse_with("node: ${env:MIRA_MISSING}", &env).unwrap_err();
assert!(e.contains("is not set"), "{e}");
let e = Config::parse("node: ${a}\na: ${node}").unwrap_err();
assert!(e.contains("cycle"), "{e}");
for text in ["node: ${nope}", "node: ${storage}\nstorage:\n dir: /x"] {
let e = Config::parse(text).unwrap_err();
assert!(e.contains("does not name a scalar key"), "{text}: {e}");
}
let e = Config::parse("storage:\n retention: 7 fortnights").unwrap_err();
assert!(e.contains("duration"), "{e}");
assert!(
Config::parse("node: ${env:X")
.unwrap_err()
.contains("unterminated")
);
assert!(
Config::parse("node: ${env:X,${env:Y,z}")
.unwrap_err()
.contains("unterminated")
);
}
#[test]
fn a_key_mira_does_not_read_refuses_to_start() {
let e = Config::parse(r#"{ "storage": { "retension": "30d" } }"#).unwrap_err();
assert!(e.contains("storage.retension"), "{e}");
let e = Config::parse(r#"{ "retention": "30d" }"#).unwrap_err();
assert!(e.contains("unknown key \"retention\""), "{e}");
let e = Config::parse(r#"{ "storage": { "dir": { "path": "/x" } } }"#).unwrap_err();
assert!(e.contains("storage.dir.path"), "{e}");
let e = Config::parse(r#"{ "cluster": { "peers": "a:1" } }"#).unwrap_err();
assert!(e.contains("unknown key \"cluster\""), "{e}");
let e = Config::parse(r#"{ "cluster": {} }"#).unwrap_err();
assert!(e.contains("unknown key \"cluster\""), "{e}");
let e = Config::parse("2: x").unwrap_err();
assert!(e.contains("quote"), "{e}");
Config::parse(r#"{ "node": "a", "listen": {}, "ingest": { "max_request_bytes": "1k" } }"#)
.unwrap();
}
#[test]
fn a_value_of_the_wrong_shape_refuses_to_start() {
let e = Config::parse(r#"{ "node": ["a"] }"#).unwrap_err();
assert!(e.contains("node: expected a string, found a list"), "{e}");
let e = Config::parse(r#"{ "storage": { "dir": {} } }"#).unwrap_err();
assert!(
e.contains("storage.dir: expected a string, found a map"),
"{e}"
);
let e = Config::parse(r#"{ "listen": "0.0.0.0:4317" }"#).unwrap_err();
assert!(e.contains("listen: expected a map of settings"), "{e}");
let cfg = Config::parse(r#"{ "node": null, "listen": null }"#).unwrap();
assert_eq!(cfg.node, "mira");
assert_eq!(cfg.http.port(), 4318);
}
#[test]
fn sizes_parse_in_binary_units_or_not_at_all() {
assert_eq!(bytes("1048576"), Ok(1 << 20));
assert_eq!(bytes(" 512k "), Ok(512 << 10));
assert_eq!(bytes("4MiB"), Ok(4 << 20));
assert_eq!(bytes("4MB"), bytes("4MiB"));
assert_eq!(bytes("2g"), Ok(2 << 30));
assert!(bytes("4 fortnights").unwrap_err().contains("unit"));
assert!(bytes("MiB").unwrap_err().contains("size"));
assert!(bytes("-1").unwrap_err().contains("size"));
assert!(bytes("99999999999g").unwrap_err().contains("overflow"));
let cfg = Config::parse(r#"{ "ingest": { "max_request_bytes": "32MiB" } }"#).unwrap();
assert_eq!(cfg.max_request_bytes, 32 << 20);
let e = Config::parse(r#"{ "ingest": { "max_request_bytes": "big" } }"#).unwrap_err();
assert!(e.contains("ingest.max_request_bytes"), "{e}");
}
#[test]
fn a_queue_depth_is_a_count_of_slots_and_not_a_size() {
assert_eq!(positive("2048"), Ok(2048));
assert_eq!(positive(" 1 "), Ok(1));
assert!(positive("0").unwrap_err().contains("at least 1"));
assert!(positive("-1").unwrap_err().contains("whole number"));
assert!(positive("4k").unwrap_err().contains("whole number"));
let cfg = Config::parse(r#"{ "ingest": { "queue": "512" } }"#).unwrap();
assert_eq!(cfg.queue, 512);
assert_eq!(Config::default().queue, 128);
let e = Config::parse(r#"{ "ingest": { "queue": "0" } }"#).unwrap_err();
assert!(e.contains("ingest.queue"), "{e}");
let cfg = Config::parse(r#"{ "ingest": { "shards": "4" } }"#).unwrap();
assert_eq!(cfg.shards, 4);
assert_eq!(
Config::parse(r#"{ "ingest": { "shards": "0" } }"#)
.unwrap()
.shards,
0
);
assert_eq!(Config::default().shards, 0, "one flusher per core");
let e = Config::parse(r#"{ "ingest": { "shards": "4k" } }"#).unwrap_err();
assert!(e.contains("ingest.shards"), "{e}");
}
#[test]
fn self_telemetry_is_off_until_it_is_turned_on() {
let d = Config::default();
assert!(!d.self_telemetry);
assert_eq!(d.telemetry_interval, Duration::from_secs(15));
let cfg =
Config::parse(r#"{ "telemetry": { "self": "true", "interval": "1m" } }"#).unwrap();
assert!(cfg.self_telemetry);
assert_eq!(cfg.telemetry_interval, Duration::from_secs(60));
let cfg = Config::parse(r#"{ "telemetry": { "self": "false" } }"#).unwrap();
assert!(!cfg.self_telemetry);
let e = Config::parse(r#"{ "telemetry": { "self": "yes please" } }"#).unwrap_err();
assert!(e.contains("telemetry.self"), "{e}");
let e = Config::parse(r#"{ "telemetry": { "interval": "soon" } }"#).unwrap_err();
assert!(e.contains("telemetry.interval"), "{e}");
}
#[test]
fn a_replica_list_is_one_scalar_of_http_addresses() {
let cfg = Config::parse(
r#"{ "proxy": { "replicas": "http://a:4318, http://b:4318/ ,, http://c:4318" } }"#,
)
.unwrap();
assert_eq!(
cfg.replicas,
["http://a:4318", "http://b:4318", "http://c:4318"]
);
assert!(Config::default().replicas.is_empty());
assert!(replicas("").unwrap().is_empty());
for bad in ["https://a:4318", "a:4318", "http://"] {
let e =
Config::parse(&format!(r#"{{ "proxy": {{ "replicas": "{bad}" }} }}"#)).unwrap_err();
assert!(
e.contains("proxy.replicas") && e.contains("http://host:port"),
"{bad}: {e}"
);
}
}
}