shep_core/config/daemon.rs
1//! Daemon-level configuration: `$SHEP_HOME/shep.toml`
2//!
3//! Layering (spec §5): file < `SHEP_*` env < CLI flags. This module applies
4//! the first two; the CLI applies its flags onto the returned struct.
5
6use core::fmt;
7
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use serde::Deserialize;
12
13use crate::secrets;
14use crate::values::UpDuration;
15
16/// The `[daemon]` section
17#[derive(Debug, Clone, PartialEq, Deserialize)]
18#[serde(deny_unknown_fields, default)]
19pub struct DaemonSection {
20 /// Emit the daemon's own logs as JSON lines
21 pub log_json: bool,
22 /// Lowest severity of the daemon's own records that reaches its log
23 pub log_level: LogLevel,
24 /// The environment every sheep resolves in unless it sets its own.
25 ///
26 /// A shepherd supervising real processes on a host is production unless
27 /// somebody says otherwise.
28 pub environment: String,
29 /// Control-socket path override (default: `$SHEP_HOME/run/shep.sock`)
30 pub socket: Option<std::path::PathBuf>,
31 /// Dogs to autostart with the daemon (`shep enable` writes this)
32 pub enabled_dogs: Vec<String>,
33 /// Where an adopted dog's binary lives, keyed by dog name
34 /// (`shep adopt` writes this; `shep rehome` removes it).
35 ///
36 /// A name in [`Self::enabled_dogs`] with no entry here is a built-in
37 /// dog, an argv branch of the shep binary itself. Not recorded inside
38 /// `[dog.<name>]`: that table is the dog's own opaque configuration,
39 /// and a shep-owned key inside it would collide with a third-party
40 /// dog's schema.
41 pub adopted_dogs: BTreeMap<String, PathBuf>,
42 /// Dogs that run before every sheep, rather than after the flock.
43 ///
44 /// The default position for a dog is a final stage, for the reason
45 /// `boot.rs` gives: a metrics dog must not answer for a flock that is not
46 /// up yet. A log-rotation dog is the opposite case, since it has to be
47 /// running before a sheep starts writing. shep cannot tell which is
48 /// which, because an adopted dog is a third-party binary, so the
49 /// operator says.
50 ///
51 /// Here rather than in `dogs.toml` for the reason [`Self::adopted_dogs`]
52 /// gives: that file's `[<name>]` table is the dog's own opaque
53 /// configuration and a shep-owned key inside it would collide with a
54 /// third-party dog's schema.
55 ///
56 /// A name absent from [`Self::enabled_dogs`] is inert here.
57 pub boot_first_dogs: Vec<String>,
58 /// Longest a cron worker sleeps before re-deriving its next occurrence.
59 ///
60 /// Shorter recovers faster from a suspended laptop or an NTP step and
61 /// costs proportionally more wakeups per cron-configured sheep; longer
62 /// is cheaper and drifts further. Unset means the daemon's own default.
63 /// There is no upper bound: a very long value only degrades to sleeping
64 /// straight through to the occurrence, which still fires.
65 pub max_cron_sleep: Option<UpDuration>,
66}
67
68/// Not derived: [`DaemonSection::environment`] defaults to `"production"`,
69/// which `String`'s own `Default` cannot express.
70impl Default for DaemonSection {
71 fn default() -> Self {
72 Self {
73 log_json: false,
74 log_level: LogLevel::default(),
75 environment: "production".to_string(),
76 socket: None,
77 enabled_dogs: Vec::new(),
78 adopted_dogs: BTreeMap::new(),
79 boot_first_dogs: Vec::new(),
80 max_cron_sleep: None,
81 }
82 }
83}
84
85/// How much of the daemon's own diagnostics reaches its log.
86///
87/// Written as one of the names below in `[daemon] log_level` or in
88/// `SHEP_LOG_LEVEL`, lowercase and nothing else, the same closed grammar
89/// `log_json` accepts, so a typo is a startup error naming the value
90/// rather than a level silently reverting to the default.
91///
92/// The default is [`LogLevel::Warn`]. The daemon's records are dominated
93/// by warn-and-continue arms, each the only account of a decision the
94/// operator cannot otherwise see. [`LogLevel::Debug`] adds per-decision
95/// detail firing per dropped restart and per child metric sample, a
96/// firehose on a busy flock.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum LogLevel {
100 /// Nothing at all: the daemon writes no records of its own.
101 Off,
102 /// Only faults the daemon could not work around.
103 Error,
104 /// Faults the daemon worked around, and what working around them cost.
105 #[default]
106 Warn,
107 /// Lifecycle milestones: the daemon came up, the daemon is going down.
108 Info,
109 /// Per-decision detail: every restart weighed, every metric sampled.
110 Debug,
111 /// Everything the daemon can say about itself.
112 Trace,
113}
114
115impl LogLevel {
116 /// The one spelling this level is written as, in the file and in the
117 /// environment alike
118 #[must_use]
119 pub const fn as_str(self) -> &'static str {
120 match self {
121 Self::Off => "off",
122 Self::Error => "error",
123 Self::Warn => "warn",
124 Self::Info => "info",
125 Self::Debug => "debug",
126 Self::Trace => "trace",
127 }
128 }
129
130 /// The level `name` spells, or `None` when it spells no level.
131 ///
132 /// The inverse of [`LogLevel::as_str`], and exact: an uppercase or
133 /// mixed-case name is not a level here, because `SHEP_LOG_JSON` accepts
134 /// no `TRUE` either.
135 #[must_use]
136 pub fn from_name(name: &str) -> Option<Self> {
137 match name {
138 "off" => Some(Self::Off),
139 "error" => Some(Self::Error),
140 "warn" => Some(Self::Warn),
141 "info" => Some(Self::Info),
142 "debug" => Some(Self::Debug),
143 "trace" => Some(Self::Trace),
144 _ => None,
145 }
146 }
147}
148
149/// Floor on `[daemon] max_cron_sleep`.
150///
151/// Zero makes every sleep return immediately, spinning the loop while
152/// still firing correctly, which is what makes it hard to attribute. One
153/// second is a floor no legitimate configuration wants to be under: a
154/// five-field cron pattern cannot name anything finer than a minute.
155const MIN_CRON_SLEEP: UpDuration = UpDuration::from_millis(1_000);
156
157/// The `[whistle]` section.
158///
159/// One key, a gate rather than a tuning knob: `shep whistle`'s four
160/// control tools exist only when this is `true`; its five read-only
161/// tools exist regardless.
162///
163/// Lives only in `shep.toml`, no flag or env var, since config is
164/// auditable where a flag is not. The shepherd itself never reads this
165/// key; `shep whistle` reads the file directly. Declared here anyway
166/// because `RawDaemonConfig` denies unknown fields, so an undeclared
167/// `[whistle]` section would refuse the whole file to boot. `Debug` is
168/// derived, not redacted: one boolean, nothing to leak.
169#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
170#[serde(deny_unknown_fields, default)]
171pub struct WhistleSection {
172 /// Whether `shep whistle` offers its control tools. Default `false`.
173 pub allow_control: bool,
174}
175
176/// The `[secrets]` section: whether the CLI will print a stored value back.
177///
178/// One key, a gate rather than a tuning knob, for [`WhistleSection`]'s
179/// reason and read the same way: `shep secret get` reads this file itself,
180/// the shepherd never reads this key, and it is declared here so an
181/// undeclared `[secrets]` section is not a refused boot.
182///
183/// `Debug` is derived rather than redacted: one boolean, no secret.
184#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
185#[serde(deny_unknown_fields, default)]
186pub struct SecretsSection {
187 /// Whether `shep secret get` prints a value. Default `false`.
188 pub allow_read: bool,
189}
190
191/// The `[style]` section: how much the CLI dresses up its output.
192///
193/// Read by the CLI only. The daemon has no opinion about how anyone likes
194/// their tables, and parses this solely so an unknown key is not an error.
195///
196/// `Debug` is derived rather than redacted: one optional string, no
197/// secret, nothing a `{:?}` could leak.
198#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
199#[serde(deny_unknown_fields, default)]
200pub struct StyleSection {
201 /// `full`, `plain` or `bare`. Absent means the CLI decides.
202 pub level: Option<String>,
203}
204
205/// Parsed daemon configuration with raw per-dog sections.
206///
207/// Dog sections stay untyped here: each dog deserializes its own
208/// `[dog.<name>]` table, so dog config schemas live with the dog code.
209///
210/// `#[non_exhaustive]` guards against a breaking struct literal as this
211/// type grows sections, but is not a validation gate: its `pub` fields
212/// can still be mutated after [`Self::load`]/[`Self::load_layered`]
213/// validate, and shep-core cannot detect that.
214#[non_exhaustive]
215#[derive(Clone, Default, PartialEq)]
216pub struct DaemonConfig {
217 /// The `[daemon]` section
218 pub daemon: DaemonSection,
219 /// The `[whistle]` section
220 pub whistle: WhistleSection,
221 /// The `[secrets]` section
222 pub secrets: SecretsSection,
223 /// The `[style]` section
224 pub style: StyleSection,
225 /// The `[interpreters]` section: a script extension (no leading dot,
226 /// `"js"` not `".js"`) mapped to the interpreter that runs it.
227 ///
228 /// Read by the CLI only, before a request reaches the wire: target
229 /// resolution folds a match into an app's own
230 /// [`AppConfig::interpreter`](crate::config::AppConfig::interpreter)
231 /// only when that field is unset, and `--interpreter` on the command
232 /// line outranks both. The daemon itself never reads this field.
233 ///
234 /// Declared here, like [`StyleSection`], so `RawDaemonConfig`'s
235 /// `deny_unknown_fields` does not turn an unrecognized `[interpreters]`
236 /// section into a hard parse error on every boot.
237 pub interpreters: BTreeMap<String, String>,
238 /// Raw `[dog.<name>]` sections keyed by dog name
239 pub dog: BTreeMap<String, toml::Table>,
240}
241
242/// Redacts `dog`: only the table count is printed.
243impl fmt::Debug for DaemonConfig {
244 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245 f.debug_struct("DaemonConfig")
246 .field("daemon", &self.daemon)
247 .field("whistle", &self.whistle)
248 .field("secrets", &self.secrets)
249 .field("style", &self.style)
250 .field("interpreters", &self.interpreters)
251 .field("dog", &format_args!("<{} tables>", self.dog.len()))
252 .finish()
253 }
254}
255
256#[derive(Deserialize, Default)]
257#[serde(deny_unknown_fields, default)]
258struct RawDaemonConfig {
259 daemon: DaemonSection,
260 whistle: WhistleSection,
261 secrets: SecretsSection,
262 style: StyleSection,
263 interpreters: BTreeMap<String, String>,
264 dog: BTreeMap<String, toml::Table>,
265}
266
267impl DaemonConfig {
268 /// Builds config from optional file source + environment overrides.
269 ///
270 /// `file < env`, validated. Equivalent to [`Self::load_layered`] with
271 /// an empty [`DaemonOverrides`].
272 ///
273 /// # Errors
274 /// - [`DaemonConfigError::Toml`]: the file source is invalid TOML.
275 /// - [`DaemonConfigError::BadEnvValue`]: a `SHEP_*` value is not parseable.
276 /// - [`DaemonConfigError::BelowMinimum`]: the effective `max_cron_sleep` is below the floor.
277 /// - [`DaemonConfigError::InvalidEnvironment`]: the effective `environment` is `all` or falls outside the secrets store's name grammar.
278 pub fn load(
279 file_source: Option<&str>,
280 env: &dyn Fn(&str) -> Option<String>,
281 ) -> Result<Self, DaemonConfigError> {
282 Self::load_layered(file_source, env, &DaemonOverrides::new())
283 }
284
285 /// Builds config from optional file source + environment + CLI-flag
286 /// overrides.
287 ///
288 /// `file < env < flags` (spec §5), validated exactly once, at the end,
289 /// so a later layer can rescue a value an earlier one would reject.
290 ///
291 /// # Errors
292 /// - [`DaemonConfigError::Toml`]: the file source is invalid TOML.
293 /// - [`DaemonConfigError::BadEnvValue`]: a `SHEP_*` value is not parseable.
294 /// - [`DaemonConfigError::BelowMinimum`]: the effective `max_cron_sleep` is below the floor.
295 /// - [`DaemonConfigError::InvalidEnvironment`]: the effective `environment` is `all` or falls outside the secrets store's name grammar.
296 pub fn load_layered(
297 file_source: Option<&str>,
298 env: &dyn Fn(&str) -> Option<String>,
299 overrides: &DaemonOverrides,
300 ) -> Result<Self, DaemonConfigError> {
301 let raw: RawDaemonConfig = match file_source {
302 Some(src) => toml::from_str(src).map_err(|e| DaemonConfigError::Toml(e.to_string()))?,
303 None => RawDaemonConfig::default(),
304 };
305 let mut cfg = Self {
306 daemon: raw.daemon,
307 whistle: raw.whistle,
308 secrets: raw.secrets,
309 style: raw.style,
310 interpreters: raw.interpreters,
311 dog: raw.dog,
312 };
313 if let Some(v) = env("SHEP_LOG_JSON") {
314 cfg.daemon.log_json = match parse_daemon_bool(&v) {
315 Some(value) => value,
316 None => return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_JSON", v)),
317 };
318 }
319 if let Some(v) = env("SHEP_LOG_LEVEL") {
320 let Some(level) = LogLevel::from_name(&v) else {
321 return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_LEVEL", v));
322 };
323 cfg.daemon.log_level = level;
324 }
325 if let Some(v) = env("SHEP_SOCKET") {
326 cfg.daemon.socket = Some(std::path::PathBuf::from(v));
327 }
328 // Whichever layer last wrote max_cron_sleep is the key the refusal
329 // names, so the operator is pointed at the thing they can edit.
330 // Validating per layer instead would stop a good override from
331 // rescuing a bad one below it.
332 let mut max_cron_sleep_key = "max_cron_sleep";
333 if let Some(v) = env("SHEP_MAX_CRON_SLEEP") {
334 let parsed = v
335 .parse::<UpDuration>()
336 .map_err(|_| DaemonConfigError::BadEnvValue("SHEP_MAX_CRON_SLEEP", v))?;
337 cfg.daemon.max_cron_sleep = Some(parsed);
338 max_cron_sleep_key = "SHEP_MAX_CRON_SLEEP";
339 }
340 if let Some(value) = overrides.log_json {
341 cfg.daemon.log_json = value;
342 }
343 if let Some(value) = overrides.log_level {
344 cfg.daemon.log_level = value;
345 }
346 if let Some(value) = &overrides.socket {
347 cfg.daemon.socket = Some(value.clone());
348 }
349 if let Some(value) = overrides.max_cron_sleep {
350 cfg.daemon.max_cron_sleep = Some(value);
351 max_cron_sleep_key = "--max-cron-sleep";
352 }
353 cfg.validate(max_cron_sleep_key)?;
354 Ok(cfg)
355 }
356
357 /// Checks every invariant a `DaemonConfig` carries, whatever layers
358 /// produced it. One call site, at the bottom of [`Self::load_layered`]: validating
359 /// per layer would stop a good `--max-cron-sleep` from rescuing a
360 /// broken `shep.toml`.
361 ///
362 /// `key` is provenance: the spelling the operator actually set, so the
363 /// refusal names the thing they can edit. Private; guards construction,
364 /// not a later mutation of a `pub` field.
365 ///
366 /// # Errors
367 /// - [`DaemonConfigError::BelowMinimum`]: `max_cron_sleep` is under the floor.
368 /// - [`DaemonConfigError::InvalidEnvironment`]: `environment` is `all` or falls outside the secrets store's name grammar.
369 fn validate(&self, key: &'static str) -> Result<(), DaemonConfigError> {
370 if self.daemon.environment == secrets::ALL_ENVIRONMENTS
371 || !secrets::is_name(&self.daemon.environment)
372 {
373 return Err(DaemonConfigError::InvalidEnvironment(
374 self.daemon.environment.clone(),
375 ));
376 }
377 if let Some(value) = self.daemon.max_cron_sleep
378 && value < MIN_CRON_SLEEP
379 {
380 return Err(DaemonConfigError::BelowMinimum {
381 key,
382 value,
383 min: MIN_CRON_SLEEP,
384 });
385 }
386 Ok(())
387 }
388}
389
390/// The CLI-flag layer of `file < env < flags` (spec §5).
391///
392/// Every field is `Option`: `None` means the flag was absent and the
393/// layer below wins. Nothing here validates; [`DaemonConfig::load_layered`]
394/// runs the single validation pass once, after all three layers.
395///
396/// `#[non_exhaustive]`: this type grows a field whenever the hidden
397/// `daemon` subcommand grows a flag. Build one with [`Self::new`] and the
398/// chained setters.
399///
400/// `Debug` is derived, not redacted: four values, none a secret.
401#[non_exhaustive]
402#[derive(Debug, Clone, Default, PartialEq, Eq)]
403pub struct DaemonOverrides {
404 /// `--log-json`
405 pub log_json: Option<bool>,
406 /// `--log-level`
407 pub log_level: Option<LogLevel>,
408 /// `--socket`
409 pub socket: Option<PathBuf>,
410 /// `--max-cron-sleep`
411 pub max_cron_sleep: Option<UpDuration>,
412}
413
414impl DaemonOverrides {
415 /// An empty layer: every flag absent.
416 #[must_use]
417 pub fn new() -> Self {
418 Self::default()
419 }
420
421 /// Sets the `--log-json` override.
422 #[must_use]
423 pub fn log_json(mut self, value: Option<bool>) -> Self {
424 self.log_json = value;
425 self
426 }
427
428 /// Sets the `--log-level` override.
429 #[must_use]
430 pub fn log_level(mut self, value: Option<LogLevel>) -> Self {
431 self.log_level = value;
432 self
433 }
434
435 /// Sets the `--socket` override.
436 #[must_use]
437 pub fn socket(mut self, value: Option<PathBuf>) -> Self {
438 self.socket = value;
439 self
440 }
441
442 /// Sets the `--max-cron-sleep` override.
443 #[must_use]
444 pub fn max_cron_sleep(mut self, value: Option<UpDuration>) -> Self {
445 self.max_cron_sleep = value;
446 self
447 }
448}
449
450/// The boolean grammar of `shep.toml` and the `SHEP_*` environment: `1`,
451/// `0`, `true`, `false`, and nothing else.
452///
453/// One function so the file/env layer and the `--log-json` flag cannot
454/// drift. clap's own `BoolishValueParser` additionally accepts
455/// `yes`/`no`/`y`/`n`/`on`/`off`; using it would widen the grammar on the
456/// flag side only.
457///
458/// Not a general boolean parser: exporting it only under this name keeps
459/// exactly one answer to what counts as true in shep's daemon config.
460#[must_use]
461pub fn parse_daemon_bool(value: &str) -> Option<bool> {
462 match value {
463 "1" | "true" => Some(true),
464 "0" | "false" => Some(false),
465 _ => None,
466 }
467}
468
469/// Error type returned from [`DaemonConfig::load`].
470///
471/// `#[non_exhaustive]`: every `[daemon]` key this crate learns to validate
472/// brings its own rejection reason, and `deferred.md`'s daemon-config
473/// flags layer is a whole set of them at once.
474#[non_exhaustive]
475#[derive(Debug, Clone, PartialEq, Eq)]
476pub enum DaemonConfigError {
477 /// `shep.toml` is invalid TOML (carries the parser message)
478 Toml(String),
479 /// A `SHEP_*` env var held an unparseable value (var name, value)
480 BadEnvValue(&'static str, String),
481 /// A `[daemon]` duration is below the floor that keeps the daemon from
482 /// spinning. Carries the key the user actually set: the TOML key or
483 /// the environment variable, whichever supplied the winning value.
484 BelowMinimum {
485 /// `max_cron_sleep` or `SHEP_MAX_CRON_SLEEP`.
486 key: &'static str,
487 /// The value as the user wrote it.
488 value: UpDuration,
489 /// The floor it failed.
490 min: UpDuration,
491 },
492 /// `[daemon] environment` is [`crate::secrets::ALL_ENVIRONMENTS`], the
493 /// secrets store's every-environment slot, or falls outside the grammar
494 /// [`crate::secrets`] keys and environment names share. Carries the
495 /// value as written.
496 InvalidEnvironment(String),
497}
498
499impl fmt::Display for DaemonConfigError {
500 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
501 match self {
502 Self::Toml(m) => write!(f, "invalid shep.toml: {m}"),
503 Self::BadEnvValue(var, v) => write!(f, "invalid value `{v}` for {var}"),
504 Self::BelowMinimum { key, value, min } => {
505 write!(
506 f,
507 "invalid value `{value}` for {key}: must be at least {min}"
508 )
509 }
510 Self::InvalidEnvironment(value) => write!(
511 f,
512 "invalid value `{value}` for environment: must be 1-{} bytes of \
513 `[A-Za-z0-9._-]` not starting with `.`, and not `{}` (the secrets \
514 store's every-environment slot)",
515 secrets::MAX_KEY_BYTES,
516 secrets::ALL_ENVIRONMENTS
517 ),
518 }
519 }
520}
521
522impl core::error::Error for DaemonConfigError {}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::values::UpDuration;
528
529 fn no_env(_: &str) -> Option<String> {
530 None
531 }
532
533 // fails if a serde default invents 60s in shep-core and takes the
534 // "unset" state away from the layer below
535 #[test]
536 fn missing_max_cron_sleep_leaves_the_field_none() {
537 let cfg = DaemonConfig::load(None, &no_env).unwrap();
538 assert_eq!(cfg.daemon.max_cron_sleep, None);
539 }
540
541 // fails if the field is a bare integer, where "5m" is a TOML error and
542 // "5" is five milliseconds
543 #[test]
544 fn max_cron_sleep_file_value_parses_via_upduration() {
545 let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &no_env).unwrap();
546 assert_eq!(
547 cfg.daemon.max_cron_sleep,
548 Some(UpDuration::from_millis(5 * 60_000))
549 );
550 }
551
552 // fails if the env read is placed before the file is folded in, or
553 // omitted entirely
554 #[test]
555 fn env_max_cron_sleep_beats_file_value() {
556 let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "90s".to_string());
557 let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env).unwrap();
558 assert_eq!(
559 cfg.daemon.max_cron_sleep,
560 Some(UpDuration::from_millis(90_000))
561 );
562 }
563
564 // fails if the env read swallows its parse failure (`.ok()` and drop
565 // it, or an `Err` arm that only logs), leaving the file's value
566 // silently in force and the typo invisible
567 #[test]
568 fn bad_env_max_cron_sleep_is_a_typed_error() {
569 let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "banana".to_string());
570 assert_eq!(
571 DaemonConfig::load(None, &env),
572 Err(DaemonConfigError::BadEnvValue(
573 "SHEP_MAX_CRON_SLEEP",
574 "banana".to_string()
575 ))
576 );
577 }
578
579 // fails if the floor is compared with `>` instead of `>=`, or the check
580 // silently clamps instead of rejecting
581 #[test]
582 fn max_cron_sleep_floor_rejects_below_one_second() {
583 let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"1s\""), &no_env).unwrap();
584 assert_eq!(
585 cfg.daemon.max_cron_sleep,
586 Some(UpDuration::from_millis(1_000))
587 );
588
589 assert_eq!(
590 DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"999\""), &no_env),
591 Err(DaemonConfigError::BelowMinimum {
592 key: "max_cron_sleep",
593 value: UpDuration::from_millis(999),
594 min: UpDuration::from_millis(1_000),
595 })
596 );
597 }
598
599 // fails if only the file value is validated and never the override, or
600 // if the reported key is the file's even though the environment
601 // introduced the fault
602 #[test]
603 fn env_max_cron_sleep_floor_check_runs_on_the_winner() {
604 let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "0".to_string());
605 assert_eq!(
606 DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env),
607 Err(DaemonConfigError::BelowMinimum {
608 key: "SHEP_MAX_CRON_SLEEP",
609 value: UpDuration::from_millis(0),
610 min: UpDuration::from_millis(1_000),
611 })
612 );
613 }
614
615 // fails if the message wording drifts (e.g. "invalid" alone, or the
616 // `key`/`min` operands swapped): this is what actually reaches
617 // `shepd.err.log` on exit code 4.
618 #[test]
619 fn below_minimum_display_is_exact() {
620 let err = DaemonConfigError::BelowMinimum {
621 key: "max_cron_sleep",
622 value: UpDuration::from_millis(999),
623 min: UpDuration::from_millis(1_000),
624 };
625 assert_eq!(
626 err.to_string(),
627 "invalid value `999` for max_cron_sleep: must be at least 1s"
628 );
629 }
630
631 #[test]
632 fn missing_file_yields_defaults() {
633 let cfg = DaemonConfig::load(None, &no_env).unwrap();
634 assert!(!cfg.daemon.log_json);
635 assert!(cfg.daemon.enabled_dogs.is_empty());
636 assert!(cfg.dog.is_empty());
637 }
638
639 #[test]
640 fn file_sets_values_and_keeps_dog_sections_raw() {
641 let src = r#"
642[daemon]
643log_json = true
644enabled_dogs = ["metrics"]
645
646[dog.metrics]
647port = 9615
648"#;
649 let cfg = DaemonConfig::load(Some(src), &no_env).unwrap();
650 assert!(cfg.daemon.log_json);
651 assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics"]);
652 assert_eq!(cfg.dog["metrics"]["port"].as_integer(), Some(9615));
653 }
654
655 /// `adopted_dogs` needs `default` (existing files predate it) and
656 /// `deny_unknown_fields` (a typo names a binary shep would otherwise
657 /// run at the daemon's own trust level).
658 #[test]
659 fn adopted_dogs_default_empty_and_round_trip_by_name() {
660 let bare = DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env).unwrap();
661 assert!(bare.daemon.adopted_dogs.is_empty());
662
663 let src = r#"
664[daemon]
665enabled_dogs = ["metrics", "otel"]
666
667[daemon.adopted_dogs]
668otel = "/usr/local/bin/shep-otel"
669"#;
670 let cfg = DaemonConfig::load(Some(src), &no_env).unwrap();
671 assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics", "otel"]);
672 assert_eq!(
673 cfg.daemon.adopted_dogs.get("otel"),
674 Some(&std::path::PathBuf::from("/usr/local/bin/shep-otel"))
675 );
676 assert!(
677 !cfg.daemon.adopted_dogs.contains_key("metrics"),
678 "a name with no entry here is a built-in, and that is the whole distinction"
679 );
680 }
681
682 // fails if the key is unknown, which deny_unknown_fields turns into a
683 // startup error, or if it is not defaulted
684 #[test]
685 fn boot_first_dogs_parses_and_defaults_empty() {
686 let config = DaemonConfig::load(
687 Some(
688 r#"
689[daemon]
690enabled_dogs = ["metrics"]
691boot_first_dogs = ["log-rotate"]
692"#,
693 ),
694 &no_env,
695 )
696 .expect("boot_first_dogs is a known key");
697 assert_eq!(
698 config.daemon.boot_first_dogs,
699 vec!["log-rotate".to_string()]
700 );
701
702 let bare =
703 DaemonConfig::load(Some("[daemon]\n"), &no_env).expect("an empty section parses");
704 assert!(bare.daemon.boot_first_dogs.is_empty());
705 }
706
707 #[test]
708 fn env_overrides_file() {
709 let env = |k: &str| (k == "SHEP_LOG_JSON").then(|| "true".to_string());
710 let cfg = DaemonConfig::load(Some("[daemon]\nlog_json = false"), &env).unwrap();
711 assert!(cfg.daemon.log_json);
712 }
713
714 // The default decides what an unconfigured operator actually sees:
715 // `Off` hides every warn-and-continue arm, `Info` and below bury them.
716 // fails if `#[default]` moves, or a serde default disagrees with it.
717 #[test]
718 fn an_unset_log_level_is_warn() {
719 assert_eq!(
720 DaemonConfig::load(None, &no_env).unwrap().daemon.log_level,
721 LogLevel::Warn
722 );
723 }
724
725 #[test]
726 fn the_host_environment_defaults_to_production() {
727 let cfg = DaemonConfig::load(None, &|_| None).unwrap();
728 assert_eq!(cfg.daemon.environment, "production");
729 }
730
731 #[test]
732 fn the_host_environment_reads_from_the_file() {
733 let cfg =
734 DaemonConfig::load(Some("[daemon]\nenvironment = \"staging\"\n"), &|_| None).unwrap();
735 assert_eq!(cfg.daemon.environment, "staging");
736 }
737
738 #[test]
739 fn the_host_environment_cannot_be_all() {
740 // `all` is the secrets store's every-environment slot. A host
741 // default of `all` would put every sheep with no environment of
742 // its own there, bypassing the same refusal `normalize.rs` gives a
743 // sheep that names `all` directly.
744 let err =
745 DaemonConfig::load(Some("[daemon]\nenvironment = \"all\"\n"), &|_| None).unwrap_err();
746 // The variant and the value it carries, not the rendered text: the
747 // message interpolates `ALL_ENVIRONMENTS` whatever it refused, so its
748 // words cannot say which check fired.
749 assert_eq!(
750 err,
751 DaemonConfigError::InvalidEnvironment(secrets::ALL_ENVIRONMENTS.to_string())
752 );
753 }
754
755 #[test]
756 fn a_host_environment_outside_the_grammar_is_refused() {
757 for bad in ["", "has space", "has/slash"] {
758 let source = format!("[daemon]\nenvironment = \"{bad}\"\n");
759 assert!(
760 DaemonConfig::load(Some(&source), &|_| None).is_err(),
761 "{bad:?} must be refused"
762 );
763 }
764 }
765
766 // `as_str`, `from_name` and serde's `rename_all` are three separate
767 // spellings of the same mapping; nothing else keeps them in agreement.
768 // fails if any one drifts from the other two.
769 #[test]
770 fn every_log_level_name_means_the_same_thing_in_the_file_and_the_environment() {
771 let levels = [
772 LogLevel::Off,
773 LogLevel::Error,
774 LogLevel::Warn,
775 LogLevel::Info,
776 LogLevel::Debug,
777 LogLevel::Trace,
778 ];
779 for level in levels {
780 let name = level.as_str();
781 assert_eq!(LogLevel::from_name(name), Some(level), "from_name({name})");
782
783 let file = format!("[daemon]\nlog_level = \"{name}\"");
784 let cfg = DaemonConfig::load(Some(&file), &no_env).unwrap();
785 assert_eq!(cfg.daemon.log_level, level, "[daemon] log_level = {name:?}");
786
787 let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| name.to_string());
788 let cfg = DaemonConfig::load(None, &env).unwrap();
789 assert_eq!(cfg.daemon.log_level, level, "SHEP_LOG_LEVEL={name}");
790 }
791 }
792
793 // fails if the env read is placed before the file is folded in, or
794 // omitted entirely.
795 #[test]
796 fn env_log_level_beats_file_value() {
797 let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "debug".to_string());
798 let cfg = DaemonConfig::load(Some("[daemon]\nlog_level = \"error\""), &env).unwrap();
799 assert_eq!(cfg.daemon.log_level, LogLevel::Debug);
800 }
801
802 // fails if the env read swallows an unknown name and leaves the
803 // default standing, or if the grammar is widened to accept
804 // case-insensitive names.
805 #[test]
806 fn bad_env_log_level_is_a_typed_error() {
807 for value in ["verbose", "WARN", ""] {
808 let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| value.to_string());
809 assert_eq!(
810 DaemonConfig::load(None, &env),
811 Err(DaemonConfigError::BadEnvValue(
812 "SHEP_LOG_LEVEL",
813 value.to_string()
814 )),
815 "SHEP_LOG_LEVEL={value:?}"
816 );
817 }
818 }
819
820 // fails if a `#[serde(other)]` catch-all swallows a misspelled level
821 // into a silent fallback. Pins "unknown variant", not just the
822 // misspelled name, since that phrase is the only one exclusive to the
823 // level being rejected rather than to some other unknown key.
824 #[test]
825 fn bad_file_log_level_is_a_toml_error() {
826 let err = DaemonConfig::load(Some("[daemon]\nlog_level = \"verbose\""), &no_env)
827 .expect_err("a misspelled level must not parse");
828 let DaemonConfigError::Toml(message) = err else {
829 panic!("a misspelled level is a TOML error, not {err:?}");
830 };
831 assert!(
832 message.contains("unknown variant `verbose`"),
833 "the error must reject the level's own name, not some other key: {message:?}"
834 );
835 }
836
837 #[test]
838 fn socket_override_via_file_and_env() {
839 let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &no_env).unwrap();
840 assert_eq!(
841 cfg.daemon.socket.as_deref(),
842 Some(std::path::Path::new("/tmp/a.sock"))
843 );
844 let env = |k: &str| (k == "SHEP_SOCKET").then(|| "/tmp/b.sock".to_string());
845 let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &env).unwrap();
846 assert_eq!(
847 cfg.daemon.socket.as_deref(),
848 Some(std::path::Path::new("/tmp/b.sock"))
849 );
850 }
851
852 #[test]
853 fn bad_toml_is_a_typed_error() {
854 assert!(matches!(
855 DaemonConfig::load(Some("[daemon"), &no_env),
856 Err(DaemonConfigError::Toml(_))
857 ));
858 }
859
860 // fails if `[whistle]` becomes an unrecognized section: `shep daemon`
861 // would exit 4, and an operator who turned control tools on would
862 // lose their shepherd on the next boot.
863 #[test]
864 fn a_whistle_section_parses_and_defaults_to_refusing_control() {
865 let cfg = DaemonConfig::load(Some("[whistle]\nallow_control = true\n"), &no_env).unwrap();
866 assert!(cfg.whistle.allow_control);
867
868 let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
869 assert!(
870 !absent.whistle.allow_control,
871 "a file with no [whistle] section leaves control off"
872 );
873
874 // A present-but-empty table is the only input that reaches
875 // `allow_control`'s own field-level default; an absent `[whistle]`
876 // table is filled by the container-level default instead.
877 let empty_table = DaemonConfig::load(Some("[whistle]\n"), &no_env).unwrap();
878 assert!(
879 !empty_table.whistle.allow_control,
880 "a [whistle] section with no keys leaves control off"
881 );
882 }
883
884 // fails if `[secrets]` becomes an unrecognized section, or if the gate
885 // stops defaulting shut. A misspelled key is a named error for
886 // `[whistle]`'s reason: an operator certain a value was readable and a
887 // CLI certain it was not.
888 #[test]
889 fn a_secrets_section_parses_and_defaults_to_refusing_reads() {
890 let cfg = DaemonConfig::load(Some("[secrets]\nallow_read = true\n"), &no_env).unwrap();
891 assert!(cfg.secrets.allow_read);
892
893 let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
894 assert!(
895 !absent.secrets.allow_read,
896 "a file with no [secrets] section leaves reads off"
897 );
898
899 let empty_table = DaemonConfig::load(Some("[secrets]\n"), &no_env).unwrap();
900 assert!(
901 !empty_table.secrets.allow_read,
902 "a [secrets] section with no keys leaves reads off"
903 );
904
905 let err = DaemonConfig::load(Some("[secrets]\nallow_reads = true\n"), &no_env).unwrap_err();
906 let DaemonConfigError::Toml(message) = err else {
907 panic!("a misspelled key is a TOML error, got {err:?}")
908 };
909 assert!(
910 message.contains("unknown field `allow_reads`"),
911 "the message quotes the key that was not understood: {message}"
912 );
913 }
914
915 // fails if the section silently accepts a key it does not implement. A
916 // `[whistle] allow_contro = true` typo that parsed would leave an
917 // operator certain the gate was open and whistle certain it was shut,
918 // with nothing anywhere saying otherwise.
919 #[test]
920 fn a_misspelled_whistle_key_is_a_named_error() {
921 let err =
922 DaemonConfig::load(Some("[whistle]\nallow_contro = true\n"), &no_env).unwrap_err();
923 let DaemonConfigError::Toml(message) = err else {
924 panic!("a misspelled key is a TOML error, got {err:?}")
925 };
926 // The full quoted form, not the bare stem: `"allow_control"` also
927 // contains `"allow_contro"`, so a stem-only assertion could pass
928 // on a message naming only what serde expected.
929 assert!(
930 message.contains("unknown field `allow_contro`"),
931 "the message quotes the key that was not understood: {message}"
932 );
933 }
934
935 // fails if validation moves back into a per-layer position: a later
936 // layer must be able to rescue a value an earlier one would reject.
937 #[test]
938 fn a_flag_rescues_a_below_floor_file_value() {
939 let cfg = DaemonConfig::load_layered(
940 Some("[daemon]\nmax_cron_sleep = \"500\"\n"),
941 &no_env,
942 &DaemonOverrides::new().max_cron_sleep(Some(UpDuration::from_millis(300_000))),
943 )
944 .unwrap();
945 assert_eq!(
946 cfg.daemon.max_cron_sleep,
947 Some(UpDuration::from_millis(300_000))
948 );
949 }
950
951 // fails if a below-floor FLAG is accepted, or if the refusal names the
952 // TOML key the operator did not set.
953 #[test]
954 fn a_below_floor_flag_is_refused_naming_the_flag() {
955 let err = DaemonConfig::load_layered(
956 None,
957 &no_env,
958 &DaemonOverrides::new().max_cron_sleep(Some(UpDuration::from_millis(500))),
959 )
960 .unwrap_err();
961 assert_eq!(
962 err,
963 DaemonConfigError::BelowMinimum {
964 key: "--max-cron-sleep",
965 value: UpDuration::from_millis(500),
966 min: MIN_CRON_SLEEP,
967 }
968 );
969 assert!(err.to_string().contains("--max-cron-sleep"), "got: {err}");
970 }
971
972 // fails if a flag stops beating the env layer.
973 #[test]
974 fn a_flag_beats_the_environment() {
975 let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "trace".to_string());
976 let cfg = DaemonConfig::load_layered(
977 Some("[daemon]\nlog_level = \"error\"\n"),
978 &env,
979 &DaemonOverrides::new().log_level(Some(LogLevel::Info)),
980 )
981 .unwrap();
982 assert_eq!(cfg.daemon.log_level, LogLevel::Info);
983 }
984
985 // Pins that `load` and `load_layered` agree when no flag is set. Does
986 // not catch a `bool` standing in for `Option<bool>`, since both sides
987 // route through the same code; other tests in this file and cli_e2e
988 // pin that instead.
989 #[test]
990 fn an_absent_flag_leaves_every_lower_layer_alone() {
991 let src = "[daemon]\nlog_json = true\nlog_level = \"debug\"\nsocket = \"/tmp/s.sock\"\n";
992 let layered =
993 DaemonConfig::load_layered(Some(src), &no_env, &DaemonOverrides::new()).unwrap();
994 let plain = DaemonConfig::load(Some(src), &no_env).unwrap();
995 assert_eq!(layered, plain);
996 }
997
998 #[test]
999 fn the_bool_grammar_is_exactly_four_spellings() {
1000 assert_eq!(parse_daemon_bool("1"), Some(true));
1001 assert_eq!(parse_daemon_bool("0"), Some(false));
1002 assert_eq!(parse_daemon_bool("true"), Some(true));
1003 assert_eq!(parse_daemon_bool("false"), Some(false));
1004 for wider in ["yes", "no", "on", "off", "TRUE", "y"] {
1005 assert_eq!(
1006 parse_daemon_bool(wider),
1007 None,
1008 "{wider} must not be a boolean here"
1009 );
1010 }
1011 }
1012
1013 // fails if `[interpreters]` stops parsing as a plain extension ->
1014 // interpreter map, or if a value written as a bare word (no quotes
1015 // needed, since these are ordinary TOML strings) fails to round-trip.
1016 #[test]
1017 fn interpreters_parses_as_an_extension_map() {
1018 let cfg = DaemonConfig::load(
1019 Some("[interpreters]\njs = \"node\"\npy = \"python3\"\n"),
1020 &no_env,
1021 )
1022 .unwrap();
1023 assert_eq!(cfg.interpreters.get("js").map(String::as_str), Some("node"));
1024 assert_eq!(
1025 cfg.interpreters.get("py").map(String::as_str),
1026 Some("python3")
1027 );
1028 assert_eq!(cfg.interpreters.len(), 2);
1029 }
1030
1031 // An empty/absent `[interpreters]` must not fail a `shep.toml` that
1032 // never mentions the section, which is most of them until an operator
1033 // (or the first-run scaffold) writes one.
1034 #[test]
1035 fn interpreters_defaults_to_empty() {
1036 assert!(
1037 DaemonConfig::load(None, &no_env)
1038 .unwrap()
1039 .interpreters
1040 .is_empty()
1041 );
1042 assert!(
1043 DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env)
1044 .unwrap()
1045 .interpreters
1046 .is_empty()
1047 );
1048 }
1049
1050 // `[interpreters]` values are arbitrary extension keys, not a fixed
1051 // field set, so `deny_unknown_fields` (which governs struct fields)
1052 // must not reject an extension this build has never heard of.
1053 #[test]
1054 fn an_unrecognised_extension_is_not_an_unknown_field() {
1055 let cfg = DaemonConfig::load(Some("[interpreters]\nlua = \"lua5.4\"\n"), &no_env).unwrap();
1056 assert_eq!(
1057 cfg.interpreters.get("lua").map(String::as_str),
1058 Some("lua5.4")
1059 );
1060 }
1061
1062 // A value that is not a string (an operator's `js = 5`, say) is still
1063 // a parse error, shep-core's usual fail-loudly-at-parse-time rule.
1064 #[test]
1065 fn a_non_string_interpreter_value_is_a_parse_error() {
1066 assert!(DaemonConfig::load(Some("[interpreters]\njs = 5\n"), &no_env).is_err());
1067 }
1068
1069 #[test]
1070 fn debug_redacts_dog_values() {
1071 // Dog tables carry things like webhook URLs; a lazy derive(Debug)
1072 // would land them in daemon logs. Exact string pinned so that
1073 // regression fails here instead of leaking a secret.
1074 let cfg = DaemonConfig::load(Some("[dog.metrics]\nport = 9615"), &no_env).unwrap();
1075 assert_eq!(
1076 format!("{cfg:?}"),
1077 "DaemonConfig { daemon: DaemonSection { log_json: false, log_level: Warn, environment: \"production\", socket: None, enabled_dogs: [], adopted_dogs: {}, boot_first_dogs: [], max_cron_sleep: None }, whistle: WhistleSection { allow_control: false }, secrets: SecretsSection { allow_read: false }, style: StyleSection { level: None }, interpreters: {}, dog: <1 tables> }"
1078 );
1079 }
1080}