use crate::spec::JobSpec;
pub const SUPPORT_FLOOR: (u32, u32, u32) = (0, 6, 0);
pub fn check_floor(coordinator_version: &str, coordinator_pid: i32) -> Result<(), String> {
if parse(coordinator_version) >= SUPPORT_FLOOR {
return Ok(());
}
let (major, minor, patch) = SUPPORT_FLOOR;
Err(format!(
"the coordinator (pid {coordinator_pid}) is version {coordinator_version}, and qex \
supports {major}.{minor}.{patch} and above.\n\n\
That coordinator comes from a build that no release holds, so qex gives no promise \
about it.\n\n\
The coordinator stops when no job operates, and the next command then starts one from \
the program that you have now. To change it now:\n\
\x20 kill {coordinator_pid}\n\n\
The jobs that operate now continue; a new coordinator reads the same records."
))
}
pub const ALL: &[&str] = &[
"dependencies",
"groups",
"history",
"learn",
"locks",
"retries",
];
pub fn parse(version: &str) -> (u32, u32, u32) {
let mut parts = version.trim().split('.').map(|p| {
p.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u32>()
.unwrap_or(0)
});
(
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
)
}
pub fn required_by(spec: &JobSpec) -> Vec<&'static str> {
let mut out = Vec::new();
if !spec.needs.is_empty() || !spec.after.is_empty() {
out.push("dependencies");
}
if !spec.locks.is_empty() {
out.push("locks");
}
if spec.retries > 0 {
out.push("retries");
}
if spec.group.is_some() {
out.push("groups");
}
out
}
pub fn check(
have: &[String],
coordinator_version: &str,
coordinator_pid: i32,
spec: &JobSpec,
) -> Result<(), String> {
let missing: Vec<&str> = required_by(spec)
.into_iter()
.filter(|need| !have.iter().any(|h| h == need))
.collect();
if missing.is_empty() {
return Ok(());
}
let options: Vec<&str> = missing
.iter()
.map(|m| match *m {
"locks" => "--lock",
"retries" => "--retries",
"dependencies" => "--needs and --after",
"groups" => "qex pipeline",
other => other,
})
.collect();
Err(format!(
"the coordinator (pid {coordinator_pid}) is version {coordinator_version}, and it \
cannot obey {}.\n\n\
qex refuses this job. The coordinator would ignore that option in silence, give \
you a job id, and run the job without the rule that you asked for.\n\n\
The coordinator stops when no job operates, and the next command then starts one \
that can obey. To change it now:\n\
\x20 kill {coordinator_pid}\n\n\
The jobs that operate now continue; a new coordinator reads the same records.",
options.join(" and ")
))
}
#[cfg(test)]
mod tests {
use super::*;
fn spec() -> JobSpec {
JobSpec {
id: uuid::Uuid::new_v4(),
name: "t".into(),
cwd: "/".into(),
command: vec!["true".into()],
env: Default::default(),
cpu: 1,
mem: 1 << 20,
timeout: None,
tags: vec![],
priority: 0,
env_capture: crate::config::EnvCapture::None,
claim_source: "explicit".into(),
group: None,
group_name: None,
locks: vec![],
retries: 0,
needs: vec![],
after: vec![],
submitted_at: 0,
}
}
#[test]
fn a_version_reads_into_three_numbers() {
assert_eq!(parse("0.5.1"), (0, 5, 1));
assert_eq!(parse("1.0.0"), (1, 0, 0));
assert_eq!(parse("0.5"), (0, 5, 0));
assert_eq!(parse("not-a-version"), (0, 0, 0));
assert_eq!(parse(""), (0, 0, 0));
}
#[test]
fn a_coordinator_below_the_floor_is_refused() {
let err = check_floor("0.5.2", 4321).unwrap_err();
assert!(
err.contains("0.6.0"),
"the message must name the floor: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
assert!(
check_floor("0.6.0", 1).is_ok(),
"the floor itself is supported"
);
assert!(check_floor("0.7.3", 1).is_ok());
assert!(check_floor("1.0.0", 1).is_ok());
assert!(check_floor("", 1).is_err());
}
#[test]
fn this_build_is_not_below_the_floor() {
assert!(
parse(env!("CARGO_PKG_VERSION")) >= SUPPORT_FLOOR,
"this build is below the support floor"
);
}
#[test]
fn a_job_that_needs_nothing_passes_every_coordinator() {
assert!(required_by(&spec()).is_empty());
assert!(check(&[], "0.1.0", 1, &spec()).is_ok());
}
#[test]
fn a_lock_is_refused_by_a_coordinator_that_has_no_locks() {
let mut s = spec();
s.locks = vec!["target".into()];
let old: Vec<String> = ALL
.iter()
.filter(|c| **c != "locks")
.map(|c| c.to_string())
.collect();
let err = check(&old, "0.6.0", 4321, &s).unwrap_err();
assert!(
err.contains("--lock"),
"the message must name the option: {err}"
);
assert!(
err.contains("in silence"),
"the message must give the danger: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
let new: Vec<String> = ALL.iter().map(|c| c.to_string()).collect();
assert!(check(&new, "0.6.0", 4321, &s).is_ok());
}
#[test]
fn each_option_that_needs_the_coordinator_is_tested() {
let mut s = spec();
s.retries = 2;
assert_eq!(required_by(&s), vec!["retries"]);
let mut s = spec();
s.needs = vec![uuid::Uuid::new_v4()];
assert_eq!(required_by(&s), vec!["dependencies"]);
let mut s = spec();
s.group = Some(uuid::Uuid::new_v4());
assert_eq!(required_by(&s), vec!["groups"]);
let mut s = spec();
s.locks = vec!["a".into()];
s.retries = 1;
let err = check(&[], "0.1.0", 7, &s).unwrap_err();
assert!(
err.contains("--lock") && err.contains("--retries"),
"got: {err}"
);
}
}