Skip to main content

isoloom_compose/
start.rs

1//! How to start a Compose file: `wait` for everything but its leaf jobs (`up -d --wait`), then
2//! run each of those jobs attached, in order, failing on its exit code. Computed from the typed
3//! file this backend renders, and read back from a file's text the same way, so an embedder
4//! holding only `.isoloom/docker/compose.yml` gets the plan Isoloom has.
5
6use serde_yaml_ng::{Mapping, Value};
7
8use crate::error::{Error, Result};
9use crate::file::ComposeFile;
10
11/// How to start a Compose file: the services to wait for, then the jobs to run.
12#[derive(Debug, Clone, PartialEq, Eq, Default)]
13pub struct StartPlan {
14    /// Services to bring up with `docker compose up -d --wait` (empty: all of them).
15    pub wait: Vec<String>,
16    /// One-shot jobs nothing running waits for, to run after `wait`, in order.
17    pub jobs: Vec<String>,
18}
19
20/// A service as the start plan reads it: its name, what it waits for, whether it runs once
21/// (`restart: "no"`). Services in a profile aren't started, so they aren't read.
22struct Started {
23    name: String,
24    deps: Vec<String>,
25    one_shot: bool,
26}
27
28/// The [`StartPlan`] of a Compose file's text: one-shots (`restart: "no"`) outside profiles that
29/// no other started service depends on are jobs; when there are none, `wait` is empty (start
30/// all).
31///
32/// # Errors
33///
34/// [`Error::Unreadable`] when the text isn't YAML. A file without services starts nothing
35/// special: the default plan.
36pub 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
54/// What a service of a file's text waits for: `depends_on` as a map or as a list.
55fn 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    /// The plan of a typed Compose file: what [`start_plan`] reads from its text.
65    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    // What the long-running services need, through chains of one-shots: `--wait` accepts those
82    // exiting. The other one-shots are the jobs.
83    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    /// A service of a generated file: whether it runs once, whether it is in a profile, and
144    /// the services it waits for (by index; one out of range or its own is left out).
145    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}