1use core::fmt;
4
5use std::collections::BTreeMap;
6
7use serde::{Deserialize, Serialize};
9
10use crate::values::{MemSize, UpDuration};
11
12#[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,
20 Tcp,
22 Exec,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30#[serde(deny_unknown_fields)]
31pub struct ProbeConfig {
32 pub kind: ProbeKind,
34 pub target: String,
36 #[serde(default = "default_probe_interval")]
38 pub interval: UpDuration,
39 #[serde(default = "default_probe_timeout")]
41 pub timeout: UpDuration,
42 #[serde(default = "default_failure_threshold")]
44 pub failure_threshold: u32,
45}
46
47fn default_probe_interval() -> UpDuration {
48 UpDuration::from_millis(10_000)
49}
50fn default_probe_timeout() -> UpDuration {
51 UpDuration::from_millis(5_000)
52}
53fn default_failure_threshold() -> u32 {
54 3
55}
56
57#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73#[serde(deny_unknown_fields, default)]
74pub struct AppConfig {
75 #[cfg_attr(feature = "schema", schemars(extend("init" = {
77 "example": "my-first-sheep",
78 "group": "process",
79 "blurb": "A convenient and unique name for shep to display"
80 })))]
81 pub name: String,
82 #[cfg_attr(feature = "schema", schemars(extend("init" = {
84 "example": "./index.js",
85 "group": "process",
86 "blurb": "The script that shep should use to launch your app"
87 })))]
88 pub script: String,
89 #[cfg_attr(feature = "schema", schemars(extend("init" = {
91 "group": "inputs",
92 "blurb": "Arguments passed to the script, as a list"
93 })))]
94 pub args: Vec<String>,
95 #[cfg_attr(feature = "schema", schemars(extend("init" = {
97 "example": "/srv/app",
98 "group": "process",
99 "blurb": "Where the process runs. Without it, the daemon's own directory"
100 })))]
101 pub cwd: Option<String>,
102 #[cfg_attr(feature = "schema", schemars(extend("init" = {
104 "example": "none",
105 "group": "process",
106 "blurb": "What runs the script. Set it to none to exec the file directly"
107 })))]
108 pub interpreter: Option<String>,
109 #[cfg_attr(feature = "schema", schemars(extend("init" = {
111 "example": "{ NODE_ENV = 'production' }",
112 "group": "inputs",
113 "blurb": "Environment variables for this app, layered over the daemon's own"
114 })))]
115 pub env: BTreeMap<String, String>,
116 #[cfg_attr(feature = "schema", schemars(extend("init" = {
118 "group": "process",
119 "blurb": "How many copies of this app to run"
120 })))]
121 pub instances: u32,
122 #[cfg_attr(feature = "schema", schemars(extend("init" = {
124 "group": "control",
125 "blurb": "Restarts the process automatically when it exits unexpectedly"
126 })))]
127 pub autorestart: bool,
128 #[cfg_attr(feature = "schema", schemars(extend("init" = {
130 "group": "control",
131 "blurb": "Start this app when the daemon starts, and on shep muster"
132 })))]
133 pub autostart: bool,
134 #[cfg_attr(feature = "schema", schemars(extend("init" = {
136 "group": "control",
137 "blurb": "Exit codes that mean a clean stop, so shep will not restart"
138 })))]
139 pub stop_exit_codes: Vec<i32>,
140 #[cfg_attr(feature = "schema", schemars(extend("init" = {
142 "group": "control",
143 "blurb": "An exit sooner than this counts as unstable"
144 })))]
145 pub min_uptime: UpDuration,
146 #[cfg_attr(feature = "schema", schemars(extend("init" = {
148 "group": "control",
149 "blurb": "How many unstable exits in a row before shep gives up"
150 })))]
151 pub max_restarts: u32,
152 #[cfg_attr(feature = "schema", schemars(extend("init" = {
154 "example": "3s",
155 "group": "control",
156 "blurb": "A fixed wait before every restart, instead of growing backoff"
157 })))]
158 pub restart_delay: Option<UpDuration>,
159 #[cfg_attr(feature = "schema", schemars(extend("init" = {
161 "example": "5s",
162 "group": "control",
163 "blurb": "Starting delay between restarts, growing each time it fails again"
164 })))]
165 pub exp_backoff_restart_delay: Option<UpDuration>,
166 #[cfg_attr(feature = "schema", schemars(extend("init" = {
174 "example": "SIGTERM",
175 "group": "process",
176 "blurb": "Which signal shep sends first when stopping this app"
177 })))]
178 pub kill_signal: Option<String>,
179 #[cfg_attr(feature = "schema", schemars(extend("init" = {
181 "group": "control",
182 "blurb": "How long shep waits after the stop signal before SIGKILL"
183 })))]
184 pub kill_timeout: UpDuration,
185 #[cfg_attr(feature = "schema", schemars(extend("init" = {
187 "group": "control",
188 "blurb": "Ask the app to stop over the channel instead of signalling it"
189 })))]
190 pub shutdown_with_message: bool,
191 #[cfg_attr(feature = "schema", schemars(extend("init" = {
193 "group": "control",
194 "blurb": "How long to wait for readiness when nothing else reports it"
195 })))]
196 pub listen_timeout: UpDuration,
197 #[cfg_attr(feature = "schema", schemars(extend("init" = {
199 "group": "control",
200 "blurb": "How long the old instance gets to drain during a reload"
201 })))]
202 pub graceful_timeout: UpDuration,
203 #[cfg_attr(feature = "schema", schemars(extend("init" = {
221 "group": "control",
222 "blurb": "How long a triggered action has to answer before shep gives up"
223 })))]
224 pub action_timeout: UpDuration,
225 #[cfg_attr(feature = "schema", schemars(extend("init" = {
227 "example": "512M",
228 "group": "control",
229 "blurb": "Restart the app if it climbs above this much memory"
230 })))]
231 pub max_memory: Option<MemSize>,
232 #[cfg_attr(feature = "schema", schemars(extend("init" = {
234 "group": "control",
235 "blurb": "Restart when a file changes"
236 })))]
237 pub watch: bool,
238 #[cfg_attr(feature = "schema", schemars(extend("init" = {
240 "group": "control",
241 "blurb": "Paths watch should skip, on top of dotfiles and node_modules"
242 })))]
243 pub ignore_watch: Vec<String>,
244 #[cfg_attr(feature = "schema", schemars(extend("init" = {
246 "example": "500",
247 "group": "control",
248 "blurb": "How long to wait after a change before restarting"
249 })))]
250 pub watch_delay: Option<UpDuration>,
251 #[cfg_attr(feature = "schema", schemars(extend("init" = {
253 "example": "* * * * *",
254 "group": "cron",
255 "blurb": "Restart on a schedule, written as a cron pattern"
256 })))]
257 pub cron_restart: Option<String>,
258 #[cfg_attr(feature = "schema", schemars(extend("init" = {
260 "example": "backend",
261 "group": "process",
262 "blurb": "A fold to group this app with others, for commands that take one"
263 })))]
264 pub fold: Option<String>,
265 #[cfg_attr(feature = "schema", schemars(extend("init" = {
267 "example": "www-data",
268 "group": "process",
269 "blurb": "Run as this user, on unix"
270 })))]
271 pub user: Option<String>,
272 #[cfg_attr(feature = "schema", schemars(extend("init" = {
274 "example": "www-data",
275 "group": "process",
276 "blurb": "Run as this group, on unix"
277 })))]
278 pub group: Option<String>,
279 #[cfg_attr(feature = "schema", schemars(extend("init" = {
281 "example": "/var/log/my-first-sheep/out.log",
282 "group": "process",
283 "blurb": "Where stdout goes. Defaults to a file under $SHEP_HOME/logs"
284 })))]
285 pub out_file: Option<String>,
286 #[cfg_attr(feature = "schema", schemars(extend("init" = {
288 "example": "/var/log/my-first-sheep/err.log",
289 "group": "process",
290 "blurb": "Where stderr goes. Defaults to a file under $SHEP_HOME/logs"
291 })))]
292 pub err_file: Option<String>,
293 #[cfg_attr(feature = "schema", schemars(extend("init" = {
295 "group": "process",
296 "blurb": "Put every instance's output in one pair of files"
297 })))]
298 pub merge_logs: bool,
299 #[cfg_attr(feature = "schema", schemars(extend("init" = {
306 "group": "inputs",
307 "blurb": "Opens fd 3 so the app can talk to shep directly"
308 })))]
309 pub channel: bool,
310 #[cfg_attr(feature = "schema", schemars(extend("init" = {
336 "group": "inputs",
337 "blurb": "Keeps stdin open so shep whisper can write to the process"
338 })))]
339 pub stdin: bool,
340 #[cfg_attr(feature = "schema", schemars(extend("init" = {
342 "group": "control",
343 "blurb": "Wait for the app to say it is ready on the channel"
344 })))]
345 pub wait_ready: bool,
346 #[cfg_attr(feature = "schema", schemars(extend("init" = {
362 "group": "process",
363 "blurb": "Not built yet. Setting it is refused rather than quietly ignored"
364 })))]
365 pub reuse_port: bool,
366 #[cfg_attr(feature = "schema", schemars(extend("init" = {
368 "example": { "kind": "http", "target": "http://127.0.0.1:8080/ready" },
369 "group": "control",
370 "blurb": "A health check shep waits on before it treats a reload as finished"
371 })))]
372 pub readiness_probe: Option<ProbeConfig>,
373 #[cfg_attr(feature = "schema", schemars(extend("init" = {
375 "example": { "kind": "http", "target": "http://127.0.0.1:8080/healthz" },
376 "group": "control",
377 "blurb": "A health check that triggers a restart when it keeps failing"
378 })))]
379 pub liveness_probe: Option<ProbeConfig>,
380 #[cfg_attr(feature = "schema", schemars(extend("init" = {
382 "group": "control",
383 "blurb": "Which paths to watch. Empty means the working directory"
384 })))]
385 pub watch_options: Vec<String>,
386 #[cfg_attr(feature = "schema", schemars(extend("init" = {
388 "example": "US/Eastern",
389 "group": "cron",
390 "blurb": "Which timezone cron_restart is read in, as an IANA name"
391 })))]
392 pub cron_timezone: Option<String>,
393 #[cfg_attr(feature = "schema", schemars(extend("init" = {
395 "example": "INSTANCE_ID",
396 "group": "inputs",
397 "blurb": "The env var each instance finds its own slot number in"
398 })))]
399 pub increment_var: Option<String>,
400}
401
402impl fmt::Debug for AppConfig {
404 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405 f.debug_struct("AppConfig")
406 .field("name", &self.name)
407 .field("script", &self.script)
408 .field("env", &format_args!("<{} vars>", self.env.len()))
409 .finish_non_exhaustive()
410 }
411}
412
413impl Default for AppConfig {
414 fn default() -> Self {
415 Self {
416 name: String::new(),
417 script: String::new(),
418 args: Vec::new(),
419 cwd: None,
420 interpreter: None,
421 env: BTreeMap::new(),
422 instances: 1,
423 autorestart: true,
424 autostart: true,
425 stop_exit_codes: Vec::new(),
426 min_uptime: UpDuration::from_millis(1000),
427 max_restarts: 16,
428 restart_delay: None,
429 exp_backoff_restart_delay: None,
430 kill_signal: None,
431 kill_timeout: UpDuration::from_millis(1600),
432 shutdown_with_message: false,
433 listen_timeout: UpDuration::from_millis(3000),
434 graceful_timeout: UpDuration::from_millis(8000),
435 action_timeout: UpDuration::from_millis(3000),
436 max_memory: None,
437 watch: false,
438 ignore_watch: Vec::new(),
439 watch_delay: None,
440 cron_restart: None,
441 fold: None,
442 user: None,
443 group: None,
444 out_file: None,
445 err_file: None,
446 merge_logs: false,
447 channel: false,
448 stdin: false,
449 wait_ready: false,
450 reuse_port: false,
451 readiness_probe: None,
452 liveness_probe: None,
453 watch_options: Vec::new(),
454 cron_timezone: None,
455 increment_var: None,
456 }
457 }
458}
459
460impl AppConfig {
461 #[must_use]
463 pub fn minimal(name: &str, script: &str) -> Self {
464 Self {
465 name: name.to_string(),
466 script: script.to_string(),
467 ..Self::default()
468 }
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::values::{MemSize, UpDuration};
476
477 #[test]
478 fn minimal_config_gets_spec_defaults() {
479 let app = AppConfig::minimal("web", "./server");
480 assert_eq!(app.name, "web");
481 assert_eq!(app.script, "./server");
482 assert!(app.autorestart);
483 assert!(app.autostart);
484 assert_eq!(app.instances, 1);
485 assert_eq!(app.min_uptime, UpDuration::from_millis(1000));
486 assert_eq!(app.max_restarts, 16);
487 assert_eq!(app.kill_timeout, UpDuration::from_millis(1600));
488 assert_eq!(app.listen_timeout, UpDuration::from_millis(3000));
489 assert_eq!(app.graceful_timeout, UpDuration::from_millis(8000));
490 assert_eq!(app.action_timeout, UpDuration::from_millis(3000));
491 assert!(app.max_memory.is_none());
492 assert!(app.fold.is_none());
493 assert!(!app.channel);
494 }
495
496 #[test]
502 fn stdin_is_not_piped_unless_the_app_asks() {
503 let app = AppConfig::minimal("web", "./srv");
504 assert!(!app.stdin);
505 let parsed: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
506 assert!(!parsed.stdin);
507 }
508
509 #[test]
514 fn the_flockfile_key_is_stdin() {
515 let parsed: AppConfig =
516 toml::from_str("name = \"web\"\nscript = \"./srv\"\nstdin = true").unwrap();
517 assert!(parsed.stdin);
518 }
519
520 #[test]
521 fn toml_round_trip_with_newtypes() {
522 let toml_src = r#"
523name = "worker"
524script = "python3"
525args = ["job.py", "--fast"]
526max_memory = "512M"
527min_uptime = "5s"
528fold = "backend"
529env = { RUST_LOG = "info" }
530"#;
531 let app: AppConfig = toml::from_str(toml_src).unwrap();
532 assert_eq!(app.max_memory, Some("512M".parse::<MemSize>().unwrap()));
533 assert_eq!(app.min_uptime, UpDuration::from_millis(5000));
534 assert_eq!(app.fold.as_deref(), Some("backend"));
535 assert_eq!(app.env.get("RUST_LOG").map(String::as_str), Some("info"));
536 assert_eq!(app.args, vec!["job.py", "--fast"]);
537 }
538
539 #[test]
540 fn unknown_fields_are_rejected() {
541 let err = toml::from_str::<AppConfig>(
542 "name = \"x\"\nscript = \"y\"\nmax_memory_restart = \"1G\"",
543 )
544 .unwrap_err();
545 assert!(err.to_string().contains("max_memory_restart"), "{err}");
546 }
547
548 #[test]
549 fn probe_config_parses_with_defaults() {
550 let src = r#"
551name = "api"
552script = "./api"
553
554[readiness_probe]
555kind = "http"
556target = "http://127.0.0.1:8080/healthz"
557"#;
558 let app: AppConfig = toml::from_str(src).unwrap();
559 let probe = app.readiness_probe.unwrap();
560 assert_eq!(probe.kind, ProbeKind::Http);
561 assert_eq!(probe.target, "http://127.0.0.1:8080/healthz");
562 assert_eq!(probe.interval, UpDuration::from_millis(10_000));
563 assert_eq!(probe.timeout, UpDuration::from_millis(5_000));
564 assert_eq!(probe.failure_threshold, 3);
565 assert!(app.liveness_probe.is_none());
566 }
567
568 #[test]
569 fn debug_redacts_env_values() {
570 let mut app = AppConfig::minimal("web", "./srv");
573 app.env
574 .insert("DATABASE_URL".to_string(), "postgres://secret".to_string());
575 app.env.insert("RUST_LOG".to_string(), "info".to_string());
576 assert_eq!(
577 format!("{app:?}"),
578 "AppConfig { name: \"web\", script: \"./srv\", env: <2 vars>, .. }"
579 );
580 }
581}