1use std::collections::BTreeSet;
2use std::path::Path;
3
4use crate::config::{AppConfig, CronParseError, CronSchedule, KillSignal, LevelMatcher};
5use crate::secrets;
6use crate::values::UpDuration;
7
8use super::{NormalizeError, tilde, validate};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ResolvedApp {
13 pub(super) config: AppConfig,
14}
15
16impl ResolvedApp {
17 #[must_use]
19 pub fn config(&self) -> &AppConfig {
20 &self.config
21 }
22
23 #[must_use]
25 pub fn into_config(self) -> AppConfig {
26 self.config
27 }
28}
29
30pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
61 let home = std::env::home_dir();
62 let shep_home = crate::paths::shep_home(&|key| std::env::var(key).ok(), home.as_deref());
63 normalize_with_home(app, home.as_deref(), shep_home.as_deref())
64}
65
66pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
74 let mut seen = BTreeSet::new();
75 let resolved: Vec<ResolvedApp> = apps
76 .into_iter()
77 .map(|app| {
78 if !seen.insert(app.name.clone()) {
79 return Err(NormalizeError::DuplicateName(app.name));
80 }
81 normalize(app)
82 })
83 .collect::<Result<_, _>>()?;
84 let nodes: Vec<crate::config::graph::BootNode> = resolved
87 .iter()
88 .map(|app| crate::config::graph::BootNode {
89 name: app.config().name.clone(),
90 depends_on: app.config().depends_on.clone(),
91 kind: crate::config::graph::NodeKind::Sheep,
92 })
93 .collect();
94 if let Some(cycle) = crate::config::graph::plan(&nodes).cycles.into_iter().next() {
95 return Err(NormalizeError::DependencyCycle(cycle));
96 }
97 Ok(resolved)
98}
99
100pub(super) const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
110
111pub(super) const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
118
119const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
127
128pub fn normalize_with_home(
143 mut app: AppConfig,
144 home: Option<&Path>,
145 shep_home: Option<&Path>,
146) -> Result<ResolvedApp, NormalizeError> {
147 if app.name.is_empty() {
148 return Err(NormalizeError::MissingName);
149 }
150 if app.name.contains(['/', '\\', ':']) || app.name == "." || app.name == ".." {
151 return Err(NormalizeError::InvalidName(app.name));
152 }
153 if let Some(environment) = &app.environment
154 && (environment == secrets::ALL_ENVIRONMENTS || !secrets::is_name(environment))
155 {
156 return Err(NormalizeError::InvalidEnvironment {
157 name: app.name.clone(),
158 value: environment.clone(),
159 });
160 }
161 for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
162 if app.env.contains_key(var) {
163 return Err(NormalizeError::ReservedEnvVar {
164 name: app.name.clone(),
165 var,
166 });
167 }
168 }
169 for (key, value) in &app.env {
170 validate::validate_template(&app.name, &format!("env.{key}"), value, shep_home)?;
171 }
172 for (index, value) in app.args.iter().enumerate() {
173 validate::validate_template(&app.name, &format!("args[{index}]"), value, shep_home)?;
174 }
175 for (field, value) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
176 if let Some(value) = value {
177 validate::validate_template(&app.name, field, value, shep_home)?;
178 if crate::config::template::holds_secret(value) {
179 return Err(NormalizeError::SecretInLogPath {
180 name: app.name.clone(),
181 field,
182 });
183 }
184 }
185 }
186 if app.script.is_empty() {
187 return Err(NormalizeError::MissingScript);
188 }
189 tilde::expand_paths(&mut app, home)?;
193 if app.instances == 0 {
194 return Err(NormalizeError::ZeroInstances);
195 }
196 if app.instances > 1 && !app.merge_logs {
199 for (field, path) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
200 if let Some(path) = path
205 && crate::config::template::render_positional(path, &app.name, 0, shep_home)
206 == crate::config::template::render_positional(path, &app.name, 1, shep_home)
207 {
208 return Err(NormalizeError::SharedLogPath {
209 name: app.name.clone(),
210 field,
211 });
212 }
213 }
214 }
215 if let Some(pattern) = &app.cron_restart {
216 CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
217 CronParseError::Pattern { pattern, reason } => {
218 NormalizeError::InvalidCron { pattern, reason }
219 }
220 CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
221 })?;
222 } else if let Some(tz_name) = &app.cron_timezone {
223 crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
226 NormalizeError::InvalidTimezone {
227 name: tz_name.clone(),
228 }
229 })?;
230 }
231 validate::validate_probe(
232 app.readiness_probe.as_ref(),
233 "readiness_probe",
234 MIN_READINESS_INTERVAL,
235 )?;
236 validate::validate_probe(
237 app.liveness_probe.as_ref(),
238 "liveness_probe",
239 MIN_LIVENESS_INTERVAL,
240 )?;
241 if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
242 return Err(NormalizeError::ZeroMaxMemory { name: app.name });
247 }
248 if let Some(name) = &app.kill_signal
249 && KillSignal::parse(name).is_none()
250 {
251 return Err(NormalizeError::InvalidKillSignal {
255 name: app.name,
256 value: name.clone(),
257 });
258 }
259 if app.action_timeout > MAX_ACTION_TIMEOUT {
260 return Err(NormalizeError::ActionTimeoutTooLong {
264 name: app.name,
265 value: app.action_timeout,
266 max: MAX_ACTION_TIMEOUT,
267 });
268 }
269 if app.watch && app.cwd.is_none() {
270 return Err(NormalizeError::WatchWithoutCwd { name: app.name });
275 }
276 if app.watch_delay == Some(UpDuration::from_millis(0)) {
277 return Err(NormalizeError::ZeroWatchDelay { name: app.name });
281 }
282 validate::validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
286 validate::validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
287 LevelMatcher::compile(&app.level_rules).map_err(|err| NormalizeError::InvalidLevelRule {
292 name: app.name.clone(),
293 reason: err.to_string(),
294 })?;
295 let mut seen = BTreeSet::new();
296 let mut deduped = Vec::with_capacity(app.depends_on.len());
297 for target in &app.depends_on {
298 if target == &app.name {
299 return Err(NormalizeError::SelfDependency(app.name.clone()));
300 }
301 if target.contains(':') {
302 return Err(NormalizeError::InstanceDependency {
303 sheep: app.name.clone(),
304 target: target.clone(),
305 });
306 }
307 if seen.insert(target.clone()) {
308 deduped.push(target.clone());
309 }
310 }
311 app.depends_on = deduped;
312 Ok(ResolvedApp { config: app })
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::config::{LevelRule, LevelRuleError, LineLevel};
319
320 #[test]
324 fn reuse_port_loads_now_that_reload_reads_it() {
325 let mut app = AppConfig::minimal("web", "./server");
326 app.reuse_port = true;
327
328 let resolved = normalize(app).expect("reuse_port is implemented");
329 assert!(resolved.config().reuse_port);
330 }
331
332 #[test]
335 fn an_app_that_never_mentions_reuse_port_still_normalizes() {
336 let resolved = normalize(AppConfig::minimal("web", "./server"))
337 .expect("the common case must be untouched");
338 assert!(!resolved.config().reuse_port);
339 }
340 use crate::config::AppConfig;
341
342 #[test]
343 fn a_colon_in_a_name_is_refused_because_it_is_the_instance_separator() {
344 let err = normalize(AppConfig::minimal("web:2", "./srv")).unwrap_err();
345 assert_eq!(err, NormalizeError::InvalidName("web:2".to_string()));
346
347 let rendered = err.to_string();
348 assert!(rendered.contains(':'), "says which character: {rendered}");
349 assert!(
351 rendered.contains("`-`"),
352 "suggests the stand-in: {rendered}"
353 );
354 assert!(
355 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
356 "no em or en dash in copy a user reads: {rendered}"
357 );
358
359 assert!(normalize(AppConfig::minimal("web-2", "./srv")).is_ok());
360 }
361
362 #[test]
363 fn the_reserved_env_vars_are_refused_rather_than_overwritten() {
364 for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
365 let mut app = AppConfig::minimal("web", "./srv");
366 app.env.insert(var.to_string(), "mine".to_string());
367 let err = normalize(app).unwrap_err();
368 let rendered = err.to_string();
369 assert!(rendered.contains(var), "names the variable: {rendered}");
370 assert!(
371 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
372 "no em or en dash in copy a user reads: {rendered}"
373 );
374 }
375 }
376
377 #[test]
378 fn valid_minimal_config_normalizes() {
379 let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
380 assert_eq!(resolved.config().name, "web");
381 }
382
383 #[test]
384 fn names_that_reach_the_filesystem_are_rejected() {
385 for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
388 assert_eq!(
389 normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
390 NormalizeError::InvalidName(bad.to_string())
391 );
392 }
393 assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
394 }
395
396 #[test]
397 fn missing_name_and_script_are_distinct_errors() {
398 assert_eq!(
399 normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
400 NormalizeError::MissingName
401 );
402 assert_eq!(
403 normalize(AppConfig::minimal("web", "")).unwrap_err(),
404 NormalizeError::MissingScript
405 );
406 }
407
408 #[test]
409 fn zero_instances_rejected() {
410 let mut app = AppConfig::minimal("web", "./srv");
411 app.instances = 0;
412 assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
413 }
414
415 #[test]
416 fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
417 let mut app = AppConfig::minimal("web", "./srv");
419 app.cron_restart = Some("not a cron".to_string());
420 match normalize(app).unwrap_err() {
421 NormalizeError::InvalidCron { pattern, reason } => {
422 assert_eq!(pattern, "not a cron");
423 assert!(!reason.is_empty());
424 }
425 other => panic!("expected InvalidCron, got {other:?}"),
426 }
427 }
428
429 #[test]
430 fn five_tokens_of_garbage_cron_pattern_rejected() {
431 let mut app = AppConfig::minimal("web", "./srv");
435 app.cron_restart = Some("99 99 99 99 99".to_string());
436 match normalize(app).unwrap_err() {
437 NormalizeError::InvalidCron { pattern, .. } => {
438 assert_eq!(pattern, "99 99 99 99 99");
439 }
440 other => panic!("expected InvalidCron, got {other:?}"),
441 }
442 }
443
444 #[test]
445 fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
446 let mut app = AppConfig::minimal("web", "./srv");
451 app.cron_restart = Some("0 3 * * *".to_string());
452 app.cron_timezone = Some("Mars/Olympus".to_string());
453 match normalize(app).unwrap_err() {
454 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
455 other => panic!("expected InvalidTimezone, got {other:?}"),
456 }
457 }
458
459 #[test]
460 fn cron_timezone_validated_even_without_cron_restart() {
461 let mut app = AppConfig::minimal("web", "./srv");
465 app.cron_timezone = Some("Mars/Olympus".to_string());
466 match normalize(app).unwrap_err() {
467 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
468 other => panic!("expected InvalidTimezone, got {other:?}"),
469 }
470 }
471
472 #[test]
473 fn duplicate_names_rejected_across_a_flock() {
474 let apps = vec![
475 AppConfig::minimal("web", "./a"),
476 AppConfig::minimal("web", "./b"),
477 ];
478 assert_eq!(
479 normalize_all(apps).unwrap_err(),
480 NormalizeError::DuplicateName("web".to_string())
481 );
482 }
483
484 #[test]
485 fn a_cycle_inside_one_document_is_refused_and_named() {
486 let mut a = AppConfig::minimal("a", "./a");
488 a.depends_on = vec!["b".to_string()];
489 let mut b = AppConfig::minimal("b", "./b");
490 b.depends_on = vec!["a".to_string()];
491 match normalize_all(vec![a, b]) {
492 Err(NormalizeError::DependencyCycle(cycle)) => {
493 let rendered = NormalizeError::DependencyCycle(cycle).to_string();
494 assert!(rendered.contains("a"), "{rendered}");
495 assert!(rendered.contains("b"), "{rendered}");
496 assert!(
497 rendered.contains(" -> "),
498 "the path must be shown: {rendered}"
499 );
500 }
501 other => panic!("expected DependencyCycle, got {other:?}"),
502 }
503 }
504
505 #[test]
506 fn a_dependency_on_an_app_outside_the_document_is_accepted() {
507 let mut api = AppConfig::minimal("api", "./api");
510 api.depends_on = vec!["db-in-another-repo".to_string()];
511 normalize_all(vec![api]).expect("an unresolved name is not a document error");
512 }
513
514 #[test]
515 fn zero_max_memory_rejected() {
516 let mut app = AppConfig::minimal("web", "./srv");
521 app.max_memory = Some(crate::values::MemSize::from_bytes(0));
522 let err = normalize(app).unwrap_err();
523 assert_eq!(
524 err,
525 NormalizeError::ZeroMaxMemory {
526 name: "web".to_string()
527 }
528 );
529 assert!(err.to_string().contains("max_memory"), "{err}");
532 }
533
534 #[test]
535 fn a_nonzero_max_memory_is_accepted() {
536 let mut app = AppConfig::minimal("web", "./srv");
540 app.max_memory = Some("512M".parse().unwrap());
541 assert!(normalize(app).is_ok());
542 }
543
544 #[test]
548 fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
549 let mut app = AppConfig::minimal("web", "./srv");
550 app.kill_signal = Some("SIGUSR1".to_string());
551
552 let err = normalize(app).unwrap_err();
553
554 assert_eq!(
555 err,
556 NormalizeError::InvalidKillSignal {
557 name: "web".to_string(),
558 value: "SIGUSR1".to_string(),
559 }
560 );
561 let rendered = err.to_string();
564 assert!(rendered.contains("SIGUSR1"), "{rendered}");
565 assert!(rendered.contains("SIGTERM"), "{rendered}");
566 assert!(rendered.contains("SIGUSR2"), "{rendered}");
567 }
568
569 #[test]
573 fn every_spelling_the_daemon_already_accepted_still_normalizes() {
574 for name in [
575 "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
576 "USR2", "sigusr2",
577 ] {
578 let mut app = AppConfig::minimal("web", "./srv");
579 app.kill_signal = Some(name.to_string());
580 assert!(
581 normalize(app).is_ok(),
582 "`{name}` was accepted before this task and must still be"
583 );
584 }
585 }
586
587 #[test]
591 fn an_unset_kill_signal_is_not_a_config_error() {
592 let app = AppConfig::minimal("web", "./srv");
593 assert!(app.kill_signal.is_none());
594 assert!(normalize(app).is_ok());
595 }
596
597 #[test]
598 fn a_sheep_cannot_claim_the_all_environment() {
599 let mut app = AppConfig::minimal("web", "./srv");
602 app.environment = Some("all".to_string());
603 let err = normalize(app).unwrap_err();
604 assert!(err.to_string().contains("all"), "{err}");
605 }
606
607 #[test]
608 fn an_environment_outside_the_grammar_is_refused() {
609 for bad in ["", "has space", "has/slash"] {
610 let mut app = AppConfig::minimal("web", "./srv");
611 app.environment = Some(bad.to_string());
612 assert!(normalize(app).is_err(), "{bad:?} must be refused");
613 }
614 }
615
616 #[test]
617 fn action_timeout_past_the_ceiling_is_rejected() {
618 let mut app = AppConfig::minimal("web", "./srv");
623 app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
624 let err = normalize(app).unwrap_err();
625 assert_eq!(
626 err,
627 NormalizeError::ActionTimeoutTooLong {
628 name: "web".to_string(),
629 value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
630 max: MAX_ACTION_TIMEOUT,
631 }
632 );
633 assert!(err.to_string().contains("action_timeout"), "{err}");
636 }
637
638 #[test]
639 fn action_timeout_at_the_ceiling_is_accepted() {
640 let mut app = AppConfig::minimal("web", "./srv");
644 app.action_timeout = MAX_ACTION_TIMEOUT;
645 assert!(normalize(app).is_ok());
646 }
647
648 #[test]
649 fn the_default_action_timeout_is_accepted() {
650 assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
654 }
655
656 #[test]
657 fn zero_watch_delay_rejected() {
658 let mut app = AppConfig::minimal("web", "./srv");
663 app.watch = true;
664 app.cwd = Some("/srv/web".to_string());
665 app.watch_delay = Some(UpDuration::from_millis(0));
666 let err = normalize(app).unwrap_err();
667 assert_eq!(
668 err,
669 NormalizeError::ZeroWatchDelay {
670 name: "web".to_string()
671 }
672 );
673 assert!(err.to_string().contains("watch_delay"), "{err}");
676 }
677
678 #[test]
679 fn a_zero_watch_delay_is_rejected_with_watch_off() {
680 let mut app = AppConfig::minimal("web", "./srv");
685 app.watch_delay = Some(UpDuration::from_millis(0));
686 assert!(matches!(
687 normalize(app).unwrap_err(),
688 NormalizeError::ZeroWatchDelay { .. }
689 ));
690 }
691
692 #[test]
693 fn a_nonzero_watch_delay_is_accepted() {
694 let mut app = AppConfig::minimal("web", "./srv");
698 app.watch = true;
699 app.cwd = Some("/srv/web".to_string());
700 app.watch_delay = Some(UpDuration::from_millis(1));
701 assert!(normalize(app).is_ok());
702 }
703
704 #[test]
705 fn watch_true_without_cwd_rejected_naming_the_app() {
706 let mut app = AppConfig::minimal("web", "./srv");
710 app.watch = true;
711 let err = normalize(app).unwrap_err();
712 assert_eq!(
713 err,
714 NormalizeError::WatchWithoutCwd {
715 name: "web".to_string()
716 }
717 );
718 assert!(err.to_string().contains("no cwd to watch"), "{err}");
721 }
722
723 #[test]
724 fn watch_true_with_cwd_accepted() {
725 let mut app = AppConfig::minimal("web", "./srv");
728 app.watch = true;
729 app.cwd = Some("/srv/web".to_string());
730 assert!(normalize(app).is_ok());
731 }
732
733 #[test]
734 fn level_rules_survive_normalization_in_the_order_they_were_written() {
735 let mut app = AppConfig::minimal("web", "./srv");
736 app.level_rules = vec![
737 LevelRule {
738 pattern: r"\[ERROR\]".to_string(),
739 level: LineLevel::Error,
740 },
741 LevelRule {
742 pattern: r"\[WARN\]".to_string(),
743 level: LineLevel::Warn,
744 },
745 ];
746 let resolved = normalize(app.clone()).unwrap();
747 assert_eq!(resolved.config().level_rules, app.level_rules);
748 }
749
750 #[test]
754 fn a_level_rule_pattern_that_will_not_compile_is_rejected() {
755 let mut app = AppConfig::minimal("web", "./srv");
756 app.level_rules = vec![LevelRule {
757 pattern: "[unclosed".to_string(),
758 level: LineLevel::Error,
759 }];
760 let err = normalize(app).unwrap_err();
761 let rendered = err.to_string();
762 for expected in ["web", "[unclosed"] {
763 assert!(
764 rendered.contains(expected),
765 "{expected} missing: {rendered}"
766 );
767 }
768 }
769
770 #[test]
773 fn an_empty_level_rule_pattern_is_rejected() {
774 let mut app = AppConfig::minimal("web", "./srv");
775 app.level_rules = vec![LevelRule {
776 pattern: String::new(),
777 level: LineLevel::Warn,
778 }];
779 let err = normalize(app).unwrap_err();
780 assert_eq!(
781 err,
782 NormalizeError::InvalidLevelRule {
783 name: "web".to_string(),
784 reason: LevelRuleError::EmptyPattern {
785 level: LineLevel::Warn
786 }
787 .to_string(),
788 }
789 );
790 }
791
792 #[test]
793 fn watch_options_without_watch_or_cwd_accepted() {
794 let mut app = AppConfig::minimal("web", "./srv");
798 app.watch_options = vec!["src/**".to_string()];
799 assert!(normalize(app).is_ok());
800 }
801
802 #[test]
803 fn a_sheep_that_depends_on_itself_is_refused_by_name() {
804 let mut app = AppConfig::minimal("api", "./api");
807 app.depends_on = vec!["api".to_string()];
808 match normalize(app) {
809 Err(NormalizeError::SelfDependency(name)) => assert_eq!(name, "api"),
810 other => panic!("expected SelfDependency, got {other:?}"),
811 }
812 }
813
814 #[test]
815 fn a_dependency_on_one_instance_is_refused_naming_the_app_form() {
816 let mut app = AppConfig::minimal("api", "./api");
818 app.depends_on = vec!["db:2".to_string()];
819 match normalize(app) {
820 Err(NormalizeError::InstanceDependency { sheep, target }) => {
821 assert_eq!(sheep, "api");
822 assert_eq!(target, "db:2");
823 }
824 other => panic!("expected InstanceDependency, got {other:?}"),
825 }
826 let rendered = NormalizeError::InstanceDependency {
827 sheep: "api".to_string(),
828 target: "db:2".to_string(),
829 }
830 .to_string();
831 assert!(
832 rendered.contains("`db`"),
833 "the refusal must name the app-level form: {rendered}"
834 );
835 }
836
837 #[test]
838 fn duplicate_dependencies_dedupe_rather_than_refusing() {
839 let mut app = AppConfig::minimal("api", "./api");
842 app.depends_on = vec!["db".to_string(), "db".to_string()];
843 let resolved = normalize(app).expect("a repeated name is not an error");
844 assert_eq!(resolved.config().depends_on, vec!["db".to_string()]);
845 }
846
847 #[test]
848 fn an_ordinary_dependency_list_survives_normalize() {
849 let mut app = AppConfig::minimal("api", "./api");
851 app.depends_on = vec!["db".to_string(), "cache".to_string()];
852 let resolved = normalize(app).expect("an ordinary list normalizes");
853 assert_eq!(
854 resolved.config().depends_on,
855 vec!["db".to_string(), "cache".to_string()]
856 );
857 }
858}