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 argvs that the AWL emitter already produced from a command
7//! declaration — one argv per body line, the document's exported environment
8//! and working directory beside them — so nothing on this side ever holds a
9//! string that could be re-split.
10//!
11//! # The body ruling, honoured where the processes are
12//!
13//! A command body's lines run SEQUENTIALLY, in order; the first non-zero
14//! exit fails the command; and the command's captured output is the
15//! CONCATENATED stdout of every line in order. This is the one executor the
16//! server's declared-body dispatcher and the `aion worker awl` executor
17//! share, so the ruling cannot be answered differently by venue.
18//!
19//! Everything else is deliberately the same machinery as the string-body
20//! executor: `execve` with no shell interposed, and the world of
21//! [`super::world::place_in_declared_world`] — closed stdin, the host
22//! environment cleared but for `PATH`, the document's exports applied in
23//! declared order over it — plus process-group containment and line-by-line
24//! transcript streaming. Two executors that agreed about the argv and
25//! disagreed about the world the process runs in would be two different
26//! bodies wearing one declaration.
27
28use std::collections::BTreeMap;
29use std::path::PathBuf;
30
31use aion_package::{ArgumentValue, DeclaredCommandContract, RenderedCommand};
32use tokio::process::Command;
33
34use super::action::{ShellOutcome, trim_trailing_newline};
35use super::exit::Ending;
36use super::failure::{BodySite, ending_permits_retry, spawn_failure, unreadable_ending_clause};
37use super::world::place_in_declared_world;
38use crate::activity::ActivityFailure;
39use crate::command_transcript::CommandTranscript;
40use crate::context::ActivityContext;
41use crate::process::{CancellableCommandOutput, run_cancellable_command};
42
43/// A declared command, ready to run as an activity.
44#[derive(Debug, Clone)]
45pub struct DeclaredCommandAction {
46    contract: DeclaredCommandContract,
47    working_directory: Option<PathBuf>,
48}
49
50impl DeclaredCommandAction {
51    /// Wrap an emitted command.
52    #[must_use]
53    pub const fn new(contract: DeclaredCommandContract) -> Self {
54        Self {
55            contract,
56            working_directory: None,
57        }
58    }
59
60    /// The working directory the DOCUMENT states, verbatim.
61    ///
62    /// Returned unexpanded because a `{workspace_root}` placeholder resolves
63    /// against the executing host's own workspace, which this crate has no
64    /// business knowing. A caller reads this, resolves it however its host
65    /// resolves roots, and hands the answer back through
66    /// [`Self::with_working_directory`].
67    #[must_use]
68    pub fn declared_working_directory(&self) -> Option<&str> {
69        self.contract.cwd.as_deref()
70    }
71
72    /// Run the command in `directory`.
73    #[must_use]
74    pub fn with_working_directory(mut self, directory: impl Into<PathBuf>) -> Self {
75        self.working_directory = Some(directory.into());
76        self
77    }
78
79    /// The command's declared name, for a diagnostic.
80    #[must_use]
81    pub fn name(&self) -> &str {
82        &self.contract.name
83    }
84
85    /// Render this command against `arguments`.
86    ///
87    /// `arguments` is the ACTION's whole input and may name parameters the
88    /// command does not use — an action is free to declare more than one of
89    /// its bodies needs. The command's own declared names select from it, so
90    /// a surplus is not a refusal here; a command parameter the action cannot
91    /// supply is refused at check time, before anything is deployed.
92    ///
93    /// # Errors
94    ///
95    /// Returns a terminal [`ActivityFailure`] when a value has no unambiguous
96    /// argument form, when a declared parameter has neither a supplied value
97    /// nor a default, or when an element in option position would open with
98    /// leading-dash bytes. Every one of those fails identically on every
99    /// retry, which is why none is retryable.
100    pub fn render(
101        &self,
102        arguments: &BTreeMap<String, serde_json::Value>,
103    ) -> Result<RenderedCommand, ActivityFailure> {
104        let mut supplied = BTreeMap::new();
105        for name in self.contract.parameter_names() {
106            if let Some(value) = arguments.get(name) {
107                supplied.insert(
108                    name.to_owned(),
109                    ArgumentValue::from_json(name, value)
110                        .map_err(|error| ActivityFailure::terminal(error.to_string()))?,
111                );
112            }
113        }
114        self.contract
115            .render(&supplied)
116            .map_err(|error| ActivityFailure::terminal(error.to_string()))
117    }
118
119    /// Run the command with `arguments` bound to its declared parameters:
120    /// every body line in order, stopping at the first line that does not exit
121    /// zero, the stdout of the lines concatenated into one outcome.
122    ///
123    /// Cancellation reaches the line that is running through the same
124    /// termination ladder the string-body executor uses — `SIGTERM` → grace →
125    /// `SIGKILL` across the process group, with the verdict withheld until
126    /// the group has been proven gone — and no later line starts: the
127    /// cancellation is read at the top of the loop, before each line is handed
128    /// over, so a cancellation that landed while the previous line was
129    /// finishing stops the body instead of starting the next program.
130    ///
131    /// The residual window is stated rather than claimed away: a cancellation
132    /// landing between that read and the `execve` still starts that one line,
133    /// which is then terminated by the ladder above. There is no moment
134    /// between "decide to run" and "the kernel has run it" that this side can
135    /// hold, so what an operator may rely on is that a cancellation already
136    /// standing when a line is reached runs no part of that line or any after
137    /// it.
138    ///
139    /// That the ladder fires AT ALL for such a cancellation rests on
140    /// [`ActivityContext::cancelled`], whose future is created here before the
141    /// line is spawned and which registers its interest before it reads the
142    /// flag: a cancellation landing anywhere after that future exists reaches
143    /// the `select` that the ladder hangs off. A waiter that read the flag
144    /// first would have a window in which the signal is spent on nobody, and
145    /// this line would run to completion with the cancellation standing.
146    ///
147    /// # Errors
148    ///
149    /// Returns a terminal [`ActivityFailure`] for a command whose body renders
150    /// no line at all, for a render refusal, for a line that cannot be spawned
151    /// or observed, for a line whose ending this host cannot read (see
152    /// [`super::failure::ending_permits_retry`]), and for a cancellation; and a
153    /// retryable one for a line that exited non-zero or was ended by a signal
154    /// — carrying how the line ended, THAT line's own standard error, what the
155    /// lines before it wrote to standard error, and everything the body had
156    /// printed before it stopped.
157    pub async fn run(
158        &self,
159        arguments: &BTreeMap<String, serde_json::Value>,
160        context: &ActivityContext,
161    ) -> Result<ShellOutcome, ActivityFailure> {
162        let rendered = self.render(arguments)?;
163        // A body with no lines would otherwise run nothing and report success:
164        // exit zero, empty capture, an action downstream reading that empty
165        // capture as the answer. "Ran nothing, succeeded" is the one outcome
166        // an operator cannot tell from a real one, so it is refused here.
167        // The AWL checker refuses a command whose body states no line, so a
168        // contract reaching this arm was deployed defective.
169        //
170        // The RENDERED lines are interrogated, not the contract's, because the
171        // rendered lines are what the loop below iterates: a guard that reads
172        // one field and a loop that spends another can disagree, and the one
173        // that decides is the loop's.
174        if rendered.argv_lines.is_empty() {
175            return Err(ActivityFailure::terminal(format!(
176                "command `{}` states no line to run, so nothing was executed; a command that ran \
177                 nothing cannot be reported as having succeeded. Write the lines this command is \
178                 meant to run underneath its header, then deploy the document again",
179                self.contract.name
180            )));
181        }
182        let total = rendered.argv_lines.len();
183        let mut stdout = String::new();
184        let mut stderr = String::new();
185        for (position, argv) in rendered.argv_lines.iter().enumerate() {
186            // Read BEFORE the line is handed over. A cancellation that landed
187            // while the previous line was finishing must not start this one:
188            // the whole point of cancelling a body halfway is that the rest of
189            // it does not happen.
190            if context.is_cancelled() {
191                return Err(self.cancelled_between_lines(position, total, argv, &stdout, &stderr));
192            }
193            let site = BodySite {
194                command: &self.contract.name,
195                line: position + 1,
196                total,
197            };
198            let (program, rest) = argv.split_first().ok_or_else(|| {
199                // The AWL checker refuses a body line with no program word,
200                // so reaching this arm means a defective contract was
201                // deployed. Handled rather than indexed: a panic here would
202                // take the worker down over a document that should have been
203                // refused.
204                ActivityFailure::terminal(format!(
205                    "command `{name}`'s line {line} of {total} names no program to run, so \
206                     nothing on that line could be executed; the deployed document is defective \
207                     and no attempt at it can succeed",
208                    name = self.contract.name,
209                    line = site.line,
210                ))
211            })?;
212
213            let mut command = Command::new(program);
214            command.args(rest);
215            // Closed stdin, a cleared host environment, PATH, then the
216            // document's exports in declared order — the one world every
217            // declared command runs in, established by the one function that
218            // states it (see [`super::world`]), which is what stops this
219            // executor and the string-body one drifting apart.
220            place_in_declared_world(
221                &mut command,
222                rendered
223                    .env
224                    .iter()
225                    .map(|(name, value)| (name.as_str(), value.as_str())),
226            );
227            if let Some(directory) = &self.working_directory {
228                command.current_dir(directory);
229            }
230
231            // The transcript says WHICH line is speaking. A body of five lines
232            // publishing five programs' output under one label leaves a reader
233            // to guess which program wrote what.
234            let transcript = CommandTranscript::for_body_line(context, site.line, total);
235            match run_cancellable_command(command, context.cancelled(), &transcript).await {
236                Ok(CancellableCommandOutput::Completed(output)) => {
237                    // Where the EARLIER lines' standard error ends, marked
238                    // before this line's own joins it: the failure below
239                    // attributes what is on each side of this mark to the
240                    // lines that actually wrote it. A mark rather than a copy,
241                    // so a long-running body does not re-copy everything it
242                    // has written once per line.
243                    let earlier_stderr_ends = stderr.len();
244                    let line_stderr = String::from_utf8_lossy(&output.stderr).into_owned();
245                    stdout.push_str(&String::from_utf8_lossy(&output.stdout));
246                    stderr.push_str(&line_stderr);
247                    let ending = Ending::of(output.status);
248                    if !ending.succeeded() {
249                        return Err(self.line_failure(
250                            program,
251                            site,
252                            ending,
253                            &trim_trailing_newline(&stdout),
254                            &trim_trailing_newline(&line_stderr),
255                            &trim_trailing_newline(&stderr[..earlier_stderr_ends]),
256                        ));
257                    }
258                }
259                Ok(CancellableCommandOutput::Cancelled) => {
260                    return Err(self.cancelled_mid_line(program, site, &stdout, &stderr));
261                }
262                Err(error) => return Err(spawn_failure(program, Some(site), &error)),
263            }
264        }
265        Ok(ShellOutcome {
266            // Every line exited zero — the loop returns at the first that did
267            // not — so the command's own status is zero. It is stated rather
268            // than carried forward from the last line, because "the last
269            // line's code" and "the command succeeded" are the same number
270            // only by accident.
271            exit_code: 0,
272            stdout: trim_trailing_newline(&stdout),
273            stderr: trim_trailing_newline(&stderr),
274        })
275    }
276
277    /// The failure for a line that ran and did not exit zero.
278    ///
279    /// Carries four things an operator acts on: HOW the line ended (an exit
280    /// code, or the signal that ended it — never a number standing in for a
281    /// signal), THAT LINE'S OWN standard error, what the lines before it wrote
282    /// to standard error, said of those lines rather than of this one, and the
283    /// output the body had already produced.
284    ///
285    /// The attribution is the point. A body's stderr accumulates across lines,
286    /// and a failure that quotes the whole accumulation as "it wrote" puts an
287    /// earlier line's words in the failing program's mouth — which is how an
288    /// operator comes to debug the wrong program. So the failing line's own
289    /// standard error is quoted as its own, and the rest is attributed to the
290    /// lines that produced it.
291    ///
292    /// Retryable when the ending is one this host can read: the common causes
293    /// of a failing command — a busy resource, an unreachable host, a
294    /// transient permission state — are the ones a second attempt clears.
295    /// Terminal when it is not; see
296    /// [`super::failure::ending_permits_retry`] for the one rationale both
297    /// executors read.
298    fn line_failure(
299        &self,
300        program: &str,
301        site: BodySite<'_>,
302        ending: Ending,
303        stdout: &str,
304        line_stderr: &str,
305        earlier_stderr: &str,
306    ) -> ActivityFailure {
307        let mut sentences = vec![format!(
308            "command `{name}` stopped at line {line} of {total}: `{program}` {ended}{unreadable}",
309            name = self.contract.name,
310            line = site.line,
311            total = site.total,
312            ended = ending.described(),
313            unreadable = unreadable_ending_clause(ending),
314        )];
315        sentences.push(if line_stderr.is_empty() {
316            "That line wrote nothing to standard error".to_owned()
317        } else {
318            format!("That line wrote to standard error: {line_stderr}")
319        });
320        // Only said when there WERE lines before it. A body that failed on its
321        // first line has no earlier lines, and a sentence about them would be
322        // a sentence about nothing.
323        if site.line > 1 {
324            let before = lines_phrase(site.line - 1, "before it");
325            sentences.push(if earlier_stderr.is_empty() {
326                format!("The {before} wrote nothing to standard error")
327            } else {
328                format!("The {before} wrote to standard error: {earlier_stderr}")
329            });
330        }
331        sentences.push(if stdout.is_empty() {
332            "The command had printed nothing before it stopped".to_owned()
333        } else {
334            format!("What the command had printed before it stopped: {stdout}")
335        });
336        let message = sentences.join(". ");
337        if ending_permits_retry(ending) {
338            ActivityFailure::retryable(message)
339        } else {
340            ActivityFailure::terminal(message)
341        }
342    }
343
344    /// The failure for a cancellation that landed between two lines.
345    ///
346    /// Terminal, and it names the line that did NOT start: an operator reading
347    /// it needs to know how much of the body ran, because the part that ran
348    /// has already changed whatever it changes — so what those lines printed
349    /// and what they wrote to standard error ride with it.
350    fn cancelled_between_lines(
351        &self,
352        position: usize,
353        total: usize,
354        argv: &[String],
355        stdout: &str,
356        stderr: &str,
357    ) -> ActivityFailure {
358        let next = argv.first().map_or_else(
359            || "the next line".to_owned(),
360            |program| format!("`{program}`"),
361        );
362        ActivityFailure::terminal(format!(
363            "command `{name}` was cancelled after {ran} of its {total} lines had run, so {next} \
364             and everything after it never started. {story}",
365            name = self.contract.name,
366            ran = position,
367            story = finished_lines_story(position, stdout, stderr),
368        ))
369    }
370
371    /// The failure for a cancellation that landed while a line was running.
372    ///
373    /// Terminal, and it carries what the finished lines left behind — both
374    /// streams: a cancelled command leaves whatever its finished lines already
375    /// did behind, and an operator deciding what to clean up needs to see how
376    /// far it got and what it complained about on the way.
377    ///
378    /// The CANCELLED line's own output is not among it. A cancelled run
379    /// returns no capture at all (see
380    /// [`crate::process::CancellableCommandOutput::Cancelled`]); what that line
381    /// wrote before it was stopped went out line by line as it wrote it,
382    /// through the transcript observer, and the failure says so rather than
383    /// implying the bytes were lost.
384    fn cancelled_mid_line(
385        &self,
386        program: &str,
387        site: BodySite<'_>,
388        stdout: &str,
389        stderr: &str,
390    ) -> ActivityFailure {
391        ActivityFailure::terminal(format!(
392            "command `{name}` was cancelled while line {line} of {total} (`{program}`) was \
393             running: that line's process group was terminated and proven gone, so nothing it \
394             started is still running, and no later line ran. {story}. The cancelled line's own \
395             output is not captured here — it was streamed line by line as it was written",
396            name = self.contract.name,
397            line = site.line,
398            total = site.total,
399            story = finished_lines_story(site.line - 1, stdout, stderr),
400        ))
401    }
402}
403
404/// `line before it` / `3 lines before it` — a count said in words that agree
405/// with it, so a failure never reads "the 1 lines".
406fn lines_phrase(count: usize, relation: &str) -> String {
407    if count == 1 {
408        format!("line {relation}")
409    } else {
410        format!("{count} lines {relation}")
411    }
412}
413
414/// What the lines that had FINISHED left behind, said of those lines and of
415/// nothing else.
416///
417/// `finished` is how many lines of the body had run to completion; `stdout`
418/// and `stderr` are what those lines — and only those lines — produced.
419fn finished_lines_story(finished: usize, stdout: &str, stderr: &str) -> String {
420    if finished == 0 {
421        return "No line of the body had finished".to_owned();
422    }
423    let printed = trim_trailing_newline(stdout);
424    let wrote = trim_trailing_newline(stderr);
425    let subject = lines_phrase(finished, "that had finished");
426    let pronoun = if finished == 1 {
427        "That line".to_owned()
428    } else {
429        format!("Those {finished} lines")
430    };
431    let printed_sentence = if printed.is_empty() {
432        format!("The {subject} printed nothing")
433    } else {
434        format!("What the {subject} printed: {printed}")
435    };
436    let wrote_sentence = if wrote.is_empty() {
437        format!("{pronoun} wrote nothing to standard error")
438    } else {
439        format!("{pronoun} wrote to standard error: {wrote}")
440    };
441    format!("{printed_sentence}. {wrote_sentence}")
442}
443
444/// Shape a successful command's outcome into the action's declared result.
445///
446/// The one place a `runs command` body's capture is honoured, so the server
447/// and the `aion worker awl` executor cannot answer differently about what an
448/// action returns. Failure classification is NOT here: it belongs to the run
449/// and is the same for both captures, which is what stops two forms drifting
450/// into two failure vocabularies.
451///
452/// # Errors
453///
454/// Returns a terminal [`ActivityFailure`] when a `json` capture's command
455/// printed output that is not valid JSON. Re-running it would print the same
456/// bytes, so nothing is gained by a retry.
457pub fn shape_command_result(
458    action: &str,
459    capture: aion_package::contract::CommandBodyCapture,
460    outcome: ShellOutcome,
461) -> Result<serde_json::Value, ActivityFailure> {
462    match capture {
463        aion_package::contract::CommandBodyCapture::Text => {
464            Ok(serde_json::Value::String(outcome.stdout))
465        }
466        aion_package::contract::CommandBodyCapture::Json => serde_json::from_str(&outcome.stdout)
467            .map_err(|error| {
468                ActivityFailure::terminal(format!(
469                    "action `{action}` declares a `runs json command` body and its command \
470                     printed output that is not valid JSON: {error}"
471                ))
472            }),
473    }
474}
475
476#[cfg(test)]
477#[path = "declared_tests.rs"]
478mod tests;