use std::time::Duration;
use isoloom_manifest::{Action, Run, Step};
use regex::Regex;
use super::delivery::step_env;
use super::{Cx, plan};
use crate::Result;
use crate::error::Error;
use crate::events::Stream;
use crate::process::{Captured, Exit, ProcessError, ProcessSpec, Tail, capture};
use crate::scope::Scope;
pub(crate) async fn run_argv(cx: &Cx<'_>, list: &str, step: &Step, observe: &mut (dyn FnMut(&str) + Send)) -> Result<()> {
if let Some(wait) = crate::ssh::wait_of(cx, list, step)? {
return super::reconnect::run(cx, list, step, observe, &wait).await;
}
once(cx, list, step, observe, Hold::No).await?.result(cx, step)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Hold {
No,
UntilOutput,
}
#[derive(Debug)]
pub(crate) struct Ended {
pub(crate) exit: Exit,
pub(crate) printed: bool,
lead: Option<String>,
pub(crate) tail: Vec<String>,
held: Vec<String>,
}
impl Ended {
pub(crate) fn result(self, cx: &Cx<'_>, step: &Step) -> Result<()> {
if self.exit.success() {
return Ok(());
}
let reason = self.lead.unwrap_or_else(|| failure_text(&self.tail, self.exit));
Err(Error::ToolFailed {
step: step.id.to_string(),
reason: cx.events.mask(&reason),
})
}
pub(crate) fn show_held(&mut self, cx: &Cx<'_>, step: &Step, observe: &mut (dyn FnMut(&str) + Send)) {
for line in std::mem::take(&mut self.held) {
cx.events.log(step.id.as_str(), Stream::Stderr, &line);
observe(&line);
}
}
}
pub(crate) async fn once(cx: &Cx<'_>, list: &str, step: &Step, observe: &mut (dyn FnMut(&str) + Send), hold: Hold) -> Result<Ended> {
let id = step.id.as_str();
let spec = spec_for(cx, list, step)?;
let verdict = match &step.verdict {
Some(pattern) => Some(Regex::new(pattern).map_err(|e| Error::Unsupported {
what: format!("step `{id}`'s verdict `{pattern}` ({e})"),
})?),
None => None,
};
let (mut tail, mut lead, mut printed, mut kept_back) = (Tail::default(), None, false, Vec::new());
let mut on_line = |stream: Stream, line: &str| {
if stream == Stream::Stderr {
tail.push(line);
}
if hold == Hold::UntilOutput && !printed && stream == Stream::Stderr {
kept_back.push(line.to_owned());
return;
}
if stream == Stream::Stdout && !printed {
printed = true;
for earlier in kept_back.drain(..) {
cx.events.log(id, Stream::Stderr, &earlier);
observe(&earlier);
}
}
cx.events.log(id, stream, line);
observe(line);
if let Some(found) = verdict.as_ref().and_then(|v| verdict_of(v, line)) {
lead = Some(found);
}
};
let exit = timed(step, &cx.scope, cx.processes.run(&spec, &mut on_line)).await?;
Ok(Ended {
exit,
printed,
lead,
tail: tail.into_lines(),
held: kept_back,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Echo {
Quiet,
Shown,
}
pub(crate) async fn call(cx: &Cx<'_>, id: &str, spec: &ProcessSpec, limit: Duration, echo: Echo) -> Result<Captured> {
let mut stdout = Vec::new();
let mut tail = Tail::default();
let mut keep = |stream: Stream, line: &str| {
if echo == Echo::Shown {
cx.events.log(id, stream, line);
}
match stream {
Stream::Stdout => stdout.push(line.to_owned()),
Stream::Stderr => tail.push(line),
}
};
let run = cx.processes.run(spec, &mut keep);
let exit = tokio::time::timeout(limit, run).await.map_err(|_| Error::Timeout {
step: id.to_owned(),
after_s: limit.as_secs(),
})?;
let exit = exit.map_err(|e| process_error(id, e))?;
Ok(Captured {
stdout,
stderr: tail.into_lines(),
exit: Some(exit),
})
}
pub(crate) async fn call_ok(cx: &Cx<'_>, id: &str, spec: &ProcessSpec, limit: Duration, echo: Echo) -> Result<String> {
let out = call(cx, id, spec, limit, echo).await?;
if out.exit.is_some_and(Exit::success) {
return Ok(out.stdout.join("\n"));
}
let reason = format!("`{}` failed: {}", spec.line(), out.stderr.join("\n").trim());
Err(Error::ToolFailed {
step: id.to_owned(),
reason: cx.events.mask(&reason),
})
}
pub(crate) async fn capture_step(cx: &Cx<'_>, list: &str, step: &Step) -> Result<String> {
let spec = spec_for(cx, list, step)?;
let out = timed(step, &cx.scope, capture(cx.processes.as_ref(), &spec)).await?;
let exit = out.exit.unwrap_or(Exit::new(None));
if !exit.success() {
let reason = failure_text(&out.stderr, exit);
return Err(Error::ToolFailed {
step: step.id.to_string(),
reason: cx.events.mask(&reason),
});
}
Ok(out.stdout.join("\n"))
}
pub(crate) fn spec_for(cx: &Cx<'_>, list: &str, step: &Step) -> Result<ProcessSpec> {
let at = plan::at(list, step);
let argv = match &step.run {
Run::Argv(argv) => argv,
Run::Action { action: Action::SshRun, with } => return crate::ssh::step_spec(cx, list, step, with),
Run::Action { .. } => {
return Err(Error::Unsupported {
what: format!("{at}: an action where a command was expected"),
});
}
};
let command = plan::command(argv, cx.lab.runner.config.host, &cx.vars, &at)?;
let dir = cx.lab.dir();
let spec = ProcessSpec {
program: command.program,
args: command.args,
cwd: Some(step.cwd.as_ref().map_or_else(|| dir.to_path_buf(), |c| dir.join(c.as_str()))),
env: env_of(cx, list, step)?,
stdin: stdin_of(cx, list, step)?,
};
crate::container::wrap(cx, spec)
}
pub(crate) fn stdin_of(cx: &Cx<'_>, list: &str, step: &Step) -> Result<Option<std::path::PathBuf>> {
let Some(path) = &step.stdin else { return Ok(None) };
let filled = cx.vars.render(path.template(), &format!("{}.stdin", plan::at(list, step)))?;
Ok(Some(cx.lab.dir().join(filled)))
}
pub(crate) fn env_of(cx: &Cx<'_>, list: &str, step: &Step) -> Result<Vec<(String, String)>> {
let at = plan::at(list, step);
let mut env = cx.lab.driver.env(cx)?;
for (name, value) in &step.env {
env.push((name.to_string(), cx.vars.render(value, &at)?));
}
env.extend(step_env(cx, step.id.as_str()));
env.extend(cx.relayed.iter().flat_map(|r| r.env.iter().cloned()));
Ok(env)
}
async fn timed<T>(step: &Step, scope: &Scope, run: impl Future<Output = Result<T, ProcessError>>) -> Result<T> {
let secs = plan::limit_s(step, scope);
let done = tokio::time::timeout(Duration::from_secs(secs), run).await.map_err(|_| Error::Timeout {
step: step.id.to_string(),
after_s: secs,
})?;
done.map_err(|e| process_error(step.id.as_str(), e))
}
fn process_error(step: &str, e: ProcessError) -> Error {
match e {
ProcessError::Missing { program } => Error::ToolMissing {
step: step.to_owned(),
tool: program,
},
ProcessError::Elevation { reason } => Error::Elevation { reason },
other => Error::ToolFailed {
step: step.to_owned(),
reason: other.to_string(),
},
}
}
const VERDICT_MAX: usize = 300;
fn verdict_of(verdict: &Regex, line: &str) -> Option<String> {
let found = verdict.find(line)?;
let rest = line.get(found.start()..)?.trim_end();
if rest.len() <= VERDICT_MAX {
return Some(rest.to_owned());
}
let cut = (0..=VERDICT_MAX - 3).rev().find(|&i| rest.is_char_boundary(i)).unwrap_or(0);
Some(format!("{}...", rest.get(..cut).unwrap_or_default()))
}
fn failure_text(tail: &[String], exit: Exit) -> String {
let status = exit.code.map_or_else(|| "a signal".to_owned(), |c| format!("code {c}"));
let cause = tail.join("\n");
if cause.trim().is_empty() {
format!("exited with {status}")
} else {
format!("{} (exited with {status})", cause.trim())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_silent_failure_reports_its_exit_status() {
assert_eq!(failure_text(&[], Exit { code: Some(1) }), "exited with code 1");
assert_eq!(failure_text(&[" ".into()], Exit { code: None }), "exited with a signal");
assert_eq!(
failure_text(&["no space left".into()], Exit { code: Some(2) }),
"no space left (exited with code 2)"
);
}
#[test]
fn a_verdict_keeps_the_fatal_line_without_vagrants_prefix_and_short() {
let v = Regex::new("fatal: \\[[^\\]]+\\]: (UNREACHABLE|FAILED)!.*").unwrap();
let line = " isoloom-controller: fatal: [ws01]: UNREACHABLE! => {\"msg\": \"winrm connection error\"}";
assert_eq!(
verdict_of(&v, line).as_deref(),
Some("fatal: [ws01]: UNREACHABLE! => {\"msg\": \"winrm connection error\"}")
);
assert_eq!(verdict_of(&v, " dc01: ok: [dc01]"), None);
let ascii = format!("fatal: [a]: FAILED! => {}", "x".repeat(400));
assert_eq!(verdict_of(&v, &ascii).map(|c| c.len()), Some(VERDICT_MAX), "cut at the limit, `...` included");
let long = format!("fatal: [a]: FAILED! => {}", "é".repeat(300));
let cut = verdict_of(&v, &long).unwrap();
assert!(cut.ends_with("...") && cut.len() <= VERDICT_MAX, "{} bytes", cut.len());
}
}