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;