use core::fmt;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::values::{MemSize, UpDuration};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ProbeKind {
Http,
Tcp,
Exec,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
pub struct ProbeConfig {
pub kind: ProbeKind,
pub target: String,
#[serde(default = "default_probe_interval")]
pub interval: UpDuration,
#[serde(default = "default_probe_timeout")]
pub timeout: UpDuration,
#[serde(default = "default_failure_threshold")]
pub failure_threshold: u32,
}
fn default_probe_interval() -> UpDuration {
UpDuration::from_millis(10_000)
}
fn default_probe_timeout() -> UpDuration {
UpDuration::from_millis(5_000)
}
fn default_failure_threshold() -> u32 {
3
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
#[serde(default)]
pub struct AppConfig {
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "my-first-sheep",
"group": "process",
"blurb": "A convenient and unique name for shep to display"
})))]
pub name: String,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "./index.js",
"group": "process",
"blurb": "The script that shep should use to launch your app"
})))]
pub script: String,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "inputs",
"blurb": "Arguments passed to the script, as a list"
})))]
pub args: Vec<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "/srv/app",
"group": "process",
"blurb": "Where the process runs. Without it, the daemon's own directory"
})))]
pub cwd: Option<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "none",
"group": "process",
"blurb": "What runs the script. Set it to none to exec the file directly"
})))]
pub interpreter: Option<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "{ NODE_ENV = 'production' }",
"group": "inputs",
"blurb": "Environment variables for this app, layered over the daemon's own"
})))]
pub env: BTreeMap<String, String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "process",
"blurb": "How many copies of this app to run"
})))]
pub instances: u32,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "restart",
"blurb": "Restarts the process automatically when it exits unexpectedly"
})))]
pub autorestart: bool,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "restart",
"blurb": "Start this app when the daemon starts, and on shep muster"
})))]
pub autostart: bool,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "restart",
"blurb": "Exit codes that mean a clean stop, so shep will not restart"
})))]
pub stop_exit_codes: Vec<i32>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "restart",
"blurb": "An exit sooner than this counts as unstable"
})))]
pub min_uptime: UpDuration,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "restart",
"blurb": "How many unstable exits in a row before shep gives up"
})))]
pub max_restarts: u32,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "3s",
"group": "restart",
"blurb": "A fixed wait before every restart, instead of growing backoff"
})))]
pub restart_delay: Option<UpDuration>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "5s",
"group": "restart",
"blurb": "Starting delay between restarts, growing each time it fails again"
})))]
pub exp_backoff_restart_delay: Option<UpDuration>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "SIGTERM",
"group": "shutdown",
"blurb": "Which signal shep sends first when stopping this app",
"suggest": ["SIGTERM", "SIGINT", "SIGQUIT", "SIGUSR2"]
})))]
pub kill_signal: Option<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "shutdown",
"blurb": "How long shep waits after the stop signal before SIGKILL"
})))]
pub kill_timeout: UpDuration,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "shutdown",
"blurb": "Ask the app to stop over the channel instead of signalling it"
})))]
pub shutdown_with_message: bool,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "readiness",
"blurb": "How long to wait for readiness when nothing else reports it"
})))]
pub listen_timeout: UpDuration,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "shutdown",
"blurb": "How long the old instance gets to drain during a reload"
})))]
pub graceful_timeout: UpDuration,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "shutdown",
"blurb": "How long a triggered action has to answer before shep gives up"
})))]
pub action_timeout: UpDuration,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "512M",
"group": "restart",
"blurb": "Restart the app if it climbs above this much memory"
})))]
pub max_memory: Option<MemSize>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "watch",
"blurb": "Restart when a file changes"
})))]
pub watch: bool,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "watch",
"blurb": "Paths watch should skip, on top of dotfiles and node_modules"
})))]
pub ignore_watch: Vec<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "500",
"group": "watch",
"blurb": "How long to wait after a change before restarting"
})))]
pub watch_delay: Option<UpDuration>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "* * * * *",
"group": "cron",
"blurb": "Restart on a schedule, written as a cron pattern",
"suggest": ["*/5 * * * *", "0 * * * *", "0 0 * * *", "0 0 * * 0"]
})))]
pub cron_restart: Option<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "backend",
"group": "process",
"blurb": "A fold to group this app with others, for commands that take one"
})))]
pub fold: Option<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "[\"db\", \"cache\"]",
"group": "process",
"blurb": "Other sheep or dogs that must be up before this one starts"
})))]
pub depends_on: Vec<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "www-data",
"group": "process",
"blurb": "Run as this user, on unix"
})))]
pub user: Option<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "www-data",
"group": "process",
"blurb": "Run as this group, on unix"
})))]
pub group: Option<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "/var/log/my-first-sheep/out.log",
"group": "logging",
"blurb": "Where stdout goes. Defaults to a file under $SHEP_HOME/logs"
})))]
pub out_file: Option<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "/var/log/my-first-sheep/err.log",
"group": "logging",
"blurb": "Where stderr goes. Defaults to a file under $SHEP_HOME/logs"
})))]
pub err_file: Option<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "logging",
"blurb": "Put every instance's output in one pair of files"
})))]
pub merge_logs: bool,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "inputs",
"blurb": "Opens fd 3 so the app can talk to shep directly"
})))]
pub channel: bool,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "inputs",
"blurb": "Keeps stdin open so shep whisper can write to the process"
})))]
pub stdin: bool,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "readiness",
"blurb": "Wait for the app to say it is ready on the channel"
})))]
pub wait_ready: bool,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "process",
"blurb": "The app sets SO_REUSEPORT itself, so reload may overlap the two instances"
})))]
pub reuse_port: bool,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": { "kind": "http", "target": "http://127.0.0.1:8080/ready" },
"group": "readiness",
"blurb": "A health check shep waits on before it treats a reload as finished"
})))]
pub readiness_probe: Option<ProbeConfig>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": { "kind": "http", "target": "http://127.0.0.1:8080/healthz" },
"group": "readiness",
"blurb": "A health check that triggers a restart when it keeps failing"
})))]
pub liveness_probe: Option<ProbeConfig>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"group": "watch",
"blurb": "Which paths to watch. Empty means the working directory"
})))]
pub watch_options: Vec<String>,
#[cfg_attr(feature = "schema", schemars(extend("init" = {
"example": "US/Eastern",
"group": "cron",
"blurb": "Which timezone cron_restart is read in, as an IANA name"
})))]
pub cron_timezone: Option<String>,
#[cfg_attr(feature = "schema", schemars(skip))]
pub increment_var: Option<String>,
}
impl fmt::Debug for AppConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AppConfig")
.field("name", &self.name)
.field("script", &self.script)
.field("env", &format_args!("<{} vars>", self.env.len()))
.finish_non_exhaustive()
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
name: String::new(),
script: String::new(),
args: Vec::new(),
cwd: None,
interpreter: None,
env: BTreeMap::new(),
instances: 1,
autorestart: true,
autostart: true,
stop_exit_codes: Vec::new(),
min_uptime: UpDuration::from_millis(1000),
max_restarts: 16,
restart_delay: None,
exp_backoff_restart_delay: Some(UpDuration::from_millis(100)),
kill_signal: None,
kill_timeout: UpDuration::from_millis(1600),
shutdown_with_message: false,
listen_timeout: UpDuration::from_millis(3000),
graceful_timeout: UpDuration::from_millis(8000),
action_timeout: UpDuration::from_millis(3000),
max_memory: None,
watch: false,
ignore_watch: Vec::new(),
watch_delay: None,
cron_restart: None,
fold: None,
depends_on: Vec::new(),
user: None,
group: None,
out_file: None,
err_file: None,
merge_logs: false,
channel: false,
stdin: false,
wait_ready: false,
reuse_port: false,
readiness_probe: None,
liveness_probe: None,
watch_options: Vec::new(),
cron_timezone: None,
increment_var: None,
}
}
}
impl AppConfig {
#[must_use]
pub fn minimal(name: &str, script: &str) -> Self {
Self {
name: name.to_string(),
script: script.to_string(),
..Self::default()
}
}
#[must_use]
pub fn drifted_fields(&self, other: &Self) -> Vec<String> {
if self == other {
return Vec::new();
}
let (Ok(serde_json::Value::Object(mine)), Ok(serde_json::Value::Object(theirs))) =
(serde_json::to_value(self), serde_json::to_value(other))
else {
return Vec::new();
};
let mut fields: Vec<String> = mine
.iter()
.filter(|(key, value)| theirs.get(key.as_str()) != Some(value))
.map(|(key, _)| key.clone())
.collect();
fields.sort_unstable();
fields
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::values::{MemSize, UpDuration};
#[test]
fn minimal_config_gets_spec_defaults() {
let app = AppConfig::minimal("web", "./server");
assert_eq!(app.name, "web");
assert_eq!(app.script, "./server");
assert!(app.autorestart);
assert!(app.autostart);
assert_eq!(app.instances, 1);
assert_eq!(app.min_uptime, UpDuration::from_millis(1000));
assert_eq!(app.max_restarts, 16);
assert_eq!(app.kill_timeout, UpDuration::from_millis(1600));
assert_eq!(app.listen_timeout, UpDuration::from_millis(3000));
assert_eq!(app.graceful_timeout, UpDuration::from_millis(8000));
assert_eq!(app.action_timeout, UpDuration::from_millis(3000));
assert!(app.max_memory.is_none());
assert!(app.fold.is_none());
assert!(!app.channel);
}
#[test]
fn unstable_restarts_are_throttled_by_default() {
let app = AppConfig::minimal("web", "./srv");
assert_eq!(
app.exp_backoff_restart_delay,
Some(UpDuration::from_millis(100))
);
}
#[test]
fn stdin_is_not_piped_unless_the_app_asks() {
let app = AppConfig::minimal("web", "./srv");
assert!(!app.stdin);
let parsed: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
assert!(!parsed.stdin);
}
#[test]
fn the_flockfile_key_is_stdin() {
let parsed: AppConfig =
toml::from_str("name = \"web\"\nscript = \"./srv\"\nstdin = true").unwrap();
assert!(parsed.stdin);
}
#[test]
fn toml_round_trip_with_newtypes() {
let toml_src = r#"
name = "worker"
script = "python3"
args = ["job.py", "--fast"]
max_memory = "512M"
min_uptime = "5s"
fold = "backend"
env = { RUST_LOG = "info" }
"#;
let app: AppConfig = toml::from_str(toml_src).unwrap();
assert_eq!(app.max_memory, Some("512M".parse::<MemSize>().unwrap()));
assert_eq!(app.min_uptime, UpDuration::from_millis(5000));
assert_eq!(app.fold.as_deref(), Some("backend"));
assert_eq!(app.env.get("RUST_LOG").map(String::as_str), Some("info"));
assert_eq!(app.args, vec!["job.py", "--fast"]);
}
#[test]
fn an_unknown_field_on_the_wire_is_ignored_rather_than_refused() {
let config: AppConfig =
serde_json::from_str(r#"{"name":"web","script":"./srv","invented_next_year":true}"#)
.expect("the wire path tolerates what it does not know");
assert_eq!(config.name, "web");
}
#[test]
fn probe_config_parses_with_defaults() {
let src = r#"
name = "api"
script = "./api"
[readiness_probe]
kind = "http"
target = "http://127.0.0.1:8080/healthz"
"#;
let app: AppConfig = toml::from_str(src).unwrap();
let probe = app.readiness_probe.unwrap();
assert_eq!(probe.kind, ProbeKind::Http);
assert_eq!(probe.target, "http://127.0.0.1:8080/healthz");
assert_eq!(probe.interval, UpDuration::from_millis(10_000));
assert_eq!(probe.timeout, UpDuration::from_millis(5_000));
assert_eq!(probe.failure_threshold, 3);
assert!(app.liveness_probe.is_none());
}
#[test]
fn debug_redacts_env_values() {
let mut app = AppConfig::minimal("web", "./srv");
app.env
.insert("DATABASE_URL".to_string(), "postgres://secret".to_string());
app.env.insert("RUST_LOG".to_string(), "info".to_string());
assert_eq!(
format!("{app:?}"),
"AppConfig { name: \"web\", script: \"./srv\", env: <2 vars>, .. }"
);
}
#[test]
fn an_unedited_config_has_drifted_in_no_field() {
let app = AppConfig::minimal("web", "./srv");
assert!(app.drifted_fields(&app.clone()).is_empty());
}
#[test]
fn drift_names_every_edited_field_and_no_other() {
let stored = AppConfig::minimal("proto-api", "./proto-enum-api");
let mut edited = stored.clone();
edited.cwd = Some("/srv/pogo-proto-api".to_string());
edited.args = vec!["-config".to_string(), "config.toml".to_string()];
assert_eq!(
stored.drifted_fields(&edited),
vec!["args".to_string(), "cwd".to_string()]
);
}
#[test]
fn drift_reports_env_by_name_and_never_by_value() {
let stored = AppConfig::minimal("web", "./srv");
let mut edited = stored.clone();
edited
.env
.insert("DATABASE_URL".to_string(), "postgres://hunter2".to_string());
let fields = edited.drifted_fields(&stored);
assert_eq!(fields, vec!["env".to_string()]);
assert!(!fields.concat().contains("hunter2"));
}
#[test]
fn drift_is_symmetric() {
let stored = AppConfig::minimal("web", "./srv");
let mut edited = stored.clone();
edited.instances = 4;
assert_eq!(
stored.drifted_fields(&edited),
edited.drifted_fields(&stored)
);
assert_eq!(
stored.drifted_fields(&edited),
vec!["instances".to_string()]
);
}
}