use std::num::NonZeroU32;
use isoloom_manifest::{Argv, HostArgv, Program, Step, StepId, Template, Tool};
use crate::Result;
use crate::error::Error;
use crate::events::StepState;
use crate::executor::{Cx, capture_step};
const STEP: &str = "tools";
const PROBE_TIMEOUT_S: u32 = 25;
pub(crate) async fn probe(cx: &Cx<'_>) -> Result<()> {
let tools: Vec<&Tool> = cx.run.manifest().tools.iter().filter(|t| used(cx, t)).collect();
if tools.is_empty() {
return Ok(());
}
cx.events.step(STEP, StepState::Running, Some("Checking the tools…"));
let mut done = Ok(());
for tool in tools {
done = probe_one(cx, tool).await;
if done.is_err() {
break;
}
}
cx.events.step(STEP, if done.is_ok() { StepState::Done } else { StepState::Failed }, None);
done
}
fn used(cx: &Cx<'_>, tool: &Tool) -> bool {
cx.scope.admits(&tool.hosts, &tool.providers)
}
async fn probe_one(cx: &Cx<'_>, tool: &Tool) -> Result<()> {
let id = StepId::parse(STEP).map_err(|e| Error::Unsupported { what: e.to_string() })?;
if let Some(image) = tool.container.as_ref().filter(|_| tool.container_hosts.contains(&cx.run.runner.config.host)) {
return engine(cx, id, image.as_str()).await;
}
let mut step = Step::argv(id, tool.probe.clone());
step.timeout_s = NonZeroU32::new(PROBE_TIMEOUT_S);
let out = capture_step(cx, "tools", &step)
.await
.map_err(|e| named(e, tool.name.as_str(), |reason| format!("`{}` doesn't answer: {reason}", tool.name)))?;
let Some(min) = &tool.min_version else { return Ok(()) };
match (version_in(&out), version_in(min.as_str())) {
(Some(found), Some(required)) if found < required => Err(Error::ToolTooOld {
tool: tool.name.to_string(),
found: format!("{}.{}.{}", found.0, found.1, found.2),
required: min.as_str().to_owned(),
}),
_ => Ok(()),
}
}
async fn engine(cx: &Cx<'_>, id: StepId, image: &str) -> Result<()> {
let probe = HostArgv::Every(Argv::new(
Program::parse("docker").map_err(|e| Error::Unsupported { what: e.to_string() })?,
["info", "--format", "{{.ServerVersion}}"].map(Template::text).into(),
));
let mut step = Step::argv(id, probe);
step.timeout_s = NonZeroU32::new(PROBE_TIMEOUT_S);
capture_step(cx, "tools", &step).await.map(drop).map_err(|e| {
named(e, "docker", |reason| {
format!("Docker, which runs `{image}` on this host, doesn't answer: {reason}")
})
})
}
fn named(e: Error, tool: &str, failed: impl FnOnce(String) -> String) -> Error {
match e {
Error::ToolMissing { step: probe, .. } => Error::ToolMissing {
step: probe,
tool: tool.to_owned(),
},
Error::ToolFailed { step: probe, reason } => Error::ToolFailed {
step: probe,
reason: failed(reason),
},
other => other,
}
}
fn version_in(text: &str) -> Option<(u64, u64, u64)> {
text.split(|c: char| !(c.is_ascii_digit() || c == '.')).find_map(|word| {
let mut parts = word.split('.').filter(|p| !p.is_empty()).map(str::parse::<u64>);
let major = parts.next()?.ok()?;
let minor = parts.next()?.ok()?;
let patch = parts.next().and_then(Result::ok).unwrap_or(0);
Some((major, minor, patch))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_probes_failure_is_said_of_its_tool() {
let missing = named(
Error::ToolMissing {
step: "tools".into(),
tool: "vagrant.exe".into(),
},
"vagrant",
|r| r,
);
assert!(matches!(missing, Error::ToolMissing { step, tool } if step == "tools" && tool == "vagrant"));
let failed = named(
Error::ToolFailed {
step: "tools".into(),
reason: "exit 1".into(),
},
"x",
|r| format!("x: {r}"),
);
assert!(matches!(failed, Error::ToolFailed { reason, .. } if reason == "x: exit 1"));
let other = named(
Error::Timeout {
step: "tools".into(),
after_s: 25,
},
"x",
|r| r,
);
assert!(matches!(other, Error::Timeout { after_s: 25, .. }));
}
#[test]
fn versions_are_read_from_what_a_tool_prints() {
assert_eq!(version_in("5.5.1"), Some((5, 5, 1)));
assert_eq!(version_in("v2.29.1-desktop.1"), Some((2, 29, 1)));
assert_eq!(version_in("Docker Compose version 2.17"), Some((2, 17, 0)));
assert_eq!(version_in("29.8.1"), Some((29, 8, 1)));
assert_eq!(version_in("no version here"), None);
assert!(version_in("2.16.9") < version_in("2.17.0"));
}
}