Skip to main content

shep_core/config/
apply.rs

1//! How each `AppConfig` field reaches a running sheep.
2//!
3//! Four answers, and the difference between them is where the daemon READS
4//! the field, not what the field means. A value read fresh at each decision
5//! can be swapped under a running process with no disruption; one baked into
6//! the child at exec cannot change until that process is replaced.
7//!
8//! The four entries most likely to look wrong carry their reasoning at their
9//! own arm below. All four were measured against the read sites rather than
10//! inferred from the field's name.
11
12use serde::{Deserialize, Serialize};
13
14/// Where a field's new value takes effect.
15///
16/// `#[non_exhaustive]`: a field could someday be applied by nudging the
17/// running child through the existing `shep reopen` (SIGUSR2) signal path
18/// rather than by a fresh read, a next spawn, or a full respawn -- a
19/// reopen-triggered fifth group distinct from all four below. shep-core is
20/// published, so an out-of-tree match on this enum needs a wildcard arm to
21/// survive that addition without a major version bump.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum ApplyGroup {
25    /// Read fresh at each decision, so a write to the stored spec is enough.
26    Live,
27    /// Read when a process spawns, so a write reaches the next one.
28    NextSpawn,
29    /// Held by the running child, so that instance must be replaced.
30    NeedsRespawn,
31    /// Identity or flock shape, not a runtime knob.
32    Structural,
33}
34
35/// Every `AppConfig` field name this table has been taught about, paired with
36/// its group.
37///
38/// `apply_group` and `is_classified` both read this single list rather than
39/// keeping two hand-maintained lists in step: a field named here only once
40/// cannot drift between "classified" and "has a group".
41const FIELDS: &[(&str, ApplyGroup)] = &[
42    // Read by `brain::decide` when a sheep exits.
43    ("autorestart", ApplyGroup::Live),
44    ("max_restarts", ApplyGroup::Live),
45    ("min_uptime", ApplyGroup::Live),
46    ("restart_delay", ApplyGroup::Live),
47    ("exp_backoff_restart_delay", ApplyGroup::Live),
48    ("stop_exit_codes", ApplyGroup::Live),
49    // Read by `claim_manual` when a kill ladder runs.
50    ("kill_timeout", ApplyGroup::Live),
51    ("graceful_timeout", ApplyGroup::Live),
52    // Read fresh when extras arms a worker. These need a re-arm to take
53    // effect; see `ExtrasRegistry::rearm_name`.
54    ("max_memory", ApplyGroup::Live),
55    ("watch", ApplyGroup::Live),
56    ("ignore_watch", ApplyGroup::Live),
57    ("watch_delay", ApplyGroup::Live),
58    ("watch_options", ApplyGroup::Live),
59    ("cron_restart", ApplyGroup::Live),
60    ("cron_timezone", ApplyGroup::Live),
61    ("liveness_probe", ApplyGroup::Live),
62    // Read fresh per command.
63    ("fold", ApplyGroup::Live),
64    ("reuse_port", ApplyGroup::Live),
65    // Read fresh from the stored spec each time an action is dispatched, at
66    // `supervisor.rs`'s `begin_action` (`config.action_timeout.as_duration()`),
67    // not baked into the long-lived per-sheep task.
68    ("action_timeout", ApplyGroup::Live),
69    // `kill_signal` is NOT Live, despite its two ladder-mates above. It is
70    // read inside `kill_process` from the `app: &AppConfig` parameter of the
71    // long-lived per-sheep task, whose `ResolvedApp` is moved in once at
72    // `spawn_sheep_task` and never refreshed.
73    ("kill_signal", ApplyGroup::NextSpawn),
74    ("listen_timeout", ApplyGroup::NextSpawn),
75    ("readiness_probe", ApplyGroup::NextSpawn),
76    // Read once at muster or boot, by `restorable()`.
77    ("autostart", ApplyGroup::NextSpawn),
78    // Baked into the child at exec: argv, cwd, environment, credentials, the
79    // fd table, the log paths it is already writing to.
80    ("script", ApplyGroup::NeedsRespawn),
81    ("args", ApplyGroup::NeedsRespawn),
82    ("cwd", ApplyGroup::NeedsRespawn),
83    ("interpreter", ApplyGroup::NeedsRespawn),
84    ("env", ApplyGroup::NeedsRespawn),
85    ("user", ApplyGroup::NeedsRespawn),
86    ("group", ApplyGroup::NeedsRespawn),
87    ("out_file", ApplyGroup::NeedsRespawn),
88    ("err_file", ApplyGroup::NeedsRespawn),
89    ("merge_logs", ApplyGroup::NeedsRespawn),
90    ("channel", ApplyGroup::NeedsRespawn),
91    ("stdin", ApplyGroup::NeedsRespawn),
92    ("wait_ready", ApplyGroup::NeedsRespawn),
93    // `shutdown_with_message` belongs here rather than with the kill ladder:
94    // `assemble()` ORs it into whether fd 3 is opened, and that is the
95    // child's own fd table.
96    ("shutdown_with_message", ApplyGroup::NeedsRespawn),
97    ("name", ApplyGroup::Structural),
98    ("instances", ApplyGroup::Structural),
99    // Read only by `normalize` to refuse it by name.
100    ("increment_var", ApplyGroup::Structural),
101];
102
103/// The group `field` belongs to.
104///
105/// An unknown name answers [`ApplyGroup::NeedsRespawn`], the most
106/// conservative of the four: a field this table has not been taught about
107/// gets a restart rather than a silent claim that it applied.
108/// `every_appconfig_field_has_a_group` keeps that arm unreachable for real
109/// fields.
110#[must_use]
111pub fn apply_group(field: &str) -> ApplyGroup {
112    FIELDS
113        .iter()
114        .find(|(name, _)| *name == field)
115        .map_or(ApplyGroup::NeedsRespawn, |(_, group)| *group)
116}
117
118/// Whether `field` is named explicitly in the table above, as opposed to
119/// reaching the conservative fallback. Test-facing.
120#[must_use]
121pub fn is_classified(field: &str) -> bool {
122    FIELDS.iter().any(|(name, _)| *name == field)
123}
124
125/// How much of a Flockfile load overwrites what the operator has set since.
126///
127/// A mode touches what its name says, and the design spec
128/// (`2026-09-02-config-overrides-design.md` ยง3) states each variant against
129/// three columns rather than two: whether `env` is reset, whether a key the
130/// template declares is reset, and whether a key it does not declare is.
131///
132/// **Not a two-by-two grid**, and an earlier version of this comment said it
133/// was. There are two independent choices, but the settings one has three
134/// settings rather than two -- untouched, declared only, or everything --
135/// which makes six combinations. These four are the ones worth having. One
136/// discarded combination resets nothing at all, so it is the additive
137/// default with extra typing. The other is `File` plus `Env`: reset `env`,
138/// and reset only what the template declares, sparing everything it does
139/// not. That one is coherent, not useless, and is left out only because
140/// nobody has asked for it.
141///
142/// `#[non_exhaustive]`: no fifth depth is anticipated, and the attribute buys
143/// SOURCE compatibility rather than wire compatibility. It forces a crate
144/// outside this one to carry a wildcard arm, so adding a variant does not
145/// break its build. It does nothing for serde, which is worth stating because
146/// an earlier version of this comment claimed otherwise: this enum carries no
147/// `#[serde(other)]`, so a build meeting a variant it predates fails to
148/// deserialize with `unknown variant`, measured rather than assumed. That is
149/// the whole reason renaming `Settings` to `Policy` moved `PROTOCOL_VERSION`
150/// to 3 instead of riding the additive precedent.
151#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153#[non_exhaustive]
154pub enum ResetDepth {
155    /// Append keys nobody established. Overwrite nothing. The default,
156    /// because a Flockfile arrives from the app's own repository.
157    ///
158    /// `env`: kept. A key the template declares: kept, unless nobody has
159    /// established it yet, which is the append. A key it does not declare:
160    /// kept, since there is nothing to append it against.
161    #[default]
162    None,
163    /// Put back what the template declares, and nothing else.
164    ///
165    /// `env`: kept. A key the template declares: reset. A key it does not
166    /// declare: kept -- an app stocked to four instances against a file with
167    /// no `instances` line keeps its count, because the file never entered
168    /// that argument. This is the mode that fixes the footgun `Policy` below
169    /// reintroduces.
170    File,
171    /// Put non-`env` settings back to the template, `env` kept. Every
172    /// setting goes back, declared or not: a key the template is silent
173    /// about goes to the value a fresh start off that template would give
174    /// it. `env` is operator-supplied data while the rest is operator-tuned
175    /// policy: resetting policy is recoverable, resetting data takes the
176    /// app's database away.
177    ///
178    /// `env`: kept. A key the template declares: reset. A key it does not
179    /// declare: reset too, to the template's own default.
180    Policy,
181    /// Reset `env` back to the template and leave everything else alone.
182    ///
183    /// This mode touches data, not policy, so it has no opinion about any
184    /// setting: a restart budget the template happens to mention is not the
185    /// operator's to lose to a flag that says `env`. On the settings axis it
186    /// is therefore `None`, append included, because the flag widens a load
187    /// rather than narrowing one.
188    ///
189    /// `env`: reset. A key the template declares: kept, save for the same
190    /// append `None` does. A key it does not declare: kept.
191    Env,
192    /// Put everything back to the template, `env` included, and drop the
193    /// override record.
194    ///
195    /// `env`: reset. A key the template declares: reset. A key it does not
196    /// declare: reset too, to the template's own default.
197    All,
198}
199
200#[cfg(test)]
201mod tests {
202    use super::{ApplyGroup, apply_group, is_classified};
203    use crate::config::AppConfig;
204
205    /// fails if any AppConfig field is missing from the table. A field added
206    /// to the struct without a group would route as its default and either
207    /// apply live when it cannot, or need a restart when it does not.
208    #[test]
209    fn every_appconfig_field_has_a_group() {
210        let serde_json::Value::Object(fields) = serde_json::to_value(AppConfig::default()).unwrap()
211        else {
212            panic!("AppConfig must serialize as an object");
213        };
214        let missing: Vec<&String> = fields.keys().filter(|k| !is_classified(k)).collect();
215        assert!(
216            missing.is_empty(),
217            "unclassified AppConfig fields: {missing:?}"
218        );
219    }
220
221    /// fails if kill_signal is classified Live. It is read from the
222    /// per-sheep task's frozen ResolvedApp, moved in once at
223    /// spawn_sheep_task and never refreshed, so an edit reaches the next
224    /// spawn and not the next kill. Its ladder-mates kill_timeout and
225    /// graceful_timeout ARE read fresh, in claim_manual, which is why this
226    /// one looks like it belongs with them.
227    #[test]
228    fn kill_signal_reaches_the_next_spawn_not_the_next_kill() {
229        assert_eq!(apply_group("kill_signal"), ApplyGroup::NextSpawn);
230        assert_eq!(apply_group("kill_timeout"), ApplyGroup::Live);
231        assert_eq!(apply_group("graceful_timeout"), ApplyGroup::Live);
232    }
233
234    /// fails if shutdown_with_message is classified anything but
235    /// NeedsRespawn. assemble() ORs it into whether fd 3 is opened for the
236    /// child, which is the child's own fd table and cannot change under a
237    /// running process.
238    #[test]
239    fn shutdown_with_message_is_baked_into_the_child() {
240        assert_eq!(
241            apply_group("shutdown_with_message"),
242            ApplyGroup::NeedsRespawn
243        );
244    }
245
246    /// fails if the split drifts from what the spec recorded.
247    #[test]
248    fn the_split_is_nineteen_four_fourteen_three() {
249        let serde_json::Value::Object(fields) = serde_json::to_value(AppConfig::default()).unwrap()
250        else {
251            panic!("AppConfig must serialize as an object");
252        };
253        let count = |want: ApplyGroup| fields.keys().filter(|k| apply_group(k) == want).count();
254        assert_eq!(count(ApplyGroup::Live), 19, "Live");
255        assert_eq!(count(ApplyGroup::NextSpawn), 4, "NextSpawn");
256        assert_eq!(count(ApplyGroup::NeedsRespawn), 14, "NeedsRespawn");
257        assert_eq!(count(ApplyGroup::Structural), 3, "Structural");
258    }
259}