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" = {
291 "example": "[\"db\", \"cache\"]",
292 "group": "process",
293 "blurb": "Other sheep or dogs that must be up before this one starts"
294 })))]
295 pub depends_on: Vec<String>,
296 #[cfg_attr(feature = "schema", schemars(extend("init" = {
298 "example": "www-data",
299 "group": "process",
300 "blurb": "Run as this user, on unix"
301 })))]
302 pub user: Option<String>,
303 #[cfg_attr(feature = "schema", schemars(extend("init" = {
305 "example": "www-data",
306 "group": "process",
307 "blurb": "Run as this group, on unix"
308 })))]
309 pub group: Option<String>,
310 #[cfg_attr(feature = "schema", schemars(extend("init" = {
312 "example": "/var/log/my-first-sheep/out.log",
313 "group": "logging",
314 "blurb": "Where stdout goes. Defaults to a file under $SHEP_HOME/logs"
315 })))]
316 pub out_file: Option<String>,
317 #[cfg_attr(feature = "schema", schemars(extend("init" = {
319 "example": "/var/log/my-first-sheep/err.log",
320 "group": "logging",
321 "blurb": "Where stderr goes. Defaults to a file under $SHEP_HOME/logs"
322 })))]
323 pub err_file: Option<String>,
324 #[cfg_attr(feature = "schema", schemars(extend("init" = {
326 "group": "logging",
327 "blurb": "Put every instance's output in one pair of files"
328 })))]
329 pub merge_logs: bool,
330 #[cfg_attr(feature = "schema", schemars(extend("init" = {
337 "group": "inputs",
338 "blurb": "Opens fd 3 so the app can talk to shep directly"
339 })))]
340 pub channel: bool,
341 #[cfg_attr(feature = "schema", schemars(extend("init" = {
367 "group": "inputs",
368 "blurb": "Keeps stdin open so shep whisper can write to the process"
369 })))]
370 pub stdin: bool,
371 #[cfg_attr(feature = "schema", schemars(extend("init" = {
373 "group": "readiness",
374 "blurb": "Wait for the app to say it is ready on the channel"
375 })))]
376 pub wait_ready: bool,
377 #[cfg_attr(feature = "schema", schemars(extend("init" = {
413 "group": "process",
414 "blurb": "The app sets SO_REUSEPORT itself, so reload may overlap the two instances"
415 })))]
416 pub reuse_port: bool,
417 #[cfg_attr(feature = "schema", schemars(extend("init" = {
419 "example": { "kind": "http", "target": "http://127.0.0.1:8080/ready" },
420 "group": "readiness",
421 "blurb": "A health check shep waits on before it treats a reload as finished"
422 })))]
423 pub readiness_probe: Option<ProbeConfig>,
424 #[cfg_attr(feature = "schema", schemars(extend("init" = {
426 "example": { "kind": "http", "target": "http://127.0.0.1:8080/healthz" },
427 "group": "readiness",
428 "blurb": "A health check that triggers a restart when it keeps failing"
429 })))]
430 pub liveness_probe: Option<ProbeConfig>,
431 #[cfg_attr(feature = "schema", schemars(extend("init" = {
433 "group": "watch",
434 "blurb": "Which paths to watch. Empty means the working directory"
435 })))]
436 pub watch_options: Vec<String>,
437 #[cfg_attr(feature = "schema", schemars(extend("init" = {
439 "example": "US/Eastern",
440 "group": "cron",
441 "blurb": "Which timezone cron_restart is read in, as an IANA name"
442 })))]
443 pub cron_timezone: Option<String>,
444 #[cfg_attr(feature = "schema", schemars(skip))]
450 pub increment_var: Option<String>,
451}
452
453impl fmt::Debug for AppConfig {
455 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
456 f.debug_struct("AppConfig")
457 .field("name", &self.name)
458 .field("script", &self.script)
459 .field("env", &format_args!("<{} vars>", self.env.len()))
460 .finish_non_exhaustive()
461 }
462}
463
464impl Default for AppConfig {
465 fn default() -> Self {
466 Self {
467 name: String::new(),
468 script: String::new(),
469 args: Vec::new(),
470 cwd: None,
471 interpreter: None,
472 env: BTreeMap::new(),
473 instances: 1,
474 autorestart: true,
475 autostart: true,
476 stop_exit_codes: Vec::new(),
477 min_uptime: UpDuration::from_millis(1000),
478 max_restarts: 16,
479 restart_delay: None,
480 exp_backoff_restart_delay: Some(UpDuration::from_millis(100)),
483 kill_signal: None,
484 kill_timeout: UpDuration::from_millis(1600),
485 shutdown_with_message: false,
486 listen_timeout: UpDuration::from_millis(3000),
487 graceful_timeout: UpDuration::from_millis(8000),
488 action_timeout: UpDuration::from_millis(3000),
489 max_memory: None,
490 watch: false,
491 ignore_watch: Vec::new(),
492 watch_delay: None,
493 cron_restart: None,
494 fold: None,
495 depends_on: Vec::new(),
496 user: None,
497 group: None,
498 out_file: None,
499 err_file: None,
500 merge_logs: false,
501 channel: false,
502 stdin: false,
503 wait_ready: false,
504 reuse_port: false,
505 readiness_probe: None,
506 liveness_probe: None,
507 watch_options: Vec::new(),
508 cron_timezone: None,
509 increment_var: None,
510 }
511 }
512}
513
514impl AppConfig {
515 #[must_use]
517 pub fn minimal(name: &str, script: &str) -> Self {
518 Self {
519 name: name.to_string(),
520 script: script.to_string(),
521 ..Self::default()
522 }
523 }
524
525 #[must_use]
551 pub fn drifted_fields(&self, other: &Self) -> Vec<String> {
552 if self == other {
553 return Vec::new();
554 }
555 let (Ok(serde_json::Value::Object(mine)), Ok(serde_json::Value::Object(theirs))) =
560 (serde_json::to_value(self), serde_json::to_value(other))
561 else {
562 return Vec::new();
563 };
564 let mut fields: Vec<String> = mine
565 .iter()
566 .filter(|(key, value)| theirs.get(key.as_str()) != Some(value))
567 .map(|(key, _)| key.clone())
568 .collect();
569 fields.sort_unstable();
570 fields
571 }
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::values::{MemSize, UpDuration};
578
579 #[test]
580 fn minimal_config_gets_spec_defaults() {
581 let app = AppConfig::minimal("web", "./server");
582 assert_eq!(app.name, "web");
583 assert_eq!(app.script, "./server");
584 assert!(app.autorestart);
585 assert!(app.autostart);
586 assert_eq!(app.instances, 1);
587 assert_eq!(app.min_uptime, UpDuration::from_millis(1000));
588 assert_eq!(app.max_restarts, 16);
589 assert_eq!(app.kill_timeout, UpDuration::from_millis(1600));
590 assert_eq!(app.listen_timeout, UpDuration::from_millis(3000));
591 assert_eq!(app.graceful_timeout, UpDuration::from_millis(8000));
592 assert_eq!(app.action_timeout, UpDuration::from_millis(3000));
593 assert!(app.max_memory.is_none());
594 assert!(app.fold.is_none());
595 assert!(!app.channel);
596 }
597
598 #[test]
599 fn unstable_restarts_are_throttled_by_default() {
600 let app = AppConfig::minimal("web", "./srv");
601 assert_eq!(
602 app.exp_backoff_restart_delay,
603 Some(UpDuration::from_millis(100))
604 );
605 }
606
607 #[test]
608 fn stdin_is_not_piped_unless_the_app_asks() {
609 let app = AppConfig::minimal("web", "./srv");
610 assert!(!app.stdin);
611 let parsed: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
612 assert!(!parsed.stdin);
613 }
614
615 #[test]
616 fn the_flockfile_key_is_stdin() {
617 let parsed: AppConfig =
618 toml::from_str("name = \"web\"\nscript = \"./srv\"\nstdin = true").unwrap();
619 assert!(parsed.stdin);
620 }
621
622 #[test]
623 fn toml_round_trip_with_newtypes() {
624 let toml_src = r#"
625name = "worker"
626script = "python3"
627args = ["job.py", "--fast"]
628max_memory = "512M"
629min_uptime = "5s"
630fold = "backend"
631env = { RUST_LOG = "info" }
632"#;
633 let app: AppConfig = toml::from_str(toml_src).unwrap();
634 assert_eq!(app.max_memory, Some("512M".parse::<MemSize>().unwrap()));
635 assert_eq!(app.min_uptime, UpDuration::from_millis(5000));
636 assert_eq!(app.fold.as_deref(), Some("backend"));
637 assert_eq!(app.env.get("RUST_LOG").map(String::as_str), Some("info"));
638 assert_eq!(app.args, vec!["job.py", "--fast"]);
639 }
640
641 #[test]
642 fn unknown_fields_are_rejected() {
643 let err = toml::from_str::<AppConfig>(
644 "name = \"x\"\nscript = \"y\"\nmax_memory_restart = \"1G\"",
645 )
646 .unwrap_err();
647 assert!(err.to_string().contains("max_memory_restart"), "{err}");
648 }
649
650 #[test]
651 fn probe_config_parses_with_defaults() {
652 let src = r#"
653name = "api"
654script = "./api"
655
656[readiness_probe]
657kind = "http"
658target = "http://127.0.0.1:8080/healthz"
659"#;
660 let app: AppConfig = toml::from_str(src).unwrap();
661 let probe = app.readiness_probe.unwrap();
662 assert_eq!(probe.kind, ProbeKind::Http);
663 assert_eq!(probe.target, "http://127.0.0.1:8080/healthz");
664 assert_eq!(probe.interval, UpDuration::from_millis(10_000));
665 assert_eq!(probe.timeout, UpDuration::from_millis(5_000));
666 assert_eq!(probe.failure_threshold, 3);
667 assert!(app.liveness_probe.is_none());
668 }
669
670 #[test]
671 fn debug_redacts_env_values() {
672 let mut app = AppConfig::minimal("web", "./srv");
674 app.env
675 .insert("DATABASE_URL".to_string(), "postgres://secret".to_string());
676 app.env.insert("RUST_LOG".to_string(), "info".to_string());
677 assert_eq!(
678 format!("{app:?}"),
679 "AppConfig { name: \"web\", script: \"./srv\", env: <2 vars>, .. }"
680 );
681 }
682
683 #[test]
684 fn an_unedited_config_has_drifted_in_no_field() {
685 let app = AppConfig::minimal("web", "./srv");
686
687 assert!(app.drifted_fields(&app.clone()).is_empty());
688 }
689
690 #[test]
691 fn drift_names_every_edited_field_and_no_other() {
692 let stored = AppConfig::minimal("proto-api", "./proto-enum-api");
695 let mut edited = stored.clone();
696 edited.cwd = Some("/srv/pogo-proto-api".to_string());
697 edited.args = vec!["-config".to_string(), "config.toml".to_string()];
698
699 assert_eq!(
700 stored.drifted_fields(&edited),
701 vec!["args".to_string(), "cwd".to_string()]
702 );
703 }
704
705 #[test]
706 fn drift_reports_env_by_name_and_never_by_value() {
707 let stored = AppConfig::minimal("web", "./srv");
708 let mut edited = stored.clone();
709 edited
710 .env
711 .insert("DATABASE_URL".to_string(), "postgres://hunter2".to_string());
712
713 let fields = edited.drifted_fields(&stored);
714
715 assert_eq!(fields, vec!["env".to_string()]);
716 assert!(!fields.concat().contains("hunter2"));
718 }
719
720 #[test]
721 fn drift_is_symmetric() {
722 let stored = AppConfig::minimal("web", "./srv");
723 let mut edited = stored.clone();
724 edited.instances = 4;
725
726 assert_eq!(
727 stored.drifted_fields(&edited),
728 edited.drifted_fields(&stored)
729 );
730 assert_eq!(
731 stored.drifted_fields(&edited),
732 vec!["instances".to_string()]
733 );
734 }
735}