Skip to main content

shep_core/config/app/
schema.rs

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