Skip to main content

shep_core/config/app/
schema.rs

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