Skip to main content

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