Skip to main content

shep_core/config/app/
schema.rs

1use super::env_value::deserialize_env;
2use super::probe::ProbeConfig;
3use std::collections::BTreeMap;
4// use schemars::generate
5use crate::config::LevelRule;
6use crate::values::{MemSize, UpDuration};
7use serde::{Deserialize, Serialize};
8
9/// Per-app configuration — one sheep's entry in a Flockfile
10///
11/// Field names are the Flockfile contract (sheep-native; pm2 spellings are
12/// rejected — the importer translates them). Deserializing this type
13/// directly tolerates an unknown field, since it also rides the wire; a
14/// Flockfile typo instead fails loudly at [`Flockfile::parse`](crate::config::Flockfile::parse),
15/// the input that really is hand-written.
16///
17/// # Example
18/// ```
19/// use shep_core::config::AppConfig;
20///
21/// let app: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
22/// assert!(app.autorestart); // spec default
23/// ```
24// wire format: changing field names/defaults is a breaking change
25//
26// `deny_unknown_fields` used to sit beside `default` here. This type rides
27// the wire inside `Request::Start`, `Request::Add`, and
28// `Response::SheepConfig` — the last of which is a newer daemon handing an
29// older client a config it does not fully understand, which is exactly the
30// case an unknown field means "a newer peer", not a typo. The denial moved
31// to `Flockfile::parse`, where the input really is a hand-written file. Do
32// not restore the serde attribute here.
33//
34// The schema-only sibling attribute below is not the same thing and stays:
35// `schemars(deny_unknown_fields)` only shapes the generated
36// `additionalProperties: false`, which an editor uses to flag a Flockfile
37// typo before a parse ever runs. It never reaches `#[derive(Deserialize)]`
38// (schemars mirrors it into a synthesized attribute its own macro expansion
39// reads, not the real one), so the wire still tolerates an unknown field.
40#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
43#[serde(default)]
44pub struct AppConfig {
45    /// Unique sheep name (required)
46    #[cfg_attr(feature = "schema", schemars(extend("init" = {
47        "example": "my-first-sheep",
48        "group": "process",
49        "blurb": "A convenient and unique name for shep to display",
50        "accepts": ["letters, digits, and most punctuation"],
51        "refuses": ["a path separator or a colon", "a bare . or .."]
52    })))]
53    pub name: String,
54    /// Executable or script path (required)
55    #[cfg_attr(feature = "schema", schemars(extend("init" = {
56        "example": "./index.js",
57        "group": "process",
58        "blurb": "The script that shep should use to launch your app",
59        "accepts": ["an absolute or relative path, expanded from cwd",
60                    "~ expands, $VARS do not",
61                    "a script shep cannot resolve yet: warned"],
62        "neighbours": [{"field": "cwd",         "note": "resolved against this cwd"},
63                       {"field": "interpreter", "note": "picks what runs this script"}]
64    })))]
65    pub script: String,
66    /// Arguments passed to the script
67    #[cfg_attr(feature = "schema", schemars(extend("init" = {
68        "group": "inputs",
69        "blurb": "Arguments passed to the script, as a list",
70        "accepts": ["a list of strings, one argument each",
71                    "{{instance}}, {{name}}, {{SHEP_HOME}} and {{secret:key}} expand"],
72        "refuses": ["an unclosed {{ token", "a token shep does not define"]
73    })))]
74    pub args: Vec<String>,
75    /// Working directory (default: daemon's cwd at spawn registration)
76    #[cfg_attr(feature = "schema", schemars(extend("init" = {
77        "example": "/srv/app",
78        "group": "process",
79        "blurb": "Where the process runs. Without it, the daemon's own directory",
80        "accepts": ["an absolute or relative path, expanded from cwd",
81                    "~ expands, $VARS do not",
82                    "a directory that does not exist yet: warned"],
83        "neighbours": [{"field": "script",        "note": "resolved against this cwd"},
84                       {"field": "out_file",      "note": "relative paths follow it too"},
85                       {"field": "watch_options", "note": "globs are rooted here"}]
86    })))]
87    pub cwd: Option<String>,
88    /// Interpreter override (`"none"` = run script directly)
89    #[cfg_attr(feature = "schema", schemars(extend("init" = {
90        "example": "none",
91        "group": "process",
92        "blurb": "What runs the script. Set it to none to exec the file directly",
93        "accepts": ["a command name found on PATH", "none, to exec the script directly"]
94    })))]
95    pub interpreter: Option<String>,
96    /// Environment for the sheep (merged over the daemon's filtered env).
97    ///
98    /// A value may arrive as a string, a bare boolean, or a bare whole
99    /// number (`SOME_BOOL = true`, `PORT = 8080`), and leaves as a string
100    /// either way, so this field stays `BTreeMap<String, String>` and the
101    /// wire always carries strings. A float is refused: `1.10` would reach
102    /// the process as `1.1`. YAML resolves an unquoted `yes` to `"true"`
103    /// and `0x1F` to `"31"`, so quote a value whose text must survive.
104    #[serde(deserialize_with = "deserialize_env")]
105    #[cfg_attr(feature = "schema", schemars(
106        extend(
107            "init" = {
108                "example": "{ NODE_ENV = 'production' }",
109                "group": "inputs",
110                "blurb": "Environment variables for this app, layered over the daemon's own",
111                "accepts": ["a table of KEY = value pairs",
112                            "a bare true or 8080, which arrives as text",
113                            "{{instance}}, {{name}}, {{SHEP_HOME}} and {{secret:key}} expand in a value"],
114                "refuses": ["a float, since 1.10 would arrive as 1.1",
115                            "SHEP_INSTANCE, SHEP_NAME, or SHEP_ENVIRONMENT, which shep sets itself",
116                            "an unclosed {{ token",
117                            "a token shep does not define"],
118                "neighbours": [{"field": "environment", "note": "which environment {{secret:...}} reads from"}]
119            },
120            "additionalProperties" = {
121                "anyOf": [{ "type": "string" }, { "type": "boolean" }, { "type": "integer" }]
122            }
123        )
124    ))]
125    pub env: BTreeMap<String, String>,
126    /// Which environment this sheep resolves `{{secret:...}}` in.
127    ///
128    /// Absent falls back to `[daemon] environment` in `shep.toml`, which
129    /// itself defaults to `production`. Never `all`: that is the store's
130    /// every-environment slot, and a sheep claiming it would read that slot
131    /// twice and never one of its own.
132    #[cfg_attr(feature = "schema", schemars(extend("init" = {
133        "example": "staging",
134        "group": "inputs",
135        "blurb": "Which environment this app resolves secrets in",
136        "accepts": ["a name from the secrets store"],
137        "refuses": ["all, the store's every-environment slot",
138                    "a name outside letters, digits, dot, underscore, or dash"],
139        "neighbours": [{"field": "env", "note": "sets which environment its {{secret:...}} reads from"}]
140    })))]
141    pub environment: Option<String>,
142    /// Instance count ("cluster" = N fork instances; spec §4)
143    #[cfg_attr(feature = "schema", schemars(extend("init" = {
144        "group": "process",
145        "blurb": "How many copies of this app to run"
146    })))]
147    pub instances: u32,
148    /// Restart on unexpected exit
149    #[cfg_attr(feature = "schema", schemars(extend("init" = {
150        "group": "restart",
151        "blurb": "Restarts the process automatically when it exits unexpectedly"
152    })))]
153    pub autorestart: bool,
154    /// Start when the daemon starts / on `shep muster`
155    #[cfg_attr(feature = "schema", schemars(extend("init" = {
156        "group": "restart",
157        "blurb": "Start this app when the daemon starts, and on shep muster"
158    })))]
159    pub autostart: bool,
160    /// Exit codes treated as clean stop (no restart)
161    #[cfg_attr(feature = "schema", schemars(extend("init" = {
162        "group": "restart",
163        "blurb": "Exit codes that mean a clean stop, so shep will not restart",
164        "accepts": ["a list of exit codes"]
165    })))]
166    pub stop_exit_codes: Vec<i32>,
167    /// Uptime below this marks an exit as unstable
168    #[cfg_attr(feature = "schema", schemars(extend("init" = {
169        "group": "restart",
170        "blurb": "An exit sooner than this counts as unstable"
171    })))]
172    pub min_uptime: UpDuration,
173    /// Consecutive unstable exits before `errored`
174    #[cfg_attr(feature = "schema", schemars(extend("init" = {
175        "group": "restart",
176        "blurb": "How many unstable exits in a row before shep gives up"
177    })))]
178    pub max_restarts: u32,
179    /// Fixed delay before every restart (alternative to backoff)
180    #[cfg_attr(feature = "schema", schemars(extend("init" = {
181        "example": "3s",
182        "group": "restart",
183        "blurb": "A fixed wait before every restart, instead of growing backoff"
184    })))]
185    pub restart_delay: Option<UpDuration>,
186    /// Initial backoff delay; grows ×1.5 capped at 15s (spec §4)
187    ///
188    /// Defaults to 100ms, not unset. An unstable exit (sooner than
189    /// `min_uptime`) with neither this nor `restart_delay` configured would
190    /// otherwise restart with no delay at all, so an app that can never
191    /// start (a missing dependency, a bad config) would burn its whole
192    /// `max_restarts` budget inside a second, logging the same failure
193    /// dozens of times.
194    ///
195    /// All of the above assumes `restart_delay` is unset. A fixed
196    /// `restart_delay` takes precedence over this field on every exit,
197    /// stable or not, so a stable exit restarts immediately only while
198    /// `restart_delay` stays unset, and setting this field to `"0"`
199    /// disables the backoff without producing an immediate restart if a
200    /// nonzero `restart_delay` is also configured.
201    #[cfg_attr(feature = "schema", schemars(extend("init" = {
202        "example": "5s",
203        "group": "restart",
204        "blurb": "Starting delay between restarts, growing each time it fails again"
205    })))]
206    pub exp_backoff_restart_delay: Option<UpDuration>,
207    /// Stop signal, one of `SIGTERM`/`SIGINT`/`SIGQUIT`/`SIGUSR2` (the `SIG`
208    /// prefix and the case are both optional). Unset means `SIGTERM`.
209    ///
210    /// A `String` rather than a [`KillSignal`](crate::config::KillSignal) so
211    /// the Flockfile schema and this struct's wire form stay plain text;
212    /// `normalize` is what refuses a name outside that set, the same split
213    /// `cron_restart` and the watch globs already use.
214    #[cfg_attr(feature = "schema", schemars(extend("init" = {
215        "example": "SIGTERM",
216        "group": "shutdown",
217        "blurb": "Which signal shep sends first when stopping this app",
218        "suggest": ["SIGTERM", "SIGINT", "SIGQUIT", "SIGUSR2"],
219        "accepts": ["SIGTERM, SIGINT, SIGQUIT, or SIGUSR2",
220                    "the SIG prefix and case are both optional"],
221        "refuses": ["a signal outside that list"]
222    })))]
223    pub kill_signal: Option<String>,
224    /// Grace period between stop signal and SIGKILL
225    #[cfg_attr(feature = "schema", schemars(extend("init" = {
226        "group": "shutdown",
227        "blurb": "How long shep waits after the stop signal before SIGKILL"
228    })))]
229    pub kill_timeout: UpDuration,
230    /// Send `{"kind":"shutdown"}` on the shepherd channel instead of a signal
231    #[cfg_attr(feature = "schema", schemars(extend("init" = {
232        "group": "shutdown",
233        "blurb": "Ask the app to stop over the channel instead of signalling it"
234    })))]
235    pub shutdown_with_message: bool,
236    /// Readiness fallback window when no ready signal/probe configured
237    #[cfg_attr(feature = "schema", schemars(extend("init" = {
238        "group": "readiness",
239        "blurb": "How long to wait for readiness when nothing else reports it"
240    })))]
241    pub listen_timeout: UpDuration,
242    /// Drain window for the old instance during reload
243    #[cfg_attr(feature = "schema", schemars(extend("init" = {
244        "group": "shutdown",
245        "blurb": "How long the old instance gets to drain during a reload"
246    })))]
247    pub graceful_timeout: UpDuration,
248    /// How long a triggered action gets to answer on the shepherd channel
249    /// before its row becomes `ActionOutcome::TimedOut`.
250    ///
251    /// Defaults to 3s — comfortably under the 5s an RPC caller gets when it
252    /// sends no deadline of its own (`shep-client`'s `DEFAULT_DEADLINE`,
253    /// mirrored daemon-side as `rpc`'s `DEFAULT_DEADLINE_MS`). The margin
254    /// matters more than the number: push this past that budget and a caller
255    /// using the plain default gives up with `DeadlineExceeded` before the
256    /// daemon's own honest `TimedOut` row ever reaches it. A legitimately
257    /// slow action (a cache flush, say) can still ask for longer, but its
258    /// caller has to ask for a longer deadline in step —
259    /// `Client::request_with_deadline`, the way `shep logs -f` already asks
260    /// for `LOG_PLANE_DEADLINE` rather than the client's default. `normalize`
261    /// refuses a value no caller could ever satisfy, however long a deadline
262    /// it asks for; a value merely above the *default* budget is a caller's
263    /// choice to widen its own deadline, not a config error this crate can
264    /// see.
265    #[cfg_attr(feature = "schema", schemars(extend("init" = {
266        "group": "shutdown",
267        "blurb": "How long a triggered action has to answer before shep gives up"
268    })))]
269    pub action_timeout: UpDuration,
270    /// Memory ceiling — polling enforcer restarts above this
271    #[cfg_attr(feature = "schema", schemars(extend("init" = {
272        "example": "512M",
273        "group": "restart",
274        "blurb": "Restart the app if it climbs above this much memory"
275    })))]
276    pub max_memory: Option<MemSize>,
277    /// Watch files and restart on change
278    #[cfg_attr(feature = "schema", schemars(extend("init" = {
279        "group": "watch",
280        "blurb": "Restart when a file changes"
281    })))]
282    pub watch: bool,
283    /// Watch ignore globs (defaults added daemon-side: dot-entries, node_modules)
284    #[cfg_attr(feature = "schema", schemars(extend("init" = {
285        "group": "watch",
286        "blurb": "Paths watch should skip, on top of dotfiles and node_modules",
287        "accepts": ["a list of glob patterns"],
288        "refuses": ["a pattern globset cannot compile"],
289        "neighbours": [{"field": "cwd",           "note": "globs are rooted here"},
290                       {"field": "watch_options", "note": "removed from what this matches"}]
291    })))]
292    pub ignore_watch: Vec<String>,
293    /// Watch debounce window (default 500ms, applied daemon-side)
294    #[cfg_attr(feature = "schema", schemars(extend("init" = {
295        "example": "500",
296        "group": "watch",
297        "blurb": "How long to wait after a change before restarting"
298    })))]
299    pub watch_delay: Option<UpDuration>,
300    /// Cron pattern for scheduled restarts (croner dialect)
301    #[cfg_attr(feature = "schema", schemars(extend("init" = {
302        "example": "* * * * *",
303        "group": "cron",
304        "blurb": "Restart on a schedule, written as a cron pattern",
305        "suggest": ["*/5 * * * *", "0 * * * *", "0 0 * * *", "0 0 * * 0"],
306        "accepts": ["five fields: minute hour day month weekday",
307                    "a nickname like @daily or @hourly"],
308        "refuses": ["a field outside its valid range",
309                    "a sixth seconds field, or L, W, # or ?",
310                    "a pattern croner cannot parse"],
311        "neighbours": [{"field": "cron_timezone", "note": "sets which zone this pattern reads in"}]
312    })))]
313    pub cron_restart: Option<String>,
314    /// Fold (group) this sheep belongs to
315    #[cfg_attr(feature = "schema", schemars(extend("init" = {
316        "example": "backend",
317        "group": "process",
318        "blurb": "A fold to group this app with others, for commands that take one"
319    })))]
320    pub fold: Option<String>,
321    /// Sheep or dogs that must be up before this one starts
322    ///
323    /// Names, never `name:slot`: a dependency on one instance of a
324    /// load-balanced app is not a claim about availability. A dependency on
325    /// a multi-instance app waits for every instance.
326    ///
327    /// Read once when a batch is ordered, at a boot, a muster, or a staged
328    /// start, so an edit reaches the next such operation rather than the
329    /// running child.
330    #[cfg_attr(feature = "schema", schemars(extend("init" = {
331        "example": "[\"db\", \"cache\"]",
332        "group": "process",
333        "blurb": "Other sheep or dogs that must be up before this one starts",
334        "accepts": ["a list of sheep or dog names"],
335        "refuses": ["this sheep's own name", "a name:slot instance reference"]
336    })))]
337    pub depends_on: Vec<String>,
338    /// Run as this user (unix)
339    #[cfg_attr(feature = "schema", schemars(extend("init" = {
340        "example": "www-data",
341        "group": "process",
342        "blurb": "Run as this user, on unix",
343        "accepts": ["a unix user name"],
344        "refuses": ["a name with no passwd entry",
345                    "another user, unless the shepherd runs as root"],
346        "neighbours": [{"field": "group", "note": "resolved together at spawn"}]
347    })))]
348    pub user: Option<String>,
349    /// Run as this group (unix)
350    #[cfg_attr(feature = "schema", schemars(extend("init" = {
351        "example": "www-data",
352        "group": "process",
353        "blurb": "Run as this group, on unix",
354        "accepts": ["a unix group name"],
355        "refuses": ["a name with no group entry",
356                    "another group, unless the shepherd runs as root"],
357        "neighbours": [{"field": "user", "note": "resolved together at spawn"}]
358    })))]
359    pub group: Option<String>,
360    /// Stdout log file (default: `{{SHEP_HOME}}/logs/{{name}}-{{instance}}-out.log`; `merge_logs` collapses to `{{name}}-out.log`)
361    #[cfg_attr(feature = "schema", schemars(extend("init" = {
362        "example": "{{SHEP_HOME}}/logs/{{name}}-{{instance}}-out.log",
363        "group": "logging",
364        "blurb": "Where stdout goes. Written out, the default is {{SHEP_HOME}}/logs/{{name}}-{{instance}}-out.log",
365        "accepts": ["a path, relative paths follow cwd",
366                    "{{instance}}, {{name}} and {{SHEP_HOME}} expand",
367                    "a parent that does not exist yet: warned"],
368        "refuses": ["a {{secret:...}} token",
369                    "one path for every instance, without merge_logs"],
370        "neighbours": [{"field": "err_file",   "note": "shares the same collision rule"},
371                       {"field": "merge_logs", "note": "shares one file, unless this path spells {{instance}} out"}]
372    })))]
373    pub out_file: Option<String>,
374    /// Stderr log file (default: `{{SHEP_HOME}}/logs/{{name}}-{{instance}}-err.log`; `merge_logs` collapses to `{{name}}-err.log`)
375    #[cfg_attr(feature = "schema", schemars(extend("init" = {
376        "example": "{{SHEP_HOME}}/logs/{{name}}-{{instance}}-err.log",
377        "group": "logging",
378        "blurb": "Where stderr goes. Written out, the default is {{SHEP_HOME}}/logs/{{name}}-{{instance}}-err.log",
379        "accepts": ["a path, relative paths follow cwd",
380                    "{{instance}}, {{name}} and {{SHEP_HOME}} expand",
381                    "a parent that does not exist yet: warned"],
382        "refuses": ["a {{secret:...}} token",
383                    "one path for every instance, without merge_logs"],
384        "neighbours": [{"field": "out_file",   "note": "shares the same collision rule"},
385                       {"field": "merge_logs", "note": "shares one file, unless this path spells {{instance}} out"}]
386    })))]
387    pub err_file: Option<String>,
388    /// Merge instance logs into one file pair
389    #[cfg_attr(feature = "schema", schemars(extend("init" = {
390        "group": "logging",
391        "blurb": "Put every instance's output in one pair of files"
392    })))]
393    pub merge_logs: bool,
394    /// How this app's own lines announce their level, for a client that
395    /// filters by one.
396    ///
397    /// Ordered: rules are tried as written and the first match wins.
398    /// Declaring any replaces the reader's built-in guess for this app
399    /// rather than adding to it, so a line matching no rule announces no
400    /// level. Nothing here hides a line: an unclassified line survives
401    /// every level filter.
402    #[cfg_attr(feature = "schema", schemars(extend("init" = {
403        "example": "[{ pattern = '\\[ERROR\\]', level = 'error' }]",
404        "group": "logging",
405        "blurb": "What this app's own log levels look like, for filtering",
406        "accepts": ["a list of { pattern, level } rules, tried in order",
407                    "a regex matched against the whole line, (?i) folds case",
408                    "a level of trace, debug, info, warn, or error"],
409        "refuses": ["an empty pattern, which would claim every line",
410                    "a pattern regex cannot compile"],
411        "neighbours": [{"field": "out_file", "note": "the lines these rules read"}]
412    })))]
413    pub level_rules: Vec<LevelRule>,
414    /// Open the shepherd channel on fd 3 for this app on its own, without
415    /// needing `wait_ready` or `shutdown_with_message` to imply it.
416    ///
417    /// Defaults to `false`: a socketpair plus two pump tasks per sheep is
418    /// real cost weighed against spec §14.11's single-digit-MB idle-RSS
419    /// goal, so a channel is opened only when something asks for one.
420    #[cfg_attr(feature = "schema", schemars(extend("init" = {
421        "group": "inputs",
422        "blurb": "Opens fd 3 so the app can talk to shep directly"
423    })))]
424    pub channel: bool,
425    /// Open a pipe on this sheep's stdin, so `shep whisper` can write to it.
426    ///
427    /// Defaults to `false`, and the default is the decision rather than a
428    /// convenience. Without it a sheep gets `/dev/null` on fd 0, which is what
429    /// every sheep has had until now, and three things argue for keeping it
430    /// that way unless an app asks otherwise:
431    ///
432    /// - Flipping it for the whole flock is a behaviour change to processes
433    ///   nobody asked to change.
434    /// - **Programs detect stdin.** A closed or null fd 0 is how a great many
435    ///   programs decide they are non-interactive — no prompt, no pager, no
436    ///   readline, no colour. Handing them a pipe silently moves them to the
437    ///   other branch.
438    /// - It costs a descriptor and a pump task per sheep for the whole life of
439    ///   the process, against spec §14.11's single-digit-MB idle-RSS goal — the
440    ///   same budget [`Self::channel`]'s own default is protecting.
441    ///
442    /// Unlike `channel`, nothing implies this: `wait_ready` and
443    /// `shutdown_with_message` both need fd 3 and so turn `channel` on for you,
444    /// while nothing in shep needs a sheep's stdin except an operator typing
445    /// `shep whisper`. A sheep without it answers a `no_stdin` row and names
446    /// this field.
447    ///
448    /// The pipe's write end lives as long as the sheep does, so the app sees
449    /// EOF on stdin when the process is on its way out, never before.
450    #[cfg_attr(feature = "schema", schemars(extend("init" = {
451        "group": "inputs",
452        "blurb": "Keeps stdin open so shep whisper can write to the process"
453    })))]
454    pub stdin: bool,
455    /// Expect `{"kind":"ready"}` on the shepherd channel
456    #[cfg_attr(feature = "schema", schemars(extend("init" = {
457        "group": "readiness",
458        "blurb": "Wait for the app to say it is ready on the channel"
459    })))]
460    pub wait_ready: bool,
461    /// Asserts that the app itself sets `SO_REUSEPORT` before it binds —
462    /// shep binds nothing, so it cannot set the option on the app's behalf.
463    /// The child process owns the mechanism (Node ≥22's `reusePort`, Go's
464    /// `net.ListenConfig.Control`, nginx's `reuseport`); shep's contribution
465    /// is permission for the old and new instance to overlap during reload,
466    /// not the socket option itself.
467    ///
468    /// That permission is what the field buys, and it is read by exactly one
469    /// thing: which reload the daemon runs for the app.
470    ///
471    /// - **Unset**, and the app has a `readiness_probe`: reload is SERIAL.
472    ///   The instance being replaced is drained first and its replacement is
473    ///   spawned into the empty slot, so the app is down for the length of
474    ///   the drain. That is the cost of an honest answer — while both
475    ///   instances are up, a probe against an address cannot say which of
476    ///   them answered, and shep would take the outgoing instance's reply as
477    ///   proof the incoming one is ready.
478    /// - **Set**: reload OVERLAPS. The replacement is spawned alongside the
479    ///   instance it replaces and takes over without a gap — if the app really does set
480    ///   `SO_REUSEPORT`. If it does not, the replacement takes `EADDRINUSE`
481    ///   and the reload fails, which is the failure this field exists to keep
482    ///   opt-in.
483    ///
484    /// An app with no `readiness_probe` overlaps either way: with nothing
485    /// probing an address, there is no answer for the wrong instance to give.
486    /// So does one using `wait_ready`, because the shepherd channel a
487    /// replacement reports on is its own — the instance being replaced has no
488    /// way to answer it. Both of those need `SO_REUSEPORT` as much as a
489    /// `reuse_port` app does if they bind an address, since they are overlapped
490    /// too; what this field changes is which apps get overlapped, not what an
491    /// overlap costs.
492    ///
493    /// Setting this on an app that does NOT set the socket option is the one
494    /// way to get it wrong, and shep cannot check it: the option is set
495    /// inside the child, after the fork, on a socket shep never sees.
496    #[cfg_attr(feature = "schema", schemars(extend("init" = {
497        "group": "process",
498        "blurb": "The app sets SO_REUSEPORT itself, so reload may overlap the two instances"
499    })))]
500    pub reuse_port: bool,
501    /// Readiness probe — gates reload's AwaitReady (spec §7)
502    #[cfg_attr(feature = "schema", schemars(extend("init" = {
503        "example": { "kind": "http", "target": "http://127.0.0.1:8080/ready" },
504        "group": "readiness",
505        "blurb": "A health check shep waits on before it treats a reload as finished",
506        "accepts": ["kind: http, tcp, or exec",
507                    "a target matching the kind: a url, host:port, or command"],
508        "refuses": ["a failure_threshold of 0", "an interval below its own floor"],
509        "neighbours": [{"field": "liveness_probe", "note": "uses a different interval floor"},
510                       {"field": "reuse_port",     "note": "governs whether reload overlaps it"}]
511    })))]
512    pub readiness_probe: Option<ProbeConfig>,
513    /// Liveness probe — failures feed the restart policy (spec §7)
514    #[cfg_attr(feature = "schema", schemars(extend("init" = {
515        "example": { "kind": "http", "target": "http://127.0.0.1:8080/healthz" },
516        "group": "readiness",
517        "blurb": "A health check that triggers a restart when it keeps failing",
518        "accepts": ["kind: http, tcp, or exec",
519                    "a target matching the kind: a url, host:port, or command"],
520        "refuses": ["a failure_threshold of 0", "an interval below its own floor"],
521        "neighbours": [{"field": "readiness_probe", "note": "uses a different interval floor"}]
522    })))]
523    pub liveness_probe: Option<ProbeConfig>,
524    /// Watch include globs (empty = watch cwd)
525    #[cfg_attr(feature = "schema", schemars(extend("init" = {
526        "group": "watch",
527        "blurb": "Which paths to watch. Empty means the working directory",
528        "accepts": ["a list of glob patterns", "empty watches the whole cwd"],
529        "refuses": ["a pattern globset cannot compile"],
530        "neighbours": [{"field": "cwd",          "note": "globs are rooted here"},
531                       {"field": "watch",        "note": "has no effect unless watch is on"},
532                       {"field": "ignore_watch", "note": "skipped even when matched here"}]
533    })))]
534    pub watch_options: Vec<String>,
535    /// Timezone for `cron_restart` (IANA name)
536    #[cfg_attr(feature = "schema", schemars(extend("init" = {
537        "example": "US/Eastern",
538        "group": "cron",
539        "blurb": "Which timezone cron_restart is read in, as an IANA name",
540        "accepts": ["an IANA zone name, like US/Eastern"],
541        "refuses": ["a name outside the IANA database"],
542        "neighbours": [{"field": "cron_restart", "note": "the pattern this zone is read against"}]
543    })))]
544    pub cron_timezone: Option<String>,
545}
546
547#[cfg(test)]
548mod tests {
549    use super::super::probe::ProbeKind;
550
551    // use schemars::generate
552
553    use super::*;
554    use crate::values::{MemSize, UpDuration};
555
556    use super::super::testing::*;
557
558    #[test]
559    fn minimal_config_gets_spec_defaults() {
560        let app = AppConfig::minimal("web", "./server");
561        assert_eq!(app.name, "web");
562        assert_eq!(app.script, "./server");
563        assert!(app.autorestart);
564        assert!(app.autostart);
565        assert_eq!(app.instances, 1);
566        assert_eq!(app.min_uptime, UpDuration::from_millis(1000));
567        assert_eq!(app.max_restarts, 16);
568        assert_eq!(app.kill_timeout, UpDuration::from_millis(1600));
569        assert_eq!(app.listen_timeout, UpDuration::from_millis(3000));
570        assert_eq!(app.graceful_timeout, UpDuration::from_millis(8000));
571        assert_eq!(app.action_timeout, UpDuration::from_millis(3000));
572        assert!(app.max_memory.is_none());
573        assert!(app.fold.is_none());
574        assert!(!app.channel);
575    }
576
577    #[test]
578    fn unstable_restarts_are_throttled_by_default() {
579        let app = AppConfig::minimal("web", "./srv");
580        assert_eq!(
581            app.exp_backoff_restart_delay,
582            Some(UpDuration::from_millis(100))
583        );
584    }
585
586    #[test]
587    fn stdin_is_not_piped_unless_the_app_asks() {
588        let app = AppConfig::minimal("web", "./srv");
589        assert!(!app.stdin);
590        let parsed: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
591        assert!(!parsed.stdin);
592    }
593
594    #[test]
595    fn the_flockfile_key_is_stdin() {
596        let parsed: AppConfig =
597            toml::from_str("name = \"web\"\nscript = \"./srv\"\nstdin = true").unwrap();
598        assert!(parsed.stdin);
599    }
600
601    #[test]
602    fn environment_defaults_to_absent_and_parses_from_a_flockfile() {
603        assert_eq!(AppConfig::default().environment, None);
604        let app: AppConfig =
605            toml::from_str("name = \"web\"\nscript = \"./srv\"\nenvironment = \"staging\"")
606                .unwrap();
607        assert_eq!(app.environment.as_deref(), Some("staging"));
608    }
609
610    #[test]
611    fn toml_round_trip_with_newtypes() {
612        let toml_src = r#"
613    name = "worker"
614    script = "python3"
615    args = ["job.py", "--fast"]
616    max_memory = "512M"
617    min_uptime = "5s"
618    fold = "backend"
619    env = { RUST_LOG = "info" }
620    "#;
621        let app: AppConfig = toml::from_str(toml_src).unwrap();
622        assert_eq!(app.max_memory, Some("512M".parse::<MemSize>().unwrap()));
623        assert_eq!(app.min_uptime, UpDuration::from_millis(5000));
624        assert_eq!(app.fold.as_deref(), Some("backend"));
625        assert_eq!(app.env.get("RUST_LOG").map(String::as_str), Some("info"));
626        assert_eq!(app.args, vec!["job.py", "--fast"]);
627    }
628
629    /// Raw TOML scalars in `env` read as their string form. `true` becomes
630    /// `"true"`, `8080` becomes `"8080"`, and quoted strings pass through.
631    /// A single test here covers the deserialization path; the
632    /// `flockfile` test that reads the same document through all four
633    /// formats covers the format dispatch.
634    #[test]
635    fn env_coerces_raw_scalars_to_their_string_form() {
636        let src = r#"
637    name = "web"
638    script = "./srv"
639    env = { SOME_BOOL = true, PORT = 8080, NEG = -1, STR = "plain" }
640    "#;
641        let app: AppConfig = toml::from_str(src).unwrap();
642        assert_eq!(app.env["SOME_BOOL"], "true");
643        assert_eq!(app.env["PORT"], "8080");
644        assert_eq!(app.env["NEG"], "-1");
645        assert_eq!(app.env["STR"], "plain");
646    }
647
648    /// A protocol-8 peer still serializes `increment_var`, which this build
649    /// no longer has. It must deserialize and be dropped rather than error,
650    /// since `MIN_SUPPORTED` is 8 and an error here would refuse every peer
651    /// built before the field went.
652    ///
653    /// Fails if `deny_unknown_fields` comes back to this struct. It was
654    /// moved to `Flockfile::parse` on purpose, so a newer daemon can hand an
655    /// older client a config it does not fully understand. Why dropping the
656    /// field is safe rather than merely tolerated is in `docs/decisions.md`.
657    #[test]
658    fn a_protocol_8_payload_carrying_increment_var_still_deserializes() {
659        let src = r#"{ "name":"web","script":"./srv","increment_var":null }"#;
660        let app = serde_json::from_str::<AppConfig>(src)
661            .expect("a version-8 payload must not be refused for a field this build dropped");
662        assert_eq!(app.name, "web");
663        assert_eq!(app.script, "./srv");
664
665        let populated = r#"{ "name":"web","script":"./srv","increment_var":"WORKER_ID" }"#;
666        let app = serde_json::from_str::<AppConfig>(populated)
667            .expect("a populated one is ignored too, not refused");
668        assert_eq!(app.name, "web");
669    }
670
671    /// A float is refused rather than coerced. `f64` carries no trailing zero
672    /// and no written precision, so accepting one hands the process a value
673    /// the operator did not write. Quoting is the way to keep the text.
674    ///
675    /// Asserted through JSON: a TOML error echoes the offending source line,
676    /// which would put the value in the message whatever serde said.
677    #[test]
678    fn env_refuses_a_float_because_its_written_form_would_not_survive() {
679        let src = r#"{ "name":"web","script":"./srv","env":{ "RATIO": 1.10 } }"#;
680        let err = serde_json::from_str::<AppConfig>(src)
681            .expect_err("a float env value must be refused, not rounded")
682            .to_string();
683        assert!(
684            err.contains("quote it"),
685            "the error must name the fix, got: {err}"
686        );
687        assert!(!err.contains("1.1"), "the error leaked the value: {err}");
688
689        let quoted = src.replace("1.10", r#""1.10""#);
690        let app: AppConfig = serde_json::from_str(&quoted).unwrap();
691        assert_eq!(app.env["RATIO"], "1.10");
692
693        assert!(
694            toml::from_str::<AppConfig>(
695                "name = \"web\"\nscript = \"./srv\"\nenv = { RATIO = 1.10 }\n"
696            )
697            .is_err(),
698            "TOML must refuse a float too"
699        );
700    }
701
702    /// A whole number larger than `i64::MAX` is valid JSON and must load.
703    /// TOML cannot reach this test: its spec bounds integers to signed 64-bit,
704    /// so only JSON exercises this path. The value stringifies to its full
705    /// positive form, not a negative wrap. Pinned to the exact string.
706    #[test]
707    fn env_reads_a_number_beyond_i64_max_without_wrapping() {
708        let beyond_i64 = u64::MAX; // 18446744073709551615
709        let src = format!(r#"{{ "name":"web","script":"./srv","env":{{ "BIG": {beyond_i64} }} }}"#);
710        let app = serde_json::from_str::<AppConfig>(&src)
711            .expect("a u64 beyond i64::MAX is valid JSON and must load");
712        assert_eq!(app.env["BIG"], "18446744073709551615");
713    }
714
715    /// Serialization is the inverse of the coercion: whatever form a value
716    /// arrived as, the wire form is a string. A `true` that deserialized
717    /// into `"true"` must serialize to the JSON string `"true"`, not the
718    /// boolean `true`.
719    #[test]
720    fn env_serialization_is_always_string_regardless_of_input_form() {
721        let src = r#"
722    name = "web"
723    script = "./srv"
724    env = { SOME_BOOL = true, PORT = 8080, STR = "hello" }
725    "#;
726        let app: AppConfig = toml::from_str(src).unwrap();
727        let wire = serde_json::to_value(&app).unwrap();
728        for (key, expected) in [("SOME_BOOL", "true"), ("PORT", "8080"), ("STR", "hello")] {
729            assert_eq!(
730                wire["env"].get(key).and_then(serde_json::Value::as_str),
731                Some(expected),
732                "wire form of {key} must be a string, not a scalar"
733            );
734        }
735    }
736
737    /// A value that is neither a string, boolean, nor number is refused, not
738    /// guessed at. An array or object under `env` is a structural mistake —
739    /// an operator meant a table or a list, and guessing a serialization is
740    /// how a wrong value hides for months.
741    #[test]
742    fn env_refuses_structural_values() {
743        for (label, inner) in [("array", r#"["a", "b"]"#), ("object", r#"{"k": "v"}"#)] {
744            let src = format!(r#"{{ "name":"web","script":"./srv","env":{{"X":{inner}}}}}"#);
745            assert!(
746                serde_json::from_str::<AppConfig>(&src).is_err(),
747                "a {label} env value must be refused"
748            );
749        }
750    }
751
752    /// The wire path is the opposite of a Flockfile's: an unknown field
753    /// means a newer peer, and ignoring it is what stops a new Flockfile
754    /// field breaking an older client that reads a config off the wire.
755    /// `deny_unknown_fields` used to live here; the same typo is now
756    /// refused only at `Flockfile::parse`, where the input really is a
757    /// hand-written file.
758    #[test]
759    fn an_unknown_field_on_the_wire_is_ignored_rather_than_refused() {
760        let config: AppConfig =
761            serde_json::from_str(r#"{"name":"web","script":"./srv","invented_next_year":true}"#)
762                .expect("the wire path tolerates what it does not know");
763        assert_eq!(config.name, "web");
764    }
765
766    #[test]
767    fn probe_config_parses_with_defaults() {
768        let src = r#"
769    name = "api"
770    script = "./api"
771
772    [readiness_probe]
773    kind = "http"
774    target = "http://127.0.0.1:8080/healthz"
775    "#;
776        let app: AppConfig = toml::from_str(src).unwrap();
777        let probe = app.readiness_probe.unwrap();
778        assert_eq!(probe.kind, ProbeKind::Http);
779        assert_eq!(probe.target, "http://127.0.0.1:8080/healthz");
780        assert_eq!(probe.interval, UpDuration::from_millis(10_000));
781        assert_eq!(probe.timeout, UpDuration::from_millis(5_000));
782        assert_eq!(probe.failure_threshold, 3);
783        assert!(app.liveness_probe.is_none());
784    }
785
786    #[test]
787    fn debug_redacts_env_values() {
788        // Exact string pinned so a lazy derive(Debug) refactor fails here.
789        let mut app = AppConfig::minimal("web", "./srv");
790        app.env
791            .insert("DATABASE_URL".to_string(), "postgres://secret".to_string());
792        app.env.insert("RUST_LOG".to_string(), "info".to_string());
793        assert_eq!(
794            format!("{app:?}"),
795            "AppConfig { name: \"web\", script: \"./srv\", env: <2 vars>, .. }"
796        );
797    }
798
799    /// The path fields are the ones an operator gets wrong, and the ones the
800    /// type table cannot describe. Each states what it takes.
801    #[test]
802    fn the_path_fields_state_what_they_accept() {
803        let schema = crate::config::flockfile_schema_json().to_value();
804        let props = schema
805            .pointer("/$defs/AppConfig/properties")
806            .and_then(serde_json::Value::as_object)
807            .expect("app config properties must exist");
808        for name in ["cwd", "script", "out_file", "err_file"] {
809            let accepts = props[name]["init"]["accepts"].as_array();
810            assert!(
811                accepts.is_some_and(|forms| !forms.is_empty()),
812                "{name} carries no accepted forms"
813            );
814        }
815    }
816
817    /// Every entry carries both halves, so nothing renders half a line.
818    #[test]
819    fn every_neighbour_entry_carries_a_field_and_a_note() {
820        let schema = crate::config::flockfile_schema_json().to_value();
821        let props = schema
822            .pointer("/$defs/AppConfig/properties")
823            .and_then(serde_json::Value::as_object)
824            .expect("app config properties must exist");
825        for (name, prop) in props {
826            let Some(entries) = prop["init"]["neighbours"].as_array() else {
827                continue;
828            };
829            for entry in entries {
830                assert!(
831                    entry["field"].is_string(),
832                    "{name} has a neighbour with no field"
833                );
834                assert!(
835                    entry["note"].is_string(),
836                    "{name} has a neighbour with no note"
837                );
838            }
839        }
840    }
841
842    /// fails if a field claims a refusal `normalize` does not make. The
843    /// static tables behind the same panel are parser-backed in
844    /// `shep-cli`'s `lookout::validation`; a per-field clause is only
845    /// backed here.
846    #[test]
847    fn every_per_field_refusal_is_one_normalize_really_makes() {
848        for claim in refusal_claims() {
849            let Proof::Refused { value, matches } = claim.proof else {
850                continue;
851            };
852            let field = claim.field;
853            let refusal = claim.refusal;
854            match crate::config::normalize(*value) {
855                Ok(_) => panic!("{field} says it refuses {refusal}, and normalize accepted it"),
856                Err(err) => assert!(
857                    matches(&err),
858                    "{field}'s \"{refusal}\" was refused as {err:?}, which is not the variant \
859                         the table names"
860                ),
861            }
862        }
863    }
864
865    /// fails if a value the panel offers with one keypress is one
866    /// `normalize` turns around. A suggestion is a literal fill, so it is
867    /// the one claim a config can be checked against directly.
868    #[test]
869    fn every_suggested_value_is_one_normalize_accepts() {
870        let schema = crate::config::flockfile_schema_json().to_value();
871        let props = schema
872            .pointer("/$defs/AppConfig/properties")
873            .and_then(serde_json::Value::as_object)
874            .expect("app config properties must exist");
875        for (field, prop) in props {
876            let Some(values) = prop["init"]["suggest"].as_array() else {
877                continue;
878            };
879            for value in values {
880                let value = value.as_str().expect("a suggestion is a string");
881                let app = sheep(|a| match field.as_str() {
882                    "cron_restart" => a.cron_restart = Some(value.to_owned()),
883                    "kill_signal" => a.kill_signal = Some(value.to_owned()),
884                    other => panic!("{other} suggests values that this test cannot place"),
885                });
886                assert!(
887                    crate::config::normalize(app).is_ok(),
888                    "{field} suggests `{value}`, which normalize refuses"
889                );
890            }
891        }
892    }
893
894    /// fails if the schema and the table have drifted apart in either
895    /// direction: a new clause with no proof, or a proof for a clause no
896    /// field writes any more.
897    #[test]
898    fn the_refusal_table_and_the_schema_name_the_same_clauses() {
899        let schema = crate::config::flockfile_schema_json().to_value();
900        let props = schema
901            .pointer("/$defs/AppConfig/properties")
902            .and_then(serde_json::Value::as_object)
903            .expect("app config properties must exist");
904        let in_schema: Vec<(&str, &str)> = props
905            .iter()
906            .filter_map(|(field, prop)| Some((field.as_str(), prop["init"]["refuses"].as_array()?)))
907            .flat_map(|(field, clauses)| {
908                clauses.iter().map(move |clause| {
909                    (
910                        field,
911                        clause.as_str().expect("a refusal clause is a string"),
912                    )
913                })
914            })
915            .collect();
916
917        let claims = refusal_claims();
918        let proved: Vec<(&str, &str)> = claims.iter().map(|c| (c.field, c.refusal)).collect();
919
920        let unproved: Vec<_> = in_schema
921            .iter()
922            .filter(|clause| !proved.contains(clause))
923            .collect();
924        let stale: Vec<_> = proved
925            .iter()
926            .filter(|clause| !in_schema.contains(clause))
927            .collect();
928        assert!(
929            unproved.is_empty() && stale.is_empty(),
930            "a refusal and its proof live together, in refusal_claims in this file.\n  \
931                 claimed by a field, proved nowhere: {unproved:?}\n  \
932                 proved here, claimed by no field: {stale:?}"
933        );
934    }
935}