aion_worker/shell/
registry.rs1use std::collections::BTreeMap;
9
10use crate::activity::{ActivityRegistry, HandlerFuture};
11use crate::error::WorkerError;
12
13use super::action::{ShellAction, ShellOutcome};
14
15pub type DeclaredArguments = BTreeMap<String, serde_json::Value>;
24
25pub trait DeclaredCommands: Sized {
27 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 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 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 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 ®istry,
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(®istry, "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 let registry = ActivityRegistry::new()
171 .register_declared_command("echo", ShellAction::new("echo $wanted")?)?;
172
173 let outcome = dispatch(
174 ®istry,
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}