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