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