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": "restart",
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": "restart",
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": "restart",
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": "restart",
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": "restart",
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": "restart",
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" = {
175 "example": "5s",
176 "group": "restart",
177 "blurb": "Starting delay between restarts, growing each time it fails again"
178 })))]
179 pub exp_backoff_restart_delay: Option<UpDuration>,
180 #[cfg_attr(feature = "schema", schemars(extend("init" = {
188 "example": "SIGTERM",
189 "group": "shutdown",
190 "blurb": "Which signal shep sends first when stopping this app",
191 "suggest": ["SIGTERM", "SIGINT", "SIGQUIT", "SIGUSR2"]
192 })))]
193 pub kill_signal: Option<String>,
194 #[cfg_attr(feature = "schema", schemars(extend("init" = {
196 "group": "shutdown",
197 "blurb": "How long shep waits after the stop signal before SIGKILL"
198 })))]
199 pub kill_timeout: UpDuration,
200 #[cfg_attr(feature = "schema", schemars(extend("init" = {
202 "group": "shutdown",
203 "blurb": "Ask the app to stop over the channel instead of signalling it"
204 })))]
205 pub shutdown_with_message: bool,
206 #[cfg_attr(feature = "schema", schemars(extend("init" = {
208 "group": "readiness",
209 "blurb": "How long to wait for readiness when nothing else reports it"
210 })))]
211 pub listen_timeout: UpDuration,
212 #[cfg_attr(feature = "schema", schemars(extend("init" = {
214 "group": "shutdown",
215 "blurb": "How long the old instance gets to drain during a reload"
216 })))]
217 pub graceful_timeout: UpDuration,
218 #[cfg_attr(feature = "schema", schemars(extend("init" = {
236 "group": "shutdown",
237 "blurb": "How long a triggered action has to answer before shep gives up"
238 })))]
239 pub action_timeout: UpDuration,
240 #[cfg_attr(feature = "schema", schemars(extend("init" = {
242 "example": "512M",
243 "group": "restart",
244 "blurb": "Restart the app if it climbs above this much memory"
245 })))]
246 pub max_memory: Option<MemSize>,
247 #[cfg_attr(feature = "schema", schemars(extend("init" = {
249 "group": "watch",
250 "blurb": "Restart when a file changes"
251 })))]
252 pub watch: bool,
253 #[cfg_attr(feature = "schema", schemars(extend("init" = {
255 "group": "watch",
256 "blurb": "Paths watch should skip, on top of dotfiles and node_modules"
257 })))]
258 pub ignore_watch: Vec<String>,
259 #[cfg_attr(feature = "schema", schemars(extend("init" = {
261 "example": "500",
262 "group": "watch",
263 "blurb": "How long to wait after a change before restarting"
264 })))]
265 pub watch_delay: Option<UpDuration>,
266 #[cfg_attr(feature = "schema", schemars(extend("init" = {
268 "example": "* * * * *",
269 "group": "cron",
270 "blurb": "Restart on a schedule, written as a cron pattern",
271 "suggest": ["*/5 * * * *", "0 * * * *", "0 0 * * *", "0 0 * * 0"]
272 })))]
273 pub cron_restart: Option<String>,
274 #[cfg_attr(feature = "schema", schemars(extend("init" = {
276 "example": "backend",
277 "group": "process",
278 "blurb": "A fold to group this app with others, for commands that take one"
279 })))]
280 pub fold: Option<String>,
281 #[cfg_attr(feature = "schema", schemars(extend("init" = {
283 "example": "www-data",
284 "group": "process",
285 "blurb": "Run as this user, on unix"
286 })))]
287 pub user: Option<String>,
288 #[cfg_attr(feature = "schema", schemars(extend("init" = {
290 "example": "www-data",
291 "group": "process",
292 "blurb": "Run as this group, on unix"
293 })))]
294 pub group: Option<String>,
295 #[cfg_attr(feature = "schema", schemars(extend("init" = {
297 "example": "/var/log/my-first-sheep/out.log",
298 "group": "logging",
299 "blurb": "Where stdout goes. Defaults to a file under $SHEP_HOME/logs"
300 })))]
301 pub out_file: Option<String>,
302 #[cfg_attr(feature = "schema", schemars(extend("init" = {
304 "example": "/var/log/my-first-sheep/err.log",
305 "group": "logging",
306 "blurb": "Where stderr goes. Defaults to a file under $SHEP_HOME/logs"
307 })))]
308 pub err_file: Option<String>,
309 #[cfg_attr(feature = "schema", schemars(extend("init" = {
311 "group": "logging",
312 "blurb": "Put every instance's output in one pair of files"
313 })))]
314 pub merge_logs: bool,
315 #[cfg_attr(feature = "schema", schemars(extend("init" = {
322 "group": "inputs",
323 "blurb": "Opens fd 3 so the app can talk to shep directly"
324 })))]
325 pub channel: bool,
326 #[cfg_attr(feature = "schema", schemars(extend("init" = {
352 "group": "inputs",
353 "blurb": "Keeps stdin open so shep whisper can write to the process"
354 })))]
355 pub stdin: bool,
356 #[cfg_attr(feature = "schema", schemars(extend("init" = {
358 "group": "readiness",
359 "blurb": "Wait for the app to say it is ready on the channel"
360 })))]
361 pub wait_ready: bool,
362 #[cfg_attr(feature = "schema", schemars(extend("init" = {
398 "group": "process",
399 "blurb": "The app sets SO_REUSEPORT itself, so reload may overlap the two instances"
400 })))]
401 pub reuse_port: bool,
402 #[cfg_attr(feature = "schema", schemars(extend("init" = {
404 "example": { "kind": "http", "target": "http://127.0.0.1:8080/ready" },
405 "group": "readiness",
406 "blurb": "A health check shep waits on before it treats a reload as finished"
407 })))]
408 pub readiness_probe: Option<ProbeConfig>,
409 #[cfg_attr(feature = "schema", schemars(extend("init" = {
411 "example": { "kind": "http", "target": "http://127.0.0.1:8080/healthz" },
412 "group": "readiness",
413 "blurb": "A health check that triggers a restart when it keeps failing"
414 })))]
415 pub liveness_probe: Option<ProbeConfig>,
416 #[cfg_attr(feature = "schema", schemars(extend("init" = {
418 "group": "watch",
419 "blurb": "Which paths to watch. Empty means the working directory"
420 })))]
421 pub watch_options: Vec<String>,
422 #[cfg_attr(feature = "schema", schemars(extend("init" = {
424 "example": "US/Eastern",
425 "group": "cron",
426 "blurb": "Which timezone cron_restart is read in, as an IANA name"
427 })))]
428 pub cron_timezone: Option<String>,
429 #[cfg_attr(feature = "schema", schemars(skip))]
435 pub increment_var: Option<String>,
436}
437
438impl fmt::Debug for AppConfig {
440 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
441 f.debug_struct("AppConfig")
442 .field("name", &self.name)
443 .field("script", &self.script)
444 .field("env", &format_args!("<{} vars>", self.env.len()))
445 .finish_non_exhaustive()
446 }
447}
448
449impl Default for AppConfig {
450 fn default() -> Self {
451 Self {
452 name: String::new(),
453 script: String::new(),
454 args: Vec::new(),
455 cwd: None,
456 interpreter: None,
457 env: BTreeMap::new(),
458 instances: 1,
459 autorestart: true,
460 autostart: true,
461 stop_exit_codes: Vec::new(),
462 min_uptime: UpDuration::from_millis(1000),
463 max_restarts: 16,
464 restart_delay: None,
465 exp_backoff_restart_delay: Some(UpDuration::from_millis(100)),
468 kill_signal: None,
469 kill_timeout: UpDuration::from_millis(1600),
470 shutdown_with_message: false,
471 listen_timeout: UpDuration::from_millis(3000),
472 graceful_timeout: UpDuration::from_millis(8000),
473 action_timeout: UpDuration::from_millis(3000),
474 max_memory: None,
475 watch: false,
476 ignore_watch: Vec::new(),
477 watch_delay: None,
478 cron_restart: None,
479 fold: None,
480 user: None,
481 group: None,
482 out_file: None,
483 err_file: None,
484 merge_logs: false,
485 channel: false,
486 stdin: false,
487 wait_ready: false,
488 reuse_port: false,
489 readiness_probe: None,
490 liveness_probe: None,
491 watch_options: Vec::new(),
492 cron_timezone: None,
493 increment_var: None,
494 }
495 }
496}
497
498impl AppConfig {
499 #[must_use]
501 pub fn minimal(name: &str, script: &str) -> Self {
502 Self {
503 name: name.to_string(),
504 script: script.to_string(),
505 ..Self::default()
506 }
507 }
508
509 #[must_use]
535 pub fn drifted_fields(&self, other: &Self) -> Vec<String> {
536 if self == other {
537 return Vec::new();
538 }
539 let (Ok(serde_json::Value::Object(mine)), Ok(serde_json::Value::Object(theirs))) =
544 (serde_json::to_value(self), serde_json::to_value(other))
545 else {
546 return Vec::new();
547 };
548 let mut fields: Vec<String> = mine
549 .iter()
550 .filter(|(key, value)| theirs.get(key.as_str()) != Some(value))
551 .map(|(key, _)| key.clone())
552 .collect();
553 fields.sort_unstable();
554 fields
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use crate::values::{MemSize, UpDuration};
562
563 #[test]
564 fn minimal_config_gets_spec_defaults() {
565 let app = AppConfig::minimal("web", "./server");
566 assert_eq!(app.name, "web");
567 assert_eq!(app.script, "./server");
568 assert!(app.autorestart);
569 assert!(app.autostart);
570 assert_eq!(app.instances, 1);
571 assert_eq!(app.min_uptime, UpDuration::from_millis(1000));
572 assert_eq!(app.max_restarts, 16);
573 assert_eq!(app.kill_timeout, UpDuration::from_millis(1600));
574 assert_eq!(app.listen_timeout, UpDuration::from_millis(3000));
575 assert_eq!(app.graceful_timeout, UpDuration::from_millis(8000));
576 assert_eq!(app.action_timeout, UpDuration::from_millis(3000));
577 assert!(app.max_memory.is_none());
578 assert!(app.fold.is_none());
579 assert!(!app.channel);
580 }
581
582 #[test]
583 fn unstable_restarts_are_throttled_by_default() {
584 let app = AppConfig::minimal("web", "./srv");
585 assert_eq!(
586 app.exp_backoff_restart_delay,
587 Some(UpDuration::from_millis(100))
588 );
589 }
590
591 #[test]
592 fn stdin_is_not_piped_unless_the_app_asks() {
593 let app = AppConfig::minimal("web", "./srv");
594 assert!(!app.stdin);
595 let parsed: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
596 assert!(!parsed.stdin);
597 }
598
599 #[test]
600 fn the_flockfile_key_is_stdin() {
601 let parsed: AppConfig =
602 toml::from_str("name = \"web\"\nscript = \"./srv\"\nstdin = true").unwrap();
603 assert!(parsed.stdin);
604 }
605
606 #[test]
607 fn toml_round_trip_with_newtypes() {
608 let toml_src = r#"
609name = "worker"
610script = "python3"
611args = ["job.py", "--fast"]
612max_memory = "512M"
613min_uptime = "5s"
614fold = "backend"
615env = { RUST_LOG = "info" }
616"#;
617 let app: AppConfig = toml::from_str(toml_src).unwrap();
618 assert_eq!(app.max_memory, Some("512M".parse::<MemSize>().unwrap()));
619 assert_eq!(app.min_uptime, UpDuration::from_millis(5000));
620 assert_eq!(app.fold.as_deref(), Some("backend"));
621 assert_eq!(app.env.get("RUST_LOG").map(String::as_str), Some("info"));
622 assert_eq!(app.args, vec!["job.py", "--fast"]);
623 }
624
625 #[test]
626 fn unknown_fields_are_rejected() {
627 let err = toml::from_str::<AppConfig>(
628 "name = \"x\"\nscript = \"y\"\nmax_memory_restart = \"1G\"",
629 )
630 .unwrap_err();
631 assert!(err.to_string().contains("max_memory_restart"), "{err}");
632 }
633
634 #[test]
635 fn probe_config_parses_with_defaults() {
636 let src = r#"
637name = "api"
638script = "./api"
639
640[readiness_probe]
641kind = "http"
642target = "http://127.0.0.1:8080/healthz"
643"#;
644 let app: AppConfig = toml::from_str(src).unwrap();
645 let probe = app.readiness_probe.unwrap();
646 assert_eq!(probe.kind, ProbeKind::Http);
647 assert_eq!(probe.target, "http://127.0.0.1:8080/healthz");
648 assert_eq!(probe.interval, UpDuration::from_millis(10_000));
649 assert_eq!(probe.timeout, UpDuration::from_millis(5_000));
650 assert_eq!(probe.failure_threshold, 3);
651 assert!(app.liveness_probe.is_none());
652 }
653
654 #[test]
655 fn debug_redacts_env_values() {
656 let mut app = AppConfig::minimal("web", "./srv");
658 app.env
659 .insert("DATABASE_URL".to_string(), "postgres://secret".to_string());
660 app.env.insert("RUST_LOG".to_string(), "info".to_string());
661 assert_eq!(
662 format!("{app:?}"),
663 "AppConfig { name: \"web\", script: \"./srv\", env: <2 vars>, .. }"
664 );
665 }
666
667 #[test]
668 fn an_unedited_config_has_drifted_in_no_field() {
669 let app = AppConfig::minimal("web", "./srv");
670
671 assert!(app.drifted_fields(&app.clone()).is_empty());
672 }
673
674 #[test]
675 fn drift_names_every_edited_field_and_no_other() {
676 let stored = AppConfig::minimal("proto-api", "./proto-enum-api");
679 let mut edited = stored.clone();
680 edited.cwd = Some("/srv/pogo-proto-api".to_string());
681 edited.args = vec!["-config".to_string(), "config.toml".to_string()];
682
683 assert_eq!(
684 stored.drifted_fields(&edited),
685 vec!["args".to_string(), "cwd".to_string()]
686 );
687 }
688
689 #[test]
690 fn drift_reports_env_by_name_and_never_by_value() {
691 let stored = AppConfig::minimal("web", "./srv");
692 let mut edited = stored.clone();
693 edited
694 .env
695 .insert("DATABASE_URL".to_string(), "postgres://hunter2".to_string());
696
697 let fields = edited.drifted_fields(&stored);
698
699 assert_eq!(fields, vec!["env".to_string()]);
700 assert!(!fields.concat().contains("hunter2"));
702 }
703
704 #[test]
705 fn drift_is_symmetric() {
706 let stored = AppConfig::minimal("web", "./srv");
707 let mut edited = stored.clone();
708 edited.instances = 4;
709
710 assert_eq!(
711 stored.drifted_fields(&edited),
712 edited.drifted_fields(&stored)
713 );
714 assert_eq!(
715 stored.drifted_fields(&edited),
716 vec!["instances".to_string()]
717 );
718 }
719}