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}