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> {
208 normalize_with_home(app, std::env::home_dir().as_deref())
209}
210
211pub fn normalize_with_home(
222 mut app: AppConfig,
223 home: Option<&Path>,
224) -> Result<ResolvedApp, NormalizeError> {
225 if app.name.is_empty() {
226 return Err(NormalizeError::MissingName);
227 }
228 if app.name.contains(['/', '\\', ':']) || app.name == "." || app.name == ".." {
229 return Err(NormalizeError::InvalidName(app.name));
230 }
231 for var in ["SHEP_INSTANCE", "SHEP_NAME"] {
232 if app.env.contains_key(var) {
233 return Err(NormalizeError::ReservedEnvVar {
234 name: app.name.clone(),
235 var,
236 });
237 }
238 }
239 if let Some(var) = app.increment_var.take() {
240 return Err(NormalizeError::IncrementVarRemoved {
241 name: app.name.clone(),
242 var,
243 });
244 }
245 for (key, value) in &app.env {
246 validate_template(&app.name, &format!("env.{key}"), value)?;
247 }
248 for (index, value) in app.args.iter().enumerate() {
249 validate_template(&app.name, &format!("args[{index}]"), value)?;
250 }
251 for (field, value) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
252 if let Some(value) = value {
253 validate_template(&app.name, field, value)?;
254 }
255 }
256 if app.script.is_empty() {
257 return Err(NormalizeError::MissingScript);
258 }
259 expand_paths(&mut app, home)?;
263 if app.instances == 0 {
264 return Err(NormalizeError::ZeroInstances);
265 }
266 if app.instances > 1 && !app.merge_logs {
269 for (field, path) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
270 if let Some(path) = path
275 && crate::config::template::render(path, &app.name, 0)
276 == crate::config::template::render(path, &app.name, 1)
277 {
278 return Err(NormalizeError::SharedLogPath {
279 name: app.name.clone(),
280 field,
281 });
282 }
283 }
284 }
285 if let Some(pattern) = &app.cron_restart {
286 CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
287 CronParseError::Pattern { pattern, reason } => {
288 NormalizeError::InvalidCron { pattern, reason }
289 }
290 CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
291 })?;
292 } else if let Some(tz_name) = &app.cron_timezone {
293 crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
296 NormalizeError::InvalidTimezone {
297 name: tz_name.clone(),
298 }
299 })?;
300 }
301 validate_probe(
302 app.readiness_probe.as_ref(),
303 "readiness_probe",
304 MIN_READINESS_INTERVAL,
305 )?;
306 validate_probe(
307 app.liveness_probe.as_ref(),
308 "liveness_probe",
309 MIN_LIVENESS_INTERVAL,
310 )?;
311 if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
312 return Err(NormalizeError::ZeroMaxMemory { name: app.name });
317 }
318 if let Some(name) = &app.kill_signal
319 && KillSignal::parse(name).is_none()
320 {
321 return Err(NormalizeError::InvalidKillSignal {
325 name: app.name,
326 value: name.clone(),
327 });
328 }
329 if app.action_timeout > MAX_ACTION_TIMEOUT {
330 return Err(NormalizeError::ActionTimeoutTooLong {
334 name: app.name,
335 value: app.action_timeout,
336 max: MAX_ACTION_TIMEOUT,
337 });
338 }
339 if app.watch && app.cwd.is_none() {
340 return Err(NormalizeError::WatchWithoutCwd { name: app.name });
345 }
346 if app.watch_delay == Some(UpDuration::from_millis(0)) {
347 return Err(NormalizeError::ZeroWatchDelay { name: app.name });
351 }
352 validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
356 validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
357 Ok(ResolvedApp { config: app })
358}
359
360fn validate_template(name: &str, field: &str, value: &str) -> Result<(), NormalizeError> {
370 crate::config::template::validate(value).map_err(|reason| NormalizeError::BadTemplate {
371 name: name.to_string(),
372 field: field.to_string(),
373 reason: reason.to_string(),
374 })
375}
376
377fn validate_watch_globs(
384 name: &str,
385 field: &'static str,
386 patterns: &[String],
387) -> Result<(), NormalizeError> {
388 for pattern in patterns {
389 Glob::new(pattern).map_err(|err| NormalizeError::InvalidWatchGlob {
390 name: name.to_string(),
391 field,
392 pattern: pattern.clone(),
393 reason: err.to_string(),
394 })?;
395 }
396 Ok(())
397}
398
399fn validate_probe(
406 probe: Option<&ProbeConfig>,
407 name: &'static str,
408 min_interval: UpDuration,
409) -> Result<(), NormalizeError> {
410 let Some(probe) = probe else {
411 return Ok(());
412 };
413 ProbeTarget::parse(probe).map_err(|reason| NormalizeError::InvalidProbe {
414 probe: name,
415 reason: reason.to_string(),
416 })?;
417 if probe.failure_threshold == 0 {
418 return Err(NormalizeError::ZeroFailureThreshold { probe: name });
421 }
422 if probe.interval < min_interval {
423 return Err(NormalizeError::IntervalBelowMinimum {
428 probe: name,
429 value: probe.interval,
430 min: min_interval,
431 });
432 }
433 Ok(())
434}
435
436pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
443 let mut seen = BTreeSet::new();
444 apps.into_iter()
445 .map(|app| {
446 if !seen.insert(app.name.clone()) {
447 return Err(NormalizeError::DuplicateName(app.name));
448 }
449 normalize(app)
450 })
451 .collect()
452}
453
454#[non_exhaustive]
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub enum NormalizeError {
461 MissingName,
463 InvalidName(String),
469 ReservedEnvVar {
472 name: String,
474 var: &'static str,
476 },
477 IncrementVarRemoved {
481 name: String,
483 var: String,
485 },
486 MissingScript,
488 ZeroInstances,
490 InvalidCron {
493 pattern: String,
495 reason: String,
497 },
498 InvalidTimezone {
500 name: String,
502 },
503 DuplicateName(String),
505 InvalidProbe {
508 probe: &'static str,
511 reason: String,
513 },
514 ZeroFailureThreshold {
516 probe: &'static str,
519 },
520 IntervalBelowMinimum {
525 probe: &'static str,
528 value: UpDuration,
530 min: UpDuration,
532 },
533 ZeroMaxMemory {
537 name: String,
539 },
540 ActionTimeoutTooLong {
545 name: String,
547 value: UpDuration,
549 max: UpDuration,
551 },
552 InvalidKillSignal {
555 name: String,
557 value: String,
559 },
560 WatchWithoutCwd {
563 name: String,
565 },
566 TildeUser {
572 name: String,
574 field: &'static str,
576 value: String,
578 },
579 NoHomeForTilde {
581 name: String,
583 field: &'static str,
585 },
586 ZeroWatchDelay {
589 name: String,
591 },
592 InvalidWatchGlob {
595 name: String,
597 field: &'static str,
600 pattern: String,
602 reason: String,
604 },
605 BadTemplate {
609 name: String,
611 field: String,
613 reason: String,
616 },
617 SharedLogPath {
623 name: String,
625 field: &'static str,
627 },
628}
629
630impl fmt::Display for NormalizeError {
631 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
632 match self {
633 Self::MissingName => f.write_str("app config is missing a name"),
634 Self::InvalidName(n) => {
635 write!(
636 f,
637 "sheep name `{n}` may not contain a path separator or a colon, or be `.` or `..`; use `-` in place of a colon"
638 )
639 }
640 Self::ReservedEnvVar { name, var } => write!(
641 f,
642 "sheep `{name}` sets `{var}` in env, but shep injects it: use a different name, or `{{{{instance}}}}` in your own variable"
643 ),
644 Self::IncrementVarRemoved { name, var } => write!(
645 f,
646 "sheep `{name}` sets `increment_var`, which was removed: write `{var} = \"{{{{instance}}}}\"` under `[app.env]` instead"
647 ),
648 Self::MissingScript => f.write_str("app config is missing a script"),
649 Self::ZeroInstances => f.write_str("instances must be at least 1"),
650 Self::InvalidCron { pattern, reason } => {
651 write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
652 }
653 Self::InvalidTimezone { name } => {
654 write!(f, "`{name}` is not a recognized IANA timezone")
655 }
656 Self::DuplicateName(n) => write!(f, "duplicate sheep name `{n}`"),
657 Self::InvalidProbe { probe, reason } => write!(f, "{probe}: {reason}"),
658 Self::ZeroFailureThreshold { probe } => {
659 write!(f, "{probe}.failure_threshold must be at least 1")
660 }
661 Self::IntervalBelowMinimum { probe, value, min } => {
662 write!(f, "{probe}.interval is `{value}`: must be at least {min}")
663 }
664 Self::ZeroMaxMemory { name } => {
665 write!(
666 f,
667 "sheep `{name}` has max_memory = 0, a limit nothing can stay under"
668 )
669 }
670 Self::ActionTimeoutTooLong { name, value, max } => {
671 write!(
672 f,
673 "sheep `{name}` has action_timeout = {value}: must be at most {max}, \
674 the longest wait any caller's deadline could ever cover"
675 )
676 }
677 Self::InvalidKillSignal { name, value } => {
678 write!(
679 f,
680 "`{name}`: kill_signal `{value}` is not one shep can send (accepted: {})",
681 KillSignal::ACCEPTED.join(", ")
682 )
683 }
684 Self::TildeUser { name, field, value } => write!(
685 f,
686 "`{name}`: {field} is `{value}`, and shep expands only `~/` (your own home). \
687 Another user's home needs a passwd lookup whose answer depends on who the \
688 daemon runs as, so write the path out in full instead."
689 ),
690 Self::NoHomeForTilde { name, field } => write!(
691 f,
692 "`{name}`: {field} begins with `~/` but no home directory could be found. \
693 Set $HOME, or write the path out in full."
694 ),
695 Self::WatchWithoutCwd { name } => {
696 write!(f, "sheep `{name}` has watch = true but no cwd to watch")
697 }
698 Self::ZeroWatchDelay { name } => {
699 write!(
700 f,
701 "sheep `{name}` has watch_delay = 0: must be greater than 0"
702 )
703 }
704 Self::InvalidWatchGlob {
705 name,
706 field,
707 pattern,
708 reason,
709 } => write!(
710 f,
711 "sheep `{name}` has an invalid {field} pattern `{pattern}`: {reason}"
712 ),
713 Self::BadTemplate {
714 name,
715 field,
716 reason,
717 } => write!(f, "sheep `{name}`, {field}: {reason}"),
718 Self::SharedLogPath { name, field } => write!(
719 f,
720 "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"
721 ),
722 }
723 }
724}
725
726impl core::error::Error for NormalizeError {}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 #[test]
736 fn every_path_field_expands_a_leading_tilde() {
737 let home = Path::new("/home/ada");
738 let mut app = AppConfig::minimal("web", "~/app/server.js");
739 app.cwd = Some("~/app".to_string());
740 app.out_file = Some("~/logs/out.log".to_string());
741 app.err_file = Some("~/logs/err.log".to_string());
742
743 let resolved = normalize_with_home(app, Some(home)).expect("all four expand");
744 let c = resolved.config();
745 let expect = |rest: &str| home.join(rest).to_string_lossy().into_owned();
749 assert_eq!(c.script, expect("app/server.js"));
750 assert_eq!(c.cwd.as_deref(), Some(expect("app").as_str()));
751 assert_eq!(c.out_file.as_deref(), Some(expect("logs/out.log").as_str()));
752 assert_eq!(c.err_file.as_deref(), Some(expect("logs/err.log").as_str()));
753 }
754
755 #[test]
759 fn the_path_field_list_matches_what_expand_paths_walks() {
760 let home = Path::new("/home/ada");
761 let mut app = AppConfig::minimal("web", "~/s");
762 app.cwd = Some("~/c".to_string());
763 app.out_file = Some("~/o".to_string());
764 app.err_file = Some("~/e".to_string());
765
766 let resolved = normalize_with_home(app, Some(home)).expect("expands");
767 let c = resolved.config();
768 let expanded = [
769 ("script", Some(c.script.as_str())),
770 ("cwd", c.cwd.as_deref()),
771 ("out_file", c.out_file.as_deref()),
772 ("err_file", c.err_file.as_deref()),
773 ];
774 assert_eq!(
775 expanded.len(),
776 PATH_FIELDS.len(),
777 "PATH_FIELDS and this test must name the same set"
778 );
779 for (field, value) in expanded {
780 assert!(
781 PATH_FIELDS.contains(&field),
782 "`{field}` is not in PATH_FIELDS"
783 );
784 assert!(
785 value.is_some_and(|v| v.starts_with("/home/ada")),
786 "`{field}` was not expanded: {value:?}"
787 );
788 }
789 }
790
791 #[test]
794 fn a_path_without_a_tilde_is_left_exactly_as_written() {
795 let app = AppConfig::minimal("web", "./server.js");
796 let resolved =
797 normalize_with_home(app, Some(Path::new("/home/ada"))).expect("no tilde, no change");
798 assert_eq!(resolved.config().script, "./server.js");
799 }
800
801 #[test]
804 fn another_users_home_is_refused_rather_than_resolved() {
805 let app = AppConfig::minimal("web", "~deploy/app/server.js");
806 let err = normalize_with_home(app, Some(Path::new("/home/ada")))
807 .expect_err("~user/ must not resolve");
808 assert!(
809 matches!(err, NormalizeError::TildeUser { field, .. } if field == "script"),
810 "the refusal names the field: {err:?}"
811 );
812 let rendered = err.to_string();
813 assert!(
814 rendered.contains("~/"),
815 "and says what IS supported: {rendered}"
816 );
817 assert!(
818 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
819 "no em or en dash in copy a user reads: {rendered}"
820 );
821 }
822
823 #[test]
826 fn a_dollar_variable_is_not_expanded() {
827 let app = AppConfig::minimal("web", "$HOME/server.js");
828 let resolved = normalize_with_home(app, Some(Path::new("/home/ada"))).expect("left alone");
829 assert_eq!(resolved.config().script, "$HOME/server.js");
830 }
831
832 #[test]
835 fn a_tilde_with_no_home_is_an_error_not_a_literal_path() {
836 let app = AppConfig::minimal("web", "~/server.js");
837 let err = normalize_with_home(app, None).expect_err("nothing to expand against");
838 assert!(
839 matches!(err, NormalizeError::NoHomeForTilde { .. }),
840 "{err:?}"
841 );
842 }
843
844 #[test]
848 fn expand_home_tilde_covers_its_four_documented_cases() {
849 let home = Path::new("/home/ada");
850 assert_eq!(
851 expand_home_tilde("~/bin/dog", Some(home)).unwrap(),
852 home.join("bin/dog").to_string_lossy()
853 );
854 assert_eq!(
855 expand_home_tilde("/opt/bin/dog", Some(home)).unwrap(),
856 "/opt/bin/dog",
857 "a value with no leading ~ is returned unchanged"
858 );
859 assert_eq!(
860 expand_home_tilde("~/bin/dog", None).unwrap_err(),
861 TildeError::NoHome
862 );
863 assert_eq!(
864 expand_home_tilde("~deploy/bin/dog", Some(home)).unwrap_err(),
865 TildeError::OtherUser
866 );
867 }
868
869 #[test]
873 fn reuse_port_loads_now_that_reload_reads_it() {
874 let mut app = AppConfig::minimal("web", "./server");
875 app.reuse_port = true;
876
877 let resolved = normalize(app).expect("reuse_port is implemented");
878 assert!(resolved.config().reuse_port);
879 }
880
881 #[test]
884 fn an_app_that_never_mentions_reuse_port_still_normalizes() {
885 let resolved = normalize(AppConfig::minimal("web", "./server"))
886 .expect("the common case must be untouched");
887 assert!(!resolved.config().reuse_port);
888 }
889 use crate::config::AppConfig;
890
891 #[test]
892 fn a_colon_in_a_name_is_refused_because_it_is_the_instance_separator() {
893 let err = normalize(AppConfig::minimal("web:2", "./srv")).unwrap_err();
894 assert_eq!(err, NormalizeError::InvalidName("web:2".to_string()));
895
896 let rendered = err.to_string();
897 assert!(rendered.contains(':'), "says which character: {rendered}");
898 assert!(
900 rendered.contains("`-`"),
901 "suggests the stand-in: {rendered}"
902 );
903 assert!(
904 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
905 "no em or en dash in copy a user reads: {rendered}"
906 );
907
908 assert!(normalize(AppConfig::minimal("web-2", "./srv")).is_ok());
909 }
910
911 #[test]
912 fn increment_var_is_refused_and_says_what_replaced_it() {
913 let mut app = AppConfig::minimal("web", "./srv");
914 app.increment_var = Some("WORKER_ID".to_string());
915 let err = normalize(app).unwrap_err();
916 let rendered = err.to_string();
917 assert!(rendered.contains("increment_var"), "{rendered}");
918 assert!(
919 rendered.contains("WORKER_ID"),
920 "keeps their name: {rendered}"
921 );
922 assert!(rendered.contains("{{instance}}"), "and the fix: {rendered}");
923 assert!(
924 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
925 "no em or en dash in copy a user reads: {rendered}"
926 );
927 }
928
929 #[test]
930 fn the_reserved_env_vars_are_refused_rather_than_overwritten() {
931 for var in ["SHEP_INSTANCE", "SHEP_NAME"] {
932 let mut app = AppConfig::minimal("web", "./srv");
933 app.env.insert(var.to_string(), "mine".to_string());
934 let err = normalize(app).unwrap_err();
935 let rendered = err.to_string();
936 assert!(rendered.contains(var), "names the variable: {rendered}");
937 assert!(
938 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
939 "no em or en dash in copy a user reads: {rendered}"
940 );
941 }
942 }
943
944 #[test]
945 fn valid_minimal_config_normalizes() {
946 let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
947 assert_eq!(resolved.config().name, "web");
948 }
949
950 #[test]
951 fn names_that_reach_the_filesystem_are_rejected() {
952 for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
955 assert_eq!(
956 normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
957 NormalizeError::InvalidName(bad.to_string())
958 );
959 }
960 assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
961 }
962
963 #[test]
964 fn missing_name_and_script_are_distinct_errors() {
965 assert_eq!(
966 normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
967 NormalizeError::MissingName
968 );
969 assert_eq!(
970 normalize(AppConfig::minimal("web", "")).unwrap_err(),
971 NormalizeError::MissingScript
972 );
973 }
974
975 #[test]
976 fn zero_instances_rejected() {
977 let mut app = AppConfig::minimal("web", "./srv");
978 app.instances = 0;
979 assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
980 }
981
982 #[test]
983 fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
984 let mut app = AppConfig::minimal("web", "./srv");
986 app.cron_restart = Some("not a cron".to_string());
987 match normalize(app).unwrap_err() {
988 NormalizeError::InvalidCron { pattern, reason } => {
989 assert_eq!(pattern, "not a cron");
990 assert!(!reason.is_empty());
991 }
992 other => panic!("expected InvalidCron, got {other:?}"),
993 }
994 }
995
996 #[test]
997 fn five_tokens_of_garbage_cron_pattern_rejected() {
998 let mut app = AppConfig::minimal("web", "./srv");
1002 app.cron_restart = Some("99 99 99 99 99".to_string());
1003 match normalize(app).unwrap_err() {
1004 NormalizeError::InvalidCron { pattern, .. } => {
1005 assert_eq!(pattern, "99 99 99 99 99");
1006 }
1007 other => panic!("expected InvalidCron, got {other:?}"),
1008 }
1009 }
1010
1011 #[test]
1012 fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
1013 let mut app = AppConfig::minimal("web", "./srv");
1018 app.cron_restart = Some("0 3 * * *".to_string());
1019 app.cron_timezone = Some("Mars/Olympus".to_string());
1020 match normalize(app).unwrap_err() {
1021 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
1022 other => panic!("expected InvalidTimezone, got {other:?}"),
1023 }
1024 }
1025
1026 #[test]
1027 fn cron_timezone_validated_even_without_cron_restart() {
1028 let mut app = AppConfig::minimal("web", "./srv");
1032 app.cron_timezone = Some("Mars/Olympus".to_string());
1033 match normalize(app).unwrap_err() {
1034 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
1035 other => panic!("expected InvalidTimezone, got {other:?}"),
1036 }
1037 }
1038
1039 #[test]
1040 fn duplicate_names_rejected_across_a_flock() {
1041 let apps = vec![
1042 AppConfig::minimal("web", "./a"),
1043 AppConfig::minimal("web", "./b"),
1044 ];
1045 assert_eq!(
1046 normalize_all(apps).unwrap_err(),
1047 NormalizeError::DuplicateName("web".to_string())
1048 );
1049 }
1050
1051 fn probe_config(target: &str) -> crate::config::ProbeConfig {
1052 crate::config::ProbeConfig {
1053 kind: crate::config::ProbeKind::Http,
1054 target: target.to_string(),
1055 interval: crate::values::UpDuration::from_millis(10_000),
1056 timeout: crate::values::UpDuration::from_millis(5_000),
1057 failure_threshold: 3,
1058 }
1059 }
1060
1061 #[test]
1062 fn malformed_readiness_probe_target_rejected_naming_the_field() {
1063 let mut app = AppConfig::minimal("web", "./srv");
1066 app.readiness_probe = Some(probe_config("not-a-url"));
1067 match normalize(app).unwrap_err() {
1068 NormalizeError::InvalidProbe { probe, reason } => {
1069 assert_eq!(probe, "readiness_probe");
1070 assert!(!reason.is_empty());
1071 }
1072 other => panic!("expected InvalidProbe, got {other:?}"),
1073 }
1074 }
1075
1076 #[test]
1077 fn malformed_liveness_probe_target_rejected_naming_the_field() {
1078 let mut app = AppConfig::minimal("web", "./srv");
1081 app.liveness_probe = Some(probe_config("not-a-url"));
1082 match normalize(app).unwrap_err() {
1083 NormalizeError::InvalidProbe { probe, .. } => assert_eq!(probe, "liveness_probe"),
1084 other => panic!("expected InvalidProbe, got {other:?}"),
1085 }
1086 }
1087
1088 #[test]
1089 fn valid_probe_targets_accepted() {
1090 let mut app = AppConfig::minimal("web", "./srv");
1092 app.readiness_probe = Some(probe_config("http://127.0.0.1:8080/healthz"));
1093 assert!(normalize(app).is_ok());
1094 }
1095
1096 #[test]
1097 fn zero_failure_threshold_rejected() {
1098 let mut app = AppConfig::minimal("web", "./srv");
1101 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1102 probe.failure_threshold = 0;
1103 app.readiness_probe = Some(probe);
1104 let err = normalize(app).unwrap_err();
1105 assert_eq!(
1106 err,
1107 NormalizeError::ZeroFailureThreshold {
1108 probe: "readiness_probe"
1109 }
1110 );
1111 assert!(err.to_string().contains("at least 1"), "{err}");
1114 }
1115
1116 #[test]
1117 fn zero_interval_rejected() {
1118 let mut app = AppConfig::minimal("web", "./srv");
1122 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1123 probe.interval = UpDuration::from_millis(0);
1124 app.readiness_probe = Some(probe);
1125 let err = normalize(app).unwrap_err();
1126 assert_eq!(
1127 err,
1128 NormalizeError::IntervalBelowMinimum {
1129 probe: "readiness_probe",
1130 value: UpDuration::from_millis(0),
1131 min: MIN_READINESS_INTERVAL,
1132 }
1133 );
1134 assert!(err.to_string().contains("must be at least"), "{err}");
1137 }
1138
1139 #[test]
1140 fn a_liveness_interval_under_the_floor_is_rejected_rather_than_clamped() {
1141 let mut app = AppConfig::minimal("web", "./srv");
1146 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1147 probe.interval = UpDuration::from_millis(500);
1148 app.liveness_probe = Some(probe);
1149 let err = normalize(app).unwrap_err();
1150 assert_eq!(
1151 err,
1152 NormalizeError::IntervalBelowMinimum {
1153 probe: "liveness_probe",
1154 value: UpDuration::from_millis(500),
1155 min: MIN_LIVENESS_INTERVAL,
1156 }
1157 );
1158 assert!(err.to_string().contains("500"), "{err}");
1159 }
1160
1161 #[test]
1162 fn a_liveness_interval_exactly_at_the_floor_is_accepted() {
1163 let mut app = AppConfig::minimal("web", "./srv");
1167 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1168 probe.interval = MIN_LIVENESS_INTERVAL;
1169 app.liveness_probe = Some(probe);
1170 assert!(normalize(app).is_ok());
1171 }
1172
1173 #[test]
1174 fn a_sub_second_readiness_interval_is_accepted() {
1175 let mut app = AppConfig::minimal("web", "./srv");
1180 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1181 probe.interval = UpDuration::from_millis(50);
1182 app.readiness_probe = Some(probe);
1183 assert!(normalize(app).is_ok());
1184 }
1185
1186 #[test]
1187 fn zero_max_memory_rejected() {
1188 let mut app = AppConfig::minimal("web", "./srv");
1193 app.max_memory = Some(crate::values::MemSize::from_bytes(0));
1194 let err = normalize(app).unwrap_err();
1195 assert_eq!(
1196 err,
1197 NormalizeError::ZeroMaxMemory {
1198 name: "web".to_string()
1199 }
1200 );
1201 assert!(err.to_string().contains("max_memory"), "{err}");
1204 }
1205
1206 #[test]
1207 fn a_nonzero_max_memory_is_accepted() {
1208 let mut app = AppConfig::minimal("web", "./srv");
1212 app.max_memory = Some("512M".parse().unwrap());
1213 assert!(normalize(app).is_ok());
1214 }
1215
1216 #[test]
1220 fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
1221 let mut app = AppConfig::minimal("web", "./srv");
1222 app.kill_signal = Some("SIGUSR1".to_string());
1223
1224 let err = normalize(app).unwrap_err();
1225
1226 assert_eq!(
1227 err,
1228 NormalizeError::InvalidKillSignal {
1229 name: "web".to_string(),
1230 value: "SIGUSR1".to_string(),
1231 }
1232 );
1233 let rendered = err.to_string();
1236 assert!(rendered.contains("SIGUSR1"), "{rendered}");
1237 assert!(rendered.contains("SIGTERM"), "{rendered}");
1238 assert!(rendered.contains("SIGUSR2"), "{rendered}");
1239 }
1240
1241 #[test]
1245 fn every_spelling_the_daemon_already_accepted_still_normalizes() {
1246 for name in [
1247 "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
1248 "USR2", "sigusr2",
1249 ] {
1250 let mut app = AppConfig::minimal("web", "./srv");
1251 app.kill_signal = Some(name.to_string());
1252 assert!(
1253 normalize(app).is_ok(),
1254 "`{name}` was accepted before this task and must still be"
1255 );
1256 }
1257 }
1258
1259 #[test]
1263 fn an_unset_kill_signal_is_not_a_config_error() {
1264 let app = AppConfig::minimal("web", "./srv");
1265 assert!(app.kill_signal.is_none());
1266 assert!(normalize(app).is_ok());
1267 }
1268
1269 #[test]
1270 fn action_timeout_past_the_ceiling_is_rejected() {
1271 let mut app = AppConfig::minimal("web", "./srv");
1276 app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
1277 let err = normalize(app).unwrap_err();
1278 assert_eq!(
1279 err,
1280 NormalizeError::ActionTimeoutTooLong {
1281 name: "web".to_string(),
1282 value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
1283 max: MAX_ACTION_TIMEOUT,
1284 }
1285 );
1286 assert!(err.to_string().contains("action_timeout"), "{err}");
1289 }
1290
1291 #[test]
1292 fn action_timeout_at_the_ceiling_is_accepted() {
1293 let mut app = AppConfig::minimal("web", "./srv");
1297 app.action_timeout = MAX_ACTION_TIMEOUT;
1298 assert!(normalize(app).is_ok());
1299 }
1300
1301 #[test]
1302 fn the_default_action_timeout_is_accepted() {
1303 assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
1307 }
1308
1309 #[test]
1310 fn zero_watch_delay_rejected() {
1311 let mut app = AppConfig::minimal("web", "./srv");
1316 app.watch = true;
1317 app.cwd = Some("/srv/web".to_string());
1318 app.watch_delay = Some(UpDuration::from_millis(0));
1319 let err = normalize(app).unwrap_err();
1320 assert_eq!(
1321 err,
1322 NormalizeError::ZeroWatchDelay {
1323 name: "web".to_string()
1324 }
1325 );
1326 assert!(err.to_string().contains("watch_delay"), "{err}");
1329 }
1330
1331 #[test]
1332 fn a_zero_watch_delay_is_rejected_with_watch_off() {
1333 let mut app = AppConfig::minimal("web", "./srv");
1338 app.watch_delay = Some(UpDuration::from_millis(0));
1339 assert!(matches!(
1340 normalize(app).unwrap_err(),
1341 NormalizeError::ZeroWatchDelay { .. }
1342 ));
1343 }
1344
1345 #[test]
1346 fn a_nonzero_watch_delay_is_accepted() {
1347 let mut app = AppConfig::minimal("web", "./srv");
1351 app.watch = true;
1352 app.cwd = Some("/srv/web".to_string());
1353 app.watch_delay = Some(UpDuration::from_millis(1));
1354 assert!(normalize(app).is_ok());
1355 }
1356
1357 #[test]
1358 fn default_failure_threshold_from_toml_accepted() {
1359 let src = r#"
1364name = "web"
1365script = "./srv"
1366
1367[readiness_probe]
1368kind = "http"
1369target = "http://127.0.0.1:8080/healthz"
1370"#;
1371 let app: AppConfig = toml::from_str(src).unwrap();
1372 assert!(normalize(app).is_ok());
1373 }
1374
1375 #[test]
1376 fn watch_true_without_cwd_rejected_naming_the_app() {
1377 let mut app = AppConfig::minimal("web", "./srv");
1381 app.watch = true;
1382 let err = normalize(app).unwrap_err();
1383 assert_eq!(
1384 err,
1385 NormalizeError::WatchWithoutCwd {
1386 name: "web".to_string()
1387 }
1388 );
1389 assert!(err.to_string().contains("no cwd to watch"), "{err}");
1392 }
1393
1394 #[test]
1395 fn watch_true_with_cwd_accepted() {
1396 let mut app = AppConfig::minimal("web", "./srv");
1399 app.watch = true;
1400 app.cwd = Some("/srv/web".to_string());
1401 assert!(normalize(app).is_ok());
1402 }
1403
1404 #[test]
1405 fn a_watch_options_glob_that_will_not_compile_is_rejected() {
1406 let mut app = AppConfig::minimal("web", "./srv");
1411 app.watch = true;
1412 app.cwd = Some("/srv/web".to_string());
1413 app.watch_options = vec!["src/**".to_string(), "[".to_string()];
1414 let err = normalize(app).unwrap_err();
1415 assert_eq!(
1416 err,
1417 NormalizeError::InvalidWatchGlob {
1418 name: "web".to_string(),
1419 field: "watch_options",
1420 pattern: "[".to_string(),
1421 reason: Glob::new("[").unwrap_err().to_string(),
1422 }
1423 );
1424 let rendered = err.to_string();
1427 for expected in ["web", "watch_options", "`[`"] {
1428 assert!(
1429 rendered.contains(expected),
1430 "{expected} missing: {rendered}"
1431 );
1432 }
1433 }
1434
1435 #[test]
1436 fn an_ignore_watch_glob_that_will_not_compile_is_rejected() {
1437 let mut app = AppConfig::minimal("web", "./srv");
1440 app.watch = true;
1441 app.cwd = Some("/srv/web".to_string());
1442 app.ignore_watch = vec!["[".to_string()];
1443 match normalize(app).unwrap_err() {
1444 NormalizeError::InvalidWatchGlob { field, pattern, .. } => {
1445 assert_eq!(field, "ignore_watch");
1446 assert_eq!(pattern, "[");
1447 }
1448 other => panic!("expected InvalidWatchGlob, got {other:?}"),
1449 }
1450 }
1451
1452 #[test]
1453 fn a_glob_that_will_not_compile_is_rejected_with_watch_off() {
1454 let mut app = AppConfig::minimal("web", "./srv");
1459 app.watch_options = vec!["[".to_string()];
1460 assert!(matches!(
1461 normalize(app).unwrap_err(),
1462 NormalizeError::InvalidWatchGlob { .. }
1463 ));
1464 }
1465
1466 #[test]
1467 fn well_formed_watch_globs_are_accepted() {
1468 let mut app = AppConfig::minimal("web", "./srv");
1473 app.watch = true;
1474 app.cwd = Some("/srv/web".to_string());
1475 app.watch_options = vec!["src/**/*.rs".to_string(), "*.[ch]".to_string()];
1476 app.ignore_watch = vec!["target/**".to_string(), "**/[!.]*.{tmp,swp}".to_string()];
1477 assert!(normalize(app).is_ok());
1478 }
1479
1480 #[test]
1481 fn a_typo_in_an_env_template_is_refused_and_names_the_field() {
1482 let mut app = AppConfig::minimal("web", "./srv");
1483 app.env
1484 .insert("WORKER".to_string(), "w-{{instnace}}".to_string());
1485 let err = normalize(app).unwrap_err();
1486 let rendered = err.to_string();
1487 assert!(rendered.contains("instnace"), "names the typo: {rendered}");
1488 assert!(rendered.contains("WORKER"), "and the field: {rendered}");
1489 }
1490
1491 #[test]
1492 fn a_typo_in_an_arg_template_is_refused_too() {
1493 let mut app = AppConfig::minimal("web", "./srv");
1494 app.args = vec!["--port".to_string(), "91{{slot}}".to_string()];
1495 let err = normalize(app).unwrap_err();
1496 assert!(err.to_string().contains("slot"), "{err}");
1497 }
1498
1499 #[test]
1500 fn an_explicit_log_path_shared_by_every_instance_is_refused() {
1501 let mut app = AppConfig::minimal("web", "./srv");
1502 app.instances = 3;
1503 app.out_file = Some("/var/log/web.log".to_string());
1504 let err = normalize(app).unwrap_err();
1505 let rendered = err.to_string();
1506 assert!(rendered.contains("out_file"), "names the field: {rendered}");
1507 assert!(
1508 rendered.contains("{{instance}}") && rendered.contains("merge_logs"),
1509 "and both ways out: {rendered}"
1510 );
1511 assert!(
1512 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1513 "no em or en dash in copy a user reads: {rendered}"
1514 );
1515 }
1516
1517 #[test]
1518 fn the_three_ways_out_of_the_shared_log_refusal_all_work() {
1519 let mut templated = AppConfig::minimal("web", "./srv");
1521 templated.instances = 3;
1522 templated.out_file = Some("/var/log/web-{{instance}}.log".to_string());
1523 assert!(normalize(templated).is_ok());
1524
1525 let mut merged = AppConfig::minimal("web", "./srv");
1527 merged.instances = 3;
1528 merged.out_file = Some("/var/log/web.log".to_string());
1529 merged.merge_logs = true;
1530 assert!(normalize(merged).is_ok());
1531
1532 let mut single = AppConfig::minimal("web", "./srv");
1534 single.out_file = Some("/var/log/web.log".to_string());
1535 assert!(normalize(single).is_ok());
1536 }
1537
1538 #[test]
1539 fn an_escaped_template_in_a_log_path_does_not_satisfy_the_refusal() {
1540 let mut app = AppConfig::minimal("web", "./srv");
1543 app.instances = 3;
1544 app.out_file = Some("/var/log/web-{{{{instance}}}}.log".to_string());
1545 assert!(normalize(app).is_err());
1546 }
1547
1548 #[test]
1549 fn a_name_only_template_does_not_resolve_the_collision() {
1550 let mut app = AppConfig::minimal("web", "./srv");
1554 app.instances = 3;
1555 app.out_file = Some("/var/log/{{name}}.log".to_string());
1556 assert!(normalize(app).is_err());
1557 }
1558
1559 #[test]
1560 fn a_bad_template_in_a_log_path_is_reported_as_bad_template_not_shared_path() {
1561 let mut app = AppConfig::minimal("web", "./srv");
1562 app.instances = 3;
1563 app.out_file = Some("/var/log/web-{{instnace}}.log".to_string());
1564 match normalize(app).unwrap_err() {
1565 NormalizeError::BadTemplate { field, reason, .. } => {
1566 assert_eq!(field, "out_file");
1567 assert!(reason.contains("instnace"), "{reason}");
1568 }
1569 other => panic!("expected BadTemplate, got {other:?}"),
1570 }
1571 }
1572
1573 #[test]
1574 fn watch_options_without_watch_or_cwd_accepted() {
1575 let mut app = AppConfig::minimal("web", "./srv");
1579 app.watch_options = vec!["src/**".to_string()];
1580 assert!(normalize(app).is_ok());
1581 }
1582}