use std::collections::BTreeSet;
use std::path::Path;
use crate::config::{AppConfig, CronParseError, CronSchedule, KillSignal, LevelMatcher};
use crate::secrets;
use crate::values::UpDuration;
use super::{NormalizeError, tilde, validate};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedApp {
pub(super) config: AppConfig,
}
impl ResolvedApp {
#[must_use]
pub fn config(&self) -> &AppConfig {
&self.config
}
#[must_use]
pub fn into_config(self) -> AppConfig {
self.config
}
}
pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
let home = std::env::home_dir();
let shep_home = crate::paths::shep_home(&|key| std::env::var(key).ok(), home.as_deref());
normalize_with_home(app, home.as_deref(), shep_home.as_deref())
}
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)
}
pub(super) const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
pub(super) const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
pub fn normalize_with_home(
mut app: AppConfig,
home: Option<&Path>,
shep_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,
});
}
}
for (key, value) in &app.env {
validate::validate_template(&app.name, &format!("env.{key}"), value, shep_home)?;
}
for (index, value) in app.args.iter().enumerate() {
validate::validate_template(&app.name, &format!("args[{index}]"), value, shep_home)?;
}
for (field, value) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
if let Some(value) = value {
validate::validate_template(&app.name, field, value, shep_home)?;
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);
}
tilde::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, shep_home)
== crate::config::template::render_positional(path, &app.name, 1, shep_home)
{
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::validate_probe(
app.readiness_probe.as_ref(),
"readiness_probe",
MIN_READINESS_INTERVAL,
)?;
validate::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::validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
validate::validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
LevelMatcher::compile(&app.level_rules).map_err(|err| NormalizeError::InvalidLevelRule {
name: app.name.clone(),
reason: err.to_string(),
})?;
if app.dogs.keys().any(String::is_empty) {
return Err(NormalizeError::EmptyDogName { name: app.name });
}
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 })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{LevelRule, LevelRuleError, LineLevel};
#[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 the_reserved_env_vars_are_refused_rather_than_overwritten() {
let advice = [
("SHEP_INSTANCE", "or `{{instance}}` in your own variable"),
("SHEP_NAME", "or `{{name}}` in your own variable"),
("SHEP_ENVIRONMENT", "or set `environment` on the app"),
];
for (var, fitting) in advice {
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(fitting),
"{var} gets advice that fits it: {rendered}"
);
for (owner, unrelated) in advice {
assert!(
owner == var || !rendered.contains(unrelated),
"{var} carries {owner}'s advice: {rendered}"
);
}
assert_eq!(
rendered.contains("{{"),
fitting.contains("{{"),
"{var} offers a template only if its own advice does: {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");
}
#[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 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 level_rules_survive_normalization_in_the_order_they_were_written() {
let mut app = AppConfig::minimal("web", "./srv");
app.level_rules = vec![
LevelRule {
pattern: r"\[ERROR\]".to_string(),
level: LineLevel::Error,
},
LevelRule {
pattern: r"\[WARN\]".to_string(),
level: LineLevel::Warn,
},
];
let resolved = normalize(app.clone()).unwrap();
assert_eq!(resolved.config().level_rules, app.level_rules);
}
#[test]
fn a_level_rule_pattern_that_will_not_compile_is_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.level_rules = vec![LevelRule {
pattern: "[unclosed".to_string(),
level: LineLevel::Error,
}];
let err = normalize(app).unwrap_err();
let rendered = err.to_string();
for expected in ["web", "[unclosed"] {
assert!(
rendered.contains(expected),
"{expected} missing: {rendered}"
);
}
}
#[test]
fn an_empty_level_rule_pattern_is_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.level_rules = vec![LevelRule {
pattern: String::new(),
level: LineLevel::Warn,
}];
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::InvalidLevelRule {
name: "web".to_string(),
reason: LevelRuleError::EmptyPattern {
level: LineLevel::Warn
}
.to_string(),
}
);
}
#[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()]
);
}
#[test]
fn an_empty_dog_name_is_refused_naming_the_sheep() {
let mut app = AppConfig::minimal("web", "./srv");
app.dogs.insert(
String::new(),
crate::config::DogTable::from(serde_json::Map::new()),
);
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::EmptyDogName {
name: "web".to_string()
}
);
assert!(err.to_string().contains("web"));
}
#[test]
fn a_named_dog_table_survives_normalize() {
let mut app = AppConfig::minimal("web", "./srv");
let mut table = serde_json::Map::new();
table.insert("concurrency".to_string(), serde_json::json!(2));
app.dogs
.insert("jobs".to_string(), crate::config::DogTable::from(table));
let resolved = normalize(app).expect("a named dog table normalizes");
assert!(resolved.config().dogs.contains_key("jobs"));
}
}