use core::fmt;
use std::path::Path;
use std::collections::BTreeSet;
use globset::Glob;
use crate::config::{
AppConfig, CronParseError, CronSchedule, KillSignal, ProbeConfig, ProbeTarget,
};
use crate::secrets;
use crate::values::UpDuration;
const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedApp {
config: AppConfig,
}
impl ResolvedApp {
#[must_use]
pub fn config(&self) -> &AppConfig {
&self.config
}
#[must_use]
pub fn into_config(self) -> AppConfig {
self.config
}
}
fn expand_tilde(
value: &str,
home: Option<&Path>,
name: &str,
field: &'static str,
) -> Result<String, NormalizeError> {
expand_home_tilde(value, home).map_err(|err| match err {
TildeError::OtherUser => NormalizeError::TildeUser {
name: name.to_string(),
field,
value: value.to_string(),
},
TildeError::NoHome => NormalizeError::NoHomeForTilde {
name: name.to_string(),
field,
},
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TildeError {
OtherUser,
NoHome,
}
impl fmt::Display for TildeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OtherUser => write!(
f,
"shep expands only `~/` (your own home); another user's home needs a \
passwd lookup whose answer depends on who the process runs as"
),
Self::NoHome => write!(f, "begins with `~/` but no home directory could be found"),
}
}
}
impl core::error::Error for TildeError {}
pub fn expand_home_tilde(value: &str, home: Option<&Path>) -> Result<String, TildeError> {
let Some(rest) = value.strip_prefix('~') else {
return Ok(value.to_string());
};
if !(rest.is_empty() || rest.starts_with('/')) {
return Err(TildeError::OtherUser);
}
let Some(home) = home else {
return Err(TildeError::NoHome);
};
let joined = home.join(rest.trim_start_matches('/'));
Ok(joined.to_string_lossy().into_owned())
}
#[cfg(test)]
const PATH_FIELDS: &[&str] = &["script", "cwd", "out_file", "err_file"];
fn expand_paths(app: &mut AppConfig, home: Option<&Path>) -> Result<(), NormalizeError> {
let name = app.name.clone();
app.script = expand_tilde(&app.script, home, &name, "script")?;
for (field, slot) in [
("cwd", &mut app.cwd),
("out_file", &mut app.out_file),
("err_file", &mut app.err_file),
] {
if let Some(value) = slot {
*slot = Some(expand_tilde(value, home, &name, field)?);
}
}
Ok(())
}
pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
normalize_with_home(app, std::env::home_dir().as_deref())
}
pub fn normalize_with_home(
mut app: AppConfig,
home: Option<&Path>,
) -> Result<ResolvedApp, NormalizeError> {
if app.name.is_empty() {
return Err(NormalizeError::MissingName);
}
if app.name.contains(['/', '\\', ':']) || app.name == "." || app.name == ".." {
return Err(NormalizeError::InvalidName(app.name));
}
if let Some(environment) = &app.environment
&& (environment == secrets::ALL_ENVIRONMENTS || !secrets::is_name(environment))
{
return Err(NormalizeError::InvalidEnvironment {
name: app.name.clone(),
value: environment.clone(),
});
}
for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
if app.env.contains_key(var) {
return Err(NormalizeError::ReservedEnvVar {
name: app.name.clone(),
var,
});
}
}
if let Some(var) = app.increment_var.take() {
return Err(NormalizeError::IncrementVarRemoved {
name: app.name.clone(),
var,
});
}
for (key, value) in &app.env {
validate_template(&app.name, &format!("env.{key}"), value)?;
}
for (index, value) in app.args.iter().enumerate() {
validate_template(&app.name, &format!("args[{index}]"), value)?;
}
for (field, value) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
if let Some(value) = value {
validate_template(&app.name, field, value)?;
if crate::config::template::holds_secret(value) {
return Err(NormalizeError::SecretInLogPath {
name: app.name.clone(),
field,
});
}
}
}
if app.script.is_empty() {
return Err(NormalizeError::MissingScript);
}
expand_paths(&mut app, home)?;
if app.instances == 0 {
return Err(NormalizeError::ZeroInstances);
}
if app.instances > 1 && !app.merge_logs {
for (field, path) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
if let Some(path) = path
&& crate::config::template::render_positional(path, &app.name, 0)
== crate::config::template::render_positional(path, &app.name, 1)
{
return Err(NormalizeError::SharedLogPath {
name: app.name.clone(),
field,
});
}
}
}
if let Some(pattern) = &app.cron_restart {
CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
CronParseError::Pattern { pattern, reason } => {
NormalizeError::InvalidCron { pattern, reason }
}
CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
})?;
} else if let Some(tz_name) = &app.cron_timezone {
crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
NormalizeError::InvalidTimezone {
name: tz_name.clone(),
}
})?;
}
validate_probe(
app.readiness_probe.as_ref(),
"readiness_probe",
MIN_READINESS_INTERVAL,
)?;
validate_probe(
app.liveness_probe.as_ref(),
"liveness_probe",
MIN_LIVENESS_INTERVAL,
)?;
if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
return Err(NormalizeError::ZeroMaxMemory { name: app.name });
}
if let Some(name) = &app.kill_signal
&& KillSignal::parse(name).is_none()
{
return Err(NormalizeError::InvalidKillSignal {
name: app.name,
value: name.clone(),
});
}
if app.action_timeout > MAX_ACTION_TIMEOUT {
return Err(NormalizeError::ActionTimeoutTooLong {
name: app.name,
value: app.action_timeout,
max: MAX_ACTION_TIMEOUT,
});
}
if app.watch && app.cwd.is_none() {
return Err(NormalizeError::WatchWithoutCwd { name: app.name });
}
if app.watch_delay == Some(UpDuration::from_millis(0)) {
return Err(NormalizeError::ZeroWatchDelay { name: app.name });
}
validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
let mut seen = BTreeSet::new();
let mut deduped = Vec::with_capacity(app.depends_on.len());
for target in &app.depends_on {
if target == &app.name {
return Err(NormalizeError::SelfDependency(app.name.clone()));
}
if target.contains(':') {
return Err(NormalizeError::InstanceDependency {
sheep: app.name.clone(),
target: target.clone(),
});
}
if seen.insert(target.clone()) {
deduped.push(target.clone());
}
}
app.depends_on = deduped;
Ok(ResolvedApp { config: app })
}
fn validate_template(name: &str, field: &str, value: &str) -> Result<(), NormalizeError> {
crate::config::template::validate(value).map_err(|reason| NormalizeError::BadTemplate {
name: name.to_string(),
field: field.to_string(),
reason: reason.to_string(),
})
}
fn validate_watch_globs(
name: &str,
field: &'static str,
patterns: &[String],
) -> Result<(), NormalizeError> {
for pattern in patterns {
Glob::new(pattern).map_err(|err| NormalizeError::InvalidWatchGlob {
name: name.to_string(),
field,
pattern: pattern.clone(),
reason: err.to_string(),
})?;
}
Ok(())
}
fn validate_probe(
probe: Option<&ProbeConfig>,
name: &'static str,
min_interval: UpDuration,
) -> Result<(), NormalizeError> {
let Some(probe) = probe else {
return Ok(());
};
ProbeTarget::parse(probe).map_err(|reason| NormalizeError::InvalidProbe {
probe: name,
reason: reason.to_string(),
})?;
if probe.failure_threshold == 0 {
return Err(NormalizeError::ZeroFailureThreshold { probe: name });
}
if probe.interval < min_interval {
return Err(NormalizeError::IntervalBelowMinimum {
probe: name,
value: probe.interval,
min: min_interval,
});
}
Ok(())
}
pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
let mut seen = BTreeSet::new();
let resolved: Vec<ResolvedApp> = apps
.into_iter()
.map(|app| {
if !seen.insert(app.name.clone()) {
return Err(NormalizeError::DuplicateName(app.name));
}
normalize(app)
})
.collect::<Result<_, _>>()?;
let nodes: Vec<crate::config::graph::BootNode> = resolved
.iter()
.map(|app| crate::config::graph::BootNode {
name: app.config().name.clone(),
depends_on: app.config().depends_on.clone(),
kind: crate::config::graph::NodeKind::Sheep,
})
.collect();
if let Some(cycle) = crate::config::graph::plan(&nodes).cycles.into_iter().next() {
return Err(NormalizeError::DependencyCycle(cycle));
}
Ok(resolved)
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NormalizeError {
MissingName,
InvalidName(String),
InvalidEnvironment {
name: String,
value: String,
},
ReservedEnvVar {
name: String,
var: &'static str,
},
IncrementVarRemoved {
name: String,
var: String,
},
MissingScript,
ZeroInstances,
InvalidCron {
pattern: String,
reason: String,
},
InvalidTimezone {
name: String,
},
DuplicateName(String),
DependencyCycle(Vec<String>),
InvalidProbe {
probe: &'static str,
reason: String,
},
ZeroFailureThreshold {
probe: &'static str,
},
IntervalBelowMinimum {
probe: &'static str,
value: UpDuration,
min: UpDuration,
},
ZeroMaxMemory {
name: String,
},
ActionTimeoutTooLong {
name: String,
value: UpDuration,
max: UpDuration,
},
InvalidKillSignal {
name: String,
value: String,
},
WatchWithoutCwd {
name: String,
},
TildeUser {
name: String,
field: &'static str,
value: String,
},
NoHomeForTilde {
name: String,
field: &'static str,
},
ZeroWatchDelay {
name: String,
},
InvalidWatchGlob {
name: String,
field: &'static str,
pattern: String,
reason: String,
},
BadTemplate {
name: String,
field: String,
reason: String,
},
SecretInLogPath {
name: String,
field: &'static str,
},
SharedLogPath {
name: String,
field: &'static str,
},
SelfDependency(String),
InstanceDependency {
sheep: String,
target: String,
},
}
impl fmt::Display for NormalizeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingName => f.write_str("app config is missing a name"),
Self::InvalidName(n) => {
write!(
f,
"sheep name `{n}` may not contain a path separator or a colon, or be `.` or `..`; use `-` in place of a colon"
)
}
Self::InvalidEnvironment { name, value } => write!(
f,
"sheep `{name}` has environment = `{value}`: must be 1-128 bytes of \
`[A-Za-z0-9._-]` not starting with `.`, and not `{}` (the secrets \
store's every-environment slot)",
secrets::ALL_ENVIRONMENTS
),
Self::ReservedEnvVar { name, var } => write!(
f,
"sheep `{name}` sets `{var}` in env, but shep injects it: use a different name, or `{{{{instance}}}}` in your own variable"
),
Self::IncrementVarRemoved { name, var } => write!(
f,
"sheep `{name}` sets `increment_var`, which was removed: write `{var} = \"{{{{instance}}}}\"` under `[app.env]` instead"
),
Self::MissingScript => f.write_str("app config is missing a script"),
Self::ZeroInstances => f.write_str("instances must be at least 1"),
Self::InvalidCron { pattern, reason } => {
write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
}
Self::InvalidTimezone { name } => {
write!(f, "`{name}` is not a recognized IANA timezone")
}
Self::DuplicateName(n) => write!(f, "duplicate sheep name `{n}`"),
Self::DependencyCycle(cycle) => write!(
f,
"dependency cycle: {}",
crate::config::graph::render_cycle(cycle)
),
Self::InvalidProbe { probe, reason } => write!(f, "{probe}: {reason}"),
Self::ZeroFailureThreshold { probe } => {
write!(f, "{probe}.failure_threshold must be at least 1")
}
Self::IntervalBelowMinimum { probe, value, min } => {
write!(f, "{probe}.interval is `{value}`: must be at least {min}")
}
Self::ZeroMaxMemory { name } => {
write!(
f,
"sheep `{name}` has max_memory = 0, a limit nothing can stay under"
)
}
Self::ActionTimeoutTooLong { name, value, max } => {
write!(
f,
"sheep `{name}` has action_timeout = {value}: must be at most {max}, \
the longest wait any caller's deadline could ever cover"
)
}
Self::InvalidKillSignal { name, value } => {
write!(
f,
"`{name}`: kill_signal `{value}` is not one shep can send (accepted: {})",
KillSignal::ACCEPTED.join(", ")
)
}
Self::TildeUser { name, field, value } => write!(
f,
"`{name}`: {field} is `{value}`, and shep expands only `~/` (your own home). \
Another user's home needs a passwd lookup whose answer depends on who the \
daemon runs as, so write the path out in full instead."
),
Self::NoHomeForTilde { name, field } => write!(
f,
"`{name}`: {field} begins with `~/` but no home directory could be found. \
Set $HOME, or write the path out in full."
),
Self::WatchWithoutCwd { name } => {
write!(f, "sheep `{name}` has watch = true but no cwd to watch")
}
Self::ZeroWatchDelay { name } => {
write!(
f,
"sheep `{name}` has watch_delay = 0: must be greater than 0"
)
}
Self::InvalidWatchGlob {
name,
field,
pattern,
reason,
} => write!(
f,
"sheep `{name}` has an invalid {field} pattern `{pattern}`: {reason}"
),
Self::BadTemplate {
name,
field,
reason,
} => write!(f, "sheep `{name}`, {field}: {reason}"),
Self::SecretInLogPath { name, field } => write!(
f,
"sheep `{name}` puts a `{{{{secret:...}}}}` in `{field}`: a log path may not hold a secret, since it becomes a filename on disk and is reported by `shep flock` and `shep describe`"
),
Self::SharedLogPath { name, field } => write!(
f,
"sheep `{name}` runs several instances and sets `{field}` to one path: put `{{{{instance}}}}` in it, or set `merge_logs = true` to share it on purpose"
),
Self::SelfDependency(n) => {
write!(f, "`{n}` names itself in depends_on")
}
Self::InstanceDependency { sheep, target } => {
let app = target.split(':').next().unwrap_or(target);
write!(
f,
"`{sheep}` depends on `{target}`, which names one instance. \
Depend on `{app}` instead: a dependency waits for every \
instance of an app"
)
}
}
}
}
impl core::error::Error for NormalizeError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_path_field_expands_a_leading_tilde() {
let home = Path::new("/home/ada");
let mut app = AppConfig::minimal("web", "~/app/server.js");
app.cwd = Some("~/app".to_string());
app.out_file = Some("~/logs/out.log".to_string());
app.err_file = Some("~/logs/err.log".to_string());
let resolved = normalize_with_home(app, Some(home)).expect("all four expand");
let c = resolved.config();
let expect = |rest: &str| home.join(rest).to_string_lossy().into_owned();
assert_eq!(c.script, expect("app/server.js"));
assert_eq!(c.cwd.as_deref(), Some(expect("app").as_str()));
assert_eq!(c.out_file.as_deref(), Some(expect("logs/out.log").as_str()));
assert_eq!(c.err_file.as_deref(), Some(expect("logs/err.log").as_str()));
}
#[test]
fn the_path_field_list_matches_what_expand_paths_walks() {
let home = Path::new("/home/ada");
let mut app = AppConfig::minimal("web", "~/s");
app.cwd = Some("~/c".to_string());
app.out_file = Some("~/o".to_string());
app.err_file = Some("~/e".to_string());
let resolved = normalize_with_home(app, Some(home)).expect("expands");
let c = resolved.config();
let expanded = [
("script", Some(c.script.as_str())),
("cwd", c.cwd.as_deref()),
("out_file", c.out_file.as_deref()),
("err_file", c.err_file.as_deref()),
];
assert_eq!(
expanded.len(),
PATH_FIELDS.len(),
"PATH_FIELDS and this test must name the same set"
);
for (field, value) in expanded {
assert!(
PATH_FIELDS.contains(&field),
"`{field}` is not in PATH_FIELDS"
);
assert!(
value.is_some_and(|v| v.starts_with("/home/ada")),
"`{field}` was not expanded: {value:?}"
);
}
}
#[test]
fn a_path_without_a_tilde_is_left_exactly_as_written() {
let app = AppConfig::minimal("web", "./server.js");
let resolved =
normalize_with_home(app, Some(Path::new("/home/ada"))).expect("no tilde, no change");
assert_eq!(resolved.config().script, "./server.js");
}
#[test]
fn another_users_home_is_refused_rather_than_resolved() {
let app = AppConfig::minimal("web", "~deploy/app/server.js");
let err = normalize_with_home(app, Some(Path::new("/home/ada")))
.expect_err("~user/ must not resolve");
assert!(
matches!(err, NormalizeError::TildeUser { field, .. } if field == "script"),
"the refusal names the field: {err:?}"
);
let rendered = err.to_string();
assert!(
rendered.contains("~/"),
"and says what IS supported: {rendered}"
);
assert!(
!rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
"no em or en dash in copy a user reads: {rendered}"
);
}
#[test]
fn a_dollar_variable_is_not_expanded() {
let app = AppConfig::minimal("web", "$HOME/server.js");
let resolved = normalize_with_home(app, Some(Path::new("/home/ada"))).expect("left alone");
assert_eq!(resolved.config().script, "$HOME/server.js");
}
#[test]
fn a_tilde_with_no_home_is_an_error_not_a_literal_path() {
let app = AppConfig::minimal("web", "~/server.js");
let err = normalize_with_home(app, None).expect_err("nothing to expand against");
assert!(
matches!(err, NormalizeError::NoHomeForTilde { .. }),
"{err:?}"
);
}
#[test]
fn expand_home_tilde_covers_its_four_documented_cases() {
let home = Path::new("/home/ada");
assert_eq!(
expand_home_tilde("~/bin/dog", Some(home)).unwrap(),
home.join("bin/dog").to_string_lossy()
);
assert_eq!(
expand_home_tilde("/opt/bin/dog", Some(home)).unwrap(),
"/opt/bin/dog",
"a value with no leading ~ is returned unchanged"
);
assert_eq!(
expand_home_tilde("~/bin/dog", None).unwrap_err(),
TildeError::NoHome
);
assert_eq!(
expand_home_tilde("~deploy/bin/dog", Some(home)).unwrap_err(),
TildeError::OtherUser
);
}
#[test]
fn reuse_port_loads_now_that_reload_reads_it() {
let mut app = AppConfig::minimal("web", "./server");
app.reuse_port = true;
let resolved = normalize(app).expect("reuse_port is implemented");
assert!(resolved.config().reuse_port);
}
#[test]
fn an_app_that_never_mentions_reuse_port_still_normalizes() {
let resolved = normalize(AppConfig::minimal("web", "./server"))
.expect("the common case must be untouched");
assert!(!resolved.config().reuse_port);
}
use crate::config::AppConfig;
#[test]
fn a_colon_in_a_name_is_refused_because_it_is_the_instance_separator() {
let err = normalize(AppConfig::minimal("web:2", "./srv")).unwrap_err();
assert_eq!(err, NormalizeError::InvalidName("web:2".to_string()));
let rendered = err.to_string();
assert!(rendered.contains(':'), "says which character: {rendered}");
assert!(
rendered.contains("`-`"),
"suggests the stand-in: {rendered}"
);
assert!(
!rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
"no em or en dash in copy a user reads: {rendered}"
);
assert!(normalize(AppConfig::minimal("web-2", "./srv")).is_ok());
}
#[test]
fn increment_var_is_refused_and_says_what_replaced_it() {
let mut app = AppConfig::minimal("web", "./srv");
app.increment_var = Some("WORKER_ID".to_string());
let err = normalize(app).unwrap_err();
let rendered = err.to_string();
assert!(rendered.contains("increment_var"), "{rendered}");
assert!(
rendered.contains("WORKER_ID"),
"keeps their name: {rendered}"
);
assert!(rendered.contains("{{instance}}"), "and the fix: {rendered}");
assert!(
!rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
"no em or en dash in copy a user reads: {rendered}"
);
}
#[test]
fn the_reserved_env_vars_are_refused_rather_than_overwritten() {
for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
let mut app = AppConfig::minimal("web", "./srv");
app.env.insert(var.to_string(), "mine".to_string());
let err = normalize(app).unwrap_err();
let rendered = err.to_string();
assert!(rendered.contains(var), "names the variable: {rendered}");
assert!(
!rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
"no em or en dash in copy a user reads: {rendered}"
);
}
}
#[test]
fn valid_minimal_config_normalizes() {
let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
assert_eq!(resolved.config().name, "web");
}
#[test]
fn names_that_reach_the_filesystem_are_rejected() {
for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
assert_eq!(
normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
NormalizeError::InvalidName(bad.to_string())
);
}
assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
}
#[test]
fn missing_name_and_script_are_distinct_errors() {
assert_eq!(
normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
NormalizeError::MissingName
);
assert_eq!(
normalize(AppConfig::minimal("web", "")).unwrap_err(),
NormalizeError::MissingScript
);
}
#[test]
fn zero_instances_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.instances = 0;
assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
}
#[test]
fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
let mut app = AppConfig::minimal("web", "./srv");
app.cron_restart = Some("not a cron".to_string());
match normalize(app).unwrap_err() {
NormalizeError::InvalidCron { pattern, reason } => {
assert_eq!(pattern, "not a cron");
assert!(!reason.is_empty());
}
other => panic!("expected InvalidCron, got {other:?}"),
}
}
#[test]
fn five_tokens_of_garbage_cron_pattern_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.cron_restart = Some("99 99 99 99 99".to_string());
match normalize(app).unwrap_err() {
NormalizeError::InvalidCron { pattern, .. } => {
assert_eq!(pattern, "99 99 99 99 99");
}
other => panic!("expected InvalidCron, got {other:?}"),
}
}
#[test]
fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
let mut app = AppConfig::minimal("web", "./srv");
app.cron_restart = Some("0 3 * * *".to_string());
app.cron_timezone = Some("Mars/Olympus".to_string());
match normalize(app).unwrap_err() {
NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
other => panic!("expected InvalidTimezone, got {other:?}"),
}
}
#[test]
fn cron_timezone_validated_even_without_cron_restart() {
let mut app = AppConfig::minimal("web", "./srv");
app.cron_timezone = Some("Mars/Olympus".to_string());
match normalize(app).unwrap_err() {
NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
other => panic!("expected InvalidTimezone, got {other:?}"),
}
}
#[test]
fn duplicate_names_rejected_across_a_flock() {
let apps = vec![
AppConfig::minimal("web", "./a"),
AppConfig::minimal("web", "./b"),
];
assert_eq!(
normalize_all(apps).unwrap_err(),
NormalizeError::DuplicateName("web".to_string())
);
}
#[test]
fn a_cycle_inside_one_document_is_refused_and_named() {
let mut a = AppConfig::minimal("a", "./a");
a.depends_on = vec!["b".to_string()];
let mut b = AppConfig::minimal("b", "./b");
b.depends_on = vec!["a".to_string()];
match normalize_all(vec![a, b]) {
Err(NormalizeError::DependencyCycle(cycle)) => {
let rendered = NormalizeError::DependencyCycle(cycle).to_string();
assert!(rendered.contains("a"), "{rendered}");
assert!(rendered.contains("b"), "{rendered}");
assert!(
rendered.contains(" -> "),
"the path must be shown: {rendered}"
);
}
other => panic!("expected DependencyCycle, got {other:?}"),
}
}
#[test]
fn a_dependency_on_an_app_outside_the_document_is_accepted() {
let mut api = AppConfig::minimal("api", "./api");
api.depends_on = vec!["db-in-another-repo".to_string()];
normalize_all(vec![api]).expect("an unresolved name is not a document error");
}
fn probe_config(target: &str) -> crate::config::ProbeConfig {
crate::config::ProbeConfig {
kind: crate::config::ProbeKind::Http,
target: target.to_string(),
interval: crate::values::UpDuration::from_millis(10_000),
timeout: crate::values::UpDuration::from_millis(5_000),
failure_threshold: 3,
}
}
#[test]
fn malformed_readiness_probe_target_rejected_naming_the_field() {
let mut app = AppConfig::minimal("web", "./srv");
app.readiness_probe = Some(probe_config("not-a-url"));
match normalize(app).unwrap_err() {
NormalizeError::InvalidProbe { probe, reason } => {
assert_eq!(probe, "readiness_probe");
assert!(!reason.is_empty());
}
other => panic!("expected InvalidProbe, got {other:?}"),
}
}
#[test]
fn malformed_liveness_probe_target_rejected_naming_the_field() {
let mut app = AppConfig::minimal("web", "./srv");
app.liveness_probe = Some(probe_config("not-a-url"));
match normalize(app).unwrap_err() {
NormalizeError::InvalidProbe { probe, .. } => assert_eq!(probe, "liveness_probe"),
other => panic!("expected InvalidProbe, got {other:?}"),
}
}
#[test]
fn valid_probe_targets_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.readiness_probe = Some(probe_config("http://127.0.0.1:8080/healthz"));
assert!(normalize(app).is_ok());
}
#[test]
fn zero_failure_threshold_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
let mut probe = probe_config("http://127.0.0.1:8080/healthz");
probe.failure_threshold = 0;
app.readiness_probe = Some(probe);
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::ZeroFailureThreshold {
probe: "readiness_probe"
}
);
assert!(err.to_string().contains("at least 1"), "{err}");
}
#[test]
fn zero_interval_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
let mut probe = probe_config("http://127.0.0.1:8080/healthz");
probe.interval = UpDuration::from_millis(0);
app.readiness_probe = Some(probe);
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::IntervalBelowMinimum {
probe: "readiness_probe",
value: UpDuration::from_millis(0),
min: MIN_READINESS_INTERVAL,
}
);
assert!(err.to_string().contains("must be at least"), "{err}");
}
#[test]
fn a_liveness_interval_under_the_floor_is_rejected_rather_than_clamped() {
let mut app = AppConfig::minimal("web", "./srv");
let mut probe = probe_config("http://127.0.0.1:8080/healthz");
probe.interval = UpDuration::from_millis(500);
app.liveness_probe = Some(probe);
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::IntervalBelowMinimum {
probe: "liveness_probe",
value: UpDuration::from_millis(500),
min: MIN_LIVENESS_INTERVAL,
}
);
assert!(err.to_string().contains("500"), "{err}");
}
#[test]
fn a_liveness_interval_exactly_at_the_floor_is_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
let mut probe = probe_config("http://127.0.0.1:8080/healthz");
probe.interval = MIN_LIVENESS_INTERVAL;
app.liveness_probe = Some(probe);
assert!(normalize(app).is_ok());
}
#[test]
fn a_sub_second_readiness_interval_is_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
let mut probe = probe_config("http://127.0.0.1:8080/healthz");
probe.interval = UpDuration::from_millis(50);
app.readiness_probe = Some(probe);
assert!(normalize(app).is_ok());
}
#[test]
fn zero_max_memory_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.max_memory = Some(crate::values::MemSize::from_bytes(0));
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::ZeroMaxMemory {
name: "web".to_string()
}
);
assert!(err.to_string().contains("max_memory"), "{err}");
}
#[test]
fn a_nonzero_max_memory_is_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.max_memory = Some("512M".parse().unwrap());
assert!(normalize(app).is_ok());
}
#[test]
fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
let mut app = AppConfig::minimal("web", "./srv");
app.kill_signal = Some("SIGUSR1".to_string());
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::InvalidKillSignal {
name: "web".to_string(),
value: "SIGUSR1".to_string(),
}
);
let rendered = err.to_string();
assert!(rendered.contains("SIGUSR1"), "{rendered}");
assert!(rendered.contains("SIGTERM"), "{rendered}");
assert!(rendered.contains("SIGUSR2"), "{rendered}");
}
#[test]
fn every_spelling_the_daemon_already_accepted_still_normalizes() {
for name in [
"SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
"USR2", "sigusr2",
] {
let mut app = AppConfig::minimal("web", "./srv");
app.kill_signal = Some(name.to_string());
assert!(
normalize(app).is_ok(),
"`{name}` was accepted before this task and must still be"
);
}
}
#[test]
fn an_unset_kill_signal_is_not_a_config_error() {
let app = AppConfig::minimal("web", "./srv");
assert!(app.kill_signal.is_none());
assert!(normalize(app).is_ok());
}
#[test]
fn a_sheep_cannot_claim_the_all_environment() {
let mut app = AppConfig::minimal("web", "./srv");
app.environment = Some("all".to_string());
let err = normalize(app).unwrap_err();
assert!(err.to_string().contains("all"), "{err}");
}
#[test]
fn an_environment_outside_the_grammar_is_refused() {
for bad in ["", "has space", "has/slash"] {
let mut app = AppConfig::minimal("web", "./srv");
app.environment = Some(bad.to_string());
assert!(normalize(app).is_err(), "{bad:?} must be refused");
}
}
#[test]
fn action_timeout_past_the_ceiling_is_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::ActionTimeoutTooLong {
name: "web".to_string(),
value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
max: MAX_ACTION_TIMEOUT,
}
);
assert!(err.to_string().contains("action_timeout"), "{err}");
}
#[test]
fn action_timeout_at_the_ceiling_is_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.action_timeout = MAX_ACTION_TIMEOUT;
assert!(normalize(app).is_ok());
}
#[test]
fn the_default_action_timeout_is_accepted() {
assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
}
#[test]
fn zero_watch_delay_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
app.watch_delay = Some(UpDuration::from_millis(0));
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::ZeroWatchDelay {
name: "web".to_string()
}
);
assert!(err.to_string().contains("watch_delay"), "{err}");
}
#[test]
fn a_zero_watch_delay_is_rejected_with_watch_off() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch_delay = Some(UpDuration::from_millis(0));
assert!(matches!(
normalize(app).unwrap_err(),
NormalizeError::ZeroWatchDelay { .. }
));
}
#[test]
fn a_nonzero_watch_delay_is_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
app.watch_delay = Some(UpDuration::from_millis(1));
assert!(normalize(app).is_ok());
}
#[test]
fn default_failure_threshold_from_toml_accepted() {
let src = r#"
name = "web"
script = "./srv"
[readiness_probe]
kind = "http"
target = "http://127.0.0.1:8080/healthz"
"#;
let app: AppConfig = toml::from_str(src).unwrap();
assert!(normalize(app).is_ok());
}
#[test]
fn watch_true_without_cwd_rejected_naming_the_app() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::WatchWithoutCwd {
name: "web".to_string()
}
);
assert!(err.to_string().contains("no cwd to watch"), "{err}");
}
#[test]
fn watch_true_with_cwd_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
assert!(normalize(app).is_ok());
}
#[test]
fn a_watch_options_glob_that_will_not_compile_is_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
app.watch_options = vec!["src/**".to_string(), "[".to_string()];
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::InvalidWatchGlob {
name: "web".to_string(),
field: "watch_options",
pattern: "[".to_string(),
reason: Glob::new("[").unwrap_err().to_string(),
}
);
let rendered = err.to_string();
for expected in ["web", "watch_options", "`[`"] {
assert!(
rendered.contains(expected),
"{expected} missing: {rendered}"
);
}
}
#[test]
fn an_ignore_watch_glob_that_will_not_compile_is_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
app.ignore_watch = vec!["[".to_string()];
match normalize(app).unwrap_err() {
NormalizeError::InvalidWatchGlob { field, pattern, .. } => {
assert_eq!(field, "ignore_watch");
assert_eq!(pattern, "[");
}
other => panic!("expected InvalidWatchGlob, got {other:?}"),
}
}
#[test]
fn a_glob_that_will_not_compile_is_rejected_with_watch_off() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch_options = vec!["[".to_string()];
assert!(matches!(
normalize(app).unwrap_err(),
NormalizeError::InvalidWatchGlob { .. }
));
}
#[test]
fn well_formed_watch_globs_are_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
app.watch_options = vec!["src/**/*.rs".to_string(), "*.[ch]".to_string()];
app.ignore_watch = vec!["target/**".to_string(), "**/[!.]*.{tmp,swp}".to_string()];
assert!(normalize(app).is_ok());
}
#[test]
fn a_typo_in_an_env_template_is_refused_and_names_the_field() {
let mut app = AppConfig::minimal("web", "./srv");
app.env
.insert("WORKER".to_string(), "w-{{instnace}}".to_string());
let err = normalize(app).unwrap_err();
let rendered = err.to_string();
assert!(rendered.contains("instnace"), "names the typo: {rendered}");
assert!(rendered.contains("WORKER"), "and the field: {rendered}");
}
#[test]
fn a_typo_in_an_arg_template_is_refused_too() {
let mut app = AppConfig::minimal("web", "./srv");
app.args = vec!["--port".to_string(), "91{{slot}}".to_string()];
let err = normalize(app).unwrap_err();
assert!(err.to_string().contains("slot"), "{err}");
}
#[test]
fn an_explicit_log_path_shared_by_every_instance_is_refused() {
let mut app = AppConfig::minimal("web", "./srv");
app.instances = 3;
app.out_file = Some("/var/log/web.log".to_string());
let err = normalize(app).unwrap_err();
let rendered = err.to_string();
assert!(rendered.contains("out_file"), "names the field: {rendered}");
assert!(
rendered.contains("{{instance}}") && rendered.contains("merge_logs"),
"and both ways out: {rendered}"
);
assert!(
!rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
"no em or en dash in copy a user reads: {rendered}"
);
}
#[test]
fn the_three_ways_out_of_the_shared_log_refusal_all_work() {
let mut templated = AppConfig::minimal("web", "./srv");
templated.instances = 3;
templated.out_file = Some("/var/log/web-{{instance}}.log".to_string());
assert!(normalize(templated).is_ok());
let mut merged = AppConfig::minimal("web", "./srv");
merged.instances = 3;
merged.out_file = Some("/var/log/web.log".to_string());
merged.merge_logs = true;
assert!(normalize(merged).is_ok());
let mut single = AppConfig::minimal("web", "./srv");
single.out_file = Some("/var/log/web.log".to_string());
assert!(normalize(single).is_ok());
}
#[test]
fn an_escaped_template_in_a_log_path_does_not_satisfy_the_refusal() {
let mut app = AppConfig::minimal("web", "./srv");
app.instances = 3;
app.out_file = Some("/var/log/web-{{{{instance}}}}.log".to_string());
assert!(normalize(app).is_err());
}
#[test]
fn a_name_only_template_does_not_resolve_the_collision() {
let mut app = AppConfig::minimal("web", "./srv");
app.instances = 3;
app.out_file = Some("/var/log/{{name}}.log".to_string());
assert!(normalize(app).is_err());
}
#[test]
fn a_secret_in_a_log_path_is_refused() {
for field in ["out_file", "err_file"] {
let mut app = AppConfig::minimal("web", "./srv");
let path = Some("/var/log/{{secret:TOKEN}}.log".to_string());
if field == "out_file" {
app.out_file = path;
} else {
app.err_file = path;
}
match normalize(app).unwrap_err() {
NormalizeError::SecretInLogPath { name, field: got } => {
assert_eq!(name, "web");
assert_eq!(got, field);
}
other => panic!("expected SecretInLogPath for {field}, got {other:?}"),
}
}
}
#[test]
fn the_positional_tokens_still_render_in_a_log_path() {
let mut app = AppConfig::minimal("web", "./srv");
app.instances = 2;
app.out_file = Some("/var/log/{{name}}-{{instance}}.out".to_string());
app.err_file = Some("/var/log/{{name}}-{{instance}}.err".to_string());
assert!(normalize(app).is_ok());
}
#[test]
fn a_namespaced_secret_in_a_log_path_is_refused_too() {
let mut app = AppConfig::minimal("web", "./srv");
app.err_file = Some("/var/log/{{secret:vercel/TOKEN}}.log".to_string());
assert!(matches!(
normalize(app).unwrap_err(),
NormalizeError::SecretInLogPath { .. }
));
}
#[test]
fn a_bad_template_in_a_log_path_is_reported_as_bad_template_not_shared_path() {
let mut app = AppConfig::minimal("web", "./srv");
app.instances = 3;
app.out_file = Some("/var/log/web-{{instnace}}.log".to_string());
match normalize(app).unwrap_err() {
NormalizeError::BadTemplate { field, reason, .. } => {
assert_eq!(field, "out_file");
assert!(reason.contains("instnace"), "{reason}");
}
other => panic!("expected BadTemplate, got {other:?}"),
}
}
#[test]
fn watch_options_without_watch_or_cwd_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch_options = vec!["src/**".to_string()];
assert!(normalize(app).is_ok());
}
#[test]
fn a_sheep_that_depends_on_itself_is_refused_by_name() {
let mut app = AppConfig::minimal("api", "./api");
app.depends_on = vec!["api".to_string()];
match normalize(app) {
Err(NormalizeError::SelfDependency(name)) => assert_eq!(name, "api"),
other => panic!("expected SelfDependency, got {other:?}"),
}
}
#[test]
fn a_dependency_on_one_instance_is_refused_naming_the_app_form() {
let mut app = AppConfig::minimal("api", "./api");
app.depends_on = vec!["db:2".to_string()];
match normalize(app) {
Err(NormalizeError::InstanceDependency { sheep, target }) => {
assert_eq!(sheep, "api");
assert_eq!(target, "db:2");
}
other => panic!("expected InstanceDependency, got {other:?}"),
}
let rendered = NormalizeError::InstanceDependency {
sheep: "api".to_string(),
target: "db:2".to_string(),
}
.to_string();
assert!(
rendered.contains("`db`"),
"the refusal must name the app-level form: {rendered}"
);
}
#[test]
fn duplicate_dependencies_dedupe_rather_than_refusing() {
let mut app = AppConfig::minimal("api", "./api");
app.depends_on = vec!["db".to_string(), "db".to_string()];
let resolved = normalize(app).expect("a repeated name is not an error");
assert_eq!(resolved.config().depends_on, vec!["db".to_string()]);
}
#[test]
fn an_ordinary_dependency_list_survives_normalize() {
let mut app = AppConfig::minimal("api", "./api");
app.depends_on = vec!["db".to_string(), "cache".to_string()];
let resolved = normalize(app).expect("an ordinary list normalizes");
assert_eq!(
resolved.config().depends_on,
vec!["db".to_string(), "cache".to_string()]
);
}
}