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