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 workflow_id = WorkflowId::new_v4();
95 let run_id = aion_core::RunId::new_v4();
96 let activity_id = ActivityId::from_sequence_position(1);
97 let task = crate::protocol::ActivityTask {
98 workflow_id: workflow_id.clone(),
99 activity_id: activity_id.clone(),
100 run_id: run_id.clone(),
101 activity_type: activity_type.to_owned(),
102 attempt: 1,
103 completion_token: "declared-command-test".to_owned(),
104 idempotency_key: "declared-command-test".to_owned(),
105 input: encode_payload(call)?,
106 labels: std::collections::BTreeMap::new(),
107 };
108 let (context, cancellation) =
109 crate::ActivityContext::new(workflow_id, run_id, activity_id, 1);
110 drop(cancellation);
111 Ok(registry.dispatch(task, context).await?)
112 }
113
114 #[tokio::test]
115 async fn a_declared_command_is_served_through_the_normal_dispatch_path() -> TestResult {
116 let registry = ActivityRegistry::new()
117 .register_declared_command("greet", ShellAction::new("echo $greeting")?)?;
118
119 let outcome = dispatch(
120 ®istry,
121 "greet",
122 &arguments(&[("greeting", serde_json::json!("hello there"))]),
123 )
124 .await?;
125
126 let DispatchOutcome::Completed { output } = outcome else {
127 return Err("a succeeding declared command must complete".into());
128 };
129 assert_eq!(output.content_type(), &ContentType::Json);
130 let result: ShellOutcome = decode_payload(&output)?;
131 assert_eq!(result.stdout, "hello there");
132 assert_eq!(result.exit_code, 0);
133 Ok(())
134 }
135
136 #[tokio::test]
137 async fn a_failing_declared_command_reports_its_classification() -> TestResult {
138 let registry = ActivityRegistry::new().register_declared_command(
139 "fails",
140 ShellAction::new("sh -c 'exit 4'")?.with_failure_mode(FailureMode::Terminal),
141 )?;
142
143 let outcome = dispatch(®istry, "fails", &arguments(&[])).await?;
144
145 let DispatchOutcome::Failed { failure } = outcome else {
146 return Err("a non-zero exit must fail the activity".into());
147 };
148 assert_eq!(failure.kind, ActivityErrorKind::Terminal);
149 assert!(
150 !failure.is_retryable(),
151 "a terminal declared-command failure must not be retryable"
152 );
153 Ok(())
154 }
155
156 #[tokio::test]
157 async fn a_declared_command_cannot_displace_a_registered_activity() -> TestResult {
158 let registry = ActivityRegistry::new()
159 .register_declared_command("shared", ShellAction::new("echo first")?)?;
160
161 let Err(error) =
162 registry.register_declared_command("shared", ShellAction::new("echo second")?)
163 else {
164 return Err("a duplicate activity type must be refused, not silently replaced".into());
165 };
166 assert!(
167 error.to_string().contains("shared"),
168 "the refusal must name the colliding activity type: {error}"
169 );
170 Ok(())
171 }
172
173 #[tokio::test]
174 async fn an_unnamed_argument_is_ignored_rather_than_smuggled_into_the_command() -> TestResult {
175 let registry = ActivityRegistry::new()
177 .register_declared_command("echo", ShellAction::new("echo $wanted")?)?;
178
179 let outcome = dispatch(
180 ®istry,
181 "echo",
182 &arguments(&[
183 ("wanted", serde_json::json!("kept")),
184 ("ignored", serde_json::json!("; echo PWNED")),
185 ]),
186 )
187 .await?;
188
189 let DispatchOutcome::Completed { output } = outcome else {
190 return Err("the command must complete".into());
191 };
192 let result: ShellOutcome = decode_payload(&output)?;
193 assert_eq!(result.stdout, "kept");
194 Ok(())
195 }
196}