use crate::spec::{JobFile, JobSpec, SubmitOptions};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct PipelineFile {
pub name: Option<String>,
pub jobs: Vec<Stage>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Stage {
pub name: String,
pub cwd: Option<String>,
pub command: Vec<String>,
pub timeout: Option<String>,
pub tags: Vec<String>,
pub priority: Option<i32>,
pub env_capture: Option<crate::config::EnvCapture>,
pub needs: Vec<String>,
pub after: Vec<String>,
pub locks: Vec<String>,
pub retries: Option<u32>,
pub resources: crate::spec::Resources,
pub env: BTreeMap<String, String>,
}
impl PipelineFile {
pub fn load(path: &std::path::Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading the pipeline file {}", path.display()))?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("toml")
.to_ascii_lowercase();
let parsed: Self = match ext.as_str() {
"yaml" | "yml" => {
serde_yaml_ng::from_str(&text).map_err(|e| error(path, &e.to_string()))?
}
"json" => serde_json::from_str(&text).map_err(|e| error(path, &e.to_string()))?,
_ => toml::from_str(&text).map_err(|e| error(path, &e.to_string()))?,
};
parsed.validate()?;
Ok(parsed)
}
pub fn validate(&self) -> Result<()> {
if self.jobs.is_empty() {
bail!("this pipeline file has no job. Add a `[[jobs]]` section.");
}
let mut seen = BTreeSet::new();
for stage in &self.jobs {
if stage.name.trim().is_empty() {
bail!("each job needs a name, because the other jobs of the file use it");
}
if stage.name.parse::<uuid::Uuid>().is_ok() {
bail!(
"the job name `{}` has the form of a job id. Use a name that a person \
can read, such as `build`.",
stage.name
);
}
if !seen.insert(stage.name.clone()) {
bail!(
"two jobs have the name `{}`. Each name in a pipeline must be \
different, because the other jobs of the file use it.",
stage.name
);
}
if stage.command.is_empty() {
bail!("the job `{}` has no command", stage.name);
}
}
for stage in &self.jobs {
for dep in stage.needs.iter().chain(stage.after.iter()) {
if !seen.contains(dep) {
bail!(
"the job `{}` waits for `{dep}`, and this file has no job with \
that name. A job can wait for the jobs of its own file only.\n\n\
The jobs of this file are: {}",
stage.name,
self.jobs
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
}
}
self.order()?;
Ok(())
}
pub fn order(&self) -> Result<Vec<usize>> {
let index: BTreeMap<&str, usize> = self
.jobs
.iter()
.enumerate()
.map(|(i, s)| (s.name.as_str(), i))
.collect();
let mut done: Vec<usize> = Vec::new();
let mut state = vec![0u8; self.jobs.len()];
let mut path: Vec<usize> = Vec::new();
for start in 0..self.jobs.len() {
if state[start] != 0 {
continue;
}
self.visit(start, &index, &mut state, &mut done, &mut path)?;
}
Ok(done)
}
fn visit(
&self,
at: usize,
index: &BTreeMap<&str, usize>,
state: &mut [u8],
done: &mut Vec<usize>,
path: &mut Vec<usize>,
) -> Result<()> {
if state[at] == 1 {
let mut names: Vec<&str> = path
.iter()
.skip_while(|i| **i != at)
.map(|i| self.jobs[*i].name.as_str())
.collect();
names.push(self.jobs[at].name.as_str());
bail!(
"the jobs make a circle: {}. A job cannot wait for itself, and no \
job of this circle can start.",
names.join(" -> ")
);
}
if state[at] == 2 {
return Ok(());
}
state[at] = 1;
path.push(at);
let stage = &self.jobs[at];
for dep in stage.needs.iter().chain(stage.after.iter()) {
if let Some(next) = index.get(dep.as_str()) {
self.visit(*next, index, state, done, path)?;
}
}
path.pop();
state[at] = 2;
done.push(at);
Ok(())
}
}
fn error(path: &std::path::Path, detail: &str) -> anyhow::Error {
anyhow::anyhow!(
"incorrect pipeline file {}: {detail}\n\n\
A pipeline file has this form:\n\n\
\x20 [[jobs]]\n\
\x20 name = \"build\"\n\
\x20 command = [\"make\"]\n\n\
\x20 [[jobs]]\n\
\x20 name = \"test\"\n\
\x20 command = [\"make\", \"test\"]\n\
\x20 needs = [\"build\"]\n\n\
For every field, run `qex help pipeline`.",
path.display()
)
}
pub fn stage_spec(
stage: &Stage,
cfg: &crate::config::Config,
group: uuid::Uuid,
group_name: &str,
) -> Result<JobSpec> {
let file = JobFile {
name: Some(stage.name.clone()),
cwd: stage.cwd.clone(),
command: stage.command.clone(),
timeout: stage.timeout.clone(),
tags: stage.tags.clone(),
priority: stage.priority,
env_capture: stage.env_capture,
needs: Vec::new(),
after: Vec::new(),
locks: stage.locks.clone(),
retries: stage.retries,
resources: stage.resources.clone(),
env: stage.env.clone(),
};
let opts = SubmitOptions::default();
let (mut spec, _) = JobSpec::resolve_from_file(&opts, cfg, file)?;
spec.group = Some(group);
spec.group_name = Some(group_name.to_string());
Ok(spec)
}
#[cfg(test)]
mod tests {
use super::*;
fn file(text: &str) -> Result<PipelineFile> {
let parsed: PipelineFile = toml::from_str(text)?;
parsed.validate()?;
Ok(parsed)
}
#[test]
fn a_file_with_stages_in_order_is_accepted() {
let p = file(
r#"
[[jobs]]
name = "build"
command = ["make"]
[[jobs]]
name = "test"
command = ["make", "test"]
needs = ["build"]
"#,
)
.unwrap();
assert_eq!(p.jobs.len(), 2);
let order = p.order().unwrap();
let names: Vec<&str> = order.iter().map(|i| p.jobs[*i].name.as_str()).collect();
assert_eq!(names, vec!["build", "test"]);
}
#[test]
fn the_stages_can_come_in_any_order_in_the_file() {
let p = file(
r#"
[[jobs]]
name = "ship"
command = ["true"]
needs = ["test"]
[[jobs]]
name = "test"
command = ["true"]
needs = ["build"]
[[jobs]]
name = "build"
command = ["true"]
"#,
)
.unwrap();
let order = p.order().unwrap();
let names: Vec<&str> = order.iter().map(|i| p.jobs[*i].name.as_str()).collect();
assert_eq!(names, vec!["build", "test", "ship"]);
}
#[test]
fn a_circle_of_stages_is_refused() {
let err = file(
r#"
[[jobs]]
name = "a"
command = ["true"]
needs = ["b"]
[[jobs]]
name = "b"
command = ["true"]
needs = ["a"]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("circle"), "got: {err}");
assert!(
err.contains("a") && err.contains("b"),
"the error must name the stages: {err}"
);
}
#[test]
fn a_stage_that_waits_for_itself_is_refused() {
let err = file(
r#"
[[jobs]]
name = "a"
command = ["true"]
needs = ["a"]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("circle"), "got: {err}");
}
#[test]
fn a_dependency_on_a_stage_that_does_not_exist_is_refused() {
let err = file(
r#"
[[jobs]]
name = "test"
command = ["true"]
needs = ["build"]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("no job with that name"), "got: {err}");
assert!(
err.contains("test"),
"the error must list the stages: {err}"
);
}
#[test]
fn two_stages_with_one_name_are_refused() {
let err = file(
r#"
[[jobs]]
name = "build"
command = ["true"]
[[jobs]]
name = "build"
command = ["false"]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("two jobs have the name"), "got: {err}");
}
#[test]
fn a_stage_needs_a_name_and_a_command() {
assert!(file("[[jobs]]\ncommand = [\"true\"]\n")
.unwrap_err()
.to_string()
.contains("needs a name"));
assert!(file("[[jobs]]\nname = \"a\"\n")
.unwrap_err()
.to_string()
.contains("no command"));
assert!(file("").unwrap_err().to_string().contains("no job"));
}
#[test]
fn a_stage_name_with_the_form_of_an_id_is_refused() {
let err = file(
r#"
[[jobs]]
name = "550e8400-e29b-41d4-a716-446655440000"
command = ["true"]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("form of a job id"), "got: {err}");
}
#[test]
fn a_diamond_gives_a_correct_order() {
let p = file(
r#"
[[jobs]]
name = "a"
command = ["true"]
[[jobs]]
name = "b"
command = ["true"]
needs = ["a"]
[[jobs]]
name = "c"
command = ["true"]
needs = ["a"]
[[jobs]]
name = "d"
command = ["true"]
needs = ["b", "c"]
"#,
)
.unwrap();
let order = p.order().unwrap();
let names: Vec<&str> = order.iter().map(|i| p.jobs[*i].name.as_str()).collect();
let position = |n: &str| names.iter().position(|x| *x == n).unwrap();
assert!(position("a") < position("b"));
assert!(position("a") < position("c"));
assert!(position("b") < position("d"));
assert!(position("c") < position("d"));
}
}