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;