Skip to main content

mira/
config.rs

1//! Deployment configuration: KYAML with OmegaConf-style interpolation.
2//!
3//! # Why KYAML
4//!
5//! Principle 5 is KYAML-first, and this file is where it starts. KYAML is a
6//! strict subset of YAML 1.2 — explicit `{}` and `[]`, every string
7//! double-quoted, indentation carrying no meaning — so any YAML parser reads it
8//! and no YAML parser can guess wrong about it.
9//!
10//! The guessing is the point. An unquoted scalar is resolved by pattern-match
11//! against a table, and *which* table depends on the YAML version and the
12//! implementation. The famous case — a replica honestly named `no` becoming the
13//! boolean false — is YAML 1.1, and does not happen with the parser below.
14//! Measured against that parser, these do:
15//!
16//! * `node: False` → `Boolean(false)` → the string `"false"`, case flipped.
17//! * `node: 0x1f` → `Integer(31)` → the string `"31"`. The text changed.
18//! * `node: null` → `Null`, which reads as "key absent", so the default is used
19//!   and nothing is reported.
20//!
21//! A different parser, or the same one a major version later, has a different
22//! list. That is the real argument: the correct reading of an unquoted scalar is
23//! not a property of the document. Quoting makes it one, for four characters,
24//! and a generating model gets the same guarantee a careful human would — which
25//! is why the principle exists.
26//!
27//! Enforcement is [`scalar`]: every value Mira reads out of this file is a
28//! string, so anything that arrived as another type is refused at boot with a
29//! message saying to quote it. A list or a map never reaches a value at all, so
30//! [`check_keys`] refuses those, by path — quoting is not the fix for a shape.
31//!
32//! # Why there is a config file at all
33//!
34//! Principle 2c is "self-driving, no tuning knobs", which this appears to
35//! violate and does not. The distinction that matters:
36//!
37//! * A **knob** is a number the engine could work out for itself and instead
38//!   asks a human to guess — block size, buffer depth, flush interval, cache
39//!   sizes, compaction thresholds. None of those are here, and none of them will
40//!   be. They live in `pipeline::Config`, derived, with no path from this file.
41//! * **Deployment description** is what the engine cannot know: which addresses
42//!   to listen on, which directory is the data directory, how long the retention
43//!   policy is, what this replica is called. That is not tuning. Refusing to
44//!   accept it does not make an engine self-driving, it makes it unusable.
45//!
46//! The boundary is structural rather than documentary: this struct has no field
47//! that affects how the engine performs, only where it runs — with one
48//! deliberate exception, `ingest.wal`, which is not a number to guess but a
49//! choice between two correct durability promises that no measurement can make
50//! for the operator. Its own doc comment argues that.
51//!
52//! # Interpolation
53//!
54//! ```yaml
55//! {
56//!   "node": "${env:HOSTNAME,mira-0}",  # env var, default after the comma
57//!   "storage": {
58//!     "dir": "/var/lib/${node}",       # another key, by dotted path
59//!     "retention": "7d",
60//!   },
61//! }
62//! ```
63//!
64//! * `${env:NAME}` — required; a missing variable is a startup error, not an
65//!   empty string. Silently defaulting is how a staging cluster ends up writing
66//!   to a production bucket.
67//! * `${env:NAME,default}` — everything after the first comma is the default,
68//!   verbatim, including further `${...}`.
69//! * `${dotted.path}` — another key in this file. Resolved recursively; a cycle
70//!   is a startup error naming the cycle.
71//! * `$${` — a literal `${`.
72//!
73//! Resolution is lazy: only keys actually read are expanded, so an unused key
74//! with a broken reference cannot stop the process from booting.
75//!
76//! There is deliberately no second `MIRA_*` environment-override mechanism.
77//! `${env:...}` already covers every case, explicitly and visibly in one file,
78//! and two ways to set the same value is exactly the complexity this is meant to
79//! avoid.
80
81use std::net::SocketAddr;
82use std::path::{Path, PathBuf};
83use std::time::Duration;
84
85use yaml_rust2::{Yaml, YamlLoader};
86
87type Error = String;
88type Result<T> = std::result::Result<T, Error>;
89
90/// How `${env:NAME}` is resolved, handed to the parser rather than read out of
91/// the process.
92///
93/// Not an abstraction for its own sake — it is what lets the tests supply an
94/// environment without writing one. `std::env::set_var` is `unsafe` in edition
95/// 2024 because it can reallocate `environ` under a concurrent `getenv`, in any
96/// thread, including one inside libc; `cargo test` runs this binary's tests as
97/// threads of a single process, and at least two of them read: `term.rs` looks
98/// up `MIRA_PTY_CHILD`, and every `tui.rs` test that formats a timestamp reaches
99/// `localtime_r`, which reads `TZ`. No lock closes that, because the libc reader
100/// will not take it. Not writing does.
101type Env<'a> = &'a dyn Fn(&str) -> Option<String>;
102
103#[derive(Debug, Clone)]
104pub struct Config {
105    /// This replica's name. Hashed into the block directory name so that
106    /// replicas sharing a volume cannot collide (see `mira_core::block`).
107    pub node: String,
108    /// Where OTLP/gRPC listens.
109    pub grpc: SocketAddr,
110    /// Where OTLP/HTTP, the query API, the MCP endpoint and the web UI listen —
111    /// one port, because they are one surface over one set of blocks.
112    pub http: SocketAddr,
113    /// The block directory. It is the whole manifest: no catalogue, no index
114    /// file, nothing outside it to keep in sync.
115    pub data_dir: PathBuf,
116    /// How long a block is kept. Retention is a delete of whole blocks, so the
117    /// oldest data disappears in block-sized steps rather than row by row.
118    pub retention: Duration,
119    /// The largest export either listener will decode. See
120    /// `receiver::Receivers::max_request_bytes` for why it is one number.
121    pub max_request_bytes: usize,
122    /// How many exports may be queued for one signal's flusher before the next
123    /// one has to wait for a slot — and is shed with a 503 only if none frees
124    /// up within `pipeline::ADMIT_WAIT`.
125    ///
126    /// The concurrency limit Mira did not used to have. It was a fixed 128 and
127    /// a full queue meant an immediate 503, so a wide collector fleet spent
128    /// most of its time being told to retry: that sweep shed 93% of exports at
129    /// 96 connections and landed at a third of the two-connection rate. Waiting
130    /// briefly for a slot instead (`ADMIT_WAIT`, `pipeline.rs`) took the same
131    /// row to nothing shed and double the throughput, and twenty-one
132    /// consecutive runs of the whole sweep have refused nothing since. This
133    /// knob is the other half — an operator whose fleet is wide can buy queue
134    /// depth with memory they have spare.
135    ///
136    /// It buys queueing, not throughput: the flusher drains at the rate it
137    /// drains, and a queue deep enough to hide a permanently overloaded node
138    /// just moves the shed into a latency tail. Size it to absorb a burst, not
139    /// to avoid a 503. Each slot can hold a decoded export, so the worst case is
140    /// this times [`Config::max_request_bytes`] times three signals resident.
141    pub queue: usize,
142    /// How many flushers a signal runs, or 0 for "one per two cores".
143    ///
144    /// One shard per core is the sanctioned unit (architecture.md section 4);
145    /// this is only here so the number can be pinned when the machine lies
146    /// about its core count. `available_parallelism` honours cgroup v1 and v2
147    /// CPU quotas, so a container with a quota set needs no help here — but
148    /// `cpu.shares`/`cpu.weight` is a relative weight rather than a quota and
149    /// reads as the whole machine, a shared host often sets no quota at all, a
150    /// non-Linux container runtime leaves nothing to read, and hyperthreads
151    /// count as cores. A 96-core host running Mira on two cores' worth of any
152    /// of those would otherwise start the capped sixteen flushers per signal
153    /// and publish sixteen files per seal window. Set it to the cores the
154    /// process actually gets, or to 1 to get the pre-0.0.3 behaviour.
155    ///
156    /// Shards split `queue`, they do not multiply it: the resident worst case
157    /// is the same whatever this is. Capped at `pipeline::MAX_SHARDS`.
158    pub shards: usize,
159    /// Acknowledge an export once it is a frame in the write-ahead log, rather
160    /// than once the block holding it has been published.
161    ///
162    /// The one durability decision Mira does not make for the operator, and it
163    /// is not the tuning knob the module docs above rule out: both settings are
164    /// correct, they promise different things, and nothing the engine can
165    /// measure says which promise a deployment wants. On, the default, is the
166    /// log's: the export survives the process dying, `panic = "abort"`, SIGKILL
167    /// and the OOM killer, but not power loss in the last [`WAL_SYNC_PERIOD`],
168    /// at a p99 in the microseconds. Off is the block's — acknowledged means
169    /// fsynced and renamed, which survives power loss too, at a p99 of 2.6 s
170    /// because that is how long a lightly-loaded block takes to fill.
171    ///
172    /// Read-your-writes holds either way: the open block is queryable (section 4),
173    /// so a record is visible from the acknowledgement whether or not it has
174    /// been published yet.
175    ///
176    /// [`WAL_SYNC_PERIOD`]: crate::pipeline::WAL_SYNC_PERIOD
177    pub wal: bool,
178    /// Store this node's own telemetry in this node, as ordinary metrics.
179    ///
180    /// Mira already knows everything in `/api/v1/stats`; what it does not do by
181    /// default is remember it. On, a task samples those counters every
182    /// [`Config::telemetry_interval`] and submits them through the metrics
183    /// ingest path like any other exporter would — so `mira.ingest.rows`,
184    /// `mira.query.latency_ms` and the rest become series a chart, an alert rule
185    /// or an agent can read with no exporter, no scrape target and no second
186    /// system to stand up.
187    ///
188    /// Off by default, because it is not free and the operator should choose to
189    /// spend it: the samples are rows, they are subject to
190    /// [`Config::retention`] like everything else, and a node storing its own
191    /// telemetry is a node whose disk usage no longer goes to zero when nothing
192    /// is being sent to it.
193    ///
194    /// Self-import is the only destination. Shipping these somewhere else is
195    /// what an OTLP exporter is for, and Mira is not going to grow a second one
196    /// pointed at itself.
197    pub self_telemetry: bool,
198    /// How often [`Config::self_telemetry`] samples this node's counters.
199    ///
200    /// A sample is one point per series, so this is the resolution of every
201    /// chart drawn from it and also its cost. The default matches what a
202    /// collector's own scrape interval usually is; below a second it is
203    /// measuring the sampler.
204    pub telemetry_interval: Duration,
205    /// A KYAML file of alerting rules ([`crate::alert`]), or none.
206    ///
207    /// Deployment description rather than a knob, and the same argument as the
208    /// data directory: what to page on is a thing the engine cannot know. It is
209    /// a path rather than an inline section for two reasons — a rules list is a
210    /// list of maps, which this file's closed-scalar shape refuses on purpose,
211    /// and rules change on a different cadence to addresses, so they belong in
212    /// a different file and a different review.
213    ///
214    /// Absent means alerting is off, which is also the coordination mechanism:
215    /// N replicas over one block directory would each page, so exactly one
216    /// replica gets this key. See [`crate::alert`].
217    pub alerts: Option<PathBuf>,
218}
219
220impl Default for Config {
221    fn default() -> Self {
222        Self {
223            node: "mira".into(),
224            grpc: "0.0.0.0:4317".parse().unwrap(),
225            http: "0.0.0.0:4318".parse().unwrap(),
226            data_dir: PathBuf::from("./mira-data"),
227            retention: Duration::from_secs(7 * 24 * 3600),
228            // Eight times axum's default and four times tonic's. A stock
229            // collector batches 8192 records, which is already past 2 MiB of
230            // spans, and an exporter reads 413 as permanent — so the cost of
231            // this being too small is dropped data, while the cost of it being
232            // too large is bounded resident bytes per in-flight request.
233            max_request_bytes: 16 << 20,
234            // What it has always been, kept as the default so that raising it
235            // is a decision an operator makes with the sweep in front of them
236            // rather than a number that quietly moved under everyone.
237            queue: 128,
238            // Auto: `pipeline::shard_count` reads the core count at startup.
239            shards: 0,
240            // On, now that the open block is queryable (section 4). The reason it
241            // was off was that acking before the seal let a query miss data the
242            // sender had been told was stored; the snapshot closes that, so
243            // what is left is a three-orders-of-magnitude better ack latency
244            // against a strictly weaker crash promise. That is the trade the
245            // overwhelming majority of collectors already assume they have.
246            wal: true,
247            self_telemetry: false,
248            telemetry_interval: Duration::from_secs(15),
249            alerts: None,
250        }
251    }
252}
253
254impl Config {
255    pub fn load(path: &Path) -> Result<Self> {
256        let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
257        Self::parse(&text).map_err(|e| format!("{}: {e}", path.display()))
258    }
259
260    pub fn parse(text: &str) -> Result<Self> {
261        Self::parse_with(text, &|k| std::env::var(k).ok())
262    }
263
264    /// [`Config::parse`] against a supplied environment. See [`Env`].
265    fn parse_with(text: &str, env: Env) -> Result<Self> {
266        let docs = YamlLoader::load_from_str(text).map_err(|e| e.to_string())?;
267        let root = docs.into_iter().next().unwrap_or(Yaml::Null);
268        let mut cfg = Config::default();
269
270        if let Some(v) = get(&root, "node", env)? {
271            cfg.node = v;
272        }
273        if let Some(v) = get(&root, "listen.grpc", env)? {
274            cfg.grpc = v.parse().map_err(|e| format!("listen.grpc: {e}"))?;
275        }
276        if let Some(v) = get(&root, "listen.http", env)? {
277            cfg.http = v.parse().map_err(|e| format!("listen.http: {e}"))?;
278        }
279        if let Some(v) = get(&root, "storage.dir", env)? {
280            cfg.data_dir = PathBuf::from(v);
281        }
282        if let Some(v) = get(&root, "storage.retention", env)? {
283            cfg.retention = duration(&v).map_err(|e| format!("storage.retention: {e}"))?;
284        }
285        if let Some(v) = get(&root, "ingest.max_request_bytes", env)? {
286            cfg.max_request_bytes =
287                bytes(&v).map_err(|e| format!("ingest.max_request_bytes: {e}"))?;
288        }
289        if let Some(v) = get(&root, "ingest.queue", env)? {
290            cfg.queue = positive(&v).map_err(|e| format!("ingest.queue: {e}"))?;
291        }
292        if let Some(v) = get(&root, "ingest.shards", env)? {
293            cfg.shards = whole(&v).map_err(|e| format!("ingest.shards: {e}"))?;
294        }
295        if let Some(v) = get(&root, "ingest.wal", env)? {
296            cfg.wal = boolean(&v).map_err(|e| format!("ingest.wal: {e}"))?;
297        }
298        if let Some(v) = get(&root, "telemetry.self", env)? {
299            cfg.self_telemetry = boolean(&v).map_err(|e| format!("telemetry.self: {e}"))?;
300        }
301        if let Some(v) = get(&root, "telemetry.interval", env)? {
302            cfg.telemetry_interval =
303                duration(&v).map_err(|e| format!("telemetry.interval: {e}"))?;
304        }
305        if let Some(v) = get(&root, "alerts.rules", env)? {
306            cfg.alerts = Some(PathBuf::from(v));
307        }
308        check_keys(&root, "")?;
309        Ok(cfg)
310    }
311}
312
313/// Every path this file may contain, in the order [`Config::parse`] reads them.
314const KNOWN: [&str; 12] = [
315    "node",
316    "listen.grpc",
317    "listen.http",
318    "storage.dir",
319    "storage.retention",
320    "ingest.max_request_bytes",
321    "ingest.queue",
322    "ingest.shards",
323    "ingest.wal",
324    "telemetry.self",
325    "telemetry.interval",
326    "alerts.rules",
327];
328
329/// Refuse a key Mira does not read, or a shape it cannot read.
330///
331/// The `get`s above are silent about everything they do not name, so
332/// `storage.retension` and a `retention` nested one level too deep both boot
333/// happily on the 7-day default and surface a week later as a full disk. A flag
334/// Mira does not know is already `unknown flag --nope`; there is no reason a file
335/// should be the lenient half of the same interface.
336///
337/// `lookup` is just as silent about a value of the wrong *shape* — a list where
338/// a string belongs, a string where a section belongs — and the outcome is
339/// identical: the default, in silence. So the structure is checked here too,
340/// where the full path is in hand to name.
341///
342/// The closed set is also what makes deleting a setting safe: a key that no
343/// longer exists becomes a startup error naming it, rather than a value the
344/// operator still believes is in effect.
345fn check_keys(node: &Yaml, prefix: &str) -> Result<()> {
346    let Yaml::Hash(h) = node else { return Ok(()) };
347    for (k, v) in h {
348        // Keys obey the same rule as values, and for the same reason: `2: x` is
349        // a key nobody typed. `scalar` says it with the message that teaches it.
350        let path = match (prefix, scalar(k)?.unwrap_or_default()) {
351            ("", name) => name,
352            (p, name) => format!("{p}.{name}"),
353        };
354        // A name is acceptable as a whole key or as the *prefix* of one —
355        // `listen` is a section only because `listen.grpc` exists. Judging the
356        // name rather than waiting for a leaf under it is what makes the set
357        // closed: `{ "cluster": {} }` is exactly how an operator writes a
358        // section they are about to fill in, and testing leaves only would let
359        // it boot in silence and look accepted.
360        let known = KNOWN.iter().any(|k| {
361            *k == path
362                || k.strip_prefix(path.as_str())
363                    .is_some_and(|r| r.starts_with('.'))
364        });
365        if !known {
366            return Err(format!(
367                "unknown key {path:?}. Mira reads exactly {}; see https://miradb.dev/config/",
368                KNOWN.join(", ")
369            ));
370        }
371        // Shape has to match the name: a section holds a map, a setting holds a
372        // scalar. `scalar` judges the scalar itself at read time; the two
373        // collection types never reach it, because `lookup` walks past a list
374        // and stops inside a map, returning "absent" for both.
375        let leaf = KNOWN.contains(&path.as_str());
376        match v {
377            Yaml::Hash(_) => {
378                // No known key is a prefix of another, so a map under a setting
379                // holds nothing but keys nested a level too deep, and the
380                // recursion is what names them. An empty one has nothing to
381                // name, which is why the check below is not unreachable.
382                check_keys(v, &path)?;
383                if leaf {
384                    return Err(format!("{path}: expected a string, found a map"));
385                }
386            }
387            Yaml::Array(_) => return Err(format!("{path}: expected a string, found a list")),
388            // `null` is "absent, use the default" at every level, for the reason
389            // `scalar` gives: it is how a templating layer writes "not set".
390            Yaml::Null => {}
391            // A scalar where a section belongs: `{ "listen": "0.0.0.0:4317" }`
392            // reads as neither `listen.grpc` nor `listen.http`.
393            _ if !leaf => {
394                return Err(format!(
395                    "{path}: expected a map of settings, found a value; see https://miradb.dev/config/"
396                ));
397            }
398            _ => {}
399        }
400    }
401    Ok(())
402}
403
404fn lookup<'a>(root: &'a Yaml, path: &str) -> Option<&'a Yaml> {
405    let mut node = root;
406    for segment in path.split('.') {
407        node = match node {
408            Yaml::Hash(h) => h.get(&Yaml::String(segment.to_owned()))?,
409            _ => return None,
410        };
411    }
412    Some(node)
413}
414
415/// Every value Mira reads from the config file is a string — an address, a
416/// path, a name, a duration. So the rule is simply that it must have arrived as
417/// one.
418///
419/// `Ok(None)` means "no scalar here": a missing key, an explicit `null`, or a
420/// collection — and a collection at a key Mira reads is refused by
421/// [`check_keys`], which knows the path to name it by. `Err`
422/// means the key is present and the parser resolved it to some other type,
423/// which is the ambiguity KYAML exists to remove. Coercing back with
424/// `to_string()` is what makes `0x1f` silently become `31`; refusing costs the
425/// author two quote characters and cannot be wrong.
426fn scalar(y: &Yaml) -> Result<Option<String>> {
427    match y {
428        Yaml::String(s) => Ok(Some(s.clone())),
429        // The source text is already gone by the time we see this — `0x1f` and
430        // `31` are the same `Integer(31)` — so the message teaches the rule
431        // instead of quoting text we no longer have.
432        Yaml::Integer(_) | Yaml::Real(_) | Yaml::Boolean(_) => Err(format!(
433            "YAML read this as a {}, not a string; quote it \
434             (KYAML quotes every string, and every value here is one)",
435            match y {
436                Yaml::Integer(_) => "number",
437                Yaml::Real(_) => "float",
438                _ => "boolean",
439            }
440        )),
441        _ => Ok(None),
442    }
443}
444
445fn get(root: &Yaml, path: &str, env: Env) -> Result<Option<String>> {
446    let found = match lookup(root, path) {
447        Some(y) => scalar(y).map_err(|e| format!("{path}: {e}"))?,
448        None => None,
449    };
450    match found {
451        None => Ok(None),
452        Some(raw) => resolve(root, &raw, &mut vec![path.to_owned()], env).map(Some),
453    }
454}
455
456/// Expand every `${...}` in `raw`. `stack` carries the config paths currently
457/// being resolved, so a reference cycle is reported rather than overflowing.
458fn resolve(root: &Yaml, raw: &str, stack: &mut Vec<String>, env: Env) -> Result<String> {
459    let mut out = String::with_capacity(raw.len());
460    let bytes = raw.as_bytes();
461    let mut i = 0;
462
463    while i < bytes.len() {
464        // `$${` is an escaped literal `${`.
465        if raw[i..].starts_with("$${") {
466            out.push_str("${");
467            i += 3;
468            continue;
469        }
470        if !raw[i..].starts_with("${") {
471            let ch = raw[i..].chars().next().unwrap();
472            out.push(ch);
473            i += ch.len_utf8();
474            continue;
475        }
476        let rest = &raw[i + 2..];
477        let end = closing_brace(rest).ok_or_else(|| format!("unterminated `${{` in {raw:?}"))?;
478        out.push_str(&expand(root, &rest[..end], stack, env)?);
479        i += 2 + end + 1;
480    }
481    Ok(out)
482}
483
484/// Offset of the `}` that closes a `${` already consumed.
485///
486/// The matching brace, not the first one. `${env:A,${env:B,fallback}}` is a
487/// documented shape — a default that is itself an expression — and taking
488/// `find('}')` splits it in the middle: with `A` unset it complains about a `${`
489/// the author did terminate, and with `A` set it yields the value with a stray
490/// `}` welded on. That second one is the failure that matters, because `node`
491/// ends up in every block directory name.
492fn closing_brace(s: &str) -> Option<usize> {
493    let b = s.as_bytes();
494    let (mut depth, mut i) = (0usize, 0);
495    while i < b.len() {
496        // A multi-byte character's continuation bytes are all ≥ 0x80, so
497        // scanning bytes for these three ASCII ones cannot land inside one.
498        match b[i] {
499            b'$' if b.get(i + 1) == Some(&b'{') => {
500                depth += 1;
501                i += 1;
502            }
503            b'}' if depth == 0 => return Some(i),
504            b'}' => depth -= 1,
505            _ => {}
506        }
507        i += 1;
508    }
509    None
510}
511
512fn expand(root: &Yaml, expr: &str, stack: &mut Vec<String>, env: Env) -> Result<String> {
513    if let Some(rest) = expr.strip_prefix("env:") {
514        let (name, default) = match rest.split_once(',') {
515            Some((n, d)) => (n.trim(), Some(d)),
516            None => (rest.trim(), None),
517        };
518        return match (env(name), default) {
519            (Some(v), _) => Ok(v),
520            (None, Some(d)) => resolve(root, d, stack, env),
521            (None, None) => Err(format!(
522                "${{env:{name}}} is not set and has no default (write `${{env:{name},<default>}}`)"
523            )),
524        };
525    }
526
527    let path = expr.trim();
528    if stack.iter().any(|p| p == path) {
529        stack.push(path.to_owned());
530        return Err(format!("reference cycle: {}", stack.join(" -> ")));
531    }
532    let raw = match lookup(root, path) {
533        Some(y) => scalar(y).map_err(|e| format!("${{{path}}}: {e}"))?,
534        None => None,
535    }
536    .ok_or_else(|| format!("${{{path}}} does not name a scalar key"))?;
537
538    stack.push(path.to_owned());
539    let v = resolve(root, &raw, stack, env)?;
540    stack.pop();
541    Ok(v)
542}
543
544/// `true` or `false`, and nothing else.
545///
546/// Not YAML 1.1's dozen spellings. `on`, `yes` and `y` are why a Norwegian
547/// country code parses as `false`, and a config file that accepts eleven ways
548/// to say the same thing has ten ways to typo it into the other one.
549pub fn boolean(s: &str) -> Result<bool> {
550    match s.trim() {
551        "true" => Ok(true),
552        "false" => Ok(false),
553        other => Err(format!("{other:?} is not `true` or `false`")),
554    }
555}
556
557/// A count of things, which must be at least one.
558///
559/// Not [`bytes()`]: a queue depth of `4k` would read as 4,096 there and mean 4,000
560/// to whoever typed it, and a slot is not a byte. Zero is refused rather than
561/// silently meaning "rendezvous channel", which is what `mpsc::channel(0)` would
562/// panic on and what an operator typing it would never intend.
563pub fn positive(s: &str) -> Result<usize> {
564    match whole(s)? {
565        0 => Err("must be at least 1".into()),
566        n => Ok(n),
567    }
568}
569
570/// A count of things where zero is an answer rather than a mistake — see
571/// [`Config::shards`], where it means "ask the machine".
572pub fn whole(s: &str) -> Result<usize> {
573    s.trim()
574        .parse::<usize>()
575        .map_err(|_| format!("{s:?} is not a whole number"))
576}
577
578/// `500ms`, `30s`, `5m`, `2h`, `7d`. A bare number is seconds.
579pub fn duration(s: &str) -> Result<Duration> {
580    let s = s.trim();
581    let split = s.len()
582        - s.chars()
583            .rev()
584            .take_while(|c| c.is_ascii_alphabetic())
585            .count();
586    let (n, unit) = s.split_at(split);
587    let n: u64 = n
588        .trim()
589        .parse()
590        .map_err(|_| format!("{s:?} is not a duration like `7d` or `500ms`"))?;
591    let scale = match unit {
592        "ms" => return Ok(Duration::from_millis(n)),
593        "" | "s" => 1,
594        "m" => 60,
595        "h" => 3600,
596        "d" => 86_400,
597        other => return Err(format!("unknown duration unit {other:?} in {s:?}")),
598    };
599    Ok(Duration::from_secs(n * scale))
600}
601
602/// `4MiB`, `512k`, `1048576`. Binary units, because every other size in this
603/// system — page, block, mmap — is binary and a `MB` that meant 10^6 next to a
604/// block size that meant 2^20 would be a trap.
605pub fn bytes(s: &str) -> Result<usize> {
606    let s = s.trim();
607    let split = s.len()
608        - s.chars()
609            .rev()
610            .take_while(|c| c.is_ascii_alphabetic())
611            .count();
612    let (n, unit) = s.split_at(split);
613    let n: usize = n
614        .trim()
615        .parse()
616        .map_err(|_| format!("{s:?} is not a size like `4MiB` or `1048576`"))?;
617    let shift = match unit.to_ascii_lowercase().as_str() {
618        "" | "b" => 0,
619        "k" | "kb" | "kib" => 10,
620        "m" | "mb" | "mib" => 20,
621        "g" | "gb" | "gib" => 30,
622        other => return Err(format!("unknown size unit {other:?} in {s:?}")),
623    };
624    n.checked_shl(shift)
625        .filter(|v| v >> shift == n)
626        .ok_or_else(|| format!("{s:?} overflows a usize"))
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    /// The environment these tests parse against. A literal, not the process's:
634    /// see [`Env`] for why writing the real one is not an option.
635    fn env(k: &str) -> Option<String> {
636        match k {
637            "MIRA_TEST_HOST" => Some("node-7".to_owned()),
638            _ => None,
639        }
640    }
641
642    /// Written in KYAML, and deliberately so: this doubles as the check that
643    /// `yaml-rust2` really accepts the house format — explicit `{}` and `[]`,
644    /// every string quoted, trailing commas, indentation that means nothing.
645    /// The trailing commas are the part worth verifying rather than assuming;
646    /// YAML 1.2 permits them in flow collections but plenty of parsers do not.
647    #[test]
648    fn interpolation_covers_env_reference_default_and_escape() {
649        let cfg = Config::parse_with(
650            r#"{
651  "node": "${env:MIRA_TEST_HOST}",
652      "listen": { "grpc": "0.0.0.0:5317", },
653  "storage": {
654    "dir": "/var/lib/${node}/${env:MIRA_TEST_MISSING,fallback}",
655    "retention": "36h",
656  },
657  "alerts": { "rules": "/etc/${node}/rules.yaml" },
658}"#,
659            &env,
660        )
661        .unwrap();
662
663        assert_eq!(cfg.node, "node-7");
664        assert_eq!(cfg.grpc.port(), 5317);
665        assert_eq!(cfg.data_dir, PathBuf::from("/var/lib/node-7/fallback"));
666        assert_eq!(cfg.retention, Duration::from_secs(36 * 3600));
667        // Every path in the file interpolates, including the one that is read
668        // last: a rules file under `/etc/${node}` is how one image serves a
669        // fleet, and a literal `${node}` there is a boot that finds no rules.
670        assert_eq!(
671            cfg.alerts,
672            Some(PathBuf::from("/etc/node-7/rules.yaml")),
673            "alerts.rules is read and interpolated"
674        );
675        assert_eq!(Config::default().alerts, None, "and is off by default");
676        // Unset keys keep their defaults rather than becoming empty.
677        assert_eq!(cfg.http.port(), 4318);
678
679        let esc = Config::parse_with(r#"{ "node": "$${env:NOPE}" }"#, &env).unwrap();
680        assert_eq!(esc.node, "${env:NOPE}");
681
682        // Defaults nest, which only works if the scan finds the *matching*
683        // brace. Taking the first one used to boot the node called `alpha}` —
684        // a stray character in every block directory this replica writes.
685        let chain = r#"{ "node": "${env:MIRA_TEST_MISSING,${env:MIRA_TEST_HOST,last}}" }"#;
686        assert_eq!(Config::parse_with(chain, &env).unwrap().node, "node-7");
687        let all_unset =
688            r#"{ "node": "${env:MIRA_TEST_MISSING,${env:MIRA_TEST_ALSO_MISSING,last}}" }"#;
689        assert_eq!(Config::parse_with(all_unset, &env).unwrap().node, "last");
690        // And the outer value still wins without picking up the inner brace.
691        let outer = r#"{ "node": "${env:MIRA_TEST_HOST,${env:MIRA_TEST_MISSING,last}}" }"#;
692        assert_eq!(Config::parse_with(outer, &env).unwrap().node, "node-7");
693    }
694
695    /// `ingest.wal` is the only setting that changes what an acknowledgement
696    /// promises, so it is the last one that should accept a fuzzy spelling.
697    /// KYAML has already made the value a quoted string by the time it gets
698    /// here; what is refused is YAML 1.1's other ten ways to write a boolean,
699    /// each of which is a way to typo the durability contract into its
700    /// opposite.
701    #[test]
702    fn the_log_is_on_unless_it_is_spelled_false() {
703        assert!(Config::default().wal);
704        assert!(
705            Config::parse_with(r#"{ "ingest": { "wal": "true" } }"#, &env)
706                .unwrap()
707                .wal
708        );
709        assert!(
710            !Config::parse_with(r#"{ "ingest": { "wal": "false" } }"#, &env)
711                .unwrap()
712                .wal
713        );
714        for fuzzy in [r#""yes""#, r#""on""#, r#""1""#, r#""True""#] {
715            let doc = format!(r#"{{ "ingest": {{ "wal": {fuzzy} }} }}"#);
716            let e = Config::parse_with(&doc, &env).unwrap_err();
717            assert!(e.contains("ingest.wal"), "{fuzzy} was accepted: {e}");
718        }
719    }
720
721    /// The coercions KYAML exists to kill. Each of these used to be accepted,
722    /// stringified back into a *different* word, and used as the node name and
723    /// therefore as part of the block directory path.
724    #[test]
725    fn scalars_yaml_guessed_at_are_refused_rather_than_stringified_back() {
726        // `0x1f` is the worst of them: it survives as the string "31".
727        let e = Config::parse("node: 0x1f").unwrap_err();
728        assert!(e.contains("node:") && e.contains("quote"), "{e}");
729
730        // `False` comes back as "false", with the case quietly changed.
731        let e = Config::parse("node: False").unwrap_err();
732        assert!(e.contains("boolean") && e.contains("quote"), "{e}");
733
734        let e = Config::parse("storage:\n  dir: 1.10").unwrap_err();
735        assert!(e.contains("storage.dir:") && e.contains("quote"), "{e}");
736
737        // Quoted, each means exactly what it says.
738        assert_eq!(Config::parse(r#"{ "node": "0x1f" }"#).unwrap().node, "0x1f");
739
740        // This parser is YAML 1.2, so `no` was never the boolean anyway. Worth
741        // pinning: the reason to quote is that the rule varies by parser, not
742        // that this particular one gets `no` wrong.
743        assert_eq!(Config::parse("node: no").unwrap().node, "no");
744    }
745
746    #[test]
747    fn bad_config_fails_at_boot_rather_than_silently() {
748        // A missing env var with no default must not become "".
749        let e = Config::parse_with("node: ${env:MIRA_MISSING}", &env).unwrap_err();
750        assert!(e.contains("is not set"), "{e}");
751
752        let e = Config::parse("node: ${a}\na: ${node}").unwrap_err();
753        assert!(e.contains("cycle"), "{e}");
754
755        // A reference to a key that is not in the file, and one to a key that
756        // is a map rather than a scalar. Both used to interpolate to nothing,
757        // so `${storage.dr}` for `${storage.dir}` booted a node whose data
758        // directory was the empty string — the process's cwd.
759        for text in ["node: ${nope}", "node: ${storage}\nstorage:\n  dir: /x"] {
760            let e = Config::parse(text).unwrap_err();
761            assert!(e.contains("does not name a scalar key"), "{text}: {e}");
762        }
763
764        let e = Config::parse("storage:\n  retention: 7 fortnights").unwrap_err();
765        assert!(e.contains("duration"), "{e}");
766
767        assert!(
768            Config::parse("node: ${env:X")
769                .unwrap_err()
770                .contains("unterminated")
771        );
772        // A nested default that never closes is still unterminated, rather than
773        // swallowing the rest of the file quietly.
774        assert!(
775            Config::parse("node: ${env:X,${env:Y,z}")
776                .unwrap_err()
777                .contains("unterminated")
778        );
779    }
780
781    /// A key Mira does not read is a startup error, not a shrug.
782    ///
783    /// The failure this prevents is the quietest one in the system: `retension`
784    /// for `retention` keeps the 7-day default, says nothing, and is diagnosed
785    /// weeks later as a disk that will not stop growing.
786    #[test]
787    fn a_key_mira_does_not_read_refuses_to_start() {
788        let e = Config::parse(r#"{ "storage": { "retension": "30d" } }"#).unwrap_err();
789        assert!(e.contains("storage.retension"), "{e}");
790
791        // Right name, wrong depth. Reported by its full path, because that is
792        // the thing that is wrong about it.
793        let e = Config::parse(r#"{ "retention": "30d" }"#).unwrap_err();
794        assert!(e.contains("unknown key \"retention\""), "{e}");
795        let e = Config::parse(r#"{ "storage": { "dir": { "path": "/x" } } }"#).unwrap_err();
796        assert!(e.contains("storage.dir.path"), "{e}");
797
798        // A setting that was deleted becomes loud for free — no special case.
799        // The section is what no longer exists, so that is what the error names.
800        let e = Config::parse(r#"{ "cluster": { "peers": "a:1" } }"#).unwrap_err();
801        assert!(e.contains("unknown key \"cluster\""), "{e}");
802
803        // And with nothing in it yet, which is how an operator writes a section
804        // they are about to fill in — so it is the reading most likely to be
805        // believed, and it used to be the one that booted.
806        let e = Config::parse(r#"{ "cluster": {} }"#).unwrap_err();
807        assert!(e.contains("unknown key \"cluster\""), "{e}");
808
809        // Keys are held to the same quoting rule as values.
810        let e = Config::parse("2: x").unwrap_err();
811        assert!(e.contains("quote"), "{e}");
812
813        // And the shipped shape passes, including sections with nothing in them.
814        Config::parse(r#"{ "node": "a", "listen": {}, "ingest": { "max_request_bytes": "1k" } }"#)
815            .unwrap();
816    }
817
818    /// A list or a map where a value belongs is refused, not ignored.
819    ///
820    /// `lookup` returns "absent" for both — it walks past a list and stops
821    /// inside a map — so each of these used to boot on the default: the wrong
822    /// listen address, or worse, the wrong data directory, with nothing said.
823    #[test]
824    fn a_value_of_the_wrong_shape_refuses_to_start() {
825        let e = Config::parse(r#"{ "node": ["a"] }"#).unwrap_err();
826        assert!(e.contains("node: expected a string, found a list"), "{e}");
827
828        // The empty map is the one the leaf-only check missed.
829        let e = Config::parse(r#"{ "storage": { "dir": {} } }"#).unwrap_err();
830        assert!(
831            e.contains("storage.dir: expected a string, found a map"),
832            "{e}"
833        );
834
835        // A section given a value instead of its settings.
836        let e = Config::parse(r#"{ "listen": "0.0.0.0:4317" }"#).unwrap_err();
837        assert!(e.contains("listen: expected a map of settings"), "{e}");
838
839        // `null` still means "absent, use the default" — a key present-but-null
840        // is how a templating layer says "not set", at either level.
841        let cfg = Config::parse(r#"{ "node": null, "listen": null }"#).unwrap();
842        assert_eq!(cfg.node, "mira");
843        assert_eq!(cfg.http.port(), 4318);
844    }
845
846    /// Binary units throughout, and no silent second meaning for `MB`.
847    #[test]
848    fn sizes_parse_in_binary_units_or_not_at_all() {
849        assert_eq!(bytes("1048576"), Ok(1 << 20));
850        assert_eq!(bytes(" 512k "), Ok(512 << 10));
851        assert_eq!(bytes("4MiB"), Ok(4 << 20));
852        // `MB` is the same as `MiB` here rather than 10^6, because a config
853        // where `block: 4MiB` and `request: 4MB` differed by 5% would be read
854        // as equal by everyone.
855        assert_eq!(bytes("4MB"), bytes("4MiB"));
856        assert_eq!(bytes("2g"), Ok(2 << 30));
857
858        assert!(bytes("4 fortnights").unwrap_err().contains("unit"));
859        assert!(bytes("MiB").unwrap_err().contains("size"));
860        assert!(bytes("-1").unwrap_err().contains("size"));
861        // The shift is checked, so a plausible typo is an error and not a wrap
862        // to some small number that then silently truncates every export.
863        assert!(bytes("99999999999g").unwrap_err().contains("overflow"));
864
865        let cfg = Config::parse(r#"{ "ingest": { "max_request_bytes": "32MiB" } }"#).unwrap();
866        assert_eq!(cfg.max_request_bytes, 32 << 20);
867        let e = Config::parse(r#"{ "ingest": { "max_request_bytes": "big" } }"#).unwrap_err();
868        assert!(e.contains("ingest.max_request_bytes"), "{e}");
869    }
870
871    /// A queue depth is a count of slots, and the two ways of writing a number
872    /// that [`bytes`] accepts are both wrong for it: `4k` would be 4,096 slots
873    /// to the parser and 4,000 to whoever typed it, and zero would be a
874    /// rendezvous channel nobody asks for on purpose.
875    #[test]
876    fn a_queue_depth_is_a_count_of_slots_and_not_a_size() {
877        assert_eq!(positive("2048"), Ok(2048));
878        assert_eq!(positive("  1  "), Ok(1));
879        assert!(positive("0").unwrap_err().contains("at least 1"));
880        assert!(positive("-1").unwrap_err().contains("whole number"));
881        assert!(positive("4k").unwrap_err().contains("whole number"));
882
883        let cfg = Config::parse(r#"{ "ingest": { "queue": "512" } }"#).unwrap();
884        assert_eq!(cfg.queue, 512);
885        assert_eq!(Config::default().queue, 128);
886        let e = Config::parse(r#"{ "ingest": { "queue": "0" } }"#).unwrap_err();
887        assert!(e.contains("ingest.queue"), "{e}");
888    }
889
890    /// Off by default: a node that stores its own telemetry is writing to the
891    /// disk it is being measured on, and that is a decision, not a default.
892    #[test]
893    fn self_telemetry_is_off_until_it_is_turned_on() {
894        let d = Config::default();
895        assert!(!d.self_telemetry);
896        assert_eq!(d.telemetry_interval, Duration::from_secs(15));
897
898        let cfg =
899            Config::parse(r#"{ "telemetry": { "self": "true", "interval": "1m" } }"#).unwrap();
900        assert!(cfg.self_telemetry);
901        assert_eq!(cfg.telemetry_interval, Duration::from_secs(60));
902
903        // And `false` in a file has to be able to turn off what an inherited
904        // file turned on, which is the reason it is a key and not only a flag.
905        let cfg = Config::parse(r#"{ "telemetry": { "self": "false" } }"#).unwrap();
906        assert!(!cfg.self_telemetry);
907
908        let e = Config::parse(r#"{ "telemetry": { "self": "yes please" } }"#).unwrap_err();
909        assert!(e.contains("telemetry.self"), "{e}");
910        let e = Config::parse(r#"{ "telemetry": { "interval": "soon" } }"#).unwrap_err();
911        assert!(e.contains("telemetry.interval"), "{e}");
912    }
913}