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" = {
176 "example": "5s",
177 "group": "control",
178 "blurb": "Starting delay between restarts, growing each time it fails again"
179 })))]
180 pub exp_backoff_restart_delay: Option<UpDuration>,
181 #[cfg_attr(feature = "schema", schemars(extend("init" = {
189 "example": "SIGTERM",
190 "group": "process",
191 "blurb": "Which signal shep sends first when stopping this app"
192 })))]
193 pub kill_signal: Option<String>,
194 #[cfg_attr(feature = "schema", schemars(extend("init" = {
196 "group": "control",
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": "control",
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": "control",
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": "control",
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": "control",
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": "control",
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": "control",
250 "blurb": "Restart when a file changes"
251 })))]
252 pub watch: bool,
253 #[cfg_attr(feature = "schema", schemars(extend("init" = {
255 "group": "control",
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": "control",
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 })))]
272 pub cron_restart: Option<String>,
273 #[cfg_attr(feature = "schema", schemars(extend("init" = {
275 "example": "backend",
276 "group": "process",
277 "blurb": "A fold to group this app with others, for commands that take one"
278 })))]
279 pub fold: Option<String>,
280 #[cfg_attr(feature = "schema", schemars(extend("init" = {
282 "example": "www-data",
283 "group": "process",
284 "blurb": "Run as this user, on unix"
285 })))]
286 pub user: Option<String>,
287 #[cfg_attr(feature = "schema", schemars(extend("init" = {
289 "example": "www-data",
290 "group": "process",
291 "blurb": "Run as this group, on unix"
292 })))]
293 pub group: Option<String>,
294 #[cfg_attr(feature = "schema", schemars(extend("init" = {
296 "example": "/var/log/my-first-sheep/out.log",
297 "group": "process",
298 "blurb": "Where stdout goes. Defaults to a file under $SHEP_HOME/logs"
299 })))]
300 pub out_file: Option<String>,
301 #[cfg_attr(feature = "schema", schemars(extend("init" = {
303 "example": "/var/log/my-first-sheep/err.log",
304 "group": "process",
305 "blurb": "Where stderr goes. Defaults to a file under $SHEP_HOME/logs"
306 })))]
307 pub err_file: Option<String>,
308 #[cfg_attr(feature = "schema", schemars(extend("init" = {
310 "group": "process",
311 "blurb": "Put every instance's output in one pair of files"
312 })))]
313 pub merge_logs: bool,
314 #[cfg_attr(feature = "schema", schemars(extend("init" = {
321 "group": "inputs",
322 "blurb": "Opens fd 3 so the app can talk to shep directly"
323 })))]
324 pub channel: bool,
325 #[cfg_attr(feature = "schema", schemars(extend("init" = {
351 "group": "inputs",
352 "blurb": "Keeps stdin open so shep whisper can write to the process"
353 })))]
354 pub stdin: bool,
355 #[cfg_attr(feature = "schema", schemars(extend("init" = {
357 "group": "control",
358 "blurb": "Wait for the app to say it is ready on the channel"
359 })))]
360 pub wait_ready: bool,
361 #[cfg_attr(feature = "schema", schemars(extend("init" = {
377 "group": "process",
378 "blurb": "Not built yet. Setting it is refused rather than quietly ignored"
379 })))]
380 pub reuse_port: bool,
381 #[cfg_attr(feature = "schema", schemars(extend("init" = {
383 "example": { "kind": "http", "target": "http://127.0.0.1:8080/ready" },
384 "group": "control",
385 "blurb": "A health check shep waits on before it treats a reload as finished"
386 })))]
387 pub readiness_probe: Option<ProbeConfig>,
388 #[cfg_attr(feature = "schema", schemars(extend("init" = {
390 "example": { "kind": "http", "target": "http://127.0.0.1:8080/healthz" },
391 "group": "control",
392 "blurb": "A health check that triggers a restart when it keeps failing"
393 })))]
394 pub liveness_probe: Option<ProbeConfig>,
395 #[cfg_attr(feature = "schema", schemars(extend("init" = {
397 "group": "control",
398 "blurb": "Which paths to watch. Empty means the working directory"
399 })))]
400 pub watch_options: Vec<String>,
401 #[cfg_attr(feature = "schema", schemars(extend("init" = {
403 "example": "US/Eastern",
404 "group": "cron",
405 "blurb": "Which timezone cron_restart is read in, as an IANA name"
406 })))]
407 pub cron_timezone: Option<String>,
408 #[cfg_attr(feature = "schema", schemars(extend("init" = {
410 "example": "INSTANCE_ID",
411 "group": "inputs",
412 "blurb": "The env var each instance finds its own slot number in"
413 })))]
414 pub increment_var: Option<String>,
415}
416
417impl fmt::Debug for AppConfig {
419 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420 f.debug_struct("AppConfig")
421 .field("name", &self.name)
422 .field("script", &self.script)
423 .field("env", &format_args!("<{} vars>", self.env.len()))
424 .finish_non_exhaustive()
425 }
426}
427
428impl Default for AppConfig {
429 fn default() -> Self {
430 Self {
431 name: String::new(),
432 script: String::new(),
433 args: Vec::new(),
434 cwd: None,
435 interpreter: None,
436 env: BTreeMap::new(),
437 instances: 1,
438 autorestart: true,
439 autostart: true,
440 stop_exit_codes: Vec::new(),
441 min_uptime: UpDuration::from_millis(1000),
442 max_restarts: 16,
443 restart_delay: None,
444 exp_backoff_restart_delay: Some(UpDuration::from_millis(100)),
447 kill_signal: None,
448 kill_timeout: UpDuration::from_millis(1600),
449 shutdown_with_message: false,
450 listen_timeout: UpDuration::from_millis(3000),
451 graceful_timeout: UpDuration::from_millis(8000),
452 action_timeout: UpDuration::from_millis(3000),
453 max_memory: None,
454 watch: false,
455 ignore_watch: Vec::new(),
456 watch_delay: None,
457 cron_restart: None,
458 fold: None,
459 user: None,
460 group: None,
461 out_file: None,
462 err_file: None,
463 merge_logs: false,
464 channel: false,
465 stdin: false,
466 wait_ready: false,
467 reuse_port: false,
468 readiness_probe: None,
469 liveness_probe: None,
470 watch_options: Vec::new(),
471 cron_timezone: None,
472 increment_var: None,
473 }
474 }
475}
476
477impl AppConfig {
478 #[must_use]
480 pub fn minimal(name: &str, script: &str) -> Self {
481 Self {
482 name: name.to_string(),
483 script: script.to_string(),
484 ..Self::default()
485 }
486 }
487
488 #[must_use]
514 pub fn drifted_fields(&self, other: &Self) -> Vec<String> {
515 if self == other {
516 return Vec::new();
517 }
518 let (Ok(serde_json::Value::Object(mine)), Ok(serde_json::Value::Object(theirs))) =
537 (serde_json::to_value(self), serde_json::to_value(other))
538 else {
539 return Vec::new();
540 };
541 let mut fields: Vec<String> = mine
542 .iter()
543 .filter(|(key, value)| theirs.get(key.as_str()) != Some(value))
544 .map(|(key, _)| key.clone())
545 .collect();
546 fields.sort_unstable();
547 fields
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use crate::values::{MemSize, UpDuration};
555
556 #[test]
557 fn minimal_config_gets_spec_defaults() {
558 let app = AppConfig::minimal("web", "./server");
559 assert_eq!(app.name, "web");
560 assert_eq!(app.script, "./server");
561 assert!(app.autorestart);
562 assert!(app.autostart);
563 assert_eq!(app.instances, 1);
564 assert_eq!(app.min_uptime, UpDuration::from_millis(1000));
565 assert_eq!(app.max_restarts, 16);
566 assert_eq!(app.kill_timeout, UpDuration::from_millis(1600));
567 assert_eq!(app.listen_timeout, UpDuration::from_millis(3000));
568 assert_eq!(app.graceful_timeout, UpDuration::from_millis(8000));
569 assert_eq!(app.action_timeout, UpDuration::from_millis(3000));
570 assert!(app.max_memory.is_none());
571 assert!(app.fold.is_none());
572 assert!(!app.channel);
573 }
574
575 #[test]
582 fn unstable_restarts_are_throttled_by_default() {
583 let app = AppConfig::minimal("web", "./srv");
584 assert_eq!(
585 app.exp_backoff_restart_delay,
586 Some(UpDuration::from_millis(100))
587 );
588 }
589
590 #[test]
596 fn stdin_is_not_piped_unless_the_app_asks() {
597 let app = AppConfig::minimal("web", "./srv");
598 assert!(!app.stdin);
599 let parsed: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
600 assert!(!parsed.stdin);
601 }
602
603 #[test]
608 fn the_flockfile_key_is_stdin() {
609 let parsed: AppConfig =
610 toml::from_str("name = \"web\"\nscript = \"./srv\"\nstdin = true").unwrap();
611 assert!(parsed.stdin);
612 }
613
614 #[test]
615 fn toml_round_trip_with_newtypes() {
616 let toml_src = r#"
617name = "worker"
618script = "python3"
619args = ["job.py", "--fast"]
620max_memory = "512M"
621min_uptime = "5s"
622fold = "backend"
623env = { RUST_LOG = "info" }
624"#;
625 let app: AppConfig = toml::from_str(toml_src).unwrap();
626 assert_eq!(app.max_memory, Some("512M".parse::<MemSize>().unwrap()));
627 assert_eq!(app.min_uptime, UpDuration::from_millis(5000));
628 assert_eq!(app.fold.as_deref(), Some("backend"));
629 assert_eq!(app.env.get("RUST_LOG").map(String::as_str), Some("info"));
630 assert_eq!(app.args, vec!["job.py", "--fast"]);
631 }
632
633 #[test]
634 fn unknown_fields_are_rejected() {
635 let err = toml::from_str::<AppConfig>(
636 "name = \"x\"\nscript = \"y\"\nmax_memory_restart = \"1G\"",
637 )
638 .unwrap_err();
639 assert!(err.to_string().contains("max_memory_restart"), "{err}");
640 }
641
642 #[test]
643 fn probe_config_parses_with_defaults() {
644 let src = r#"
645name = "api"
646script = "./api"
647
648[readiness_probe]
649kind = "http"
650target = "http://127.0.0.1:8080/healthz"
651"#;
652 let app: AppConfig = toml::from_str(src).unwrap();
653 let probe = app.readiness_probe.unwrap();
654 assert_eq!(probe.kind, ProbeKind::Http);
655 assert_eq!(probe.target, "http://127.0.0.1:8080/healthz");
656 assert_eq!(probe.interval, UpDuration::from_millis(10_000));
657 assert_eq!(probe.timeout, UpDuration::from_millis(5_000));
658 assert_eq!(probe.failure_threshold, 3);
659 assert!(app.liveness_probe.is_none());
660 }
661
662 #[test]
663 fn debug_redacts_env_values() {
664 let mut app = AppConfig::minimal("web", "./srv");
667 app.env
668 .insert("DATABASE_URL".to_string(), "postgres://secret".to_string());
669 app.env.insert("RUST_LOG".to_string(), "info".to_string());
670 assert_eq!(
671 format!("{app:?}"),
672 "AppConfig { name: \"web\", script: \"./srv\", env: <2 vars>, .. }"
673 );
674 }
675
676 #[test]
677 fn an_unedited_config_has_drifted_in_no_field() {
678 let app = AppConfig::minimal("web", "./srv");
679
680 assert!(app.drifted_fields(&app.clone()).is_empty());
681 }
682
683 #[test]
684 fn drift_names_every_edited_field_and_no_other() {
685 let stored = AppConfig::minimal("proto-api", "./proto-enum-api");
689 let mut edited = stored.clone();
690 edited.cwd = Some("/Users/rin/GitHub/pogo-proto-api".to_string());
691 edited.args = vec!["-config".to_string(), "config.toml".to_string()];
692
693 assert_eq!(
694 stored.drifted_fields(&edited),
695 vec!["args".to_string(), "cwd".to_string()]
696 );
697 }
698
699 #[test]
700 fn drift_reports_env_by_name_and_never_by_value() {
701 let stored = AppConfig::minimal("web", "./srv");
702 let mut edited = stored.clone();
703 edited
704 .env
705 .insert("DATABASE_URL".to_string(), "postgres://hunter2".to_string());
706
707 let fields = edited.drifted_fields(&stored);
708
709 assert_eq!(fields, vec!["env".to_string()]);
710 assert!(!fields.concat().contains("hunter2"));
713 }
714
715 #[test]
716 fn drift_is_symmetric() {
717 let stored = AppConfig::minimal("web", "./srv");
718 let mut edited = stored.clone();
719 edited.instances = 4;
720
721 assert_eq!(
722 stored.drifted_fields(&edited),
723 edited.drifted_fields(&stored)
724 );
725 assert_eq!(
726 stored.drifted_fields(&edited),
727 vec!["instances".to_string()]
728 );
729 }
730}