use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
use aion_package::{ArgumentValue, DeclaredCommandContract, RenderedCommand};
use tokio::process::Command;
use super::action::{INHERITED_VARIABLE, ShellOutcome, trim_trailing_newline};
use crate::activity::ActivityFailure;
use crate::command_transcript::CommandTranscript;
use crate::context::ActivityContext;
use crate::process::{CancellableCommandOutput, ProcessGroupError, run_cancellable_command};
#[derive(Debug, Clone)]
pub struct DeclaredCommandAction {
contract: DeclaredCommandContract,
working_directory: Option<PathBuf>,
}
impl DeclaredCommandAction {
#[must_use]
pub const fn new(contract: DeclaredCommandContract) -> Self {
Self {
contract,
working_directory: None,
}
}
#[must_use]
pub fn declared_working_directory(&self) -> Option<&str> {
self.contract.cwd.as_deref()
}
#[must_use]
pub fn with_working_directory(mut self, directory: impl Into<PathBuf>) -> Self {
self.working_directory = Some(directory.into());
self
}
pub fn declared_timeout(&self) -> Result<Option<Duration>, ActivityFailure> {
let Some(millis) = self.contract.timeout_ms else {
return Ok(None);
};
u64::try_from(millis)
.map(Duration::from_millis)
.map_or_else(
|_| {
Err(ActivityFailure::terminal(format!(
"declared command `{name}` states a timeout of {millis}ms, which is not a \
duration; the deployed contract is defective and running the command \
unbounded would contradict the ceiling `{owner}` declared",
name = self.contract.name,
owner = self.timeout_owner().unwrap_or("its author"),
)))
},
|bound| Ok(Some(bound)),
)
}
#[must_use]
pub fn timeout_owner(&self) -> Option<&str> {
self.contract.timeout_owner.as_deref()
}
#[must_use]
pub fn name(&self) -> &str {
&self.contract.name
}
pub fn render(
&self,
arguments: &BTreeMap<String, serde_json::Value>,
) -> Result<RenderedCommand, ActivityFailure> {
let mut supplied = BTreeMap::new();
for name in self.contract.parameter_names() {
if let Some(value) = arguments.get(name) {
supplied.insert(
name.to_owned(),
ArgumentValue::from_json(name, value)
.map_err(|error| ActivityFailure::terminal(error.to_string()))?,
);
}
}
self.contract
.render(&supplied)
.map_err(|error| ActivityFailure::terminal(error.to_string()))
}
pub async fn run(
&self,
arguments: &BTreeMap<String, serde_json::Value>,
context: &ActivityContext,
) -> Result<ShellOutcome, ActivityFailure> {
let rendered = self.render(arguments)?;
let (program, rest) = rendered.argv.split_first().ok_or_else(|| {
ActivityFailure::terminal(format!(
"declared command `{}` rendered no program to execute; the deployed contract \
is defective",
self.contract.name
))
})?;
let mut command = Command::new(program);
command.args(rest);
command.stdin(std::process::Stdio::null());
command.env_clear();
if let Some(path) = std::env::var_os(INHERITED_VARIABLE) {
command.env(INHERITED_VARIABLE, path);
}
for (name, value) in &rendered.env {
command.env(name, value);
}
if let Some(path) = &rendered.hardened_path {
command.env(INHERITED_VARIABLE, path);
}
if let Some(directory) = &self.working_directory {
command.current_dir(directory);
}
let bound = self.declared_timeout()?;
let expired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop = {
let expired = std::sync::Arc::clone(&expired);
async move {
let Some(bound) = bound else {
context.cancelled().await;
return;
};
tokio::select! {
biased;
() = context.cancelled() => {}
() = tokio::time::sleep(bound) => {
expired.store(true, std::sync::atomic::Ordering::Release);
}
}
}
};
let transcript = CommandTranscript::new(context);
match run_cancellable_command(command, stop, &transcript).await {
Ok(CancellableCommandOutput::Completed(output)) => {
let outcome = ShellOutcome {
exit_code: output.status.code().unwrap_or(EXIT_CODE_SIGNALLED),
stdout: trim_trailing_newline(&String::from_utf8_lossy(&output.stdout)),
stderr: trim_trailing_newline(&String::from_utf8_lossy(&output.stderr)),
};
if output.status.success() {
Ok(outcome)
} else {
Err(self.exit_failure(program, &outcome))
}
}
Ok(CancellableCommandOutput::Cancelled) => {
if expired.load(std::sync::atomic::Ordering::Acquire) {
Err(self.timeout_failure(program, bound))
} else {
Err(ActivityFailure::terminal(format!(
"the declared command `{program}` was cancelled and its process group \
was terminated"
)))
}
}
Err(error) => Err(spawn_failure(program, &error)),
}
}
fn exit_failure(&self, program: &str, outcome: &ShellOutcome) -> ActivityFailure {
let stderr = if outcome.stderr.is_empty() {
" with no standard error output".to_owned()
} else {
format!(": {}", outcome.stderr)
};
ActivityFailure::retryable(format!(
"the declared command `{name}` (`{program}`) exited {code}{stderr}",
name = self.contract.name,
code = outcome.exit_code,
))
}
fn timeout_failure(&self, program: &str, bound: Option<Duration>) -> ActivityFailure {
let owner = self
.timeout_owner()
.map_or_else(String::new, |owner| format!(", owned by `{owner}`"));
ActivityFailure::terminal(format!(
"the declared command `{name}` (`{program}`) outlived its declared timeout of \
{bound:?}{owner}; its process group was terminated",
name = self.contract.name,
bound = bound.unwrap_or_default(),
))
}
}
const EXIT_CODE_SIGNALLED: i32 = 137;
pub fn shape_command_result(
action: &str,
capture: aion_package::contract::CommandBodyCapture,
outcome: ShellOutcome,
) -> Result<serde_json::Value, ActivityFailure> {
match capture {
aion_package::contract::CommandBodyCapture::Text => {
Ok(serde_json::Value::String(outcome.stdout))
}
aion_package::contract::CommandBodyCapture::Json => serde_json::from_str(&outcome.stdout)
.map_err(|error| {
ActivityFailure::terminal(format!(
"action `{action}` declares a `runs json command` body and its command \
printed output that is not valid JSON: {error}"
))
}),
}
}
fn spawn_failure(program: &str, error: &ProcessGroupError) -> ActivityFailure {
ActivityFailure::terminal(format!(
"the declared command `{program}` could not be run to completion: {error}"
))
}
#[cfg(test)]
#[path = "declared_tests.rs"]
mod tests;