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