1use std::sync::Arc;
2
3use chrono::Utc;
4use serde_json::json;
5
6use fakecloud_core::delivery::DeliveryBus;
7use fakecloud_lambda::SharedLambdaState;
8use fakecloud_logs::SharedLogsState;
9
10use crate::state::SharedEventBridgeState;
11
12#[derive(Debug)]
14pub struct FiredTarget {
15 pub target_type: String,
17 pub arn: String,
19}
20
21pub struct FireRuleContext<'a> {
25 pub state: &'a SharedEventBridgeState,
26 pub delivery: &'a Arc<DeliveryBus>,
27 pub lambda_state: &'a Option<SharedLambdaState>,
28 pub logs_state: &'a Option<SharedLogsState>,
29 pub logs_persist: &'a Option<fakecloud_persistence::SnapshotHook>,
32 pub container_runtime: &'a Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
33}
34
35pub fn fire_rule(
41 ctx: &FireRuleContext<'_>,
42 bus_name: &str,
43 rule_name: &str,
44) -> Result<Vec<FiredTarget>, String> {
45 let state = ctx.state;
46 let delivery = ctx.delivery;
47 let lambda_state = ctx.lambda_state;
48 let logs_state = ctx.logs_state;
49 let logs_persist = ctx.logs_persist;
50 let container_runtime = ctx.container_runtime;
51
52 let (targets, rule_arn, account_id, region) = {
53 let eb_accounts = state.read();
54 let eb_state = eb_accounts.default_ref();
55
56 if !eb_state.buses.contains_key(bus_name) {
58 return Err(format!("Event bus '{bus_name}' not found"));
59 }
60
61 let key = (bus_name.to_string(), rule_name.to_string());
62 let rule = match eb_state.rules.get(&key) {
63 Some(r) => r,
64 None => return Err(format!("Rule '{rule_name}' not found on bus '{bus_name}'")),
65 };
66
67 (
68 rule.targets.clone(),
69 rule.arn.clone(),
70 eb_state.account_id.clone(),
71 eb_state.region.clone(),
72 )
73 };
74
75 if targets.is_empty() {
76 return Ok(Vec::new());
77 }
78
79 let now = Utc::now();
80 let event_id = uuid::Uuid::new_v4().to_string();
81
82 let event_json = json!({
84 "version": "0",
85 "id": event_id,
86 "source": "aws.events",
87 "account": account_id,
88 "detail-type": "Scheduled Event",
89 "detail": {},
90 "time": now.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
91 "region": region,
92 "resources": [],
93 });
94
95 {
97 let mut s_accounts = state.write();
98 let s = s_accounts.default_mut();
99 s.events.push(crate::state::PutEvent {
100 event_id: event_id.clone(),
101 source: "aws.events".to_string(),
102 detail_type: "Scheduled Event".to_string(),
103 detail: "{}".to_string(),
104 event_bus_name: bus_name.to_string(),
105 time: now,
106 resources: Vec::new(),
107 });
108 }
109
110 let ctx = crate::service::EventDispatchContext {
116 state,
117 delivery,
118 lambda_state: lambda_state.as_ref(),
119 logs_state: logs_state.as_ref(),
120 logs_persist: logs_persist.as_ref(),
121 container_runtime,
122 account_id: &account_id,
123 region: ®ion,
124 };
125
126 let mut fired = Vec::new();
127 for target in &targets {
128 crate::service::dispatch_event_target(
129 &ctx,
130 target,
131 &event_json,
132 &event_id,
133 "Scheduled Event",
134 Some(&rule_arn),
135 );
136 if let Some(target_type) = classify_target_type(&target.arn) {
137 fired.push(FiredTarget {
138 target_type,
139 arn: target.arn.clone(),
140 });
141 }
142 }
143
144 Ok(fired)
145}
146
147fn classify_target_type(arn: &str) -> Option<String> {
150 let ty = if arn.contains(":sqs:") {
151 "sqs"
152 } else if arn.contains(":sns:") {
153 "sns"
154 } else if arn.contains(":lambda:") {
155 "lambda"
156 } else if arn.contains(":logs:") {
157 "logs"
158 } else if arn.contains(":kinesis:") {
159 "kinesis"
160 } else if arn.contains(":states:") {
161 "stepfunctions"
162 } else if arn.contains(":api-destination/") {
163 "api-destination"
164 } else if arn.starts_with("https://") || arn.starts_with("http://") {
165 "http"
166 } else {
167 return None;
168 };
169 Some(ty.to_string())
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::state::{EventRule, EventTarget};
176 use fakecloud_aws::arn::Arn;
177 use parking_lot::RwLock;
178 use std::collections::BTreeMap;
179
180 fn make_state() -> SharedEventBridgeState {
181 Arc::new(RwLock::new(
182 fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
183 ))
184 }
185
186 fn add_rule(
187 state: &SharedEventBridgeState,
188 bus: &str,
189 name: &str,
190 enabled: bool,
191 targets: Vec<EventTarget>,
192 ) {
193 let mut s_accounts = state.write();
194 let s = s_accounts.default_mut();
195 let key = (bus.to_string(), name.to_string());
196 s.rules.insert(
197 key,
198 EventRule {
199 name: name.to_string(),
200 arn: Arn::new(
201 "events",
202 "us-east-1",
203 "123456789012",
204 &format!("rule/{bus}/{name}"),
205 )
206 .to_string(),
207 event_bus_name: bus.to_string(),
208 event_pattern: None,
209 schedule_expression: Some("rate(1 minute)".to_string()),
210 state: if enabled { "ENABLED" } else { "DISABLED" }.to_string(),
211 description: None,
212 role_arn: None,
213 managed_by: None,
214 created_by: None,
215 targets,
216 tags: BTreeMap::new(),
217 last_fired: None,
218 },
219 );
220 }
221
222 #[test]
223 fn fire_rule_with_valid_rule() {
224 let state = make_state();
225 let delivery = Arc::new(DeliveryBus::new());
226
227 add_rule(
228 &state,
229 "default",
230 "my-rule",
231 true,
232 vec![EventTarget {
233 id: "t1".to_string(),
234 arn: "arn:aws:sqs:us-east-1:123456789012:target-queue".to_string(),
235 input: None,
236 input_path: None,
237 input_transformer: None,
238 sqs_parameters: None,
239 ..Default::default()
240 }],
241 );
242
243 let ctx = FireRuleContext {
244 state: &state,
245 delivery: &delivery,
246 lambda_state: &None,
247 logs_state: &None,
248 logs_persist: &None,
249 container_runtime: &None,
250 };
251 let result = fire_rule(&ctx, "default", "my-rule");
252 let targets = result.unwrap();
253 assert_eq!(targets.len(), 1);
254 assert_eq!(targets[0].target_type, "sqs");
255 assert_eq!(
256 targets[0].arn,
257 "arn:aws:sqs:us-east-1:123456789012:target-queue"
258 );
259
260 let s_accounts = state.read();
262 let s = s_accounts.default_ref();
263 assert!(s.events.iter().any(|e| e.source == "aws.events"));
264 }
265
266 #[test]
267 fn fire_rule_nonexistent_rule() {
268 let state = make_state();
269 let delivery = Arc::new(DeliveryBus::new());
270
271 let ctx = FireRuleContext {
272 state: &state,
273 delivery: &delivery,
274 lambda_state: &None,
275 logs_state: &None,
276 logs_persist: &None,
277 container_runtime: &None,
278 };
279 let result = fire_rule(&ctx, "default", "no-such-rule");
280 assert!(result.is_err());
281 assert!(result.unwrap_err().contains("not found"));
282 }
283
284 #[test]
285 fn fire_rule_disabled_still_fires() {
286 let state = make_state();
287 let delivery = Arc::new(DeliveryBus::new());
288
289 add_rule(
290 &state,
291 "default",
292 "disabled-rule",
293 false, vec![EventTarget {
295 id: "t1".to_string(),
296 arn: "arn:aws:sqs:us-east-1:123456789012:target-queue".to_string(),
297 input: None,
298 input_path: None,
299 input_transformer: None,
300 sqs_parameters: None,
301 ..Default::default()
302 }],
303 );
304
305 let ctx = FireRuleContext {
306 state: &state,
307 delivery: &delivery,
308 lambda_state: &None,
309 logs_state: &None,
310 logs_persist: &None,
311 container_runtime: &None,
312 };
313 let result = fire_rule(&ctx, "default", "disabled-rule");
314 let targets = result.unwrap();
316 assert_eq!(targets.len(), 1);
317 }
318
319 #[test]
320 fn fire_rule_unknown_bus_errors() {
321 let state = make_state();
322 let delivery = Arc::new(DeliveryBus::new());
323 let ctx = FireRuleContext {
324 state: &state,
325 delivery: &delivery,
326 lambda_state: &None,
327 logs_state: &None,
328 logs_persist: &None,
329 container_runtime: &None,
330 };
331 let err = fire_rule(&ctx, "missing-bus", "rule").unwrap_err();
332 assert!(err.contains("missing-bus"));
333 }
334
335 #[test]
336 fn fire_rule_no_targets_returns_empty() {
337 let state = make_state();
338 let delivery = Arc::new(DeliveryBus::new());
339 add_rule(&state, "default", "no-targets", true, Vec::new());
340 let ctx = FireRuleContext {
341 state: &state,
342 delivery: &delivery,
343 lambda_state: &None,
344 logs_state: &None,
345 logs_persist: &None,
346 container_runtime: &None,
347 };
348 let targets = fire_rule(&ctx, "default", "no-targets").unwrap();
349 assert!(targets.is_empty());
350 }
351
352 #[test]
353 fn fire_rule_with_sns_and_lambda_and_logs_targets() {
354 let state = make_state();
355 let delivery = Arc::new(DeliveryBus::new());
356 add_rule(
357 &state,
358 "default",
359 "multi",
360 true,
361 vec![
362 EventTarget {
363 id: "t-sns".to_string(),
364 arn: "arn:aws:sns:us-east-1:123456789012:topic".to_string(),
365 input: None,
366 input_path: None,
367 input_transformer: None,
368 sqs_parameters: None,
369 ..Default::default()
370 },
371 EventTarget {
372 id: "t-lambda".to_string(),
373 arn: "arn:aws:lambda:us-east-1:123456789012:function:F".to_string(),
374 input: None,
375 input_path: None,
376 input_transformer: None,
377 sqs_parameters: None,
378 ..Default::default()
379 },
380 EventTarget {
381 id: "t-logs".to_string(),
382 arn: "arn:aws:logs:us-east-1:123456789012:log-group:lg".to_string(),
383 input: None,
384 input_path: None,
385 input_transformer: None,
386 sqs_parameters: None,
387 ..Default::default()
388 },
389 ],
390 );
391 let ctx = FireRuleContext {
392 state: &state,
393 delivery: &delivery,
394 lambda_state: &None,
395 logs_state: &None,
396 logs_persist: &None,
397 container_runtime: &None,
398 };
399 let fired = fire_rule(&ctx, "default", "multi").unwrap();
400 let types: Vec<&str> = fired.iter().map(|t| t.target_type.as_str()).collect();
401 assert!(types.contains(&"sns"));
402 assert!(types.contains(&"lambda"));
403 assert!(types.contains(&"logs"));
404 }
405
406 #[test]
407 fn fire_rule_with_sqs_fifo_message_group() {
408 let state = make_state();
409 let delivery = Arc::new(DeliveryBus::new());
410 add_rule(
411 &state,
412 "default",
413 "fifo",
414 true,
415 vec![EventTarget {
416 id: "t1".to_string(),
417 arn: "arn:aws:sqs:us-east-1:123456789012:queue.fifo".to_string(),
418 input: None,
419 input_path: None,
420 input_transformer: None,
421 sqs_parameters: Some(json!({"MessageGroupId": "g1"})),
422 ..Default::default()
423 }],
424 );
425 let ctx = FireRuleContext {
426 state: &state,
427 delivery: &delivery,
428 lambda_state: &None,
429 logs_state: &None,
430 logs_persist: &None,
431 container_runtime: &None,
432 };
433 let fired = fire_rule(&ctx, "default", "fifo").unwrap();
434 assert_eq!(fired.len(), 1);
435 assert_eq!(fired[0].target_type, "sqs");
436 }
437
438 #[test]
439 fn fire_rule_constant_input_delivered_via_shared_dispatch() {
440 let state = make_state();
444 let recorder = Arc::new(TestRecorder::default());
445 let bus = Arc::new(DeliveryBus::new().with_sqs(recorder.clone()));
446 add_rule(
447 &state,
448 "default",
449 "constant",
450 true,
451 vec![EventTarget {
452 id: "t1".to_string(),
453 arn: "arn:aws:sqs:us-east-1:123456789012:q".to_string(),
454 input: Some("{\"constant\":true}".to_string()),
455 ..Default::default()
456 }],
457 );
458 let ctx = FireRuleContext {
459 state: &state,
460 delivery: &bus,
461 lambda_state: &None,
462 logs_state: &None,
463 logs_persist: &None,
464 container_runtime: &None,
465 };
466 let fired = fire_rule(&ctx, "default", "constant").unwrap();
467 assert_eq!(fired.len(), 1);
468 let calls = recorder.sqs.lock().unwrap();
469 assert_eq!(calls.len(), 1);
470 assert_eq!(calls[0].1, "{\"constant\":true}");
471 }
472
473 #[derive(Default)]
474 struct TestRecorder {
475 sqs: std::sync::Mutex<Vec<(String, String)>>,
476 }
477
478 impl fakecloud_core::delivery::SqsDelivery for TestRecorder {
479 fn deliver_to_queue(
480 &self,
481 arn: &str,
482 body: &str,
483 _attrs: &std::collections::HashMap<String, String>,
484 ) {
485 self.sqs
486 .lock()
487 .unwrap()
488 .push((arn.to_string(), body.to_string()));
489 }
490
491 fn deliver_to_queue_with_attrs(
492 &self,
493 arn: &str,
494 body: &str,
495 _attrs: &std::collections::HashMap<
496 String,
497 fakecloud_core::delivery::SqsMessageAttribute,
498 >,
499 _group: Option<&str>,
500 _dedup: Option<&str>,
501 ) {
502 self.sqs
503 .lock()
504 .unwrap()
505 .push((arn.to_string(), body.to_string()));
506 }
507 }
508}