shep_core/config/app.rs
1//! Per-app configuration schema — one sheep's Flockfile entry
2
3use core::fmt;
4
5use std::collections::BTreeMap;
6
7// use schemars::generate
8use serde::{Deserialize, Serialize};
9
10use crate::values::{MemSize, UpDuration};
11
12/// How a health probe checks a sheep
13// wire format: changing these strings is a breaking change
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16#[serde(rename_all = "snake_case")]
17pub enum ProbeKind {
18 /// HTTP GET must return 2xx
19 Http,
20 /// TCP connect must succeed
21 Tcp,
22 /// Command must exit 0
23 Exec,
24}
25
26/// Readiness/liveness probe configuration (spec §7)
27// wire format: changing field names/defaults is a breaking change
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30#[serde(deny_unknown_fields)]
31pub struct ProbeConfig {
32 /// Probe mechanism
33 pub kind: ProbeKind,
34 /// URL (http), `host:port` (tcp), or command line (exec)
35 pub target: String,
36 /// Time between probes (default 10s)
37 #[serde(default = "default_probe_interval")]
38 pub interval: UpDuration,
39 /// Per-probe timeout (default 5s)
40 #[serde(default = "default_probe_timeout")]
41 pub timeout: UpDuration,
42 /// Consecutive failures before the probe reports unhealthy (default 3)
43 #[serde(default = "default_failure_threshold")]
44 pub failure_threshold: u32,
45}
46
47fn default_probe_interval() -> UpDuration {
48 UpDuration::from_millis(10_000)
49}
50fn default_probe_timeout() -> UpDuration {
51 UpDuration::from_millis(5_000)
52}
53fn default_failure_threshold() -> u32 {
54 3
55}
56
57/// Per-app configuration — one sheep's entry in a Flockfile
58///
59/// Field names are the Flockfile contract (sheep-native; pm2 spellings are
60/// rejected — the importer translates them). Unknown fields are errors so
61/// typos fail loudly at parse time.
62///
63/// # Example
64/// ```
65/// use shep_core::config::AppConfig;
66///
67/// let app: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
68/// assert!(app.autorestart); // spec default
69/// ```
70// wire format: changing field names/defaults is a breaking change
71#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73#[serde(deny_unknown_fields, default)]
74pub struct AppConfig {
75 /// Unique sheep name (required)
76 #[cfg_attr(feature = "schema", schemars(extend("init" = {
77 "example": "my-first-sheep",
78 "group": "process",
79 "blurb": "A convenient and unique name for shep to display"
80 })))]
81 pub name: String,
82 /// Executable or script path (required)
83 #[cfg_attr(feature = "schema", schemars(extend("init" = {
84 "example": "./index.js",
85 "group": "process",
86 "blurb": "The script that shep should use to launch your app"
87 })))]
88 pub script: String,
89 /// Arguments passed to the script
90 #[cfg_attr(feature = "schema", schemars(extend("init" = {
91 "group": "inputs",
92 "blurb": "Arguments passed to the script, as a list"
93 })))]
94 pub args: Vec<String>,
95 /// Working directory (default: daemon's cwd at spawn registration)
96 #[cfg_attr(feature = "schema", schemars(extend("init" = {
97 "example": "/srv/app",
98 "group": "process",
99 "blurb": "Where the process runs. Without it, the daemon's own directory"
100 })))]
101 pub cwd: Option<String>,
102 /// Interpreter override (`"none"` = run script directly)
103 #[cfg_attr(feature = "schema", schemars(extend("init" = {
104 "example": "none",
105 "group": "process",
106 "blurb": "What runs the script. Set it to none to exec the file directly"
107 })))]
108 pub interpreter: Option<String>,
109 /// Environment for the sheep (merged over the daemon's filtered env)
110 #[cfg_attr(feature = "schema", schemars(extend("init" = {
111 "example": "{ NODE_ENV = 'production' }",
112 "group": "inputs",
113 "blurb": "Environment variables for this app, layered over the daemon's own"
114 })))]
115 pub env: BTreeMap<String, String>,
116 /// Instance count ("cluster" = N fork instances; spec §4)
117 #[cfg_attr(feature = "schema", schemars(extend("init" = {
118 "group": "process",
119 "blurb": "How many copies of this app to run"
120 })))]
121 pub instances: u32,
122 /// Restart on unexpected exit
123 #[cfg_attr(feature = "schema", schemars(extend("init" = {
124 "group": "control",
125 "blurb": "Restarts the process automatically when it exits unexpectedly"
126 })))]
127 pub autorestart: bool,
128 /// Start when the daemon starts / on `shep muster`
129 #[cfg_attr(feature = "schema", schemars(extend("init" = {
130 "group": "control",
131 "blurb": "Start this app when the daemon starts, and on shep muster"
132 })))]
133 pub autostart: bool,
134 /// Exit codes treated as clean stop (no restart)
135 #[cfg_attr(feature = "schema", schemars(extend("init" = {
136 "group": "control",
137 "blurb": "Exit codes that mean a clean stop, so shep will not restart"
138 })))]
139 pub stop_exit_codes: Vec<i32>,
140 /// Uptime below this marks an exit as unstable
141 #[cfg_attr(feature = "schema", schemars(extend("init" = {
142 "group": "control",
143 "blurb": "An exit sooner than this counts as unstable"
144 })))]
145 pub min_uptime: UpDuration,
146 /// Consecutive unstable exits before `errored`
147 #[cfg_attr(feature = "schema", schemars(extend("init" = {
148 "group": "control",
149 "blurb": "How many unstable exits in a row before shep gives up"
150 })))]
151 pub max_restarts: u32,
152 /// Fixed delay before every restart (alternative to backoff)
153 #[cfg_attr(feature = "schema", schemars(extend("init" = {
154 "example": "3s",
155 "group": "control",
156 "blurb": "A fixed wait before every restart, instead of growing backoff"
157 })))]
158 pub restart_delay: Option<UpDuration>,
159 /// Initial backoff delay; grows ×1.5 capped at 15s (spec §4)
160 ///
161 /// Defaults to 100ms, not unset. An unstable exit (sooner than
162 /// `min_uptime`) with neither this nor `restart_delay` configured used
163 /// to restart with no delay at all, so an app that could never start
164 /// (a missing dependency, a bad config) burned its whole `max_restarts`
165 /// budget inside a second, logging the same failure dozens of times.
166 /// `min_uptime` already existed to name that case as unstable; only the
167 /// default that should have throttled it was missing.
168 ///
169 /// All of the above assumes `restart_delay` is unset. A fixed
170 /// `restart_delay` takes precedence over this field on every exit,
171 /// stable or not, so a stable exit restarts immediately only while
172 /// `restart_delay` stays unset, and setting this field to `"0"`
173 /// disables the backoff without producing an immediate restart if a
174 /// nonzero `restart_delay` is also configured.
175 #[cfg_attr(feature = "schema", schemars(extend("init" = {
176 "example": "5s",
177 "group": "control",
178 "blurb": "Starting delay between restarts, growing each time it fails again"
179 })))]
180 pub exp_backoff_restart_delay: Option<UpDuration>,
181 /// Stop signal, one of `SIGTERM`/`SIGINT`/`SIGQUIT`/`SIGUSR2` (the `SIG`
182 /// prefix and the case are both optional). Unset means `SIGTERM`.
183 ///
184 /// A `String` rather than a [`KillSignal`](crate::config::KillSignal) so
185 /// the Flockfile schema and this struct's wire form stay plain text;
186 /// `normalize` is what refuses a name outside that set, the same split
187 /// `cron_restart` and the watch globs already use.
188 #[cfg_attr(feature = "schema", schemars(extend("init" = {
189 "example": "SIGTERM",
190 "group": "process",
191 "blurb": "Which signal shep sends first when stopping this app"
192 })))]
193 pub kill_signal: Option<String>,
194 /// Grace period between stop signal and SIGKILL
195 #[cfg_attr(feature = "schema", schemars(extend("init" = {
196 "group": "control",
197 "blurb": "How long shep waits after the stop signal before SIGKILL"
198 })))]
199 pub kill_timeout: UpDuration,
200 /// Send `{"kind":"shutdown"}` on the shepherd channel instead of a signal
201 #[cfg_attr(feature = "schema", schemars(extend("init" = {
202 "group": "control",
203 "blurb": "Ask the app to stop over the channel instead of signalling it"
204 })))]
205 pub shutdown_with_message: bool,
206 /// Readiness fallback window when no ready signal/probe configured
207 #[cfg_attr(feature = "schema", schemars(extend("init" = {
208 "group": "control",
209 "blurb": "How long to wait for readiness when nothing else reports it"
210 })))]
211 pub listen_timeout: UpDuration,
212 /// Drain window for the old instance during reload
213 #[cfg_attr(feature = "schema", schemars(extend("init" = {
214 "group": "control",
215 "blurb": "How long the old instance gets to drain during a reload"
216 })))]
217 pub graceful_timeout: UpDuration,
218 /// How long a triggered action gets to answer on the shepherd channel
219 /// before its row becomes `ActionOutcome::TimedOut`.
220 ///
221 /// Defaults to 3s — comfortably under the 5s an RPC caller gets when it
222 /// sends no deadline of its own (`shep-client`'s `DEFAULT_DEADLINE`,
223 /// mirrored daemon-side as `rpc`'s `DEFAULT_DEADLINE_MS`). The margin
224 /// matters more than the number: push this past that budget and a caller
225 /// using the plain default gives up with `DeadlineExceeded` before the
226 /// daemon's own honest `TimedOut` row ever reaches it. A legitimately
227 /// slow action (a cache flush, say) can still ask for longer, but its
228 /// caller has to ask for a longer deadline in step —
229 /// `Client::request_with_deadline`, the way `shep logs -f` already asks
230 /// for `LOG_PLANE_DEADLINE` rather than the client's default. `normalize`
231 /// refuses a value no caller could ever satisfy, however long a deadline
232 /// it asks for; a value merely above the *default* budget is a caller's
233 /// choice to widen its own deadline, not a config error this crate can
234 /// see.
235 #[cfg_attr(feature = "schema", schemars(extend("init" = {
236 "group": "control",
237 "blurb": "How long a triggered action has to answer before shep gives up"
238 })))]
239 pub action_timeout: UpDuration,
240 /// Memory ceiling — polling enforcer restarts above this
241 #[cfg_attr(feature = "schema", schemars(extend("init" = {
242 "example": "512M",
243 "group": "control",
244 "blurb": "Restart the app if it climbs above this much memory"
245 })))]
246 pub max_memory: Option<MemSize>,
247 /// Watch files and restart on change
248 #[cfg_attr(feature = "schema", schemars(extend("init" = {
249 "group": "control",
250 "blurb": "Restart when a file changes"
251 })))]
252 pub watch: bool,
253 /// Watch ignore globs (defaults added daemon-side: dot-entries, node_modules)
254 #[cfg_attr(feature = "schema", schemars(extend("init" = {
255 "group": "control",
256 "blurb": "Paths watch should skip, on top of dotfiles and node_modules"
257 })))]
258 pub ignore_watch: Vec<String>,
259 /// Watch debounce window (default 500ms, applied daemon-side)
260 #[cfg_attr(feature = "schema", schemars(extend("init" = {
261 "example": "500",
262 "group": "control",
263 "blurb": "How long to wait after a change before restarting"
264 })))]
265 pub watch_delay: Option<UpDuration>,
266 /// Cron pattern for scheduled restarts (croner dialect)
267 #[cfg_attr(feature = "schema", schemars(extend("init" = {
268 "example": "* * * * *",
269 "group": "cron",
270 "blurb": "Restart on a schedule, written as a cron pattern"
271 })))]
272 pub cron_restart: Option<String>,
273 /// Fold (group) this sheep belongs to
274 #[cfg_attr(feature = "schema", schemars(extend("init" = {
275 "example": "backend",
276 "group": "process",
277 "blurb": "A fold to group this app with others, for commands that take one"
278 })))]
279 pub fold: Option<String>,
280 /// Run as this user (unix)
281 #[cfg_attr(feature = "schema", schemars(extend("init" = {
282 "example": "www-data",
283 "group": "process",
284 "blurb": "Run as this user, on unix"
285 })))]
286 pub user: Option<String>,
287 /// Run as this group (unix)
288 #[cfg_attr(feature = "schema", schemars(extend("init" = {
289 "example": "www-data",
290 "group": "process",
291 "blurb": "Run as this group, on unix"
292 })))]
293 pub group: Option<String>,
294 /// Stdout log file (default: `$SHEP_HOME/logs/<name>-<instance>-out.log`; `merge_logs` collapses to `<name>-out.log`)
295 #[cfg_attr(feature = "schema", schemars(extend("init" = {
296 "example": "/var/log/my-first-sheep/out.log",
297 "group": "process",
298 "blurb": "Where stdout goes. Defaults to a file under $SHEP_HOME/logs"
299 })))]
300 pub out_file: Option<String>,
301 /// Stderr log file (default: `$SHEP_HOME/logs/<name>-<instance>-err.log`; `merge_logs` collapses to `<name>-err.log`)
302 #[cfg_attr(feature = "schema", schemars(extend("init" = {
303 "example": "/var/log/my-first-sheep/err.log",
304 "group": "process",
305 "blurb": "Where stderr goes. Defaults to a file under $SHEP_HOME/logs"
306 })))]
307 pub err_file: Option<String>,
308 /// Merge instance logs into one file pair
309 #[cfg_attr(feature = "schema", schemars(extend("init" = {
310 "group": "process",
311 "blurb": "Put every instance's output in one pair of files"
312 })))]
313 pub merge_logs: bool,
314 /// Open the shepherd channel on fd 3 for this app on its own, without
315 /// needing `wait_ready` or `shutdown_with_message` to imply it.
316 ///
317 /// Defaults to `false`: a socketpair plus two pump tasks per sheep is
318 /// real cost weighed against spec §14.11's single-digit-MB idle-RSS
319 /// goal, so a channel is opened only when something asks for one.
320 #[cfg_attr(feature = "schema", schemars(extend("init" = {
321 "group": "inputs",
322 "blurb": "Opens fd 3 so the app can talk to shep directly"
323 })))]
324 pub channel: bool,
325 /// Open a pipe on this sheep's stdin, so `shep whisper` can write to it.
326 ///
327 /// Defaults to `false`, and the default is the decision rather than a
328 /// convenience. Without it a sheep gets `/dev/null` on fd 0, which is what
329 /// every sheep has had until now, and three things argue for keeping it
330 /// that way unless an app asks otherwise:
331 ///
332 /// - Flipping it for the whole flock is a behaviour change to processes
333 /// nobody asked to change.
334 /// - **Programs detect stdin.** A closed or null fd 0 is how a great many
335 /// programs decide they are non-interactive — no prompt, no pager, no
336 /// readline, no colour. Handing them a pipe silently moves them to the
337 /// other branch.
338 /// - It costs a descriptor and a pump task per sheep for the whole life of
339 /// the process, against spec §14.11's single-digit-MB idle-RSS goal — the
340 /// same budget [`Self::channel`]'s own default is protecting.
341 ///
342 /// Unlike `channel`, nothing implies this: `wait_ready` and
343 /// `shutdown_with_message` both need fd 3 and so turn `channel` on for you,
344 /// while nothing in shep needs a sheep's stdin except an operator typing
345 /// `shep whisper`. A sheep without it answers a `no_stdin` row and names
346 /// this field.
347 ///
348 /// The pipe's write end lives as long as the sheep does, so the app sees
349 /// EOF on stdin when the process is on its way out, never before.
350 #[cfg_attr(feature = "schema", schemars(extend("init" = {
351 "group": "inputs",
352 "blurb": "Keeps stdin open so shep whisper can write to the process"
353 })))]
354 pub stdin: bool,
355 /// Expect `{"kind":"ready"}` on the shepherd channel
356 #[cfg_attr(feature = "schema", schemars(extend("init" = {
357 "group": "control",
358 "blurb": "Wait for the app to say it is ready on the channel"
359 })))]
360 pub wait_ready: bool,
361 /// Asserts that the app itself sets `SO_REUSEPORT` before it binds —
362 /// shep binds nothing, so it cannot set the option on the app's behalf.
363 /// The child process owns the mechanism (Node ≥22's `reusePort`, Go's
364 /// `net.ListenConfig.Control`, nginx's `reuseport`); shep's contribution
365 /// is permission for the old and new instance to overlap during reload,
366 /// not the socket option itself.
367 ///
368 /// That permission is what the field buys, and it is read by exactly one
369 /// thing: which reload the daemon runs for the app.
370 ///
371 /// - **Unset**, and the app has a `readiness_probe`: reload is SERIAL.
372 /// The instance being replaced is drained first and its replacement is
373 /// spawned into the empty slot, so the app is down for the length of
374 /// the drain. That is the cost of an honest answer — while both
375 /// instances are up, a probe against an address cannot say which of
376 /// them answered, and shep would take the outgoing instance's reply as
377 /// proof the incoming one is ready.
378 /// - **Set**: reload OVERLAPS, which is what every reload did before this
379 /// field was read. The replacement is spawned alongside the instance it
380 /// replaces and takes over without a gap — if the app really does set
381 /// `SO_REUSEPORT`. If it does not, the replacement takes `EADDRINUSE`
382 /// and the reload fails, which is the failure this field exists to keep
383 /// opt-in.
384 ///
385 /// An app with no `readiness_probe` overlaps either way: with nothing
386 /// probing an address, there is no answer for the wrong instance to give.
387 /// So does one using `wait_ready`, because the shepherd channel a
388 /// replacement reports on is its own — the instance being replaced has no
389 /// way to answer it. Both of those need `SO_REUSEPORT` as much as a
390 /// `reuse_port` app does if they bind an address, since they are overlapped
391 /// too; what this field changes is which apps get overlapped, not what an
392 /// overlap costs.
393 ///
394 /// Setting this on an app that does NOT set the socket option is the one
395 /// way to get it wrong, and shep cannot check it: the option is set
396 /// inside the child, after the fork, on a socket shep never sees.
397 #[cfg_attr(feature = "schema", schemars(extend("init" = {
398 "group": "process",
399 "blurb": "The app sets SO_REUSEPORT itself, so reload may overlap the two instances"
400 })))]
401 pub reuse_port: bool,
402 /// Readiness probe — gates reload's AwaitReady (spec §7)
403 #[cfg_attr(feature = "schema", schemars(extend("init" = {
404 "example": { "kind": "http", "target": "http://127.0.0.1:8080/ready" },
405 "group": "control",
406 "blurb": "A health check shep waits on before it treats a reload as finished"
407 })))]
408 pub readiness_probe: Option<ProbeConfig>,
409 /// Liveness probe — failures feed the restart policy (spec §7)
410 #[cfg_attr(feature = "schema", schemars(extend("init" = {
411 "example": { "kind": "http", "target": "http://127.0.0.1:8080/healthz" },
412 "group": "control",
413 "blurb": "A health check that triggers a restart when it keeps failing"
414 })))]
415 pub liveness_probe: Option<ProbeConfig>,
416 /// Watch include globs (empty = watch cwd)
417 #[cfg_attr(feature = "schema", schemars(extend("init" = {
418 "group": "control",
419 "blurb": "Which paths to watch. Empty means the working directory"
420 })))]
421 pub watch_options: Vec<String>,
422 /// Timezone for `cron_restart` (IANA name)
423 #[cfg_attr(feature = "schema", schemars(extend("init" = {
424 "example": "US/Eastern",
425 "group": "cron",
426 "blurb": "Which timezone cron_restart is read in, as an IANA name"
427 })))]
428 pub cron_timezone: Option<String>,
429 /// Env var receiving the instance slot (default `SHEP_INSTANCE`)
430 #[cfg_attr(feature = "schema", schemars(extend("init" = {
431 "example": "INSTANCE_ID",
432 "group": "inputs",
433 "blurb": "The env var each instance finds its own slot number in"
434 })))]
435 pub increment_var: Option<String>,
436}
437
438/// Debug implementation does not leak env values (IR-41)
439impl fmt::Debug for AppConfig {
440 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
441 f.debug_struct("AppConfig")
442 .field("name", &self.name)
443 .field("script", &self.script)
444 .field("env", &format_args!("<{} vars>", self.env.len()))
445 .finish_non_exhaustive()
446 }
447}
448
449impl Default for AppConfig {
450 fn default() -> Self {
451 Self {
452 name: String::new(),
453 script: String::new(),
454 args: Vec::new(),
455 cwd: None,
456 interpreter: None,
457 env: BTreeMap::new(),
458 instances: 1,
459 autorestart: true,
460 autostart: true,
461 stop_exit_codes: Vec::new(),
462 min_uptime: UpDuration::from_millis(1000),
463 max_restarts: 16,
464 restart_delay: None,
465 // Not None: see the field's doc comment. An unstable exit with
466 // no restart policy configured must not restart instantly.
467 exp_backoff_restart_delay: Some(UpDuration::from_millis(100)),
468 kill_signal: None,
469 kill_timeout: UpDuration::from_millis(1600),
470 shutdown_with_message: false,
471 listen_timeout: UpDuration::from_millis(3000),
472 graceful_timeout: UpDuration::from_millis(8000),
473 action_timeout: UpDuration::from_millis(3000),
474 max_memory: None,
475 watch: false,
476 ignore_watch: Vec::new(),
477 watch_delay: None,
478 cron_restart: None,
479 fold: None,
480 user: None,
481 group: None,
482 out_file: None,
483 err_file: None,
484 merge_logs: false,
485 channel: false,
486 stdin: false,
487 wait_ready: false,
488 reuse_port: false,
489 readiness_probe: None,
490 liveness_probe: None,
491 watch_options: Vec::new(),
492 cron_timezone: None,
493 increment_var: None,
494 }
495 }
496}
497
498impl AppConfig {
499 /// A minimal config with spec defaults — the programmatic entry point
500 #[must_use]
501 pub fn minimal(name: &str, script: &str) -> Self {
502 Self {
503 name: name.to_string(),
504 script: script.to_string(),
505 ..Self::default()
506 }
507 }
508
509 /// The names of the fields whose values differ between `self` and
510 /// `other`, in field-name order.
511 ///
512 /// Names only, never values. The one caller sends this list across the
513 /// wire to be printed at an operator, and [`AppConfig::env`] carries
514 /// secrets, so a differing `env` reports `"env"` and stops there (IR-41).
515 ///
516 /// Compare configs that have both been through
517 /// [`normalize`](fn@crate::config::normalize). Two configs differing only
518 /// in what normalization would have filled in are not a difference an
519 /// operator can act on, and reporting them would make the caller noisy
520 /// about nothing.
521 ///
522 /// # Example
523 ///
524 /// ```
525 /// use shep_core::config::AppConfig;
526 ///
527 /// let stored = AppConfig::minimal("web", "./srv");
528 /// let mut edited = stored.clone();
529 /// edited.cwd = Some("/srv".to_string());
530 ///
531 /// assert_eq!(stored.drifted_fields(&edited), vec!["cwd".to_string()]);
532 /// assert!(stored.drifted_fields(&stored).is_empty());
533 /// ```
534 #[must_use]
535 pub fn drifted_fields(&self, other: &Self) -> Vec<String> {
536 if self == other {
537 return Vec::new();
538 }
539 // Through serde rather than field by field, so a field added to this
540 // struct is compared without a second edit here. 44 hand-written
541 // comparisons is exactly the list that goes stale. `#[serde(default)]`
542 // with no `skip_serializing_if` means both sides serialize every
543 // field, so the two key sets are identical and iterating one is
544 // enough.
545 //
546 // Sorted explicitly rather than relying on `serde_json::Map` being a
547 // `BTreeMap`: it is one only while `serde_json`'s `preserve_order`
548 // feature is off, and that feature is additive, so ANY crate in the
549 // graph turning it on would make this an `IndexMap` and silently
550 // switch the order to serialization order. Nothing enables it today.
551 // The sort costs a few field names and makes the doc above true by
552 // construction instead of by a dependency's default feature set.
553 //
554 // An empty vector when either side fails to serialize as an object:
555 // there is no honest field list to report, and the caller must not
556 // fail over a warning it could not compute.
557 let (Ok(serde_json::Value::Object(mine)), Ok(serde_json::Value::Object(theirs))) =
558 (serde_json::to_value(self), serde_json::to_value(other))
559 else {
560 return Vec::new();
561 };
562 let mut fields: Vec<String> = mine
563 .iter()
564 .filter(|(key, value)| theirs.get(key.as_str()) != Some(value))
565 .map(|(key, _)| key.clone())
566 .collect();
567 fields.sort_unstable();
568 fields
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use crate::values::{MemSize, UpDuration};
576
577 #[test]
578 fn minimal_config_gets_spec_defaults() {
579 let app = AppConfig::minimal("web", "./server");
580 assert_eq!(app.name, "web");
581 assert_eq!(app.script, "./server");
582 assert!(app.autorestart);
583 assert!(app.autostart);
584 assert_eq!(app.instances, 1);
585 assert_eq!(app.min_uptime, UpDuration::from_millis(1000));
586 assert_eq!(app.max_restarts, 16);
587 assert_eq!(app.kill_timeout, UpDuration::from_millis(1600));
588 assert_eq!(app.listen_timeout, UpDuration::from_millis(3000));
589 assert_eq!(app.graceful_timeout, UpDuration::from_millis(8000));
590 assert_eq!(app.action_timeout, UpDuration::from_millis(3000));
591 assert!(app.max_memory.is_none());
592 assert!(app.fold.is_none());
593 assert!(!app.channel);
594 }
595
596 /// fails if `exp_backoff_restart_delay` reverts to `None`. Defect 2:
597 /// with no restart policy configured, a crash-looping app used to
598 /// restart with no delay at all, burning `max_restarts` in well under a
599 /// second. `restart_delay` (the daemon-side function that reads this
600 /// field) is what actually applies the throttle; this test only pins
601 /// that the default an unconfigured app gets is "on".
602 #[test]
603 fn unstable_restarts_are_throttled_by_default() {
604 let app = AppConfig::minimal("web", "./srv");
605 assert_eq!(
606 app.exp_backoff_restart_delay,
607 Some(UpDuration::from_millis(100))
608 );
609 }
610
611 /// fails if `stdin` defaults to anything but false. The default is the
612 /// whole decision: piping stdin for every sheep would change how a great
613 /// many programs behave (a closed stdin is how they decide they are
614 /// non-interactive), and would hold a descriptor and a task per sheep for
615 /// the life of the process.
616 #[test]
617 fn stdin_is_not_piped_unless_the_app_asks() {
618 let app = AppConfig::minimal("web", "./srv");
619 assert!(!app.stdin);
620 let parsed: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
621 assert!(!parsed.stdin);
622 }
623
624 /// fails if the Flockfile key is spelled anything but `stdin`. It is a
625 /// contract with every config file already written against it the moment
626 /// this ships, and `deny_unknown_fields` means a rename is a hard parse
627 /// failure for the operator rather than a silently ignored key.
628 #[test]
629 fn the_flockfile_key_is_stdin() {
630 let parsed: AppConfig =
631 toml::from_str("name = \"web\"\nscript = \"./srv\"\nstdin = true").unwrap();
632 assert!(parsed.stdin);
633 }
634
635 #[test]
636 fn toml_round_trip_with_newtypes() {
637 let toml_src = r#"
638name = "worker"
639script = "python3"
640args = ["job.py", "--fast"]
641max_memory = "512M"
642min_uptime = "5s"
643fold = "backend"
644env = { RUST_LOG = "info" }
645"#;
646 let app: AppConfig = toml::from_str(toml_src).unwrap();
647 assert_eq!(app.max_memory, Some("512M".parse::<MemSize>().unwrap()));
648 assert_eq!(app.min_uptime, UpDuration::from_millis(5000));
649 assert_eq!(app.fold.as_deref(), Some("backend"));
650 assert_eq!(app.env.get("RUST_LOG").map(String::as_str), Some("info"));
651 assert_eq!(app.args, vec!["job.py", "--fast"]);
652 }
653
654 #[test]
655 fn unknown_fields_are_rejected() {
656 let err = toml::from_str::<AppConfig>(
657 "name = \"x\"\nscript = \"y\"\nmax_memory_restart = \"1G\"",
658 )
659 .unwrap_err();
660 assert!(err.to_string().contains("max_memory_restart"), "{err}");
661 }
662
663 #[test]
664 fn probe_config_parses_with_defaults() {
665 let src = r#"
666name = "api"
667script = "./api"
668
669[readiness_probe]
670kind = "http"
671target = "http://127.0.0.1:8080/healthz"
672"#;
673 let app: AppConfig = toml::from_str(src).unwrap();
674 let probe = app.readiness_probe.unwrap();
675 assert_eq!(probe.kind, ProbeKind::Http);
676 assert_eq!(probe.target, "http://127.0.0.1:8080/healthz");
677 assert_eq!(probe.interval, UpDuration::from_millis(10_000));
678 assert_eq!(probe.timeout, UpDuration::from_millis(5_000));
679 assert_eq!(probe.failure_threshold, 3);
680 assert!(app.liveness_probe.is_none());
681 }
682
683 #[test]
684 fn debug_redacts_env_values() {
685 // IR-41: env may carry secrets; Debug output lands in daemon logs.
686 // Exact string pinned so a lazy derive(Debug) refactor fails here.
687 let mut app = AppConfig::minimal("web", "./srv");
688 app.env
689 .insert("DATABASE_URL".to_string(), "postgres://secret".to_string());
690 app.env.insert("RUST_LOG".to_string(), "info".to_string());
691 assert_eq!(
692 format!("{app:?}"),
693 "AppConfig { name: \"web\", script: \"./srv\", env: <2 vars>, .. }"
694 );
695 }
696
697 #[test]
698 fn an_unedited_config_has_drifted_in_no_field() {
699 let app = AppConfig::minimal("web", "./srv");
700
701 assert!(app.drifted_fields(&app.clone()).is_empty());
702 }
703
704 #[test]
705 fn drift_names_every_edited_field_and_no_other() {
706 // The defect this exists for: an operator edits `cwd` in a Flockfile
707 // and re-runs `shep start`. Two fields, not one, so a comparator
708 // that stopped at the first difference fails here.
709 let stored = AppConfig::minimal("proto-api", "./proto-enum-api");
710 let mut edited = stored.clone();
711 edited.cwd = Some("/Users/rin/GitHub/pogo-proto-api".to_string());
712 edited.args = vec!["-config".to_string(), "config.toml".to_string()];
713
714 assert_eq!(
715 stored.drifted_fields(&edited),
716 vec!["args".to_string(), "cwd".to_string()]
717 );
718 }
719
720 #[test]
721 fn drift_reports_env_by_name_and_never_by_value() {
722 let stored = AppConfig::minimal("web", "./srv");
723 let mut edited = stored.clone();
724 edited
725 .env
726 .insert("DATABASE_URL".to_string(), "postgres://hunter2".to_string());
727
728 let fields = edited.drifted_fields(&stored);
729
730 assert_eq!(fields, vec!["env".to_string()]);
731 // The whole point of returning names: this list is printed at an
732 // operator, so nothing from a value may reach it (IR-41).
733 assert!(!fields.concat().contains("hunter2"));
734 }
735
736 #[test]
737 fn drift_is_symmetric() {
738 let stored = AppConfig::minimal("web", "./srv");
739 let mut edited = stored.clone();
740 edited.instances = 4;
741
742 assert_eq!(
743 stored.drifted_fields(&edited),
744 edited.drifted_fields(&stored)
745 );
746 assert_eq!(
747 stored.drifted_fields(&edited),
748 vec!["instances".to_string()]
749 );
750 }
751}