Skip to main content

aion_worker/shell/
declared.rs

1//! A DECLARED command executed as an activity.
2//!
3//! [`DeclaredCommandAction`] is the sibling of [`super::ShellAction`], and the
4//! difference is where the splitting happened. A `ShellAction` is handed a
5//! command LINE and parses it into an argv here. A `DeclaredCommandAction` is
6//! handed an argv that the AWL emitter already produced from a typed
7//! `command` declaration — program words, one templated slot per argument,
8//! declared environment bindings, a working directory and a timeout — so
9//! nothing on this side ever holds a string that could be re-split.
10//!
11//! Everything else is deliberately the same machinery: `execve` with no shell
12//! interposed, the host environment cleared but for `PATH`, closed stdin,
13//! process-group containment, and line-by-line transcript streaming. Two
14//! executors that agreed about the argv and disagreed about the world the
15//! process runs in would be two different bodies wearing one declaration.
16
17use std::collections::BTreeMap;
18use std::path::PathBuf;
19use std::time::Duration;
20
21use aion_package::{ArgumentValue, DeclaredCommandContract, RenderedCommand};
22use tokio::process::Command;
23
24use super::action::{INHERITED_VARIABLE, ShellOutcome, trim_trailing_newline};
25use crate::activity::ActivityFailure;
26use crate::command_transcript::CommandTranscript;
27use crate::context::ActivityContext;
28use crate::process::{CancellableCommandOutput, ProcessGroupError, run_cancellable_command};
29
30/// A declared command, ready to run as an activity.
31#[derive(Debug, Clone)]
32pub struct DeclaredCommandAction {
33    contract: DeclaredCommandContract,
34    working_directory: Option<PathBuf>,
35}
36
37impl DeclaredCommandAction {
38    /// Wrap an emitted command.
39    #[must_use]
40    pub const fn new(contract: DeclaredCommandContract) -> Self {
41        Self {
42            contract,
43            working_directory: None,
44        }
45    }
46
47    /// The working directory the DECLARATION states, verbatim.
48    ///
49    /// Returned unexpanded because a `{workspace_root}` placeholder resolves
50    /// against the executing host's own workspace, which this crate has no
51    /// business knowing. A caller reads this, resolves it however its host
52    /// resolves roots, and hands the answer back through
53    /// [`Self::with_working_directory`].
54    #[must_use]
55    pub fn declared_working_directory(&self) -> Option<&str> {
56        self.contract.cwd.as_deref()
57    }
58
59    /// Run the command in `directory`.
60    #[must_use]
61    pub fn with_working_directory(mut self, directory: impl Into<PathBuf>) -> Self {
62        self.working_directory = Some(directory.into());
63        self
64    }
65
66    /// The timeout the declaration states, if it states one.
67    ///
68    /// `Ok(None)` means the declaration stated NONE, which is a legal declared
69    /// state: nothing here substitutes a ceiling.
70    ///
71    /// # Errors
72    ///
73    /// Returns a terminal [`ActivityFailure`] when the contract carries a
74    /// millisecond count no duration can hold — a negative one, say. The AWL
75    /// checker refuses those before deploy, so reaching this arm means a
76    /// defective contract was deployed; it is REFUSED rather than treated as
77    /// "no ceiling", because the direction of that failure is a command
78    /// running unbounded on the operator's machine while the declaration, the
79    /// `--check` report and the named owner all say a bound was set.
80    pub fn declared_timeout(&self) -> Result<Option<Duration>, ActivityFailure> {
81        let Some(millis) = self.contract.timeout_ms else {
82            return Ok(None);
83        };
84        u64::try_from(millis)
85            .map(Duration::from_millis)
86            .map_or_else(
87                |_| {
88                    Err(ActivityFailure::terminal(format!(
89                        "declared command `{name}` states a timeout of {millis}ms, which is not a \
90                     duration; the deployed contract is defective and running the command \
91                     unbounded would contradict the ceiling `{owner}` declared",
92                        name = self.contract.name,
93                        owner = self.timeout_owner().unwrap_or("its author"),
94                    )))
95                },
96                |bound| Ok(Some(bound)),
97            )
98    }
99
100    /// Who owns the declared timeout, when there is one.
101    #[must_use]
102    pub fn timeout_owner(&self) -> Option<&str> {
103        self.contract.timeout_owner.as_deref()
104    }
105
106    /// The command's declared name, for a diagnostic.
107    #[must_use]
108    pub fn name(&self) -> &str {
109        &self.contract.name
110    }
111
112    /// Render this command against `arguments`.
113    ///
114    /// `arguments` is the ACTION's whole input and may name parameters the
115    /// command does not use — an action is free to declare more than one of
116    /// its bodies needs. The command's own declared names select from it, so
117    /// a surplus is not a refusal here; a command parameter the action cannot
118    /// supply is refused at check time, before anything is deployed.
119    ///
120    /// # Errors
121    ///
122    /// Returns a terminal [`ActivityFailure`] when a value has no unambiguous
123    /// argument form, when a declared parameter has neither a supplied value
124    /// nor a default, or when an operand would carry leading-dash bytes into a
125    /// position the program still reads options in. Every one of those fails
126    /// identically on every retry, which is why none is retryable.
127    pub fn render(
128        &self,
129        arguments: &BTreeMap<String, serde_json::Value>,
130    ) -> Result<RenderedCommand, ActivityFailure> {
131        let mut supplied = BTreeMap::new();
132        for name in self.contract.parameter_names() {
133            if let Some(value) = arguments.get(name) {
134                supplied.insert(
135                    name.to_owned(),
136                    ArgumentValue::from_json(name, value)
137                        .map_err(|error| ActivityFailure::terminal(error.to_string()))?,
138                );
139            }
140        }
141        self.contract
142            .render(&supplied)
143            .map_err(|error| ActivityFailure::terminal(error.to_string()))
144    }
145
146    /// Run the command with `arguments` bound to its declared parameters.
147    ///
148    /// Two things end the command early and both reach the same termination
149    /// ladder the string-body executor uses — `SIGTERM` → grace → `SIGKILL`
150    /// across the whole process group, with the verdict withheld until the
151    /// group has been proven gone. The first is the activity being cancelled.
152    /// The second is the command's OWN declared timeout, which is enforced
153    /// here, where the process is: a deadline applied only to the waiting side
154    /// stops a caller waiting and leaves a process running.
155    ///
156    /// # Errors
157    ///
158    /// Returns a terminal [`ActivityFailure`] for a render refusal, a command
159    /// that cannot be spawned or observed, a cancellation, or a declared
160    /// timeout; and a retryable one for a non-zero exit, carrying the exit
161    /// code and the command's own standard error.
162    pub async fn run(
163        &self,
164        arguments: &BTreeMap<String, serde_json::Value>,
165        context: &ActivityContext,
166    ) -> Result<ShellOutcome, ActivityFailure> {
167        let rendered = self.render(arguments)?;
168        let (program, rest) = rendered.argv.split_first().ok_or_else(|| {
169            // The AWL checker refuses a declaration with no `program` clause,
170            // so reaching this arm means a defective contract was deployed.
171            // Handled rather than indexed: a panic here would take the worker
172            // down over a document that should have been refused.
173            ActivityFailure::terminal(format!(
174                "declared command `{}` rendered no program to execute; the deployed contract \
175                 is defective",
176                self.contract.name
177            ))
178        })?;
179
180        let mut command = Command::new(program);
181        command.args(rest);
182        // stdin is CLOSED, never inherited: a declared command that read
183        // standard input would otherwise read the host's and block forever.
184        command.stdin(std::process::Stdio::null());
185        // The host's environment does not cross into a declared command. Only
186        // `PATH` does, and only so a bare program name resolves — see
187        // `INHERITED_VARIABLE`.
188        command.env_clear();
189        if let Some(path) = std::env::var_os(INHERITED_VARIABLE) {
190            command.env(INHERITED_VARIABLE, path);
191        }
192        for (name, value) in &rendered.env {
193            command.env(name, value);
194        }
195        // The hardened PATH is applied LAST, so it wins over both the
196        // inherited value and any `env PATH` binding. A `hardening` block that
197        // could be overridden by an ordinary binding elsewhere in the same
198        // declaration would harden nothing.
199        if let Some(path) = &rendered.hardened_path {
200            command.env(INHERITED_VARIABLE, path);
201        }
202        if let Some(directory) = &self.working_directory {
203            command.current_dir(directory);
204        }
205
206        let bound = self.declared_timeout()?;
207        let expired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
208        let stop = {
209            let expired = std::sync::Arc::clone(&expired);
210            async move {
211                let Some(bound) = bound else {
212                    context.cancelled().await;
213                    return;
214                };
215                // BIASED, and cancellation first. When a real cancel and the
216                // declared bound become ready in the same poll, an unbiased
217                // select would report a cancelled attempt as a timeout half
218                // the time — sending an operator to the ceiling's owner over a
219                // stop somebody else asked for. Both outcomes are terminal, so
220                // the only thing at stake is which one the failure names, and
221                // the cancel is the truthful cause when both hold.
222                tokio::select! {
223                    biased;
224                    () = context.cancelled() => {}
225                    () = tokio::time::sleep(bound) => {
226                        expired.store(true, std::sync::atomic::Ordering::Release);
227                    }
228                }
229            }
230        };
231
232        let transcript = CommandTranscript::new(context);
233        match run_cancellable_command(command, stop, &transcript).await {
234            Ok(CancellableCommandOutput::Completed(output)) => {
235                let outcome = ShellOutcome {
236                    exit_code: output.status.code().unwrap_or(EXIT_CODE_SIGNALLED),
237                    stdout: trim_trailing_newline(&String::from_utf8_lossy(&output.stdout)),
238                    stderr: trim_trailing_newline(&String::from_utf8_lossy(&output.stderr)),
239                };
240                if output.status.success() {
241                    Ok(outcome)
242                } else {
243                    Err(self.exit_failure(program, &outcome))
244                }
245            }
246            Ok(CancellableCommandOutput::Cancelled) => {
247                if expired.load(std::sync::atomic::Ordering::Acquire) {
248                    Err(self.timeout_failure(program, bound))
249                } else {
250                    Err(ActivityFailure::terminal(format!(
251                        "the declared command `{program}` was cancelled and its process group \
252                         was terminated"
253                    )))
254                }
255            }
256            Err(error) => Err(spawn_failure(program, &error)),
257        }
258    }
259
260    /// The failure for a command that ran and exited non-zero.
261    ///
262    /// Retryable, and carrying the command's own standard error: the common
263    /// causes of a failing command — a busy resource, an unreachable host, a
264    /// transient permission state — are the ones a second attempt clears, and
265    /// a failure that said only "exited non-zero" would leave an operator with
266    /// nothing to act on.
267    fn exit_failure(&self, program: &str, outcome: &ShellOutcome) -> ActivityFailure {
268        let stderr = if outcome.stderr.is_empty() {
269            " with no standard error output".to_owned()
270        } else {
271            format!(": {}", outcome.stderr)
272        };
273        ActivityFailure::retryable(format!(
274            "the declared command `{name}` (`{program}`) exited {code}{stderr}",
275            name = self.contract.name,
276            code = outcome.exit_code,
277        ))
278    }
279
280    /// The failure for a command stopped on its own declared timeout.
281    ///
282    /// Terminal, and it names the owner: a declared ceiling is a number
283    /// somebody chose, and an operator meeting it needs to know who to ask.
284    fn timeout_failure(&self, program: &str, bound: Option<Duration>) -> ActivityFailure {
285        let owner = self
286            .timeout_owner()
287            .map_or_else(String::new, |owner| format!(", owned by `{owner}`"));
288        ActivityFailure::terminal(format!(
289            "the declared command `{name}` (`{program}`) outlived its declared timeout of \
290             {bound:?}{owner}; its process group was terminated",
291            name = self.contract.name,
292            bound = bound.unwrap_or_default(),
293        ))
294    }
295}
296
297/// Exit code reported when a command was ended by a signal and so has none.
298const EXIT_CODE_SIGNALLED: i32 = 137;
299
300/// Shape a successful command's outcome into the action's declared result.
301///
302/// The one place a `runs command` body's capture is honoured, so the server
303/// and the `aion worker awl` executor cannot answer differently about what an
304/// action returns. Failure classification is NOT here: it belongs to the run
305/// and is the same for both captures, which is what stops two forms drifting
306/// into two failure vocabularies.
307///
308/// # Errors
309///
310/// Returns a terminal [`ActivityFailure`] when a `json` capture's command
311/// printed output that is not valid JSON. Re-running it would print the same
312/// bytes, so nothing is gained by a retry.
313pub fn shape_command_result(
314    action: &str,
315    capture: aion_package::contract::CommandBodyCapture,
316    outcome: ShellOutcome,
317) -> Result<serde_json::Value, ActivityFailure> {
318    match capture {
319        aion_package::contract::CommandBodyCapture::Text => {
320            Ok(serde_json::Value::String(outcome.stdout))
321        }
322        aion_package::contract::CommandBodyCapture::Json => serde_json::from_str(&outcome.stdout)
323            .map_err(|error| {
324                ActivityFailure::terminal(format!(
325                    "action `{action}` declares a `runs json command` body and its command \
326                     printed output that is not valid JSON: {error}"
327                ))
328            }),
329    }
330}
331
332/// Classify a spawn or observation failure.
333///
334/// Terminal in every arm: a program that is absent, unrunnable, or whose
335/// containment could not be established will be exactly as absent on the next
336/// attempt, and retrying a command whose termination could NOT be confirmed
337/// risks running it twice concurrently.
338fn spawn_failure(program: &str, error: &ProcessGroupError) -> ActivityFailure {
339    ActivityFailure::terminal(format!(
340        "the declared command `{program}` could not be run to completion: {error}"
341    ))
342}
343
344#[cfg(test)]
345#[path = "declared_tests.rs"]
346mod tests;