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]
367 fn the_reserved_env_vars_are_refused_rather_than_overwritten() {
368 let advice = [
372 ("SHEP_INSTANCE", "or `{{instance}}` in your own variable"),
373 ("SHEP_NAME", "or `{{name}}` in your own variable"),
374 ("SHEP_ENVIRONMENT", "or set `environment` on the app"),
375 ];
376 for (var, fitting) in advice {
377 let mut app = AppConfig::minimal("web", "./srv");
378 app.env.insert(var.to_string(), "mine".to_string());
379 let err = normalize(app).unwrap_err();
380 let rendered = err.to_string();
381 assert!(rendered.contains(var), "names the variable: {rendered}");
382 assert!(
383 rendered.contains(fitting),
384 "{var} gets advice that fits it: {rendered}"
385 );
386 for (owner, unrelated) in advice {
387 assert!(
388 owner == var || !rendered.contains(unrelated),
389 "{var} carries {owner}'s advice: {rendered}"
390 );
391 }
392 assert_eq!(
395 rendered.contains("{{"),
396 fitting.contains("{{"),
397 "{var} offers a template only if its own advice does: {rendered}"
398 );
399 assert!(
400 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
401 "no em or en dash in copy a user reads: {rendered}"
402 );
403 }
404 }
405
406 #[test]
407 fn valid_minimal_config_normalizes() {
408 let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
409 assert_eq!(resolved.config().name, "web");
410 }
411
412 #[test]
413 fn names_that_reach_the_filesystem_are_rejected() {
414 for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
417 assert_eq!(
418 normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
419 NormalizeError::InvalidName(bad.to_string())
420 );
421 }
422 assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
423 }
424
425 #[test]
426 fn missing_name_and_script_are_distinct_errors() {
427 assert_eq!(
428 normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
429 NormalizeError::MissingName
430 );
431 assert_eq!(
432 normalize(AppConfig::minimal("web", "")).unwrap_err(),
433 NormalizeError::MissingScript
434 );
435 }
436
437 #[test]
438 fn zero_instances_rejected() {
439 let mut app = AppConfig::minimal("web", "./srv");
440 app.instances = 0;
441 assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
442 }
443
444 #[test]
445 fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
446 let mut app = AppConfig::minimal("web", "./srv");
448 app.cron_restart = Some("not a cron".to_string());
449 match normalize(app).unwrap_err() {
450 NormalizeError::InvalidCron { pattern, reason } => {
451 assert_eq!(pattern, "not a cron");
452 assert!(!reason.is_empty());
453 }
454 other => panic!("expected InvalidCron, got {other:?}"),
455 }
456 }
457
458 #[test]
459 fn five_tokens_of_garbage_cron_pattern_rejected() {
460 let mut app = AppConfig::minimal("web", "./srv");
464 app.cron_restart = Some("99 99 99 99 99".to_string());
465 match normalize(app).unwrap_err() {
466 NormalizeError::InvalidCron { pattern, .. } => {
467 assert_eq!(pattern, "99 99 99 99 99");
468 }
469 other => panic!("expected InvalidCron, got {other:?}"),
470 }
471 }
472
473 #[test]
474 fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
475 let mut app = AppConfig::minimal("web", "./srv");
480 app.cron_restart = Some("0 3 * * *".to_string());
481 app.cron_timezone = Some("Mars/Olympus".to_string());
482 match normalize(app).unwrap_err() {
483 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
484 other => panic!("expected InvalidTimezone, got {other:?}"),
485 }
486 }
487
488 #[test]
489 fn cron_timezone_validated_even_without_cron_restart() {
490 let mut app = AppConfig::minimal("web", "./srv");
494 app.cron_timezone = Some("Mars/Olympus".to_string());
495 match normalize(app).unwrap_err() {
496 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
497 other => panic!("expected InvalidTimezone, got {other:?}"),
498 }
499 }
500
501 #[test]
502 fn duplicate_names_rejected_across_a_flock() {
503 let apps = vec![
504 AppConfig::minimal("web", "./a"),
505 AppConfig::minimal("web", "./b"),
506 ];
507 assert_eq!(
508 normalize_all(apps).unwrap_err(),
509 NormalizeError::DuplicateName("web".to_string())
510 );
511 }
512
513 #[test]
514 fn a_cycle_inside_one_document_is_refused_and_named() {
515 let mut a = AppConfig::minimal("a", "./a");
517 a.depends_on = vec!["b".to_string()];
518 let mut b = AppConfig::minimal("b", "./b");
519 b.depends_on = vec!["a".to_string()];
520 match normalize_all(vec![a, b]) {
521 Err(NormalizeError::DependencyCycle(cycle)) => {
522 let rendered = NormalizeError::DependencyCycle(cycle).to_string();
523 assert!(rendered.contains("a"), "{rendered}");
524 assert!(rendered.contains("b"), "{rendered}");
525 assert!(
526 rendered.contains(" -> "),
527 "the path must be shown: {rendered}"
528 );
529 }
530 other => panic!("expected DependencyCycle, got {other:?}"),
531 }
532 }
533
534 #[test]
535 fn a_dependency_on_an_app_outside_the_document_is_accepted() {
536 let mut api = AppConfig::minimal("api", "./api");
539 api.depends_on = vec!["db-in-another-repo".to_string()];
540 normalize_all(vec![api]).expect("an unresolved name is not a document error");
541 }
542
543 #[test]
544 fn zero_max_memory_rejected() {
545 let mut app = AppConfig::minimal("web", "./srv");
550 app.max_memory = Some(crate::values::MemSize::from_bytes(0));
551 let err = normalize(app).unwrap_err();
552 assert_eq!(
553 err,
554 NormalizeError::ZeroMaxMemory {
555 name: "web".to_string()
556 }
557 );
558 assert!(err.to_string().contains("max_memory"), "{err}");
561 }
562
563 #[test]
564 fn a_nonzero_max_memory_is_accepted() {
565 let mut app = AppConfig::minimal("web", "./srv");
569 app.max_memory = Some("512M".parse().unwrap());
570 assert!(normalize(app).is_ok());
571 }
572
573 #[test]
577 fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
578 let mut app = AppConfig::minimal("web", "./srv");
579 app.kill_signal = Some("SIGUSR1".to_string());
580
581 let err = normalize(app).unwrap_err();
582
583 assert_eq!(
584 err,
585 NormalizeError::InvalidKillSignal {
586 name: "web".to_string(),
587 value: "SIGUSR1".to_string(),
588 }
589 );
590 let rendered = err.to_string();
593 assert!(rendered.contains("SIGUSR1"), "{rendered}");
594 assert!(rendered.contains("SIGTERM"), "{rendered}");
595 assert!(rendered.contains("SIGUSR2"), "{rendered}");
596 }
597
598 #[test]
602 fn every_spelling_the_daemon_already_accepted_still_normalizes() {
603 for name in [
604 "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
605 "USR2", "sigusr2",
606 ] {
607 let mut app = AppConfig::minimal("web", "./srv");
608 app.kill_signal = Some(name.to_string());
609 assert!(
610 normalize(app).is_ok(),
611 "`{name}` was accepted before this task and must still be"
612 );
613 }
614 }
615
616 #[test]
620 fn an_unset_kill_signal_is_not_a_config_error() {
621 let app = AppConfig::minimal("web", "./srv");
622 assert!(app.kill_signal.is_none());
623 assert!(normalize(app).is_ok());
624 }
625
626 #[test]
627 fn a_sheep_cannot_claim_the_all_environment() {
628 let mut app = AppConfig::minimal("web", "./srv");
631 app.environment = Some("all".to_string());
632 let err = normalize(app).unwrap_err();
633 assert!(err.to_string().contains("all"), "{err}");
634 }
635
636 #[test]
637 fn an_environment_outside_the_grammar_is_refused() {
638 for bad in ["", "has space", "has/slash"] {
639 let mut app = AppConfig::minimal("web", "./srv");
640 app.environment = Some(bad.to_string());
641 assert!(normalize(app).is_err(), "{bad:?} must be refused");
642 }
643 }
644
645 #[test]
646 fn action_timeout_past_the_ceiling_is_rejected() {
647 let mut app = AppConfig::minimal("web", "./srv");
652 app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
653 let err = normalize(app).unwrap_err();
654 assert_eq!(
655 err,
656 NormalizeError::ActionTimeoutTooLong {
657 name: "web".to_string(),
658 value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
659 max: MAX_ACTION_TIMEOUT,
660 }
661 );
662 assert!(err.to_string().contains("action_timeout"), "{err}");
665 }
666
667 #[test]
668 fn action_timeout_at_the_ceiling_is_accepted() {
669 let mut app = AppConfig::minimal("web", "./srv");
673 app.action_timeout = MAX_ACTION_TIMEOUT;
674 assert!(normalize(app).is_ok());
675 }
676
677 #[test]
678 fn the_default_action_timeout_is_accepted() {
679 assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
683 }
684
685 #[test]
686 fn zero_watch_delay_rejected() {
687 let mut app = AppConfig::minimal("web", "./srv");
692 app.watch = true;
693 app.cwd = Some("/srv/web".to_string());
694 app.watch_delay = Some(UpDuration::from_millis(0));
695 let err = normalize(app).unwrap_err();
696 assert_eq!(
697 err,
698 NormalizeError::ZeroWatchDelay {
699 name: "web".to_string()
700 }
701 );
702 assert!(err.to_string().contains("watch_delay"), "{err}");
705 }
706
707 #[test]
708 fn a_zero_watch_delay_is_rejected_with_watch_off() {
709 let mut app = AppConfig::minimal("web", "./srv");
714 app.watch_delay = Some(UpDuration::from_millis(0));
715 assert!(matches!(
716 normalize(app).unwrap_err(),
717 NormalizeError::ZeroWatchDelay { .. }
718 ));
719 }
720
721 #[test]
722 fn a_nonzero_watch_delay_is_accepted() {
723 let mut app = AppConfig::minimal("web", "./srv");
727 app.watch = true;
728 app.cwd = Some("/srv/web".to_string());
729 app.watch_delay = Some(UpDuration::from_millis(1));
730 assert!(normalize(app).is_ok());
731 }
732
733 #[test]
734 fn watch_true_without_cwd_rejected_naming_the_app() {
735 let mut app = AppConfig::minimal("web", "./srv");
739 app.watch = true;
740 let err = normalize(app).unwrap_err();
741 assert_eq!(
742 err,
743 NormalizeError::WatchWithoutCwd {
744 name: "web".to_string()
745 }
746 );
747 assert!(err.to_string().contains("no cwd to watch"), "{err}");
750 }
751
752 #[test]
753 fn watch_true_with_cwd_accepted() {
754 let mut app = AppConfig::minimal("web", "./srv");
757 app.watch = true;
758 app.cwd = Some("/srv/web".to_string());
759 assert!(normalize(app).is_ok());
760 }
761
762 #[test]
763 fn level_rules_survive_normalization_in_the_order_they_were_written() {
764 let mut app = AppConfig::minimal("web", "./srv");
765 app.level_rules = vec![
766 LevelRule {
767 pattern: r"\[ERROR\]".to_string(),
768 level: LineLevel::Error,
769 },
770 LevelRule {
771 pattern: r"\[WARN\]".to_string(),
772 level: LineLevel::Warn,
773 },
774 ];
775 let resolved = normalize(app.clone()).unwrap();
776 assert_eq!(resolved.config().level_rules, app.level_rules);
777 }
778
779 #[test]
783 fn a_level_rule_pattern_that_will_not_compile_is_rejected() {
784 let mut app = AppConfig::minimal("web", "./srv");
785 app.level_rules = vec![LevelRule {
786 pattern: "[unclosed".to_string(),
787 level: LineLevel::Error,
788 }];
789 let err = normalize(app).unwrap_err();
790 let rendered = err.to_string();
791 for expected in ["web", "[unclosed"] {
792 assert!(
793 rendered.contains(expected),
794 "{expected} missing: {rendered}"
795 );
796 }
797 }
798
799 #[test]
802 fn an_empty_level_rule_pattern_is_rejected() {
803 let mut app = AppConfig::minimal("web", "./srv");
804 app.level_rules = vec![LevelRule {
805 pattern: String::new(),
806 level: LineLevel::Warn,
807 }];
808 let err = normalize(app).unwrap_err();
809 assert_eq!(
810 err,
811 NormalizeError::InvalidLevelRule {
812 name: "web".to_string(),
813 reason: LevelRuleError::EmptyPattern {
814 level: LineLevel::Warn
815 }
816 .to_string(),
817 }
818 );
819 }
820
821 #[test]
822 fn watch_options_without_watch_or_cwd_accepted() {
823 let mut app = AppConfig::minimal("web", "./srv");
827 app.watch_options = vec!["src/**".to_string()];
828 assert!(normalize(app).is_ok());
829 }
830
831 #[test]
832 fn a_sheep_that_depends_on_itself_is_refused_by_name() {
833 let mut app = AppConfig::minimal("api", "./api");
836 app.depends_on = vec!["api".to_string()];
837 match normalize(app) {
838 Err(NormalizeError::SelfDependency(name)) => assert_eq!(name, "api"),
839 other => panic!("expected SelfDependency, got {other:?}"),
840 }
841 }
842
843 #[test]
844 fn a_dependency_on_one_instance_is_refused_naming_the_app_form() {
845 let mut app = AppConfig::minimal("api", "./api");
847 app.depends_on = vec!["db:2".to_string()];
848 match normalize(app) {
849 Err(NormalizeError::InstanceDependency { sheep, target }) => {
850 assert_eq!(sheep, "api");
851 assert_eq!(target, "db:2");
852 }
853 other => panic!("expected InstanceDependency, got {other:?}"),
854 }
855 let rendered = NormalizeError::InstanceDependency {
856 sheep: "api".to_string(),
857 target: "db:2".to_string(),
858 }
859 .to_string();
860 assert!(
861 rendered.contains("`db`"),
862 "the refusal must name the app-level form: {rendered}"
863 );
864 }
865
866 #[test]
867 fn duplicate_dependencies_dedupe_rather_than_refusing() {
868 let mut app = AppConfig::minimal("api", "./api");
871 app.depends_on = vec!["db".to_string(), "db".to_string()];
872 let resolved = normalize(app).expect("a repeated name is not an error");
873 assert_eq!(resolved.config().depends_on, vec!["db".to_string()]);
874 }
875
876 #[test]
877 fn an_ordinary_dependency_list_survives_normalize() {
878 let mut app = AppConfig::minimal("api", "./api");
880 app.depends_on = vec!["db".to_string(), "cache".to_string()];
881 let resolved = normalize(app).expect("an ordinary list normalizes");
882 assert_eq!(
883 resolved.config().depends_on,
884 vec!["db".to_string(), "cache".to_string()]
885 );
886 }
887}