Skip to main content

fakecloud_eventbridge/
simulation.rs

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/// Result of firing a rule via simulation.
13#[derive(Debug)]
14pub struct FiredTarget {
15    /// The target type (e.g. "sqs", "sns", "lambda", "logs").
16    pub target_type: String,
17    /// The target ARN.
18    pub arn: String,
19}
20
21/// Borrowed context passed to `fire_rule` — all the surrounding state
22/// it needs to deliver to the different target protocols. Bundled so
23/// the callers don't have to thread five positional args through.
24pub 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    /// Persist hook for CloudWatch Logs, fired after a fire-rule delivery to a
30    /// Logs target so the written event survives a restart.
31    pub logs_persist: &'a Option<fakecloud_persistence::SnapshotHook>,
32    pub container_runtime: &'a Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
33}
34
35/// Fire a specific rule by bus name and rule name, delivering to all its
36/// targets regardless of the rule's enabled/disabled state.
37///
38/// Returns `Ok(targets)` with the list of targets that were delivered to,
39/// or `Err(message)` if the bus or rule doesn't exist.
40pub 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        // Verify bus exists
57        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    // Build the scheduled-event envelope (same shape as the real scheduler)
83    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    // Record the event in state
96    {
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    // Deliver through the shared single-target dispatch so the simulation
111    // endpoint honours exactly the same target handling as real PutEvents /
112    // scheduler delivery — Input / InputPath / InputTransformer resolution and
113    // the SQS(FIFO) / SNS / Lambda / Logs / Kinesis / StepFunctions /
114    // api-destination / HTTP branches — instead of a reduced local copy.
115    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: &region,
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
147/// Map a target ARN to the target-type label reported by the simulation
148/// endpoint. Mirrors the branch selection in `dispatch_event_target`.
149fn 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        // Verify event was recorded
261        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, // DISABLED
294            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        // Simulation overrides disabled state
315        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        // The simulation path now reuses dispatch_event_target, so a target
441        // with a constant Input delivers that constant, and InputPath /
442        // InputTransformer resolution is honoured identically to PutEvents.
443        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}