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    /// 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}