1use core::fmt;
7
8use std::path::Path;
9
10use std::collections::BTreeSet;
11
12use globset::Glob;
13
14use crate::config::{
15 AppConfig, CronParseError, CronSchedule, KillSignal, ProbeConfig, ProbeTarget,
16};
17use crate::values::UpDuration;
18
19const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
29
30const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
37
38const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ResolvedApp {
50 config: AppConfig,
51}
52
53impl ResolvedApp {
54 #[must_use]
56 pub fn config(&self) -> &AppConfig {
57 &self.config
58 }
59
60 #[must_use]
62 pub fn into_config(self) -> AppConfig {
63 self.config
64 }
65}
66
67fn expand_tilde(
80 value: &str,
81 home: Option<&Path>,
82 name: &str,
83 field: &'static str,
84) -> Result<String, NormalizeError> {
85 expand_home_tilde(value, home).map_err(|err| match err {
86 TildeError::OtherUser => NormalizeError::TildeUser {
87 name: name.to_string(),
88 field,
89 value: value.to_string(),
90 },
91 TildeError::NoHome => NormalizeError::NoHomeForTilde {
92 name: name.to_string(),
93 field,
94 },
95 })
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum TildeError {
102 OtherUser,
107 NoHome,
109}
110
111impl fmt::Display for TildeError {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 match self {
114 Self::OtherUser => write!(
115 f,
116 "shep expands only `~/` (your own home); another user's home needs a \
117 passwd lookup whose answer depends on who the process runs as"
118 ),
119 Self::NoHome => write!(f, "begins with `~/` but no home directory could be found"),
120 }
121 }
122}
123
124impl core::error::Error for TildeError {}
125
126pub fn expand_home_tilde(value: &str, home: Option<&Path>) -> Result<String, TildeError> {
137 let Some(rest) = value.strip_prefix('~') else {
138 return Ok(value.to_string());
139 };
140 if !(rest.is_empty() || rest.starts_with('/')) {
142 return Err(TildeError::OtherUser);
143 }
144 let Some(home) = home else {
145 return Err(TildeError::NoHome);
146 };
147 let joined = home.join(rest.trim_start_matches('/'));
150 Ok(joined.to_string_lossy().into_owned())
151}
152
153#[cfg(test)]
161const PATH_FIELDS: &[&str] = &["script", "cwd", "out_file", "err_file"];
162
163fn expand_paths(app: &mut AppConfig, home: Option<&Path>) -> Result<(), NormalizeError> {
168 let name = app.name.clone();
169 app.script = expand_tilde(&app.script, home, &name, "script")?;
170 for (field, slot) in [
171 ("cwd", &mut app.cwd),
172 ("out_file", &mut app.out_file),
173 ("err_file", &mut app.err_file),
174 ] {
175 if let Some(value) = slot {
176 *slot = Some(expand_tilde(value, home, &name, field)?);
177 }
178 }
179 Ok(())
180}
181
182pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
210 normalize_with_home(app, std::env::home_dir().as_deref())
211}
212
213pub fn normalize_with_home(
224 mut app: AppConfig,
225 home: Option<&Path>,
226) -> Result<ResolvedApp, NormalizeError> {
227 if app.name.is_empty() {
228 return Err(NormalizeError::MissingName);
229 }
230 if app.name.contains(['/', '\\', ':']) || app.name == "." || app.name == ".." {
231 return Err(NormalizeError::InvalidName(app.name));
232 }
233 for var in ["SHEP_INSTANCE", "SHEP_NAME"] {
234 if app.env.contains_key(var) {
235 return Err(NormalizeError::ReservedEnvVar {
236 name: app.name.clone(),
237 var,
238 });
239 }
240 }
241 if let Some(var) = app.increment_var.take() {
242 return Err(NormalizeError::IncrementVarRemoved {
243 name: app.name.clone(),
244 var,
245 });
246 }
247 for (key, value) in &app.env {
248 validate_template(&app.name, &format!("env.{key}"), value)?;
249 }
250 for (index, value) in app.args.iter().enumerate() {
251 validate_template(&app.name, &format!("args[{index}]"), value)?;
252 }
253 for (field, value) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
254 if let Some(value) = value {
255 validate_template(&app.name, field, value)?;
256 }
257 }
258 if app.script.is_empty() {
259 return Err(NormalizeError::MissingScript);
260 }
261 expand_paths(&mut app, home)?;
265 if app.instances == 0 {
266 return Err(NormalizeError::ZeroInstances);
267 }
268 if app.instances > 1 && !app.merge_logs {
271 for (field, path) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
272 if let Some(path) = path
277 && crate::config::template::render(path, &app.name, 0)
278 == crate::config::template::render(path, &app.name, 1)
279 {
280 return Err(NormalizeError::SharedLogPath {
281 name: app.name.clone(),
282 field,
283 });
284 }
285 }
286 }
287 if let Some(pattern) = &app.cron_restart {
288 CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
289 CronParseError::Pattern { pattern, reason } => {
290 NormalizeError::InvalidCron { pattern, reason }
291 }
292 CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
293 })?;
294 } else if let Some(tz_name) = &app.cron_timezone {
295 crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
298 NormalizeError::InvalidTimezone {
299 name: tz_name.clone(),
300 }
301 })?;
302 }
303 validate_probe(
304 app.readiness_probe.as_ref(),
305 "readiness_probe",
306 MIN_READINESS_INTERVAL,
307 )?;
308 validate_probe(
309 app.liveness_probe.as_ref(),
310 "liveness_probe",
311 MIN_LIVENESS_INTERVAL,
312 )?;
313 if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
314 return Err(NormalizeError::ZeroMaxMemory { name: app.name });
319 }
320 if let Some(name) = &app.kill_signal
321 && KillSignal::parse(name).is_none()
322 {
323 return Err(NormalizeError::InvalidKillSignal {
327 name: app.name,
328 value: name.clone(),
329 });
330 }
331 if app.action_timeout > MAX_ACTION_TIMEOUT {
332 return Err(NormalizeError::ActionTimeoutTooLong {
336 name: app.name,
337 value: app.action_timeout,
338 max: MAX_ACTION_TIMEOUT,
339 });
340 }
341 if app.watch && app.cwd.is_none() {
342 return Err(NormalizeError::WatchWithoutCwd { name: app.name });
347 }
348 if app.watch_delay == Some(UpDuration::from_millis(0)) {
349 return Err(NormalizeError::ZeroWatchDelay { name: app.name });
353 }
354 validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
358 validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
359 let mut seen = BTreeSet::new();
360 let mut deduped = Vec::with_capacity(app.depends_on.len());
361 for target in &app.depends_on {
362 if target == &app.name {
363 return Err(NormalizeError::SelfDependency(app.name.clone()));
364 }
365 if target.contains(':') {
366 return Err(NormalizeError::InstanceDependency {
367 sheep: app.name.clone(),
368 target: target.clone(),
369 });
370 }
371 if seen.insert(target.clone()) {
372 deduped.push(target.clone());
373 }
374 }
375 app.depends_on = deduped;
376 Ok(ResolvedApp { config: app })
377}
378
379fn validate_template(name: &str, field: &str, value: &str) -> Result<(), NormalizeError> {
389 crate::config::template::validate(value).map_err(|reason| NormalizeError::BadTemplate {
390 name: name.to_string(),
391 field: field.to_string(),
392 reason: reason.to_string(),
393 })
394}
395
396fn validate_watch_globs(
403 name: &str,
404 field: &'static str,
405 patterns: &[String],
406) -> Result<(), NormalizeError> {
407 for pattern in patterns {
408 Glob::new(pattern).map_err(|err| NormalizeError::InvalidWatchGlob {
409 name: name.to_string(),
410 field,
411 pattern: pattern.clone(),
412 reason: err.to_string(),
413 })?;
414 }
415 Ok(())
416}
417
418fn validate_probe(
425 probe: Option<&ProbeConfig>,
426 name: &'static str,
427 min_interval: UpDuration,
428) -> Result<(), NormalizeError> {
429 let Some(probe) = probe else {
430 return Ok(());
431 };
432 ProbeTarget::parse(probe).map_err(|reason| NormalizeError::InvalidProbe {
433 probe: name,
434 reason: reason.to_string(),
435 })?;
436 if probe.failure_threshold == 0 {
437 return Err(NormalizeError::ZeroFailureThreshold { probe: name });
440 }
441 if probe.interval < min_interval {
442 return Err(NormalizeError::IntervalBelowMinimum {
447 probe: name,
448 value: probe.interval,
449 min: min_interval,
450 });
451 }
452 Ok(())
453}
454
455pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
463 let mut seen = BTreeSet::new();
464 let resolved: Vec<ResolvedApp> = apps
465 .into_iter()
466 .map(|app| {
467 if !seen.insert(app.name.clone()) {
468 return Err(NormalizeError::DuplicateName(app.name));
469 }
470 normalize(app)
471 })
472 .collect::<Result<_, _>>()?;
473 let nodes: Vec<crate::config::graph::BootNode> = resolved
476 .iter()
477 .map(|app| crate::config::graph::BootNode {
478 name: app.config().name.clone(),
479 depends_on: app.config().depends_on.clone(),
480 kind: crate::config::graph::NodeKind::Sheep,
481 })
482 .collect();
483 if let Some(cycle) = crate::config::graph::plan(&nodes).cycles.into_iter().next() {
484 return Err(NormalizeError::DependencyCycle(cycle));
485 }
486 Ok(resolved)
487}
488
489#[non_exhaustive]
494#[derive(Debug, Clone, PartialEq, Eq)]
495pub enum NormalizeError {
496 MissingName,
498 InvalidName(String),
504 ReservedEnvVar {
507 name: String,
509 var: &'static str,
511 },
512 IncrementVarRemoved {
516 name: String,
518 var: String,
520 },
521 MissingScript,
523 ZeroInstances,
525 InvalidCron {
528 pattern: String,
530 reason: String,
532 },
533 InvalidTimezone {
535 name: String,
537 },
538 DuplicateName(String),
540 DependencyCycle(Vec<String>),
544 InvalidProbe {
547 probe: &'static str,
550 reason: String,
552 },
553 ZeroFailureThreshold {
555 probe: &'static str,
558 },
559 IntervalBelowMinimum {
564 probe: &'static str,
567 value: UpDuration,
569 min: UpDuration,
571 },
572 ZeroMaxMemory {
576 name: String,
578 },
579 ActionTimeoutTooLong {
584 name: String,
586 value: UpDuration,
588 max: UpDuration,
590 },
591 InvalidKillSignal {
594 name: String,
596 value: String,
598 },
599 WatchWithoutCwd {
602 name: String,
604 },
605 TildeUser {
611 name: String,
613 field: &'static str,
615 value: String,
617 },
618 NoHomeForTilde {
620 name: String,
622 field: &'static str,
624 },
625 ZeroWatchDelay {
628 name: String,
630 },
631 InvalidWatchGlob {
634 name: String,
636 field: &'static str,
639 pattern: String,
641 reason: String,
643 },
644 BadTemplate {
648 name: String,
650 field: String,
652 reason: String,
655 },
656 SharedLogPath {
662 name: String,
664 field: &'static str,
666 },
667 SelfDependency(String),
671 InstanceDependency {
674 sheep: String,
676 target: String,
678 },
679}
680
681impl fmt::Display for NormalizeError {
682 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
683 match self {
684 Self::MissingName => f.write_str("app config is missing a name"),
685 Self::InvalidName(n) => {
686 write!(
687 f,
688 "sheep name `{n}` may not contain a path separator or a colon, or be `.` or `..`; use `-` in place of a colon"
689 )
690 }
691 Self::ReservedEnvVar { name, var } => write!(
692 f,
693 "sheep `{name}` sets `{var}` in env, but shep injects it: use a different name, or `{{{{instance}}}}` in your own variable"
694 ),
695 Self::IncrementVarRemoved { name, var } => write!(
696 f,
697 "sheep `{name}` sets `increment_var`, which was removed: write `{var} = \"{{{{instance}}}}\"` under `[app.env]` instead"
698 ),
699 Self::MissingScript => f.write_str("app config is missing a script"),
700 Self::ZeroInstances => f.write_str("instances must be at least 1"),
701 Self::InvalidCron { pattern, reason } => {
702 write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
703 }
704 Self::InvalidTimezone { name } => {
705 write!(f, "`{name}` is not a recognized IANA timezone")
706 }
707 Self::DuplicateName(n) => write!(f, "duplicate sheep name `{n}`"),
708 Self::DependencyCycle(cycle) => write!(
709 f,
710 "dependency cycle: {}",
711 crate::config::graph::render_cycle(cycle)
712 ),
713 Self::InvalidProbe { probe, reason } => write!(f, "{probe}: {reason}"),
714 Self::ZeroFailureThreshold { probe } => {
715 write!(f, "{probe}.failure_threshold must be at least 1")
716 }
717 Self::IntervalBelowMinimum { probe, value, min } => {
718 write!(f, "{probe}.interval is `{value}`: must be at least {min}")
719 }
720 Self::ZeroMaxMemory { name } => {
721 write!(
722 f,
723 "sheep `{name}` has max_memory = 0, a limit nothing can stay under"
724 )
725 }
726 Self::ActionTimeoutTooLong { name, value, max } => {
727 write!(
728 f,
729 "sheep `{name}` has action_timeout = {value}: must be at most {max}, \
730 the longest wait any caller's deadline could ever cover"
731 )
732 }
733 Self::InvalidKillSignal { name, value } => {
734 write!(
735 f,
736 "`{name}`: kill_signal `{value}` is not one shep can send (accepted: {})",
737 KillSignal::ACCEPTED.join(", ")
738 )
739 }
740 Self::TildeUser { name, field, value } => write!(
741 f,
742 "`{name}`: {field} is `{value}`, and shep expands only `~/` (your own home). \
743 Another user's home needs a passwd lookup whose answer depends on who the \
744 daemon runs as, so write the path out in full instead."
745 ),
746 Self::NoHomeForTilde { name, field } => write!(
747 f,
748 "`{name}`: {field} begins with `~/` but no home directory could be found. \
749 Set $HOME, or write the path out in full."
750 ),
751 Self::WatchWithoutCwd { name } => {
752 write!(f, "sheep `{name}` has watch = true but no cwd to watch")
753 }
754 Self::ZeroWatchDelay { name } => {
755 write!(
756 f,
757 "sheep `{name}` has watch_delay = 0: must be greater than 0"
758 )
759 }
760 Self::InvalidWatchGlob {
761 name,
762 field,
763 pattern,
764 reason,
765 } => write!(
766 f,
767 "sheep `{name}` has an invalid {field} pattern `{pattern}`: {reason}"
768 ),
769 Self::BadTemplate {
770 name,
771 field,
772 reason,
773 } => write!(f, "sheep `{name}`, {field}: {reason}"),
774 Self::SharedLogPath { name, field } => write!(
775 f,
776 "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"
777 ),
778 Self::SelfDependency(n) => {
779 write!(f, "`{n}` names itself in depends_on")
780 }
781 Self::InstanceDependency { sheep, target } => {
782 let app = target.split(':').next().unwrap_or(target);
783 write!(
784 f,
785 "`{sheep}` depends on `{target}`, which names one instance. \
786 Depend on `{app}` instead: a dependency waits for every \
787 instance of an app"
788 )
789 }
790 }
791 }
792}
793
794impl core::error::Error for NormalizeError {}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 #[test]
804 fn every_path_field_expands_a_leading_tilde() {
805 let home = Path::new("/home/ada");
806 let mut app = AppConfig::minimal("web", "~/app/server.js");
807 app.cwd = Some("~/app".to_string());
808 app.out_file = Some("~/logs/out.log".to_string());
809 app.err_file = Some("~/logs/err.log".to_string());
810
811 let resolved = normalize_with_home(app, Some(home)).expect("all four expand");
812 let c = resolved.config();
813 let expect = |rest: &str| home.join(rest).to_string_lossy().into_owned();
817 assert_eq!(c.script, expect("app/server.js"));
818 assert_eq!(c.cwd.as_deref(), Some(expect("app").as_str()));
819 assert_eq!(c.out_file.as_deref(), Some(expect("logs/out.log").as_str()));
820 assert_eq!(c.err_file.as_deref(), Some(expect("logs/err.log").as_str()));
821 }
822
823 #[test]
827 fn the_path_field_list_matches_what_expand_paths_walks() {
828 let home = Path::new("/home/ada");
829 let mut app = AppConfig::minimal("web", "~/s");
830 app.cwd = Some("~/c".to_string());
831 app.out_file = Some("~/o".to_string());
832 app.err_file = Some("~/e".to_string());
833
834 let resolved = normalize_with_home(app, Some(home)).expect("expands");
835 let c = resolved.config();
836 let expanded = [
837 ("script", Some(c.script.as_str())),
838 ("cwd", c.cwd.as_deref()),
839 ("out_file", c.out_file.as_deref()),
840 ("err_file", c.err_file.as_deref()),
841 ];
842 assert_eq!(
843 expanded.len(),
844 PATH_FIELDS.len(),
845 "PATH_FIELDS and this test must name the same set"
846 );
847 for (field, value) in expanded {
848 assert!(
849 PATH_FIELDS.contains(&field),
850 "`{field}` is not in PATH_FIELDS"
851 );
852 assert!(
853 value.is_some_and(|v| v.starts_with("/home/ada")),
854 "`{field}` was not expanded: {value:?}"
855 );
856 }
857 }
858
859 #[test]
862 fn a_path_without_a_tilde_is_left_exactly_as_written() {
863 let app = AppConfig::minimal("web", "./server.js");
864 let resolved =
865 normalize_with_home(app, Some(Path::new("/home/ada"))).expect("no tilde, no change");
866 assert_eq!(resolved.config().script, "./server.js");
867 }
868
869 #[test]
872 fn another_users_home_is_refused_rather_than_resolved() {
873 let app = AppConfig::minimal("web", "~deploy/app/server.js");
874 let err = normalize_with_home(app, Some(Path::new("/home/ada")))
875 .expect_err("~user/ must not resolve");
876 assert!(
877 matches!(err, NormalizeError::TildeUser { field, .. } if field == "script"),
878 "the refusal names the field: {err:?}"
879 );
880 let rendered = err.to_string();
881 assert!(
882 rendered.contains("~/"),
883 "and says what IS supported: {rendered}"
884 );
885 assert!(
886 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
887 "no em or en dash in copy a user reads: {rendered}"
888 );
889 }
890
891 #[test]
894 fn a_dollar_variable_is_not_expanded() {
895 let app = AppConfig::minimal("web", "$HOME/server.js");
896 let resolved = normalize_with_home(app, Some(Path::new("/home/ada"))).expect("left alone");
897 assert_eq!(resolved.config().script, "$HOME/server.js");
898 }
899
900 #[test]
903 fn a_tilde_with_no_home_is_an_error_not_a_literal_path() {
904 let app = AppConfig::minimal("web", "~/server.js");
905 let err = normalize_with_home(app, None).expect_err("nothing to expand against");
906 assert!(
907 matches!(err, NormalizeError::NoHomeForTilde { .. }),
908 "{err:?}"
909 );
910 }
911
912 #[test]
916 fn expand_home_tilde_covers_its_four_documented_cases() {
917 let home = Path::new("/home/ada");
918 assert_eq!(
919 expand_home_tilde("~/bin/dog", Some(home)).unwrap(),
920 home.join("bin/dog").to_string_lossy()
921 );
922 assert_eq!(
923 expand_home_tilde("/opt/bin/dog", Some(home)).unwrap(),
924 "/opt/bin/dog",
925 "a value with no leading ~ is returned unchanged"
926 );
927 assert_eq!(
928 expand_home_tilde("~/bin/dog", None).unwrap_err(),
929 TildeError::NoHome
930 );
931 assert_eq!(
932 expand_home_tilde("~deploy/bin/dog", Some(home)).unwrap_err(),
933 TildeError::OtherUser
934 );
935 }
936
937 #[test]
941 fn reuse_port_loads_now_that_reload_reads_it() {
942 let mut app = AppConfig::minimal("web", "./server");
943 app.reuse_port = true;
944
945 let resolved = normalize(app).expect("reuse_port is implemented");
946 assert!(resolved.config().reuse_port);
947 }
948
949 #[test]
952 fn an_app_that_never_mentions_reuse_port_still_normalizes() {
953 let resolved = normalize(AppConfig::minimal("web", "./server"))
954 .expect("the common case must be untouched");
955 assert!(!resolved.config().reuse_port);
956 }
957 use crate::config::AppConfig;
958
959 #[test]
960 fn a_colon_in_a_name_is_refused_because_it_is_the_instance_separator() {
961 let err = normalize(AppConfig::minimal("web:2", "./srv")).unwrap_err();
962 assert_eq!(err, NormalizeError::InvalidName("web:2".to_string()));
963
964 let rendered = err.to_string();
965 assert!(rendered.contains(':'), "says which character: {rendered}");
966 assert!(
968 rendered.contains("`-`"),
969 "suggests the stand-in: {rendered}"
970 );
971 assert!(
972 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
973 "no em or en dash in copy a user reads: {rendered}"
974 );
975
976 assert!(normalize(AppConfig::minimal("web-2", "./srv")).is_ok());
977 }
978
979 #[test]
980 fn increment_var_is_refused_and_says_what_replaced_it() {
981 let mut app = AppConfig::minimal("web", "./srv");
982 app.increment_var = Some("WORKER_ID".to_string());
983 let err = normalize(app).unwrap_err();
984 let rendered = err.to_string();
985 assert!(rendered.contains("increment_var"), "{rendered}");
986 assert!(
987 rendered.contains("WORKER_ID"),
988 "keeps their name: {rendered}"
989 );
990 assert!(rendered.contains("{{instance}}"), "and the fix: {rendered}");
991 assert!(
992 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
993 "no em or en dash in copy a user reads: {rendered}"
994 );
995 }
996
997 #[test]
998 fn the_reserved_env_vars_are_refused_rather_than_overwritten() {
999 for var in ["SHEP_INSTANCE", "SHEP_NAME"] {
1000 let mut app = AppConfig::minimal("web", "./srv");
1001 app.env.insert(var.to_string(), "mine".to_string());
1002 let err = normalize(app).unwrap_err();
1003 let rendered = err.to_string();
1004 assert!(rendered.contains(var), "names the variable: {rendered}");
1005 assert!(
1006 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1007 "no em or en dash in copy a user reads: {rendered}"
1008 );
1009 }
1010 }
1011
1012 #[test]
1013 fn valid_minimal_config_normalizes() {
1014 let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
1015 assert_eq!(resolved.config().name, "web");
1016 }
1017
1018 #[test]
1019 fn names_that_reach_the_filesystem_are_rejected() {
1020 for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
1023 assert_eq!(
1024 normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
1025 NormalizeError::InvalidName(bad.to_string())
1026 );
1027 }
1028 assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
1029 }
1030
1031 #[test]
1032 fn missing_name_and_script_are_distinct_errors() {
1033 assert_eq!(
1034 normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
1035 NormalizeError::MissingName
1036 );
1037 assert_eq!(
1038 normalize(AppConfig::minimal("web", "")).unwrap_err(),
1039 NormalizeError::MissingScript
1040 );
1041 }
1042
1043 #[test]
1044 fn zero_instances_rejected() {
1045 let mut app = AppConfig::minimal("web", "./srv");
1046 app.instances = 0;
1047 assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
1048 }
1049
1050 #[test]
1051 fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
1052 let mut app = AppConfig::minimal("web", "./srv");
1054 app.cron_restart = Some("not a cron".to_string());
1055 match normalize(app).unwrap_err() {
1056 NormalizeError::InvalidCron { pattern, reason } => {
1057 assert_eq!(pattern, "not a cron");
1058 assert!(!reason.is_empty());
1059 }
1060 other => panic!("expected InvalidCron, got {other:?}"),
1061 }
1062 }
1063
1064 #[test]
1065 fn five_tokens_of_garbage_cron_pattern_rejected() {
1066 let mut app = AppConfig::minimal("web", "./srv");
1070 app.cron_restart = Some("99 99 99 99 99".to_string());
1071 match normalize(app).unwrap_err() {
1072 NormalizeError::InvalidCron { pattern, .. } => {
1073 assert_eq!(pattern, "99 99 99 99 99");
1074 }
1075 other => panic!("expected InvalidCron, got {other:?}"),
1076 }
1077 }
1078
1079 #[test]
1080 fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
1081 let mut app = AppConfig::minimal("web", "./srv");
1086 app.cron_restart = Some("0 3 * * *".to_string());
1087 app.cron_timezone = Some("Mars/Olympus".to_string());
1088 match normalize(app).unwrap_err() {
1089 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
1090 other => panic!("expected InvalidTimezone, got {other:?}"),
1091 }
1092 }
1093
1094 #[test]
1095 fn cron_timezone_validated_even_without_cron_restart() {
1096 let mut app = AppConfig::minimal("web", "./srv");
1100 app.cron_timezone = Some("Mars/Olympus".to_string());
1101 match normalize(app).unwrap_err() {
1102 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
1103 other => panic!("expected InvalidTimezone, got {other:?}"),
1104 }
1105 }
1106
1107 #[test]
1108 fn duplicate_names_rejected_across_a_flock() {
1109 let apps = vec![
1110 AppConfig::minimal("web", "./a"),
1111 AppConfig::minimal("web", "./b"),
1112 ];
1113 assert_eq!(
1114 normalize_all(apps).unwrap_err(),
1115 NormalizeError::DuplicateName("web".to_string())
1116 );
1117 }
1118
1119 #[test]
1120 fn a_cycle_inside_one_document_is_refused_and_named() {
1121 let mut a = AppConfig::minimal("a", "./a");
1123 a.depends_on = vec!["b".to_string()];
1124 let mut b = AppConfig::minimal("b", "./b");
1125 b.depends_on = vec!["a".to_string()];
1126 match normalize_all(vec![a, b]) {
1127 Err(NormalizeError::DependencyCycle(cycle)) => {
1128 let rendered = NormalizeError::DependencyCycle(cycle).to_string();
1129 assert!(rendered.contains("a"), "{rendered}");
1130 assert!(rendered.contains("b"), "{rendered}");
1131 assert!(
1132 rendered.contains(" -> "),
1133 "the path must be shown: {rendered}"
1134 );
1135 }
1136 other => panic!("expected DependencyCycle, got {other:?}"),
1137 }
1138 }
1139
1140 #[test]
1141 fn a_dependency_on_an_app_outside_the_document_is_accepted() {
1142 let mut api = AppConfig::minimal("api", "./api");
1145 api.depends_on = vec!["db-in-another-repo".to_string()];
1146 normalize_all(vec![api]).expect("an unresolved name is not a document error");
1147 }
1148
1149 fn probe_config(target: &str) -> crate::config::ProbeConfig {
1150 crate::config::ProbeConfig {
1151 kind: crate::config::ProbeKind::Http,
1152 target: target.to_string(),
1153 interval: crate::values::UpDuration::from_millis(10_000),
1154 timeout: crate::values::UpDuration::from_millis(5_000),
1155 failure_threshold: 3,
1156 }
1157 }
1158
1159 #[test]
1160 fn malformed_readiness_probe_target_rejected_naming_the_field() {
1161 let mut app = AppConfig::minimal("web", "./srv");
1164 app.readiness_probe = Some(probe_config("not-a-url"));
1165 match normalize(app).unwrap_err() {
1166 NormalizeError::InvalidProbe { probe, reason } => {
1167 assert_eq!(probe, "readiness_probe");
1168 assert!(!reason.is_empty());
1169 }
1170 other => panic!("expected InvalidProbe, got {other:?}"),
1171 }
1172 }
1173
1174 #[test]
1175 fn malformed_liveness_probe_target_rejected_naming_the_field() {
1176 let mut app = AppConfig::minimal("web", "./srv");
1179 app.liveness_probe = Some(probe_config("not-a-url"));
1180 match normalize(app).unwrap_err() {
1181 NormalizeError::InvalidProbe { probe, .. } => assert_eq!(probe, "liveness_probe"),
1182 other => panic!("expected InvalidProbe, got {other:?}"),
1183 }
1184 }
1185
1186 #[test]
1187 fn valid_probe_targets_accepted() {
1188 let mut app = AppConfig::minimal("web", "./srv");
1190 app.readiness_probe = Some(probe_config("http://127.0.0.1:8080/healthz"));
1191 assert!(normalize(app).is_ok());
1192 }
1193
1194 #[test]
1195 fn zero_failure_threshold_rejected() {
1196 let mut app = AppConfig::minimal("web", "./srv");
1199 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1200 probe.failure_threshold = 0;
1201 app.readiness_probe = Some(probe);
1202 let err = normalize(app).unwrap_err();
1203 assert_eq!(
1204 err,
1205 NormalizeError::ZeroFailureThreshold {
1206 probe: "readiness_probe"
1207 }
1208 );
1209 assert!(err.to_string().contains("at least 1"), "{err}");
1212 }
1213
1214 #[test]
1215 fn zero_interval_rejected() {
1216 let mut app = AppConfig::minimal("web", "./srv");
1220 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1221 probe.interval = UpDuration::from_millis(0);
1222 app.readiness_probe = Some(probe);
1223 let err = normalize(app).unwrap_err();
1224 assert_eq!(
1225 err,
1226 NormalizeError::IntervalBelowMinimum {
1227 probe: "readiness_probe",
1228 value: UpDuration::from_millis(0),
1229 min: MIN_READINESS_INTERVAL,
1230 }
1231 );
1232 assert!(err.to_string().contains("must be at least"), "{err}");
1235 }
1236
1237 #[test]
1238 fn a_liveness_interval_under_the_floor_is_rejected_rather_than_clamped() {
1239 let mut app = AppConfig::minimal("web", "./srv");
1244 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1245 probe.interval = UpDuration::from_millis(500);
1246 app.liveness_probe = Some(probe);
1247 let err = normalize(app).unwrap_err();
1248 assert_eq!(
1249 err,
1250 NormalizeError::IntervalBelowMinimum {
1251 probe: "liveness_probe",
1252 value: UpDuration::from_millis(500),
1253 min: MIN_LIVENESS_INTERVAL,
1254 }
1255 );
1256 assert!(err.to_string().contains("500"), "{err}");
1257 }
1258
1259 #[test]
1260 fn a_liveness_interval_exactly_at_the_floor_is_accepted() {
1261 let mut app = AppConfig::minimal("web", "./srv");
1265 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1266 probe.interval = MIN_LIVENESS_INTERVAL;
1267 app.liveness_probe = Some(probe);
1268 assert!(normalize(app).is_ok());
1269 }
1270
1271 #[test]
1272 fn a_sub_second_readiness_interval_is_accepted() {
1273 let mut app = AppConfig::minimal("web", "./srv");
1278 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1279 probe.interval = UpDuration::from_millis(50);
1280 app.readiness_probe = Some(probe);
1281 assert!(normalize(app).is_ok());
1282 }
1283
1284 #[test]
1285 fn zero_max_memory_rejected() {
1286 let mut app = AppConfig::minimal("web", "./srv");
1291 app.max_memory = Some(crate::values::MemSize::from_bytes(0));
1292 let err = normalize(app).unwrap_err();
1293 assert_eq!(
1294 err,
1295 NormalizeError::ZeroMaxMemory {
1296 name: "web".to_string()
1297 }
1298 );
1299 assert!(err.to_string().contains("max_memory"), "{err}");
1302 }
1303
1304 #[test]
1305 fn a_nonzero_max_memory_is_accepted() {
1306 let mut app = AppConfig::minimal("web", "./srv");
1310 app.max_memory = Some("512M".parse().unwrap());
1311 assert!(normalize(app).is_ok());
1312 }
1313
1314 #[test]
1318 fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
1319 let mut app = AppConfig::minimal("web", "./srv");
1320 app.kill_signal = Some("SIGUSR1".to_string());
1321
1322 let err = normalize(app).unwrap_err();
1323
1324 assert_eq!(
1325 err,
1326 NormalizeError::InvalidKillSignal {
1327 name: "web".to_string(),
1328 value: "SIGUSR1".to_string(),
1329 }
1330 );
1331 let rendered = err.to_string();
1334 assert!(rendered.contains("SIGUSR1"), "{rendered}");
1335 assert!(rendered.contains("SIGTERM"), "{rendered}");
1336 assert!(rendered.contains("SIGUSR2"), "{rendered}");
1337 }
1338
1339 #[test]
1343 fn every_spelling_the_daemon_already_accepted_still_normalizes() {
1344 for name in [
1345 "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
1346 "USR2", "sigusr2",
1347 ] {
1348 let mut app = AppConfig::minimal("web", "./srv");
1349 app.kill_signal = Some(name.to_string());
1350 assert!(
1351 normalize(app).is_ok(),
1352 "`{name}` was accepted before this task and must still be"
1353 );
1354 }
1355 }
1356
1357 #[test]
1361 fn an_unset_kill_signal_is_not_a_config_error() {
1362 let app = AppConfig::minimal("web", "./srv");
1363 assert!(app.kill_signal.is_none());
1364 assert!(normalize(app).is_ok());
1365 }
1366
1367 #[test]
1368 fn action_timeout_past_the_ceiling_is_rejected() {
1369 let mut app = AppConfig::minimal("web", "./srv");
1374 app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
1375 let err = normalize(app).unwrap_err();
1376 assert_eq!(
1377 err,
1378 NormalizeError::ActionTimeoutTooLong {
1379 name: "web".to_string(),
1380 value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
1381 max: MAX_ACTION_TIMEOUT,
1382 }
1383 );
1384 assert!(err.to_string().contains("action_timeout"), "{err}");
1387 }
1388
1389 #[test]
1390 fn action_timeout_at_the_ceiling_is_accepted() {
1391 let mut app = AppConfig::minimal("web", "./srv");
1395 app.action_timeout = MAX_ACTION_TIMEOUT;
1396 assert!(normalize(app).is_ok());
1397 }
1398
1399 #[test]
1400 fn the_default_action_timeout_is_accepted() {
1401 assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
1405 }
1406
1407 #[test]
1408 fn zero_watch_delay_rejected() {
1409 let mut app = AppConfig::minimal("web", "./srv");
1414 app.watch = true;
1415 app.cwd = Some("/srv/web".to_string());
1416 app.watch_delay = Some(UpDuration::from_millis(0));
1417 let err = normalize(app).unwrap_err();
1418 assert_eq!(
1419 err,
1420 NormalizeError::ZeroWatchDelay {
1421 name: "web".to_string()
1422 }
1423 );
1424 assert!(err.to_string().contains("watch_delay"), "{err}");
1427 }
1428
1429 #[test]
1430 fn a_zero_watch_delay_is_rejected_with_watch_off() {
1431 let mut app = AppConfig::minimal("web", "./srv");
1436 app.watch_delay = Some(UpDuration::from_millis(0));
1437 assert!(matches!(
1438 normalize(app).unwrap_err(),
1439 NormalizeError::ZeroWatchDelay { .. }
1440 ));
1441 }
1442
1443 #[test]
1444 fn a_nonzero_watch_delay_is_accepted() {
1445 let mut app = AppConfig::minimal("web", "./srv");
1449 app.watch = true;
1450 app.cwd = Some("/srv/web".to_string());
1451 app.watch_delay = Some(UpDuration::from_millis(1));
1452 assert!(normalize(app).is_ok());
1453 }
1454
1455 #[test]
1456 fn default_failure_threshold_from_toml_accepted() {
1457 let src = r#"
1462name = "web"
1463script = "./srv"
1464
1465[readiness_probe]
1466kind = "http"
1467target = "http://127.0.0.1:8080/healthz"
1468"#;
1469 let app: AppConfig = toml::from_str(src).unwrap();
1470 assert!(normalize(app).is_ok());
1471 }
1472
1473 #[test]
1474 fn watch_true_without_cwd_rejected_naming_the_app() {
1475 let mut app = AppConfig::minimal("web", "./srv");
1479 app.watch = true;
1480 let err = normalize(app).unwrap_err();
1481 assert_eq!(
1482 err,
1483 NormalizeError::WatchWithoutCwd {
1484 name: "web".to_string()
1485 }
1486 );
1487 assert!(err.to_string().contains("no cwd to watch"), "{err}");
1490 }
1491
1492 #[test]
1493 fn watch_true_with_cwd_accepted() {
1494 let mut app = AppConfig::minimal("web", "./srv");
1497 app.watch = true;
1498 app.cwd = Some("/srv/web".to_string());
1499 assert!(normalize(app).is_ok());
1500 }
1501
1502 #[test]
1503 fn a_watch_options_glob_that_will_not_compile_is_rejected() {
1504 let mut app = AppConfig::minimal("web", "./srv");
1509 app.watch = true;
1510 app.cwd = Some("/srv/web".to_string());
1511 app.watch_options = vec!["src/**".to_string(), "[".to_string()];
1512 let err = normalize(app).unwrap_err();
1513 assert_eq!(
1514 err,
1515 NormalizeError::InvalidWatchGlob {
1516 name: "web".to_string(),
1517 field: "watch_options",
1518 pattern: "[".to_string(),
1519 reason: Glob::new("[").unwrap_err().to_string(),
1520 }
1521 );
1522 let rendered = err.to_string();
1525 for expected in ["web", "watch_options", "`[`"] {
1526 assert!(
1527 rendered.contains(expected),
1528 "{expected} missing: {rendered}"
1529 );
1530 }
1531 }
1532
1533 #[test]
1534 fn an_ignore_watch_glob_that_will_not_compile_is_rejected() {
1535 let mut app = AppConfig::minimal("web", "./srv");
1538 app.watch = true;
1539 app.cwd = Some("/srv/web".to_string());
1540 app.ignore_watch = vec!["[".to_string()];
1541 match normalize(app).unwrap_err() {
1542 NormalizeError::InvalidWatchGlob { field, pattern, .. } => {
1543 assert_eq!(field, "ignore_watch");
1544 assert_eq!(pattern, "[");
1545 }
1546 other => panic!("expected InvalidWatchGlob, got {other:?}"),
1547 }
1548 }
1549
1550 #[test]
1551 fn a_glob_that_will_not_compile_is_rejected_with_watch_off() {
1552 let mut app = AppConfig::minimal("web", "./srv");
1557 app.watch_options = vec!["[".to_string()];
1558 assert!(matches!(
1559 normalize(app).unwrap_err(),
1560 NormalizeError::InvalidWatchGlob { .. }
1561 ));
1562 }
1563
1564 #[test]
1565 fn well_formed_watch_globs_are_accepted() {
1566 let mut app = AppConfig::minimal("web", "./srv");
1571 app.watch = true;
1572 app.cwd = Some("/srv/web".to_string());
1573 app.watch_options = vec!["src/**/*.rs".to_string(), "*.[ch]".to_string()];
1574 app.ignore_watch = vec!["target/**".to_string(), "**/[!.]*.{tmp,swp}".to_string()];
1575 assert!(normalize(app).is_ok());
1576 }
1577
1578 #[test]
1579 fn a_typo_in_an_env_template_is_refused_and_names_the_field() {
1580 let mut app = AppConfig::minimal("web", "./srv");
1581 app.env
1582 .insert("WORKER".to_string(), "w-{{instnace}}".to_string());
1583 let err = normalize(app).unwrap_err();
1584 let rendered = err.to_string();
1585 assert!(rendered.contains("instnace"), "names the typo: {rendered}");
1586 assert!(rendered.contains("WORKER"), "and the field: {rendered}");
1587 }
1588
1589 #[test]
1590 fn a_typo_in_an_arg_template_is_refused_too() {
1591 let mut app = AppConfig::minimal("web", "./srv");
1592 app.args = vec!["--port".to_string(), "91{{slot}}".to_string()];
1593 let err = normalize(app).unwrap_err();
1594 assert!(err.to_string().contains("slot"), "{err}");
1595 }
1596
1597 #[test]
1598 fn an_explicit_log_path_shared_by_every_instance_is_refused() {
1599 let mut app = AppConfig::minimal("web", "./srv");
1600 app.instances = 3;
1601 app.out_file = Some("/var/log/web.log".to_string());
1602 let err = normalize(app).unwrap_err();
1603 let rendered = err.to_string();
1604 assert!(rendered.contains("out_file"), "names the field: {rendered}");
1605 assert!(
1606 rendered.contains("{{instance}}") && rendered.contains("merge_logs"),
1607 "and both ways out: {rendered}"
1608 );
1609 assert!(
1610 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1611 "no em or en dash in copy a user reads: {rendered}"
1612 );
1613 }
1614
1615 #[test]
1616 fn the_three_ways_out_of_the_shared_log_refusal_all_work() {
1617 let mut templated = AppConfig::minimal("web", "./srv");
1619 templated.instances = 3;
1620 templated.out_file = Some("/var/log/web-{{instance}}.log".to_string());
1621 assert!(normalize(templated).is_ok());
1622
1623 let mut merged = AppConfig::minimal("web", "./srv");
1625 merged.instances = 3;
1626 merged.out_file = Some("/var/log/web.log".to_string());
1627 merged.merge_logs = true;
1628 assert!(normalize(merged).is_ok());
1629
1630 let mut single = AppConfig::minimal("web", "./srv");
1632 single.out_file = Some("/var/log/web.log".to_string());
1633 assert!(normalize(single).is_ok());
1634 }
1635
1636 #[test]
1637 fn an_escaped_template_in_a_log_path_does_not_satisfy_the_refusal() {
1638 let mut app = AppConfig::minimal("web", "./srv");
1641 app.instances = 3;
1642 app.out_file = Some("/var/log/web-{{{{instance}}}}.log".to_string());
1643 assert!(normalize(app).is_err());
1644 }
1645
1646 #[test]
1647 fn a_name_only_template_does_not_resolve_the_collision() {
1648 let mut app = AppConfig::minimal("web", "./srv");
1652 app.instances = 3;
1653 app.out_file = Some("/var/log/{{name}}.log".to_string());
1654 assert!(normalize(app).is_err());
1655 }
1656
1657 #[test]
1658 fn a_bad_template_in_a_log_path_is_reported_as_bad_template_not_shared_path() {
1659 let mut app = AppConfig::minimal("web", "./srv");
1660 app.instances = 3;
1661 app.out_file = Some("/var/log/web-{{instnace}}.log".to_string());
1662 match normalize(app).unwrap_err() {
1663 NormalizeError::BadTemplate { field, reason, .. } => {
1664 assert_eq!(field, "out_file");
1665 assert!(reason.contains("instnace"), "{reason}");
1666 }
1667 other => panic!("expected BadTemplate, got {other:?}"),
1668 }
1669 }
1670
1671 #[test]
1672 fn watch_options_without_watch_or_cwd_accepted() {
1673 let mut app = AppConfig::minimal("web", "./srv");
1677 app.watch_options = vec!["src/**".to_string()];
1678 assert!(normalize(app).is_ok());
1679 }
1680
1681 #[test]
1682 fn a_sheep_that_depends_on_itself_is_refused_by_name() {
1683 let mut app = AppConfig::minimal("api", "./api");
1686 app.depends_on = vec!["api".to_string()];
1687 match normalize(app) {
1688 Err(NormalizeError::SelfDependency(name)) => assert_eq!(name, "api"),
1689 other => panic!("expected SelfDependency, got {other:?}"),
1690 }
1691 }
1692
1693 #[test]
1694 fn a_dependency_on_one_instance_is_refused_naming_the_app_form() {
1695 let mut app = AppConfig::minimal("api", "./api");
1697 app.depends_on = vec!["db:2".to_string()];
1698 match normalize(app) {
1699 Err(NormalizeError::InstanceDependency { sheep, target }) => {
1700 assert_eq!(sheep, "api");
1701 assert_eq!(target, "db:2");
1702 }
1703 other => panic!("expected InstanceDependency, got {other:?}"),
1704 }
1705 let rendered = NormalizeError::InstanceDependency {
1706 sheep: "api".to_string(),
1707 target: "db:2".to_string(),
1708 }
1709 .to_string();
1710 assert!(
1711 rendered.contains("`db`"),
1712 "the refusal must name the app-level form: {rendered}"
1713 );
1714 }
1715
1716 #[test]
1717 fn duplicate_dependencies_dedupe_rather_than_refusing() {
1718 let mut app = AppConfig::minimal("api", "./api");
1721 app.depends_on = vec!["db".to_string(), "db".to_string()];
1722 let resolved = normalize(app).expect("a repeated name is not an error");
1723 assert_eq!(resolved.config().depends_on, vec!["db".to_string()]);
1724 }
1725
1726 #[test]
1727 fn an_ordinary_dependency_list_survives_normalize() {
1728 let mut app = AppConfig::minimal("api", "./api");
1730 app.depends_on = vec!["db".to_string(), "cache".to_string()];
1731 let resolved = normalize(app).expect("an ordinary list normalizes");
1732 assert_eq!(
1733 resolved.config().depends_on,
1734 vec!["db".to_string(), "cache".to_string()]
1735 );
1736 }
1737}