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 // Read once when a batch is ordered, at a boot, a muster, or a staged
72 // start; see the field's own doc comment on `AppConfig`.
73 ("depends_on", ApplyGroup::NextSpawn),
74 // Baked into the child at exec: argv, cwd, environment, credentials, the
75 // fd table, the log paths it is already writing to.
76 ("script", ApplyGroup::NeedsRespawn),
77 ("args", ApplyGroup::NeedsRespawn),
78 ("cwd", ApplyGroup::NeedsRespawn),
79 ("interpreter", ApplyGroup::NeedsRespawn),
80 ("env", ApplyGroup::NeedsRespawn),
81 // Decides what every `{{secret:...}}` in this child's env resolved to,
82 // and those are baked in at exec like the rest of the environment.
83 ("environment", ApplyGroup::NeedsRespawn),
84 ("user", ApplyGroup::NeedsRespawn),
85 ("group", ApplyGroup::NeedsRespawn),
86 ("out_file", ApplyGroup::NeedsRespawn),
87 ("err_file", ApplyGroup::NeedsRespawn),
88 ("merge_logs", ApplyGroup::NeedsRespawn),
89 ("channel", ApplyGroup::NeedsRespawn),
90 ("stdin", ApplyGroup::NeedsRespawn),
91 ("wait_ready", ApplyGroup::NeedsRespawn),
92 // `shutdown_with_message` belongs here rather than with the kill ladder:
93 // `assemble()` ORs it into whether fd 3 is opened, and that is the
94 // child's own fd table.
95 ("shutdown_with_message", ApplyGroup::NeedsRespawn),
96 ("name", ApplyGroup::Structural),
97 ("instances", ApplyGroup::Structural),
98 // Read only by `normalize` to refuse it by name.
99 ("increment_var", ApplyGroup::Structural),
100];
101
102/// The group `field` belongs to.
103///
104/// An unknown name answers [`ApplyGroup::NeedsRespawn`], the most
105/// conservative of the four: a field this table has not been taught about
106/// gets a restart rather than a silent claim that it applied.
107/// `every_appconfig_field_has_a_group` keeps that arm unreachable for real
108/// fields.
109#[must_use]
110pub fn apply_group(field: &str) -> ApplyGroup {
111 FIELDS
112 .iter()
113 .find(|(name, _)| *name == field)
114 .map_or(ApplyGroup::NeedsRespawn, |(_, group)| *group)
115}
116
117/// Whether `field` is named explicitly in the table above, as opposed to
118/// reaching the conservative fallback. Test-facing.
119#[must_use]
120pub fn is_classified(field: &str) -> bool {
121 FIELDS.iter().any(|(name, _)| *name == field)
122}
123
124/// How much of a Flockfile load overwrites what the operator has set since.
125///
126/// Two independent axes: whether `env` is reset, and whether a key the
127/// template declares (or does not) is reset. Five of the six combinations
128/// are named here; each variant's own doc states its column values.
129///
130/// `#[non_exhaustive]` buys source compatibility only, forcing an
131/// out-of-tree match to carry a wildcard arm. It does nothing for serde:
132/// this enum has no `#[serde(other)]`, so an older build fails to
133/// deserialize a variant it predates with `unknown variant`.
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136#[non_exhaustive]
137pub enum ResetDepth {
138 /// Append keys nobody established. Overwrite nothing. The default,
139 /// because a Flockfile arrives from the app's own repository.
140 ///
141 /// `env`: kept. A key the template declares: kept, unless nobody has
142 /// established it yet, which is the append. A key it does not declare:
143 /// kept, since there is nothing to append it against.
144 #[default]
145 None,
146 /// Put back what the template declares, and nothing else.
147 ///
148 /// `env`: kept. A key the template declares: reset. A key it does not
149 /// declare: kept, so an app stocked to four instances against a file
150 /// with no `instances` line keeps its count.
151 File,
152 /// Put non-`env` settings back to the template, `env` kept. Every
153 /// setting goes back, declared or not, to the value a fresh start off
154 /// the template would give it: `env` is operator data, the rest is
155 /// operator-tuned policy, and resetting policy is recoverable while
156 /// resetting data is not.
157 ///
158 /// `env`: kept. A key the template declares: reset. A key it does not
159 /// declare: reset too, to the template's own default.
160 Policy,
161 /// Reset `env` back to the template and leave everything else alone.
162 ///
163 /// Touches data, not policy: on the settings axis this behaves like
164 /// `None`, append included, since it widens a load rather than
165 /// narrowing one.
166 ///
167 /// `env`: reset. A key the template declares: kept, save for the same
168 /// append `None` does. A key it does not declare: kept.
169 Env,
170 /// Put everything back to the template, `env` included, and drop the
171 /// override record.
172 ///
173 /// `env`: reset. A key the template declares: reset. A key it does not
174 /// declare: reset too, to the template's own default.
175 All,
176}
177
178#[cfg(test)]
179mod tests {
180 use super::{ApplyGroup, apply_group, is_classified};
181 use crate::config::AppConfig;
182
183 /// fails if any AppConfig field is missing from the table. A field added
184 /// to the struct without a group would route as its default and either
185 /// apply live when it cannot, or need a restart when it does not.
186 #[test]
187 fn every_appconfig_field_has_a_group() {
188 let serde_json::Value::Object(fields) = serde_json::to_value(AppConfig::default()).unwrap()
189 else {
190 panic!("AppConfig must serialize as an object");
191 };
192 let missing: Vec<&String> = fields.keys().filter(|k| !is_classified(k)).collect();
193 assert!(
194 missing.is_empty(),
195 "unclassified AppConfig fields: {missing:?}"
196 );
197 }
198
199 #[test]
200 fn kill_signal_reaches_the_next_spawn_not_the_next_kill() {
201 assert_eq!(apply_group("kill_signal"), ApplyGroup::NextSpawn);
202 assert_eq!(apply_group("kill_timeout"), ApplyGroup::Live);
203 assert_eq!(apply_group("graceful_timeout"), ApplyGroup::Live);
204 }
205
206 #[test]
207 fn shutdown_with_message_is_baked_into_the_child() {
208 assert_eq!(
209 apply_group("shutdown_with_message"),
210 ApplyGroup::NeedsRespawn
211 );
212 }
213
214 #[test]
215 fn depends_on_applies_at_the_next_spawn() {
216 // fails if the field is classified Live, which would claim an edit
217 // reaches a running flock's order, or Structural, which would route it
218 // through handle_scale
219 assert_eq!(apply_group("depends_on"), ApplyGroup::NextSpawn);
220 assert!(is_classified("depends_on"));
221 }
222
223 /// fails if the split drifts from what the spec recorded.
224 #[test]
225 fn the_split_is_nineteen_five_fifteen_three() {
226 let serde_json::Value::Object(fields) = serde_json::to_value(AppConfig::default()).unwrap()
227 else {
228 panic!("AppConfig must serialize as an object");
229 };
230 let count = |want: ApplyGroup| fields.keys().filter(|k| apply_group(k) == want).count();
231 assert_eq!(count(ApplyGroup::Live), 19, "Live");
232 assert_eq!(count(ApplyGroup::NextSpawn), 5, "NextSpawn");
233 assert_eq!(count(ApplyGroup::NeedsRespawn), 15, "NeedsRespawn");
234 assert_eq!(count(ApplyGroup::Structural), 3, "Structural");
235 }
236}