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
6//! into the child at exec cannot change until that process is replaced.
7
8use serde::{Deserialize, Serialize};
9
10/// Where a field's new value takes effect.
11///
12/// `#[non_exhaustive]`: a field could someday be applied by nudging the
13/// running child through `shep reopen`'s SIGUSR2 path, a fifth group
14/// distinct from the four below. shep-core is published, so an
15/// out-of-tree match on this enum needs a wildcard arm to survive that.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum ApplyGroup {
19 /// Read fresh at each decision, so a write to the stored spec is enough.
20 Live,
21 /// Read when a process spawns, so a write reaches the next one.
22 NextSpawn,
23 /// Held by the running child, so that instance must be replaced.
24 NeedsRespawn,
25 /// Identity or flock shape, not a runtime knob.
26 Structural,
27}
28
29/// Every `AppConfig` field name this table has been taught about, paired with
30/// its group.
31///
32/// `apply_group` and `is_classified` both read this single list rather than
33/// keeping two hand-maintained lists in step: a field named here only once
34/// cannot drift between "classified" and "has a group".
35const FIELDS: &[(&str, ApplyGroup)] = &[
36 // Read by `brain::decide` when a sheep exits.
37 ("autorestart", ApplyGroup::Live),
38 ("max_restarts", ApplyGroup::Live),
39 ("min_uptime", ApplyGroup::Live),
40 ("restart_delay", ApplyGroup::Live),
41 ("exp_backoff_restart_delay", ApplyGroup::Live),
42 ("stop_exit_codes", ApplyGroup::Live),
43 // Read by `claim_manual` when a kill ladder runs.
44 ("kill_timeout", ApplyGroup::Live),
45 ("graceful_timeout", ApplyGroup::Live),
46 // Read fresh when extras arms a worker. These need a re-arm to take
47 // effect; see `ExtrasRegistry::rearm_name`.
48 ("max_memory", ApplyGroup::Live),
49 ("watch", ApplyGroup::Live),
50 ("ignore_watch", ApplyGroup::Live),
51 ("watch_delay", ApplyGroup::Live),
52 ("watch_options", ApplyGroup::Live),
53 ("cron_restart", ApplyGroup::Live),
54 ("cron_timezone", ApplyGroup::Live),
55 ("liveness_probe", ApplyGroup::Live),
56 // Read fresh per command.
57 ("fold", ApplyGroup::Live),
58 ("reuse_port", ApplyGroup::Live),
59 // Read fresh from the stored spec each time an action is dispatched, at
60 // `supervisor.rs`'s `begin_action` (`config.action_timeout.as_duration()`),
61 // not baked into the long-lived per-sheep task.
62 ("action_timeout", ApplyGroup::Live),
63 // Unlike its two ladder-mates above, `kill_signal` is read from the
64 // per-sheep task's `ResolvedApp`, moved in once at `spawn_sheep_task`
65 // and never refreshed.
66 ("kill_signal", ApplyGroup::NextSpawn),
67 ("listen_timeout", ApplyGroup::NextSpawn),
68 ("readiness_probe", ApplyGroup::NextSpawn),
69 // Read once at muster or boot, by `restorable()`.
70 ("autostart", ApplyGroup::NextSpawn),
71 // Baked into the child at exec: argv, cwd, environment, credentials, the
72 // fd table, the log paths it is already writing to.
73 ("script", ApplyGroup::NeedsRespawn),
74 ("args", ApplyGroup::NeedsRespawn),
75 ("cwd", ApplyGroup::NeedsRespawn),
76 ("interpreter", ApplyGroup::NeedsRespawn),
77 ("env", ApplyGroup::NeedsRespawn),
78 ("user", ApplyGroup::NeedsRespawn),
79 ("group", ApplyGroup::NeedsRespawn),
80 ("out_file", ApplyGroup::NeedsRespawn),
81 ("err_file", ApplyGroup::NeedsRespawn),
82 ("merge_logs", ApplyGroup::NeedsRespawn),
83 ("channel", ApplyGroup::NeedsRespawn),
84 ("stdin", ApplyGroup::NeedsRespawn),
85 ("wait_ready", ApplyGroup::NeedsRespawn),
86 // `shutdown_with_message` belongs here rather than with the kill ladder:
87 // `assemble()` ORs it into whether fd 3 is opened, and that is the
88 // child's own fd table.
89 ("shutdown_with_message", ApplyGroup::NeedsRespawn),
90 ("name", ApplyGroup::Structural),
91 ("instances", ApplyGroup::Structural),
92 // Read only by `normalize` to refuse it by name.
93 ("increment_var", ApplyGroup::Structural),
94];
95
96/// The group `field` belongs to.
97///
98/// An unknown name answers [`ApplyGroup::NeedsRespawn`], the most
99/// conservative of the four: a field this table has not been taught about
100/// gets a restart rather than a silent claim that it applied.
101/// `every_appconfig_field_has_a_group` keeps that arm unreachable for real
102/// fields.
103#[must_use]
104pub fn apply_group(field: &str) -> ApplyGroup {
105 FIELDS
106 .iter()
107 .find(|(name, _)| *name == field)
108 .map_or(ApplyGroup::NeedsRespawn, |(_, group)| *group)
109}
110
111/// Whether `field` is named explicitly in the table above, as opposed to
112/// reaching the conservative fallback. Test-facing.
113#[must_use]
114pub fn is_classified(field: &str) -> bool {
115 FIELDS.iter().any(|(name, _)| *name == field)
116}
117
118/// How much of a Flockfile load overwrites what the operator has set since.
119///
120/// Two independent axes: whether `env` is reset, and whether a key the
121/// template declares (or does not) is reset. Five of the six combinations
122/// are named here; each variant's own doc states its column values.
123///
124/// `#[non_exhaustive]` buys source compatibility only, forcing an
125/// out-of-tree match to carry a wildcard arm. It does nothing for serde:
126/// this enum has no `#[serde(other)]`, so an older build fails to
127/// deserialize a variant it predates with `unknown variant`.
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130#[non_exhaustive]
131pub enum ResetDepth {
132 /// Append keys nobody established. Overwrite nothing. The default,
133 /// because a Flockfile arrives from the app's own repository.
134 ///
135 /// `env`: kept. A key the template declares: kept, unless nobody has
136 /// established it yet, which is the append. A key it does not declare:
137 /// kept, since there is nothing to append it against.
138 #[default]
139 None,
140 /// Put back what the template declares, and nothing else.
141 ///
142 /// `env`: kept. A key the template declares: reset. A key it does not
143 /// declare: kept, so an app stocked to four instances against a file
144 /// with no `instances` line keeps its count.
145 File,
146 /// Put non-`env` settings back to the template, `env` kept. Every
147 /// setting goes back, declared or not, to the value a fresh start off
148 /// the template would give it: `env` is operator data, the rest is
149 /// operator-tuned policy, and resetting policy is recoverable while
150 /// resetting data is not.
151 ///
152 /// `env`: kept. A key the template declares: reset. A key it does not
153 /// declare: reset too, to the template's own default.
154 Policy,
155 /// Reset `env` back to the template and leave everything else alone.
156 ///
157 /// Touches data, not policy: on the settings axis this behaves like
158 /// `None`, append included, since it widens a load rather than
159 /// narrowing one.
160 ///
161 /// `env`: reset. A key the template declares: kept, save for the same
162 /// append `None` does. A key it does not declare: kept.
163 Env,
164 /// Put everything back to the template, `env` included, and drop the
165 /// override record.
166 ///
167 /// `env`: reset. A key the template declares: reset. A key it does not
168 /// declare: reset too, to the template's own default.
169 All,
170}
171
172#[cfg(test)]
173mod tests {
174 use super::{ApplyGroup, apply_group, is_classified};
175 use crate::config::AppConfig;
176
177 /// fails if any AppConfig field is missing from the table. A field added
178 /// to the struct without a group would route as its default and either
179 /// apply live when it cannot, or need a restart when it does not.
180 #[test]
181 fn every_appconfig_field_has_a_group() {
182 let serde_json::Value::Object(fields) = serde_json::to_value(AppConfig::default()).unwrap()
183 else {
184 panic!("AppConfig must serialize as an object");
185 };
186 let missing: Vec<&String> = fields.keys().filter(|k| !is_classified(k)).collect();
187 assert!(
188 missing.is_empty(),
189 "unclassified AppConfig fields: {missing:?}"
190 );
191 }
192
193 #[test]
194 fn kill_signal_reaches_the_next_spawn_not_the_next_kill() {
195 assert_eq!(apply_group("kill_signal"), ApplyGroup::NextSpawn);
196 assert_eq!(apply_group("kill_timeout"), ApplyGroup::Live);
197 assert_eq!(apply_group("graceful_timeout"), ApplyGroup::Live);
198 }
199
200 #[test]
201 fn shutdown_with_message_is_baked_into_the_child() {
202 assert_eq!(
203 apply_group("shutdown_with_message"),
204 ApplyGroup::NeedsRespawn
205 );
206 }
207
208 /// fails if the split drifts from what the spec recorded.
209 #[test]
210 fn the_split_is_nineteen_four_fourteen_three() {
211 let serde_json::Value::Object(fields) = serde_json::to_value(AppConfig::default()).unwrap()
212 else {
213 panic!("AppConfig must serialize as an object");
214 };
215 let count = |want: ApplyGroup| fields.keys().filter(|k| apply_group(k) == want).count();
216 assert_eq!(count(ApplyGroup::Live), 19, "Live");
217 assert_eq!(count(ApplyGroup::NextSpawn), 4, "NextSpawn");
218 assert_eq!(count(ApplyGroup::NeedsRespawn), 14, "NeedsRespawn");
219 assert_eq!(count(ApplyGroup::Structural), 3, "Structural");
220 }
221}