1use serde_yaml_ng::{Mapping, Value};
7
8use crate::error::{Error, Result};
9use crate::file::ComposeFile;
10
11#[derive(Debug, Clone, PartialEq, Eq, Default)]
13pub struct StartPlan {
14 pub wait: Vec<String>,
16 pub jobs: Vec<String>,
18}
19
20struct Started {
23 name: String,
24 deps: Vec<String>,
25 one_shot: bool,
26}
27
28pub fn start_plan(compose_yaml: &str) -> Result<StartPlan> {
37 let doc: Value = serde_yaml_ng::from_str(compose_yaml).map_err(|source| Error::Unreadable { source })?;
38 let Some(Value::Mapping(services)) = doc.get("services") else {
39 return Ok(StartPlan::default());
40 };
41 let started: Vec<Started> = services
42 .iter()
43 .filter_map(|(k, v)| Some((k.as_str()?, v.as_mapping()?)))
44 .filter(|(_, v)| v.get("profiles").is_none())
45 .map(|(name, v)| Started {
46 name: name.to_owned(),
47 deps: dependencies(v),
48 one_shot: v.get("restart").and_then(Value::as_str) == Some("no"),
49 })
50 .collect();
51 Ok(plan_of(&started))
52}
53
54fn dependencies(service: &Mapping) -> Vec<String> {
56 match service.get("depends_on") {
57 Some(Value::Mapping(d)) => d.keys().filter_map(|k| k.as_str().map(String::from)).collect(),
58 Some(Value::Sequence(d)) => d.iter().filter_map(|x| x.as_str().map(String::from)).collect(),
59 _ => Vec::new(),
60 }
61}
62
63impl StartPlan {
64 pub(crate) fn of(file: &ComposeFile) -> StartPlan {
66 let started: Vec<Started> = file
67 .services
68 .iter()
69 .filter(|(_, s)| s.profiles.is_empty())
70 .map(|(name, s)| Started {
71 name: name.clone(),
72 deps: s.depends_on.keys().cloned().collect(),
73 one_shot: s.runs_once(),
74 })
75 .collect();
76 plan_of(&started)
77 }
78}
79
80fn plan_of(started: &[Started]) -> StartPlan {
81 let mut needed: Vec<&str> = Vec::new();
84 let mut todo: Vec<&str> = started.iter().filter(|s| !s.one_shot).flat_map(|s| s.deps.iter().map(String::as_str)).collect();
85 while let Some(n) = todo.pop() {
86 if needed.contains(&n) {
87 continue;
88 }
89 if let Some(s) = started.iter().find(|s| s.name == n) {
90 todo.extend(s.deps.iter().map(String::as_str));
91 }
92 needed.push(n);
93 }
94 let jobs: Vec<String> = started
95 .iter()
96 .filter(|s| s.one_shot && !needed.contains(&s.name.as_str()))
97 .map(|s| s.name.clone())
98 .collect();
99 if jobs.is_empty() {
100 return StartPlan::default();
101 }
102 let wait = started.iter().map(|s| s.name.clone()).filter(|n| !jobs.contains(n)).collect();
103 StartPlan { wait, jobs }
104}
105
106#[cfg(test)]
107mod tests {
108 use proptest::prelude::*;
109
110 use super::*;
111 use crate::file::{Condition, Restart, Service};
112
113 const FILE: &str = "
114services:
115 web: { restart: unless-stopped, depends_on: { db: { condition: service_healthy }, web-init-1: { condition: service_completed_successfully } } }
116 web-init-1: { restart: 'no' }
117 db: { restart: unless-stopped }
118 seed: { restart: 'no', depends_on: [db] }
119 isoloom-check: { profiles: [check], restart: 'no' }
120";
121
122 #[test]
123 fn a_job_nothing_waits_for_runs_after_the_rest_is_up() {
124 let plan = start_plan(FILE).expect("reads");
125 assert_eq!(plan.jobs, ["seed"]);
126 assert_eq!(plan.wait, ["web", "web-init-1", "db"]);
127 }
128
129 #[test]
130 fn without_leaf_jobs_everything_is_waited_for() {
131 assert_eq!(
132 start_plan("services: { web: { restart: unless-stopped } }\n").expect("reads"),
133 StartPlan::default()
134 );
135 assert_eq!(start_plan("name: empty\n").expect("reads"), StartPlan::default());
136 }
137
138 #[test]
139 fn text_that_is_not_yaml_is_refused() {
140 assert!(matches!(start_plan("services: [unclosed"), Err(Error::Unreadable { .. })));
141 }
142
143 fn service() -> impl Strategy<Value = (bool, bool, Vec<usize>)> {
146 (any::<bool>(), any::<bool>(), prop::collection::vec(0usize..6, 0..3))
147 }
148
149 proptest! {
150 #[test]
151 fn the_text_and_the_typed_file_give_the_same_plan(services in prop::collection::vec(service(), 1..6)) {
152 let mut file = ComposeFile::default();
153 for (i, (once, profiled, deps)) in services.iter().enumerate() {
154 let mut svc = Service {
155 restart: Some(if *once { Restart::No } else { Restart::UnlessStopped }),
156 ..Service::default()
157 };
158 if *profiled {
159 svc.profiles = vec!["check".into()];
160 }
161 for d in deps.iter().filter(|d| **d < services.len() && **d != i) {
162 svc.after(format!("s{d}"), Condition::Started);
163 }
164 file.services.insert(format!("s{i}"), svc);
165 }
166 let yaml = serde_yaml_ng::to_string(&file).expect("writes");
167 prop_assert_eq!(StartPlan::of(&file), start_plan(&yaml).expect("reads back"));
168 }
169 }
170}