use crate::spec::JobSpec;
use crate::version::is_development;
pub const CAPABILITY_FLOOR: (u32, u32, u32) = (0, 6, 0);
pub enum Floor {
Supported,
Development(String),
Below(String),
}
pub fn check_floor(coordinator_version: &str, coordinator_pid: i32) -> Floor {
if is_development(coordinator_version) {
if parse(coordinator_version) >= CAPABILITY_FLOOR {
return Floor::Supported;
}
return Floor::Development(format!(
"the coordinator (pid {coordinator_pid}) is version {coordinator_version}, which is \
a development build and not a release.\n\n\
qex gives no promise about which options such a coordinator obeys. It still says \
what it can do, and qex still refuses a job with an option that it cannot obey, \
so this is a warning and not an error.\n\n\
The coordinator stops when no job operates, and the next command then starts one \
from the program that you have now. `qex info` gives its pid. To change it now:\n\
\x20 kill {coordinator_pid}\n\n\
The jobs that operate now continue; a new coordinator reads the same records."
));
}
if parse(coordinator_version) >= CAPABILITY_FLOOR {
return Floor::Supported;
}
let (major, minor, patch) = CAPABILITY_FLOOR;
Floor::Below(format!(
"the coordinator (pid {coordinator_pid}) is version {coordinator_version}, and a \
coordinator says what it can do from {major}.{minor}.{patch} and above.\n\n\
That coordinator does not answer the question, so qex cannot learn which options it \
obeys, and it must not let you believe a rule holds when it may not.\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("0.0.0-dev"), (0, 0, 0));
assert_eq!(parse("0.0.0-dev+g98513e2"), (0, 0, 0));
assert_eq!(parse("0.0.0-dev+g98513e2.dirty"), (0, 0, 0));
assert_eq!(parse("not-a-version"), (0, 0, 0));
assert_eq!(parse(""), (0, 0, 0));
}
fn below(version: &str, pid: i32) -> String {
match check_floor(version, pid) {
Floor::Below(message) => message,
Floor::Development(m) => panic!("`{version}` gave a warning and not a refusal: {m}"),
Floor::Supported => panic!("`{version}` passed the floor"),
}
}
fn supported(version: &str) -> bool {
matches!(check_floor(version, 1), Floor::Supported)
}
#[test]
fn a_coordinator_below_the_floor_is_refused() {
let err = below("0.5.2", 4321);
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!(supported("0.6.0"), "the floor itself is supported");
assert!(supported("0.7.3"));
assert!(supported("1.0.0"));
below("", 1);
below("not-a-version", 1);
}
#[test]
fn a_development_build_is_warned_about_and_not_refused() {
let warning = match check_floor("0.0.0-dev", 4321) {
Floor::Development(message) => message,
Floor::Below(m) => panic!("a development build must not be refused: {m}"),
Floor::Supported => panic!("a development build below the floor must be named"),
};
assert!(
warning.contains("development build"),
"the message must say what happened: {warning}"
);
assert!(
warning.contains("no promise"),
"the message must say why it matters: {warning}"
);
assert!(
warning.contains("kill 4321") && warning.contains("qex info"),
"the message must give the remedy: {warning}"
);
for form in [
"0.0.0-dev",
"0.0.0-dev+g98513e2",
"0.0.0-dev+g98513e2.dirty",
"0.0.0-dev+unknown",
] {
assert!(
matches!(check_floor(form, 1), Floor::Development(_)),
"`{form}` must give a warning"
);
}
assert!(supported("0.7.3"));
}
#[test]
fn the_floor_of_the_build_agrees_with_the_floor_of_the_code() {
let written = env!("QEX_BUILD_FLOOR");
let (major, minor, patch) = CAPABILITY_FLOOR;
assert_eq!(
written,
format!("{major}.{minor}.{patch}"),
"build.rs holds {written} and capabilities.rs holds {major}.{minor}.{patch}"
);
assert_eq!(parse(written), CAPABILITY_FLOOR);
}
#[test]
fn this_build_is_not_below_the_floor() {
let mine = crate::version::VERSION;
assert!(
parse(mine) >= CAPABILITY_FLOOR || crate::version::is_development(mine),
"this build reports `{mine}`, which is below the capability floor and is not a \
development build"
);
assert!(
!matches!(check_floor(mine, 1), Floor::Below(_)),
"this build reports `{mine}`, which its own CLI would refuse"
);
}
#[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}"
);
}
}