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, Deserializer, 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    ///
142    /// A value may arrive as a string, a bare boolean, or a bare whole
143    /// number (`SOME_BOOL = true`, `PORT = 8080`), and leaves as a string
144    /// either way, so this field stays `BTreeMap<String, String>` and the
145    /// wire always carries strings. A float is refused: `1.10` would reach
146    /// the process as `1.1`. YAML resolves an unquoted `yes` to `"true"`
147    /// and `0x1F` to `"31"`, so quote a value whose text must survive.
148    #[serde(deserialize_with = "deserialize_env")]
149    #[cfg_attr(feature = "schema", schemars(
150        extend(
151            "init" = {
152                "example": "{ NODE_ENV = 'production' }",
153                "group": "inputs",
154                "blurb": "Environment variables for this app, layered over the daemon's own"
155            },
156            "additionalProperties" = {
157                "anyOf": [{ "type": "string" }, { "type": "boolean" }, { "type": "integer" }]
158            }
159        )
160    ))]
161    pub env: BTreeMap<String, String>,
162    /// Which environment this sheep resolves `{{secret:...}}` in.
163    ///
164    /// Absent falls back to `[daemon] environment` in `shep.toml`, which
165    /// itself defaults to `production`. Never `all`: that is the store's
166    /// every-environment slot, and a sheep claiming it would read that slot
167    /// twice and never one of its own.
168    #[cfg_attr(feature = "schema", schemars(extend("init" = {
169        "example": "staging",
170        "group": "inputs",
171        "blurb": "Which environment this app resolves secrets in"
172    })))]
173    pub environment: Option<String>,
174    /// Instance count ("cluster" = N fork instances; spec §4)
175    #[cfg_attr(feature = "schema", schemars(extend("init" = {
176        "group": "process",
177        "blurb": "How many copies of this app to run"
178    })))]
179    pub instances: u32,
180    /// Restart on unexpected exit
181    #[cfg_attr(feature = "schema", schemars(extend("init" = {
182        "group": "restart",
183        "blurb": "Restarts the process automatically when it exits unexpectedly"
184    })))]
185    pub autorestart: bool,
186    /// Start when the daemon starts / on `shep muster`
187    #[cfg_attr(feature = "schema", schemars(extend("init" = {
188        "group": "restart",
189        "blurb": "Start this app when the daemon starts, and on shep muster"
190    })))]
191    pub autostart: bool,
192    /// Exit codes treated as clean stop (no restart)
193    #[cfg_attr(feature = "schema", schemars(extend("init" = {
194        "group": "restart",
195        "blurb": "Exit codes that mean a clean stop, so shep will not restart"
196    })))]
197    pub stop_exit_codes: Vec<i32>,
198    /// Uptime below this marks an exit as unstable
199    #[cfg_attr(feature = "schema", schemars(extend("init" = {
200        "group": "restart",
201        "blurb": "An exit sooner than this counts as unstable"
202    })))]
203    pub min_uptime: UpDuration,
204    /// Consecutive unstable exits before `errored`
205    #[cfg_attr(feature = "schema", schemars(extend("init" = {
206        "group": "restart",
207        "blurb": "How many unstable exits in a row before shep gives up"
208    })))]
209    pub max_restarts: u32,
210    /// Fixed delay before every restart (alternative to backoff)
211    #[cfg_attr(feature = "schema", schemars(extend("init" = {
212        "example": "3s",
213        "group": "restart",
214        "blurb": "A fixed wait before every restart, instead of growing backoff"
215    })))]
216    pub restart_delay: Option<UpDuration>,
217    /// Initial backoff delay; grows ×1.5 capped at 15s (spec §4)
218    ///
219    /// Defaults to 100ms, not unset. An unstable exit (sooner than
220    /// `min_uptime`) with neither this nor `restart_delay` configured would
221    /// otherwise restart with no delay at all, so an app that can never
222    /// start (a missing dependency, a bad config) would burn its whole
223    /// `max_restarts` budget inside a second, logging the same failure
224    /// dozens of times.
225    ///
226    /// All of the above assumes `restart_delay` is unset. A fixed
227    /// `restart_delay` takes precedence over this field on every exit,
228    /// stable or not, so a stable exit restarts immediately only while
229    /// `restart_delay` stays unset, and setting this field to `"0"`
230    /// disables the backoff without producing an immediate restart if a
231    /// nonzero `restart_delay` is also configured.
232    #[cfg_attr(feature = "schema", schemars(extend("init" = {
233        "example": "5s",
234        "group": "restart",
235        "blurb": "Starting delay between restarts, growing each time it fails again"
236    })))]
237    pub exp_backoff_restart_delay: Option<UpDuration>,
238    /// Stop signal, one of `SIGTERM`/`SIGINT`/`SIGQUIT`/`SIGUSR2` (the `SIG`
239    /// prefix and the case are both optional). Unset means `SIGTERM`.
240    ///
241    /// A `String` rather than a [`KillSignal`](crate::config::KillSignal) so
242    /// the Flockfile schema and this struct's wire form stay plain text;
243    /// `normalize` is what refuses a name outside that set, the same split
244    /// `cron_restart` and the watch globs already use.
245    #[cfg_attr(feature = "schema", schemars(extend("init" = {
246        "example": "SIGTERM",
247        "group": "shutdown",
248        "blurb": "Which signal shep sends first when stopping this app",
249        "suggest": ["SIGTERM", "SIGINT", "SIGQUIT", "SIGUSR2"]
250    })))]
251    pub kill_signal: Option<String>,
252    /// Grace period between stop signal and SIGKILL
253    #[cfg_attr(feature = "schema", schemars(extend("init" = {
254        "group": "shutdown",
255        "blurb": "How long shep waits after the stop signal before SIGKILL"
256    })))]
257    pub kill_timeout: UpDuration,
258    /// Send `{"kind":"shutdown"}` on the shepherd channel instead of a signal
259    #[cfg_attr(feature = "schema", schemars(extend("init" = {
260        "group": "shutdown",
261        "blurb": "Ask the app to stop over the channel instead of signalling it"
262    })))]
263    pub shutdown_with_message: bool,
264    /// Readiness fallback window when no ready signal/probe configured
265    #[cfg_attr(feature = "schema", schemars(extend("init" = {
266        "group": "readiness",
267        "blurb": "How long to wait for readiness when nothing else reports it"
268    })))]
269    pub listen_timeout: UpDuration,
270    /// Drain window for the old instance during reload
271    #[cfg_attr(feature = "schema", schemars(extend("init" = {
272        "group": "shutdown",
273        "blurb": "How long the old instance gets to drain during a reload"
274    })))]
275    pub graceful_timeout: UpDuration,
276    /// How long a triggered action gets to answer on the shepherd channel
277    /// before its row becomes `ActionOutcome::TimedOut`.
278    ///
279    /// Defaults to 3s — comfortably under the 5s an RPC caller gets when it
280    /// sends no deadline of its own (`shep-client`'s `DEFAULT_DEADLINE`,
281    /// mirrored daemon-side as `rpc`'s `DEFAULT_DEADLINE_MS`). The margin
282    /// matters more than the number: push this past that budget and a caller
283    /// using the plain default gives up with `DeadlineExceeded` before the
284    /// daemon's own honest `TimedOut` row ever reaches it. A legitimately
285    /// slow action (a cache flush, say) can still ask for longer, but its
286    /// caller has to ask for a longer deadline in step —
287    /// `Client::request_with_deadline`, the way `shep logs -f` already asks
288    /// for `LOG_PLANE_DEADLINE` rather than the client's default. `normalize`
289    /// refuses a value no caller could ever satisfy, however long a deadline
290    /// it asks for; a value merely above the *default* budget is a caller's
291    /// choice to widen its own deadline, not a config error this crate can
292    /// see.
293    #[cfg_attr(feature = "schema", schemars(extend("init" = {
294        "group": "shutdown",
295        "blurb": "How long a triggered action has to answer before shep gives up"
296    })))]
297    pub action_timeout: UpDuration,
298    /// Memory ceiling — polling enforcer restarts above this
299    #[cfg_attr(feature = "schema", schemars(extend("init" = {
300        "example": "512M",
301        "group": "restart",
302        "blurb": "Restart the app if it climbs above this much memory"
303    })))]
304    pub max_memory: Option<MemSize>,
305    /// Watch files and restart on change
306    #[cfg_attr(feature = "schema", schemars(extend("init" = {
307        "group": "watch",
308        "blurb": "Restart when a file changes"
309    })))]
310    pub watch: bool,
311    /// Watch ignore globs (defaults added daemon-side: dot-entries, node_modules)
312    #[cfg_attr(feature = "schema", schemars(extend("init" = {
313        "group": "watch",
314        "blurb": "Paths watch should skip, on top of dotfiles and node_modules"
315    })))]
316    pub ignore_watch: Vec<String>,
317    /// Watch debounce window (default 500ms, applied daemon-side)
318    #[cfg_attr(feature = "schema", schemars(extend("init" = {
319        "example": "500",
320        "group": "watch",
321        "blurb": "How long to wait after a change before restarting"
322    })))]
323    pub watch_delay: Option<UpDuration>,
324    /// Cron pattern for scheduled restarts (croner dialect)
325    #[cfg_attr(feature = "schema", schemars(extend("init" = {
326        "example": "* * * * *",
327        "group": "cron",
328        "blurb": "Restart on a schedule, written as a cron pattern",
329        "suggest": ["*/5 * * * *", "0 * * * *", "0 0 * * *", "0 0 * * 0"]
330    })))]
331    pub cron_restart: Option<String>,
332    /// Fold (group) this sheep belongs to
333    #[cfg_attr(feature = "schema", schemars(extend("init" = {
334        "example": "backend",
335        "group": "process",
336        "blurb": "A fold to group this app with others, for commands that take one"
337    })))]
338    pub fold: Option<String>,
339    /// Sheep or dogs that must be up before this one starts
340    ///
341    /// Names, never `name:slot`: a dependency on one instance of a
342    /// load-balanced app is not a claim about availability. A dependency on
343    /// a multi-instance app waits for every instance.
344    ///
345    /// Read once when a batch is ordered, at a boot, a muster, or a staged
346    /// start, so an edit reaches the next such operation rather than the
347    /// running child.
348    #[cfg_attr(feature = "schema", schemars(extend("init" = {
349        "example": "[\"db\", \"cache\"]",
350        "group": "process",
351        "blurb": "Other sheep or dogs that must be up before this one starts"
352    })))]
353    pub depends_on: Vec<String>,
354    /// Run as this user (unix)
355    #[cfg_attr(feature = "schema", schemars(extend("init" = {
356        "example": "www-data",
357        "group": "process",
358        "blurb": "Run as this user, on unix"
359    })))]
360    pub user: Option<String>,
361    /// Run as this group (unix)
362    #[cfg_attr(feature = "schema", schemars(extend("init" = {
363        "example": "www-data",
364        "group": "process",
365        "blurb": "Run as this group, on unix"
366    })))]
367    pub group: Option<String>,
368    /// Stdout log file (default: `$SHEP_HOME/logs/<name>-<instance>-out.log`; `merge_logs` collapses to `<name>-out.log`)
369    #[cfg_attr(feature = "schema", schemars(extend("init" = {
370        "example": "/var/log/my-first-sheep/out.log",
371        "group": "logging",
372        "blurb": "Where stdout goes. Defaults to a file under $SHEP_HOME/logs"
373    })))]
374    pub out_file: Option<String>,
375    /// Stderr log file (default: `$SHEP_HOME/logs/<name>-<instance>-err.log`; `merge_logs` collapses to `<name>-err.log`)
376    #[cfg_attr(feature = "schema", schemars(extend("init" = {
377        "example": "/var/log/my-first-sheep/err.log",
378        "group": "logging",
379        "blurb": "Where stderr goes. Defaults to a file under $SHEP_HOME/logs"
380    })))]
381    pub err_file: Option<String>,
382    /// Merge instance logs into one file pair
383    #[cfg_attr(feature = "schema", schemars(extend("init" = {
384        "group": "logging",
385        "blurb": "Put every instance's output in one pair of files"
386    })))]
387    pub merge_logs: bool,
388    /// Open the shepherd channel on fd 3 for this app on its own, without
389    /// needing `wait_ready` or `shutdown_with_message` to imply it.
390    ///
391    /// Defaults to `false`: a socketpair plus two pump tasks per sheep is
392    /// real cost weighed against spec §14.11's single-digit-MB idle-RSS
393    /// goal, so a channel is opened only when something asks for one.
394    #[cfg_attr(feature = "schema", schemars(extend("init" = {
395        "group": "inputs",
396        "blurb": "Opens fd 3 so the app can talk to shep directly"
397    })))]
398    pub channel: bool,
399    /// Open a pipe on this sheep's stdin, so `shep whisper` can write to it.
400    ///
401    /// Defaults to `false`, and the default is the decision rather than a
402    /// convenience. Without it a sheep gets `/dev/null` on fd 0, which is what
403    /// every sheep has had until now, and three things argue for keeping it
404    /// that way unless an app asks otherwise:
405    ///
406    /// - Flipping it for the whole flock is a behaviour change to processes
407    ///   nobody asked to change.
408    /// - **Programs detect stdin.** A closed or null fd 0 is how a great many
409    ///   programs decide they are non-interactive — no prompt, no pager, no
410    ///   readline, no colour. Handing them a pipe silently moves them to the
411    ///   other branch.
412    /// - It costs a descriptor and a pump task per sheep for the whole life of
413    ///   the process, against spec §14.11's single-digit-MB idle-RSS goal — the
414    ///   same budget [`Self::channel`]'s own default is protecting.
415    ///
416    /// Unlike `channel`, nothing implies this: `wait_ready` and
417    /// `shutdown_with_message` both need fd 3 and so turn `channel` on for you,
418    /// while nothing in shep needs a sheep's stdin except an operator typing
419    /// `shep whisper`. A sheep without it answers a `no_stdin` row and names
420    /// this field.
421    ///
422    /// The pipe's write end lives as long as the sheep does, so the app sees
423    /// EOF on stdin when the process is on its way out, never before.
424    #[cfg_attr(feature = "schema", schemars(extend("init" = {
425        "group": "inputs",
426        "blurb": "Keeps stdin open so shep whisper can write to the process"
427    })))]
428    pub stdin: bool,
429    /// Expect `{"kind":"ready"}` on the shepherd channel
430    #[cfg_attr(feature = "schema", schemars(extend("init" = {
431        "group": "readiness",
432        "blurb": "Wait for the app to say it is ready on the channel"
433    })))]
434    pub wait_ready: bool,
435    /// Asserts that the app itself sets `SO_REUSEPORT` before it binds —
436    /// shep binds nothing, so it cannot set the option on the app's behalf.
437    /// The child process owns the mechanism (Node ≥22's `reusePort`, Go's
438    /// `net.ListenConfig.Control`, nginx's `reuseport`); shep's contribution
439    /// is permission for the old and new instance to overlap during reload,
440    /// not the socket option itself.
441    ///
442    /// That permission is what the field buys, and it is read by exactly one
443    /// thing: which reload the daemon runs for the app.
444    ///
445    /// - **Unset**, and the app has a `readiness_probe`: reload is SERIAL.
446    ///   The instance being replaced is drained first and its replacement is
447    ///   spawned into the empty slot, so the app is down for the length of
448    ///   the drain. That is the cost of an honest answer — while both
449    ///   instances are up, a probe against an address cannot say which of
450    ///   them answered, and shep would take the outgoing instance's reply as
451    ///   proof the incoming one is ready.
452    /// - **Set**: reload OVERLAPS. The replacement is spawned alongside the
453    ///   instance it replaces and takes over without a gap — if the app really does set
454    ///   `SO_REUSEPORT`. If it does not, the replacement takes `EADDRINUSE`
455    ///   and the reload fails, which is the failure this field exists to keep
456    ///   opt-in.
457    ///
458    /// An app with no `readiness_probe` overlaps either way: with nothing
459    /// probing an address, there is no answer for the wrong instance to give.
460    /// So does one using `wait_ready`, because the shepherd channel a
461    /// replacement reports on is its own — the instance being replaced has no
462    /// way to answer it. Both of those need `SO_REUSEPORT` as much as a
463    /// `reuse_port` app does if they bind an address, since they are overlapped
464    /// too; what this field changes is which apps get overlapped, not what an
465    /// overlap costs.
466    ///
467    /// Setting this on an app that does NOT set the socket option is the one
468    /// way to get it wrong, and shep cannot check it: the option is set
469    /// inside the child, after the fork, on a socket shep never sees.
470    #[cfg_attr(feature = "schema", schemars(extend("init" = {
471        "group": "process",
472        "blurb": "The app sets SO_REUSEPORT itself, so reload may overlap the two instances"
473    })))]
474    pub reuse_port: bool,
475    /// Readiness probe — gates reload's AwaitReady (spec §7)
476    #[cfg_attr(feature = "schema", schemars(extend("init" = {
477        "example": { "kind": "http", "target": "http://127.0.0.1:8080/ready" },
478        "group": "readiness",
479        "blurb": "A health check shep waits on before it treats a reload as finished"
480    })))]
481    pub readiness_probe: Option<ProbeConfig>,
482    /// Liveness probe — failures feed the restart policy (spec §7)
483    #[cfg_attr(feature = "schema", schemars(extend("init" = {
484        "example": { "kind": "http", "target": "http://127.0.0.1:8080/healthz" },
485        "group": "readiness",
486        "blurb": "A health check that triggers a restart when it keeps failing"
487    })))]
488    pub liveness_probe: Option<ProbeConfig>,
489    /// Watch include globs (empty = watch cwd)
490    #[cfg_attr(feature = "schema", schemars(extend("init" = {
491        "group": "watch",
492        "blurb": "Which paths to watch. Empty means the working directory"
493    })))]
494    pub watch_options: Vec<String>,
495    /// Timezone for `cron_restart` (IANA name)
496    #[cfg_attr(feature = "schema", schemars(extend("init" = {
497        "example": "US/Eastern",
498        "group": "cron",
499        "blurb": "Which timezone cron_restart is read in, as an IANA name"
500    })))]
501    pub cron_timezone: Option<String>,
502    /// Removed. Set your own variable to `{{instance}}` in `env` instead.
503    ///
504    /// Kept only so `normalize` can reject it with that instruction: a
505    /// `deny_unknown_fields` serde error would name no replacement. Remove
506    /// in 0.2.
507    #[cfg_attr(feature = "schema", schemars(skip))]
508    pub increment_var: Option<String>,
509}
510
511/// One value an `env` table may carry: a string, or a bare boolean or whole
512/// number an operator wrote without quoting.
513///
514/// Exists only to read a Flockfile, where the document is hand-written and a
515/// bare value is a plausible shortcut. It never rides the wire: [`AppConfig`]
516/// is serialized through its own impls, which see only `String`.
517///
518/// Debug does not leak an env value. A derived one would print the contents,
519/// and a `{:?}` on a config mid-parse is how a secret reaches a log.
520enum EnvValue {
521    /// A quoted value, kept verbatim
522    Str(String),
523    /// A bare `true` or `false`
524    Bool(bool),
525    /// A whole number, signed or unsigned
526    Int(i128),
527}
528
529impl fmt::Debug for EnvValue {
530    /// Prints only the shape of the value, never its contents.
531    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
532        match self {
533            Self::Str(_) => f.write_str("<str>"),
534            Self::Bool(_) => f.write_str("<bool>"),
535            Self::Int(_) => f.write_str("<int>"),
536        }
537    }
538}
539
540impl EnvValue {
541    /// Renders the value as the string a process receives. Consuming: a borrow
542    /// would force the `Str` arm to clone.
543    #[must_use]
544    fn into_string(self) -> String {
545        match self {
546            Self::Str(s) => s,
547            Self::Bool(b) => b.to_string(),
548            Self::Int(n) => n.to_string(),
549        }
550    }
551}
552
553impl<'de> serde::de::Deserialize<'de> for EnvValue {
554    /// Reads one `env` value in whatever raw form it arrives.
555    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
556        struct EnvValueVisitor;
557
558        impl serde::de::Visitor<'_> for EnvValueVisitor {
559            type Value = EnvValue;
560
561            fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
562                f.write_str("a string, boolean, or whole number")
563            }
564
565            /// A quoted value, kept verbatim.
566            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<EnvValue, E> {
567                Ok(EnvValue::Str(v.to_string()))
568            }
569
570            /// A quoted value from a non-borrowed source.
571            fn visit_string<E: serde::de::Error>(self, v: String) -> Result<EnvValue, E> {
572                Ok(EnvValue::Str(v))
573            }
574
575            /// A bare `true` or `false`.
576            fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<EnvValue, E> {
577                Ok(EnvValue::Bool(v))
578            }
579
580            /// A whole signed number.
581            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<EnvValue, E> {
582                // i128 holds the full i64 range losslessly.
583                Ok(EnvValue::Int(i128::from(v)))
584            }
585
586            /// A whole unsigned number. Only JSON can produce one beyond
587            /// `i64::MAX`; TOML's own spec bounds integers to signed 64-bit,
588            /// so its `visit_u64` input is always within `i64::MAX` and the
589            /// wider type is invisible from a TOML Flockfile. i128 holds
590            /// whatever we receive losslessly, so no value is refused.
591            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<EnvValue, E> {
592                Ok(EnvValue::Int(i128::from(v)))
593            }
594
595            /// A float, refused. `f64` carries no trailing zero and no
596            /// written precision, so `1.10` would reach the process as
597            /// `1.1`. The value is left out of the message: an `env` value
598            /// never reaches a log.
599            fn visit_f64<E: serde::de::Error>(self, _v: f64) -> Result<EnvValue, E> {
600                Err(E::custom(
601                    "a float env value loses its written form, quote it",
602                ))
603            }
604        }
605
606        de.deserialize_any(EnvValueVisitor)
607    }
608}
609
610/// Reads an `env` table, rendering each [`EnvValue`] as the string a process
611/// receives. The `deserialize_with` on [`AppConfig::env`].
612fn deserialize_env<'de, D: Deserializer<'de>>(de: D) -> Result<BTreeMap<String, String>, D::Error> {
613    Ok(BTreeMap::<String, EnvValue>::deserialize(de)?
614        .into_iter()
615        .map(|(k, v)| (k, v.into_string()))
616        .collect())
617}
618
619/// Redacts `env`: only its length is printed.
620impl fmt::Debug for AppConfig {
621    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
622        f.debug_struct("AppConfig")
623            .field("name", &self.name)
624            .field("script", &self.script)
625            .field("env", &format_args!("<{} vars>", self.env.len()))
626            .finish_non_exhaustive()
627    }
628}
629
630impl Default for AppConfig {
631    fn default() -> Self {
632        Self {
633            name: String::new(),
634            script: String::new(),
635            args: Vec::new(),
636            cwd: None,
637            interpreter: None,
638            env: BTreeMap::new(),
639            environment: None,
640            instances: 1,
641            autorestart: true,
642            autostart: true,
643            stop_exit_codes: Vec::new(),
644            min_uptime: UpDuration::from_millis(1000),
645            max_restarts: 16,
646            restart_delay: None,
647            // Not None: see the field's doc comment. An unstable exit with
648            // no restart policy configured must not restart instantly.
649            exp_backoff_restart_delay: Some(UpDuration::from_millis(100)),
650            kill_signal: None,
651            kill_timeout: UpDuration::from_millis(1600),
652            shutdown_with_message: false,
653            listen_timeout: UpDuration::from_millis(3000),
654            graceful_timeout: UpDuration::from_millis(8000),
655            action_timeout: UpDuration::from_millis(3000),
656            max_memory: None,
657            watch: false,
658            ignore_watch: Vec::new(),
659            watch_delay: None,
660            cron_restart: None,
661            fold: None,
662            depends_on: Vec::new(),
663            user: None,
664            group: None,
665            out_file: None,
666            err_file: None,
667            merge_logs: false,
668            channel: false,
669            stdin: false,
670            wait_ready: false,
671            reuse_port: false,
672            readiness_probe: None,
673            liveness_probe: None,
674            watch_options: Vec::new(),
675            cron_timezone: None,
676            increment_var: None,
677        }
678    }
679}
680
681impl AppConfig {
682    /// A minimal config with spec defaults, the programmatic entry point.
683    #[must_use]
684    pub fn minimal(name: &str, script: &str) -> Self {
685        Self {
686            name: name.to_string(),
687            script: script.to_string(),
688            ..Self::default()
689        }
690    }
691
692    /// The names of the fields whose values differ between `self` and
693    /// `other`, in field-name order.
694    ///
695    /// Names only, never values. The one caller sends this list across the
696    /// wire to be printed at an operator, and [`AppConfig::env`] carries
697    /// secrets, so a differing `env` reports `"env"` and stops there.
698    ///
699    /// Compare configs that have both been through
700    /// [`normalize`](fn@crate::config::normalize). Two configs differing only
701    /// in what normalization would have filled in are not a difference an
702    /// operator can act on, and reporting them would make the caller noisy
703    /// about nothing.
704    ///
705    /// # Example
706    ///
707    /// ```
708    /// use shep_core::config::AppConfig;
709    ///
710    /// let stored = AppConfig::minimal("web", "./srv");
711    /// let mut edited = stored.clone();
712    /// edited.cwd = Some("/srv".to_string());
713    ///
714    /// assert_eq!(stored.drifted_fields(&edited), vec!["cwd".to_string()]);
715    /// assert!(stored.drifted_fields(&stored).is_empty());
716    /// ```
717    #[must_use]
718    pub fn drifted_fields(&self, other: &Self) -> Vec<String> {
719        if self == other {
720            return Vec::new();
721        }
722        // Serde-compared, not field by field: a new field needs no edit here.
723        // Sorted since `serde_json::Map` is a `BTreeMap` only while
724        // `preserve_order` is off crate-wide. An empty result means no
725        // drift, or none could be computed.
726        let (Ok(serde_json::Value::Object(mine)), Ok(serde_json::Value::Object(theirs))) =
727            (serde_json::to_value(self), serde_json::to_value(other))
728        else {
729            return Vec::new();
730        };
731        let mut fields: Vec<String> = mine
732            .iter()
733            .filter(|(key, value)| theirs.get(key.as_str()) != Some(value))
734            .map(|(key, _)| key.clone())
735            .collect();
736        fields.sort_unstable();
737        fields
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744    use crate::values::{MemSize, UpDuration};
745
746    #[test]
747    fn minimal_config_gets_spec_defaults() {
748        let app = AppConfig::minimal("web", "./server");
749        assert_eq!(app.name, "web");
750        assert_eq!(app.script, "./server");
751        assert!(app.autorestart);
752        assert!(app.autostart);
753        assert_eq!(app.instances, 1);
754        assert_eq!(app.min_uptime, UpDuration::from_millis(1000));
755        assert_eq!(app.max_restarts, 16);
756        assert_eq!(app.kill_timeout, UpDuration::from_millis(1600));
757        assert_eq!(app.listen_timeout, UpDuration::from_millis(3000));
758        assert_eq!(app.graceful_timeout, UpDuration::from_millis(8000));
759        assert_eq!(app.action_timeout, UpDuration::from_millis(3000));
760        assert!(app.max_memory.is_none());
761        assert!(app.fold.is_none());
762        assert!(!app.channel);
763    }
764
765    #[test]
766    fn unstable_restarts_are_throttled_by_default() {
767        let app = AppConfig::minimal("web", "./srv");
768        assert_eq!(
769            app.exp_backoff_restart_delay,
770            Some(UpDuration::from_millis(100))
771        );
772    }
773
774    #[test]
775    fn stdin_is_not_piped_unless_the_app_asks() {
776        let app = AppConfig::minimal("web", "./srv");
777        assert!(!app.stdin);
778        let parsed: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
779        assert!(!parsed.stdin);
780    }
781
782    #[test]
783    fn the_flockfile_key_is_stdin() {
784        let parsed: AppConfig =
785            toml::from_str("name = \"web\"\nscript = \"./srv\"\nstdin = true").unwrap();
786        assert!(parsed.stdin);
787    }
788
789    #[test]
790    fn environment_defaults_to_absent_and_parses_from_a_flockfile() {
791        assert_eq!(AppConfig::default().environment, None);
792        let app: AppConfig =
793            toml::from_str("name = \"web\"\nscript = \"./srv\"\nenvironment = \"staging\"")
794                .unwrap();
795        assert_eq!(app.environment.as_deref(), Some("staging"));
796    }
797
798    #[test]
799    fn toml_round_trip_with_newtypes() {
800        let toml_src = r#"
801name = "worker"
802script = "python3"
803args = ["job.py", "--fast"]
804max_memory = "512M"
805min_uptime = "5s"
806fold = "backend"
807env = { RUST_LOG = "info" }
808"#;
809        let app: AppConfig = toml::from_str(toml_src).unwrap();
810        assert_eq!(app.max_memory, Some("512M".parse::<MemSize>().unwrap()));
811        assert_eq!(app.min_uptime, UpDuration::from_millis(5000));
812        assert_eq!(app.fold.as_deref(), Some("backend"));
813        assert_eq!(app.env.get("RUST_LOG").map(String::as_str), Some("info"));
814        assert_eq!(app.args, vec!["job.py", "--fast"]);
815    }
816
817    /// Raw TOML scalars in `env` read as their string form. `true` becomes
818    /// `"true"`, `8080` becomes `"8080"`, and quoted strings pass through.
819    /// A single test here covers the deserialization path; the
820    /// `flockfile` test that reads the same document through all four
821    /// formats covers the format dispatch.
822    #[test]
823    fn env_coerces_raw_scalars_to_their_string_form() {
824        let src = r#"
825name = "web"
826script = "./srv"
827env = { SOME_BOOL = true, PORT = 8080, NEG = -1, STR = "plain" }
828"#;
829        let app: AppConfig = toml::from_str(src).unwrap();
830        assert_eq!(app.env["SOME_BOOL"], "true");
831        assert_eq!(app.env["PORT"], "8080");
832        assert_eq!(app.env["NEG"], "-1");
833        assert_eq!(app.env["STR"], "plain");
834    }
835
836    /// A float is refused rather than coerced. `f64` carries no trailing zero
837    /// and no written precision, so accepting one hands the process a value
838    /// the operator did not write. Quoting is the way to keep the text.
839    ///
840    /// Asserted through JSON: a TOML error echoes the offending source line,
841    /// which would put the value in the message whatever serde said.
842    #[test]
843    fn env_refuses_a_float_because_its_written_form_would_not_survive() {
844        let src = r#"{ "name":"web","script":"./srv","env":{ "RATIO": 1.10 } }"#;
845        let err = serde_json::from_str::<AppConfig>(src)
846            .expect_err("a float env value must be refused, not rounded")
847            .to_string();
848        assert!(
849            err.contains("quote it"),
850            "the error must name the fix, got: {err}"
851        );
852        assert!(!err.contains("1.1"), "the error leaked the value: {err}");
853
854        let quoted = src.replace("1.10", r#""1.10""#);
855        let app: AppConfig = serde_json::from_str(&quoted).unwrap();
856        assert_eq!(app.env["RATIO"], "1.10");
857
858        assert!(
859            toml::from_str::<AppConfig>(
860                "name = \"web\"\nscript = \"./srv\"\nenv = { RATIO = 1.10 }\n"
861            )
862            .is_err(),
863            "TOML must refuse a float too"
864        );
865    }
866
867    /// A whole number larger than `i64::MAX` is valid JSON and must load.
868    /// TOML cannot reach this test: its spec bounds integers to signed 64-bit,
869    /// so only JSON exercises this path. The value stringifies to its full
870    /// positive form, not a negative wrap. Pinned to the exact string.
871    #[test]
872    fn env_reads_a_number_beyond_i64_max_without_wrapping() {
873        let beyond_i64 = u64::MAX; // 18446744073709551615
874        let src = format!(r#"{{ "name":"web","script":"./srv","env":{{ "BIG": {beyond_i64} }} }}"#);
875        let app = serde_json::from_str::<AppConfig>(&src)
876            .expect("a u64 beyond i64::MAX is valid JSON and must load");
877        assert_eq!(app.env["BIG"], "18446744073709551615");
878    }
879
880    /// Serialization is the inverse of the coercion: whatever form a value
881    /// arrived as, the wire form is a string. A `true` that deserialized
882    /// into `"true"` must serialize to the JSON string `"true"`, not the
883    /// boolean `true`.
884    #[test]
885    fn env_serialization_is_always_string_regardless_of_input_form() {
886        let src = r#"
887name = "web"
888script = "./srv"
889env = { SOME_BOOL = true, PORT = 8080, STR = "hello" }
890"#;
891        let app: AppConfig = toml::from_str(src).unwrap();
892        let wire = serde_json::to_value(&app).unwrap();
893        for (key, expected) in [("SOME_BOOL", "true"), ("PORT", "8080"), ("STR", "hello")] {
894            assert_eq!(
895                wire["env"].get(key).and_then(serde_json::Value::as_str),
896                Some(expected),
897                "wire form of {key} must be a string, not a scalar"
898            );
899        }
900    }
901
902    /// A value that is neither a string, boolean, nor number is refused, not
903    /// guessed at. An array or object under `env` is a structural mistake —
904    /// an operator meant a table or a list, and guessing a serialization is
905    /// how a wrong value hides for months.
906    #[test]
907    fn env_refuses_structural_values() {
908        for (label, inner) in [("array", r#"["a", "b"]"#), ("object", r#"{"k": "v"}"#)] {
909            let src = format!(r#"{{ "name":"web","script":"./srv","env":{{"X":{inner}}}}}"#);
910            assert!(
911                serde_json::from_str::<AppConfig>(&src).is_err(),
912                "a {label} env value must be refused"
913            );
914        }
915    }
916
917    /// The wire path is the opposite of a Flockfile's: an unknown field
918    /// means a newer peer, and ignoring it is what stops a new Flockfile
919    /// field breaking an older client that reads a config off the wire.
920    /// `deny_unknown_fields` used to live here; the same typo is now
921    /// refused only at `Flockfile::parse`, where the input really is a
922    /// hand-written file.
923    #[test]
924    fn an_unknown_field_on_the_wire_is_ignored_rather_than_refused() {
925        let config: AppConfig =
926            serde_json::from_str(r#"{"name":"web","script":"./srv","invented_next_year":true}"#)
927                .expect("the wire path tolerates what it does not know");
928        assert_eq!(config.name, "web");
929    }
930
931    #[test]
932    fn probe_config_parses_with_defaults() {
933        let src = r#"
934name = "api"
935script = "./api"
936
937[readiness_probe]
938kind = "http"
939target = "http://127.0.0.1:8080/healthz"
940"#;
941        let app: AppConfig = toml::from_str(src).unwrap();
942        let probe = app.readiness_probe.unwrap();
943        assert_eq!(probe.kind, ProbeKind::Http);
944        assert_eq!(probe.target, "http://127.0.0.1:8080/healthz");
945        assert_eq!(probe.interval, UpDuration::from_millis(10_000));
946        assert_eq!(probe.timeout, UpDuration::from_millis(5_000));
947        assert_eq!(probe.failure_threshold, 3);
948        assert!(app.liveness_probe.is_none());
949    }
950
951    #[test]
952    fn debug_redacts_env_values() {
953        // Exact string pinned so a lazy derive(Debug) refactor fails here.
954        let mut app = AppConfig::minimal("web", "./srv");
955        app.env
956            .insert("DATABASE_URL".to_string(), "postgres://secret".to_string());
957        app.env.insert("RUST_LOG".to_string(), "info".to_string());
958        assert_eq!(
959            format!("{app:?}"),
960            "AppConfig { name: \"web\", script: \"./srv\", env: <2 vars>, .. }"
961        );
962    }
963
964    /// `EnvValue::Debug` prints only the kind, never the value — the exact
965    /// string is pinned so a derived `Debug` (which prints the contents) fails
966    /// here. This is the unit half of the redaction guarantee.
967    #[test]
968    fn env_value_debug_never_prints_the_value() {
969        let cases = [
970            (EnvValue::Str("postgres://secret".to_string()), "<str>"),
971            (EnvValue::Bool(true), "<bool>"),
972            (EnvValue::Int(9_223_372_036_854_775_807), "<int>"),
973        ];
974        for (value, expected) in cases {
975            assert_eq!(format!("{value:?}"), expected);
976        }
977    }
978
979    #[test]
980    fn an_unedited_config_has_drifted_in_no_field() {
981        let app = AppConfig::minimal("web", "./srv");
982
983        assert!(app.drifted_fields(&app.clone()).is_empty());
984    }
985
986    #[test]
987    fn drift_names_every_edited_field_and_no_other() {
988        // Two fields, not one, so a comparator that stopped at the first
989        // difference fails here.
990        let stored = AppConfig::minimal("proto-api", "./proto-enum-api");
991        let mut edited = stored.clone();
992        edited.cwd = Some("/srv/pogo-proto-api".to_string());
993        edited.args = vec!["-config".to_string(), "config.toml".to_string()];
994
995        assert_eq!(
996            stored.drifted_fields(&edited),
997            vec!["args".to_string(), "cwd".to_string()]
998        );
999    }
1000
1001    #[test]
1002    fn drift_reports_env_by_name_and_never_by_value() {
1003        let stored = AppConfig::minimal("web", "./srv");
1004        let mut edited = stored.clone();
1005        edited
1006            .env
1007            .insert("DATABASE_URL".to_string(), "postgres://hunter2".to_string());
1008
1009        let fields = edited.drifted_fields(&stored);
1010
1011        assert_eq!(fields, vec!["env".to_string()]);
1012        // Names go to an operator; a value never should.
1013        assert!(!fields.concat().contains("hunter2"));
1014    }
1015
1016    #[test]
1017    fn drift_is_symmetric() {
1018        let stored = AppConfig::minimal("web", "./srv");
1019        let mut edited = stored.clone();
1020        edited.instances = 4;
1021
1022        assert_eq!(
1023            stored.drifted_fields(&edited),
1024            edited.drifted_fields(&stored)
1025        );
1026        assert_eq!(
1027            stored.drifted_fields(&edited),
1028            vec!["instances".to_string()]
1029        );
1030    }
1031}