Skip to main content

shep_core/config/daemon/
sections.rs

1use crate::values::UpDuration;
2use serde::Deserialize;
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5
6/// The `[daemon]` section
7#[derive(Debug, Clone, PartialEq, Deserialize)]
8#[serde(deny_unknown_fields, default)]
9pub struct DaemonSection {
10    /// Emit the daemon's own logs as JSON lines
11    pub log_json: bool,
12    /// Lowest severity of the daemon's own records that reaches its log
13    pub log_level: LogLevel,
14    /// The environment every sheep resolves in unless it sets its own.
15    ///
16    /// A shepherd supervising real processes on a host is production unless
17    /// somebody says otherwise.
18    pub environment: String,
19    /// Control-socket path override (default: `$SHEP_HOME/run/shep.sock`)
20    pub socket: Option<PathBuf>,
21    /// Dogs to autostart with the daemon (`shep enable` writes this)
22    pub enabled_dogs: Vec<String>,
23    /// Where an adopted dog's binary lives, keyed by dog name
24    /// (`shep adopt` writes this; `shep rehome` removes it).
25    ///
26    /// A name in [`Self::enabled_dogs`] with no entry here is a built-in
27    /// dog, an argv branch of the shep binary itself. Not recorded inside
28    /// `[dog.<name>]`: that table is the dog's own opaque configuration,
29    /// and a shep-owned key inside it would collide with a third-party
30    /// dog's schema.
31    pub adopted_dogs: BTreeMap<String, PathBuf>,
32    /// Adopted dogs whose `--version` answer asked for the shepherd channel,
33    /// so they are spawned with `channel` and `shutdown_with_message` set.
34    ///
35    /// `shep adopt` writes this from the dog's own answer, never from an
36    /// operator's flag, and `shep rehome` removes it. Here rather than in
37    /// `dogs.toml` for the reason [`Self::adopted_dogs`] gives. A name
38    /// absent from [`Self::adopted_dogs`] is inert here.
39    pub channel_dogs: Vec<String>,
40    /// Dogs that run before every sheep, rather than after the flock.
41    ///
42    /// The default position for a dog is a final stage, for the reason
43    /// `boot.rs` gives: a metrics dog must not answer for a flock that is not
44    /// up yet. A log-rotation dog is the opposite case, since it has to be
45    /// running before a sheep starts writing. shep cannot tell which is
46    /// which, because an adopted dog is a third-party binary, so the
47    /// operator says.
48    ///
49    /// Here rather than in `dogs.toml` for the reason [`Self::adopted_dogs`]
50    /// gives: that file's `[<name>]` table is the dog's own opaque
51    /// configuration and a shep-owned key inside it would collide with a
52    /// third-party dog's schema.
53    ///
54    /// A name absent from [`Self::enabled_dogs`] is inert here.
55    pub boot_first_dogs: Vec<String>,
56    /// Longest a cron worker sleeps before re-deriving its next occurrence.
57    ///
58    /// Shorter recovers faster from a suspended laptop or an NTP step and
59    /// costs proportionally more wakeups per cron-configured sheep; longer
60    /// is cheaper and drifts further. Unset means the daemon's own default.
61    /// There is no upper bound: a very long value only degrades to sleeping
62    /// straight through to the occurrence, which still fires.
63    pub max_cron_sleep: Option<UpDuration>,
64}
65
66/// Not derived: [`DaemonSection::environment`] defaults to `"production"`,
67/// which `String`'s own `Default` cannot express.
68impl Default for DaemonSection {
69    fn default() -> Self {
70        Self {
71            log_json: false,
72            log_level: LogLevel::default(),
73            environment: "production".to_string(),
74            socket: None,
75            enabled_dogs: Vec::new(),
76            adopted_dogs: BTreeMap::new(),
77            channel_dogs: Vec::new(),
78            boot_first_dogs: Vec::new(),
79            max_cron_sleep: None,
80        }
81    }
82}
83
84/// How much of the daemon's own diagnostics reaches its log.
85///
86/// Written as one of the names below in `[daemon] log_level` or in
87/// `SHEP_LOG_LEVEL`, lowercase and nothing else, the same closed grammar
88/// `log_json` accepts, so a typo is a startup error naming the value
89/// rather than a level silently reverting to the default.
90///
91/// The default is [`LogLevel::Warn`]. The daemon's records are dominated
92/// by warn-and-continue arms, each the only account of a decision the
93/// operator cannot otherwise see. [`LogLevel::Debug`] adds per-decision
94/// detail firing per dropped restart and per child metric sample, a
95/// firehose on a busy flock.
96#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
97#[serde(rename_all = "lowercase")]
98pub enum LogLevel {
99    /// Nothing at all: the daemon writes no records of its own.
100    Off,
101    /// Only faults the daemon could not work around.
102    Error,
103    /// Faults the daemon worked around, and what working around them cost.
104    #[default]
105    Warn,
106    /// Lifecycle milestones: the daemon came up, the daemon is going down.
107    Info,
108    /// Per-decision detail: every restart weighed, every metric sampled.
109    Debug,
110    /// Everything the daemon can say about itself.
111    Trace,
112}
113
114/// Floor on `[daemon] max_cron_sleep`.
115///
116/// Zero makes every sleep return immediately, spinning the loop while
117/// still firing correctly, which is what makes it hard to attribute. One
118/// second is a floor no legitimate configuration wants to be under: a
119/// five-field cron pattern cannot name anything finer than a minute.
120pub(super) const MIN_CRON_SLEEP: UpDuration = UpDuration::from_millis(1_000);
121
122/// The `[whistle]` section.
123///
124/// One key, a gate rather than a tuning knob: `shep whistle`'s four
125/// control tools exist only when this is `true`; its five read-only
126/// tools exist regardless.
127///
128/// Lives only in `shep.toml`, no flag or env var, since config is
129/// auditable where a flag is not. The shepherd itself never reads this
130/// key; `shep whistle` reads the file directly. Declared here anyway
131/// because `RawDaemonConfig` denies unknown fields, so an undeclared
132/// `[whistle]` section would refuse the whole file to boot. `Debug` is
133/// derived, not redacted: one boolean, nothing to leak.
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
135#[serde(deny_unknown_fields, default)]
136pub struct WhistleSection {
137    /// Whether `shep whistle` offers its control tools. Default `false`.
138    pub allow_control: bool,
139}
140
141/// The `[secrets]` section: whether the CLI will print a stored value back.
142///
143/// One key, a gate rather than a tuning knob, for [`WhistleSection`]'s
144/// reason and read the same way: `shep secret get` reads this file itself,
145/// the shepherd never reads this key, and it is declared here so an
146/// undeclared `[secrets]` section is not a refused boot.
147///
148/// `Debug` is derived rather than redacted: one boolean, no secret.
149#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
150#[serde(deny_unknown_fields, default)]
151pub struct SecretsSection {
152    /// Whether `shep secret get` prints a value. Default `false`.
153    pub allow_read: bool,
154}
155
156/// The `[style]` section: how much the CLI dresses up its output.
157///
158/// Read by the CLI only. The daemon has no opinion about how anyone likes
159/// their tables, and parses this solely so an unknown key is not an error.
160///
161/// `Debug` is derived rather than redacted: one optional string, no
162/// secret, nothing a `{:?}` could leak.
163#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
164#[serde(deny_unknown_fields, default)]
165pub struct StyleSection {
166    /// `full`, `plain` or `bare`. Absent means the CLI decides.
167    pub level: Option<String>,
168}
169
170impl LogLevel {
171    /// The one spelling this level is written as, in the file and in the
172    /// environment alike
173    #[must_use]
174    pub const fn as_str(self) -> &'static str {
175        match self {
176            Self::Off => "off",
177            Self::Error => "error",
178            Self::Warn => "warn",
179            Self::Info => "info",
180            Self::Debug => "debug",
181            Self::Trace => "trace",
182        }
183    }
184
185    /// The level `name` spells, or `None` when it spells no level.
186    ///
187    /// The inverse of [`LogLevel::as_str`], and exact: an uppercase or
188    /// mixed-case name is not a level here, because `SHEP_LOG_JSON` accepts
189    /// no `TRUE` either.
190    #[must_use]
191    pub fn from_name(name: &str) -> Option<Self> {
192        match name {
193            "off" => Some(Self::Off),
194            "error" => Some(Self::Error),
195            "warn" => Some(Self::Warn),
196            "info" => Some(Self::Info),
197            "debug" => Some(Self::Debug),
198            "trace" => Some(Self::Trace),
199            _ => None,
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::super::config::DaemonConfig;
207
208    use super::super::testing::*;
209    use super::*;
210
211    #[test]
212    fn an_unset_log_level_is_warn() {
213        assert_eq!(
214            DaemonConfig::load(None, &no_env).unwrap().daemon.log_level,
215            LogLevel::Warn
216        );
217    }
218
219    // `as_str`, `from_name` and serde's `rename_all` are three separate
220    // spellings of the same mapping; nothing else keeps them in agreement.
221    // fails if any one drifts from the other two.
222    #[test]
223    fn every_log_level_name_means_the_same_thing_in_the_file_and_the_environment() {
224        let levels = [
225            LogLevel::Off,
226            LogLevel::Error,
227            LogLevel::Warn,
228            LogLevel::Info,
229            LogLevel::Debug,
230            LogLevel::Trace,
231        ];
232        for level in levels {
233            let name = level.as_str();
234            assert_eq!(LogLevel::from_name(name), Some(level), "from_name({name})");
235
236            let file = format!("[daemon]\nlog_level = \"{name}\"");
237            let cfg = DaemonConfig::load(Some(&file), &no_env).unwrap();
238            assert_eq!(cfg.daemon.log_level, level, "[daemon] log_level = {name:?}");
239
240            let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| name.to_string());
241            let cfg = DaemonConfig::load(None, &env).unwrap();
242            assert_eq!(cfg.daemon.log_level, level, "SHEP_LOG_LEVEL={name}");
243        }
244    }
245
246    #[test]
247    fn debug_redacts_dog_values() {
248        // Dog tables carry things like webhook URLs; a lazy derive(Debug)
249        // would land them in daemon logs. Exact string pinned so that
250        // regression fails here instead of leaking a secret.
251        let cfg = DaemonConfig::load(Some("[dog.metrics]\nport = 9615"), &no_env).unwrap();
252        assert_eq!(
253            format!("{cfg:?}"),
254            "DaemonConfig { daemon: DaemonSection { log_json: false, log_level: Warn, environment: \"production\", socket: None, enabled_dogs: [], adopted_dogs: {}, channel_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> }"
255        );
256    }
257}