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<std::path::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}