use serde_yaml_ng::{Mapping, Value};
use crate::error::{Error, Result};
use crate::file::ComposeFile;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StartPlan {
pub wait: Vec<String>,
pub jobs: Vec<String>,
}
struct Started {
name: String,
deps: Vec<String>,
one_shot: bool,
}
pub fn start_plan(compose_yaml: &str) -> Result<StartPlan> {
let doc: Value = serde_yaml_ng::from_str(compose_yaml).map_err(|source| Error::Unreadable { source })?;
let Some(Value::Mapping(services)) = doc.get("services") else {
return Ok(StartPlan::default());
};
let started: Vec<Started> = services
.iter()
.filter_map(|(k, v)| Some((k.as_str()?, v.as_mapping()?)))
.filter(|(_, v)| v.get("profiles").is_none())
.map(|(name, v)| Started {
name: name.to_owned(),
deps: dependencies(v),
one_shot: v.get("restart").and_then(Value::as_str) == Some("no"),
})
.collect();
Ok(plan_of(&started))
}
fn dependencies(service: &Mapping) -> Vec<String> {
match service.get("depends_on") {
Some(Value::Mapping(d)) => d.keys().filter_map(|k| k.as_str().map(String::from)).collect(),
Some(Value::Sequence(d)) => d.iter().filter_map(|x| x.as_str().map(String::from)).collect(),
_ => Vec::new(),
}
}
impl StartPlan {
pub(crate) fn of(file: &ComposeFile) -> StartPlan {
let started: Vec<Started> = file
.services
.iter()
.filter(|(_, s)| s.profiles.is_empty())
.map(|(name, s)| Started {
name: name.clone(),
deps: s.depends_on.keys().cloned().collect(),
one_shot: s.runs_once(),
})
.collect();
plan_of(&started)
}
}
fn plan_of(started: &[Started]) -> StartPlan {
let mut needed: Vec<&str> = Vec::new();
let mut todo: Vec<&str> = started.iter().filter(|s| !s.one_shot).flat_map(|s| s.deps.iter().map(String::as_str)).collect();
while let Some(n) = todo.pop() {
if needed.contains(&n) {
continue;
}
if let Some(s) = started.iter().find(|s| s.name == n) {
todo.extend(s.deps.iter().map(String::as_str));
}
needed.push(n);
}
let jobs: Vec<String> = started
.iter()
.filter(|s| s.one_shot && !needed.contains(&s.name.as_str()))
.map(|s| s.name.clone())
.collect();
if jobs.is_empty() {
return StartPlan::default();
}
let wait = started.iter().map(|s| s.name.clone()).filter(|n| !jobs.contains(n)).collect();
StartPlan { wait, jobs }
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
use crate::file::{Condition, Restart, Service};
const FILE: &str = "
services:
web: { restart: unless-stopped, depends_on: { db: { condition: service_healthy }, web-init-1: { condition: service_completed_successfully } } }
web-init-1: { restart: 'no' }
db: { restart: unless-stopped }
seed: { restart: 'no', depends_on: [db] }
isoloom-check: { profiles: [check], restart: 'no' }
";
#[test]
fn a_job_nothing_waits_for_runs_after_the_rest_is_up() {
let plan = start_plan(FILE).expect("reads");
assert_eq!(plan.jobs, ["seed"]);
assert_eq!(plan.wait, ["web", "web-init-1", "db"]);
}
#[test]
fn without_leaf_jobs_everything_is_waited_for() {
assert_eq!(
start_plan("services: { web: { restart: unless-stopped } }\n").expect("reads"),
StartPlan::default()
);
assert_eq!(start_plan("name: empty\n").expect("reads"), StartPlan::default());
}
#[test]
fn text_that_is_not_yaml_is_refused() {
assert!(matches!(start_plan("services: [unclosed"), Err(Error::Unreadable { .. })));
}
fn service() -> impl Strategy<Value = (bool, bool, Vec<usize>)> {
(any::<bool>(), any::<bool>(), prop::collection::vec(0usize..6, 0..3))
}
proptest! {
#[test]
fn the_text_and_the_typed_file_give_the_same_plan(services in prop::collection::vec(service(), 1..6)) {
let mut file = ComposeFile::default();
for (i, (once, profiled, deps)) in services.iter().enumerate() {
let mut svc = Service {
restart: Some(if *once { Restart::No } else { Restart::UnlessStopped }),
..Service::default()
};
if *profiled {
svc.profiles = vec!["check".into()];
}
for d in deps.iter().filter(|d| **d < services.len() && **d != i) {
svc.after(format!("s{d}"), Condition::Started);
}
file.services.insert(format!("s{i}"), svc);
}
let yaml = serde_yaml_ng::to_string(&file).expect("writes");
prop_assert_eq!(StartPlan::of(&file), start_plan(&yaml).expect("reads back"));
}
}
}