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}