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}