Skip to main content

aion_worker/shell/
registry.rs

1//! Registering a declared command as a servable activity.
2//!
3//! [`ShellAction`] knows how to run a declared command; this is how a worker
4//! offers one to the server under an activity-type name, so an action whose
5//! body was written in an `.awl` document is served by exactly the same
6//! dispatch path as a hand-written Rust activity.
7
8use std::collections::BTreeMap;
9
10use crate::activity::{ActivityRegistry, HandlerFuture};
11use crate::error::WorkerError;
12
13use super::action::{ShellAction, ShellOutcome};
14
15/// The arguments a declared action is called with.
16///
17/// A declared action has no Rust input type to deserialize into: its
18/// parameters are named in the `.awl` document, so the call arrives as a
19/// JSON object and the command's own parameter references decide which
20/// members are used. A value that no reference names is simply unused; a
21/// reference with no value is refused by name at render time rather than
22/// silently rendering empty.
23pub type DeclaredArguments = BTreeMap<String, serde_json::Value>;
24
25/// Offering declared commands as activities.
26pub trait DeclaredCommands: Sized {
27    /// Register `action` to be served under `activity_type`.
28    ///
29    /// # Errors
30    ///
31    /// Returns [`WorkerError::Registration`] when `activity_type` already has
32    /// a registered handler. A declared action never silently displaces a
33    /// hand-written one: a name collision is a deployment mistake worth
34    /// refusing, because the wrong body would otherwise run under the right
35    /// name.
36    fn register_declared_command(
37        self,
38        activity_type: impl Into<String>,
39        action: ShellAction,
40    ) -> Result<Self, WorkerError>;
41}
42
43impl DeclaredCommands for ActivityRegistry {
44    fn register_declared_command(
45        self,
46        activity_type: impl Into<String>,
47        action: ShellAction,
48    ) -> Result<Self, WorkerError> {
49        self.register_activity(
50            activity_type,
51            move |arguments: DeclaredArguments, context| -> HandlerFuture<'_, ShellOutcome> {
52                // The action is cloned per call rather than shared behind a
53                // lock: it is an immutable parsed command, so a clone is a
54                // few allocations and every call stays independent of every
55                // other. Nothing about one run can affect another.
56                let action = action.clone();
57                Box::pin(async move { action.run(&arguments, context).await })
58            },
59        )
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::{DeclaredArguments, DeclaredCommands};
66    use crate::activity::ActivityRegistry;
67    use crate::runtime::{ActivityDispatcher, DispatchOutcome, decode_payload, encode_payload};
68    use crate::shell::action::{FailureMode, ShellAction, ShellOutcome};
69    use aion_core::{ActivityErrorKind, ActivityId, ContentType, WorkflowId};
70
71    /// What a test returns. Every fallible step is carried rather than
72    /// unwrapped, because the workspace denies panicking accessors in test
73    /// code as firmly as in library code.
74    type TestResult = Result<(), Box<dyn std::error::Error>>;
75
76    fn arguments(pairs: &[(&str, serde_json::Value)]) -> DeclaredArguments {
77        pairs
78            .iter()
79            .map(|(name, value)| ((*name).to_owned(), value.clone()))
80            .collect()
81    }
82
83    /// Dispatch `activity_type` through `registry` exactly as the serving
84    /// loop would, so these tests exercise the real registered path rather
85    /// than calling the action directly.
86    async fn dispatch(
87        registry: &ActivityRegistry,
88        activity_type: &str,
89        call: &DeclaredArguments,
90    ) -> Result<DispatchOutcome, Box<dyn std::error::Error>> {
91        let task = crate::protocol::ActivityTask {
92            workflow_id: WorkflowId::new_v4(),
93            activity_id: ActivityId::from_sequence_position(1),
94            run_id: None,
95            activity_type: activity_type.to_owned(),
96            attempt: 1,
97            completion_token: "declared-command-test".to_owned(),
98            idempotency_key: "declared-command-test".to_owned(),
99            input: encode_payload(call)?,
100            labels: std::collections::BTreeMap::new(),
101        };
102        let (context, cancellation) =
103            crate::ActivityContext::new(ActivityId::from_sequence_position(1), 1);
104        drop(cancellation);
105        Ok(registry.dispatch(task, context).await?)
106    }
107
108    #[tokio::test]
109    async fn a_declared_command_is_served_through_the_normal_dispatch_path() -> TestResult {
110        let registry = ActivityRegistry::new()
111            .register_declared_command("greet", ShellAction::new("echo $greeting")?)?;
112
113        let outcome = dispatch(
114            &registry,
115            "greet",
116            &arguments(&[("greeting", serde_json::json!("hello there"))]),
117        )
118        .await?;
119
120        let DispatchOutcome::Completed { output } = outcome else {
121            return Err("a succeeding declared command must complete".into());
122        };
123        assert_eq!(output.content_type(), &ContentType::Json);
124        let result: ShellOutcome = decode_payload(&output)?;
125        assert_eq!(result.stdout, "hello there");
126        assert_eq!(result.exit_code, 0);
127        Ok(())
128    }
129
130    #[tokio::test]
131    async fn a_failing_declared_command_reports_its_classification() -> TestResult {
132        let registry = ActivityRegistry::new().register_declared_command(
133            "fails",
134            ShellAction::new("sh -c 'exit 4'")?.with_failure_mode(FailureMode::Terminal),
135        )?;
136
137        let outcome = dispatch(&registry, "fails", &arguments(&[])).await?;
138
139        let DispatchOutcome::Failed { failure } = outcome else {
140            return Err("a non-zero exit must fail the activity".into());
141        };
142        assert_eq!(failure.kind, ActivityErrorKind::Terminal);
143        assert!(
144            !failure.is_retryable(),
145            "a terminal declared-command failure must not be retryable"
146        );
147        Ok(())
148    }
149
150    #[tokio::test]
151    async fn a_declared_command_cannot_displace_a_registered_activity() -> TestResult {
152        let registry = ActivityRegistry::new()
153            .register_declared_command("shared", ShellAction::new("echo first")?)?;
154
155        let Err(error) =
156            registry.register_declared_command("shared", ShellAction::new("echo second")?)
157        else {
158            return Err("a duplicate activity type must be refused, not silently replaced".into());
159        };
160        assert!(
161            error.to_string().contains("shared"),
162            "the refusal must name the colliding activity type: {error}"
163        );
164        Ok(())
165    }
166
167    #[tokio::test]
168    async fn an_unnamed_argument_is_ignored_rather_than_smuggled_into_the_command() -> TestResult {
169        // A caller can send anything; only what the command names is used.
170        let registry = ActivityRegistry::new()
171            .register_declared_command("echo", ShellAction::new("echo $wanted")?)?;
172
173        let outcome = dispatch(
174            &registry,
175            "echo",
176            &arguments(&[
177                ("wanted", serde_json::json!("kept")),
178                ("ignored", serde_json::json!("; echo PWNED")),
179            ]),
180        )
181        .await?;
182
183        let DispatchOutcome::Completed { output } = outcome else {
184            return Err("the command must complete".into());
185        };
186        let result: ShellOutcome = decode_payload(&output)?;
187        assert_eq!(result.stdout, "kept");
188        Ok(())
189    }
190}