Skip to main content

fakecloud_cloudformation/
service.rs

1use async_trait::async_trait;
2use chrono::Utc;
3use http::StatusCode;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use fakecloud_core::delivery::DeliveryBus;
8use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
9use fakecloud_dynamodb::state::SharedDynamoDbState;
10use fakecloud_eventbridge::state::SharedEventBridgeState;
11use fakecloud_iam::state::SharedIamState;
12use fakecloud_logs::state::SharedLogsState;
13use fakecloud_s3::state::SharedS3State;
14use fakecloud_sns::state::SharedSnsState;
15use fakecloud_sqs::state::SharedSqsState;
16use fakecloud_ssm::state::SharedSsmState;
17
18use crate::resource_provisioner::ResourceProvisioner;
19use crate::state::{SharedCloudFormationState, Stack, StackResource};
20use crate::template;
21use crate::xml_responses;
22
23/// Multi-pass provisioning for all resources in a parsed template.
24///
25/// Resources may `Ref` each other in either direction, and JSON object
26/// iteration order isn't stable, so a single forward pass isn't enough
27/// to resolve them. We loop: each pass tries every pending resource, and
28/// any resource whose `Ref` targets are still unknown just stays pending
29/// for the next pass. When no pass makes progress we report the first
30/// pending failure and rollback.
31fn provision_stack_resources(
32    provisioner: &ResourceProvisioner,
33    resource_defs: &[template::ResourceDefinition],
34    template_body: &str,
35    parameters: &HashMap<String, String>,
36) -> Result<Vec<StackResource>, AwsServiceError> {
37    let mut resources = Vec::new();
38    let mut physical_ids: HashMap<String, String> = HashMap::new();
39    let mut pending: Vec<&template::ResourceDefinition> = resource_defs.iter().collect();
40    let max_passes = pending.len() + 1;
41
42    for _ in 0..max_passes {
43        if pending.is_empty() {
44            break;
45        }
46        let mut still_pending = Vec::new();
47        let mut made_progress = false;
48
49        for resource_def in pending {
50            let resolved_def = template::resolve_resource_properties(
51                resource_def,
52                template_body,
53                parameters,
54                &physical_ids,
55            )
56            .map_err(|e| {
57                AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
58            })?;
59
60            match provisioner.create_resource(&resolved_def) {
61                Ok(stack_resource) => {
62                    physical_ids.insert(
63                        stack_resource.logical_id.clone(),
64                        stack_resource.physical_id.clone(),
65                    );
66                    resources.push(stack_resource);
67                    made_progress = true;
68                }
69                Err(_) => still_pending.push(resource_def),
70            }
71        }
72
73        pending = still_pending;
74        if !made_progress && !pending.is_empty() {
75            // No progress — report the first failure and rollback anything
76            // we already created.
77            let resource_def = pending[0];
78            let resolved_def = template::resolve_resource_properties(
79                resource_def,
80                template_body,
81                parameters,
82                &physical_ids,
83            )
84            .unwrap_or_else(|_| resource_def.clone());
85            let err = provisioner.create_resource(&resolved_def).unwrap_err();
86            for r in &resources {
87                let _ = provisioner.delete_resource(r);
88            }
89            return Err(AwsServiceError::aws_error(
90                StatusCode::BAD_REQUEST,
91                "ValidationError",
92                format!(
93                    "Failed to create resource {}: {err}",
94                    resource_def.logical_id
95                ),
96            ));
97        }
98    }
99
100    Ok(resources)
101}
102
103/// State references for every service CloudFormation can provision resources in.
104pub struct CloudFormationDeps {
105    pub sqs: SharedSqsState,
106    pub sns: SharedSnsState,
107    pub ssm: SharedSsmState,
108    pub iam: SharedIamState,
109    pub s3: SharedS3State,
110    pub eventbridge: SharedEventBridgeState,
111    pub dynamodb: SharedDynamoDbState,
112    pub logs: SharedLogsState,
113    pub delivery: Arc<DeliveryBus>,
114}
115
116pub struct CloudFormationService {
117    state: SharedCloudFormationState,
118    deps: CloudFormationDeps,
119}
120
121impl CloudFormationService {
122    pub fn new(state: SharedCloudFormationState, deps: CloudFormationDeps) -> Self {
123        Self { state, deps }
124    }
125
126    fn provisioner(&self, stack_id: &str) -> ResourceProvisioner {
127        let cf_state = self.state.read();
128        ResourceProvisioner {
129            sqs_state: self.deps.sqs.clone(),
130            sns_state: self.deps.sns.clone(),
131            ssm_state: self.deps.ssm.clone(),
132            iam_state: self.deps.iam.clone(),
133            s3_state: self.deps.s3.clone(),
134            eventbridge_state: self.deps.eventbridge.clone(),
135            dynamodb_state: self.deps.dynamodb.clone(),
136            logs_state: self.deps.logs.clone(),
137            delivery: self.deps.delivery.clone(),
138            account_id: cf_state.account_id.clone(),
139            region: cf_state.region.clone(),
140            stack_id: stack_id.to_string(),
141        }
142    }
143
144    fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
145        // Check query params first (for Query protocol)
146        if let Some(v) = req.query_params.get(key) {
147            return Some(v.clone());
148        }
149        // Then check form-encoded body
150        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
151        body_params.get(key).cloned()
152    }
153
154    fn get_all_params(req: &AwsRequest) -> HashMap<String, String> {
155        let mut params = req.query_params.clone();
156        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
157        for (k, v) in body_params {
158            params.entry(k).or_insert(v);
159        }
160        params
161    }
162
163    fn extract_tags(params: &HashMap<String, String>) -> HashMap<String, String> {
164        let mut tags = HashMap::new();
165        for i in 1.. {
166            let key_param = format!("Tags.member.{i}.Key");
167            let value_param = format!("Tags.member.{i}.Value");
168            match (params.get(&key_param), params.get(&value_param)) {
169                (Some(k), Some(v)) => {
170                    tags.insert(k.clone(), v.clone());
171                }
172                _ => break,
173            }
174        }
175        tags
176    }
177
178    fn extract_parameters(params: &HashMap<String, String>) -> HashMap<String, String> {
179        let mut result = HashMap::new();
180        for i in 1.. {
181            let key_param = format!("Parameters.member.{i}.ParameterKey");
182            let value_param = format!("Parameters.member.{i}.ParameterValue");
183            match (params.get(&key_param), params.get(&value_param)) {
184                (Some(k), Some(v)) => {
185                    result.insert(k.clone(), v.clone());
186                }
187                _ => break,
188            }
189        }
190        result
191    }
192
193    fn extract_notification_arns(params: &HashMap<String, String>) -> Vec<String> {
194        let mut arns = Vec::new();
195        for i in 1.. {
196            let key = format!("NotificationARNs.member.{i}");
197            match params.get(&key) {
198                Some(arn) => arns.push(arn.clone()),
199                None => break,
200            }
201        }
202        arns
203    }
204
205    fn send_stack_notification(
206        delivery: &DeliveryBus,
207        notification_arns: &[String],
208        stack_name: &str,
209        stack_id: &str,
210        status: &str,
211    ) {
212        if notification_arns.is_empty() {
213            return;
214        }
215        let message = format!(
216            "StackId='{}'\nTimestamp='{}'\nEventId='{}'\nLogicalResourceId='{}'\nResourceStatus='{}'\nResourceType='AWS::CloudFormation::Stack'\nStackName='{}'",
217            stack_id,
218            chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
219            uuid::Uuid::new_v4(),
220            stack_name,
221            status,
222            stack_name,
223        );
224        for arn in notification_arns {
225            delivery.publish_to_sns(arn, &message, Some("AWS CloudFormation Notification"));
226        }
227    }
228
229    fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
230        let params = Self::get_all_params(req);
231
232        let stack_name = params.get("StackName").ok_or_else(|| {
233            AwsServiceError::aws_error(
234                StatusCode::BAD_REQUEST,
235                "ValidationError",
236                "StackName is required",
237            )
238        })?;
239
240        let template_body = params.get("TemplateBody").ok_or_else(|| {
241            AwsServiceError::aws_error(
242                StatusCode::BAD_REQUEST,
243                "ValidationError",
244                "TemplateBody is required",
245            )
246        })?;
247
248        // Check if stack already exists and is not deleted
249        {
250            let state = self.state.read();
251            if let Some(existing) = state.stacks.get(stack_name.as_str()) {
252                if existing.status != "DELETE_COMPLETE" {
253                    return Err(AwsServiceError::aws_error(
254                        StatusCode::BAD_REQUEST,
255                        "AlreadyExistsException",
256                        format!("Stack [{stack_name}] already exists"),
257                    ));
258                }
259            }
260        }
261
262        let tags = Self::extract_tags(&params);
263        let parameters = Self::extract_parameters(&params);
264        let notification_arns = Self::extract_notification_arns(&params);
265
266        // First pass: parse to get resource definitions (without physical ID resolution)
267        let parsed = template::parse_template(template_body, &parameters).map_err(|e| {
268            AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
269        })?;
270
271        let stack_id = {
272            let state = self.state.read();
273            format!(
274                "arn:aws:cloudformation:{}:{}:stack/{}/{}",
275                state.region,
276                state.account_id,
277                stack_name,
278                uuid::Uuid::new_v4()
279            )
280        };
281
282        let provisioner = self.provisioner(&stack_id);
283        let resources =
284            provision_stack_resources(&provisioner, &parsed.resources, template_body, &parameters)?;
285
286        let stack = Stack {
287            name: stack_name.clone(),
288            stack_id: stack_id.clone(),
289            template: template_body.clone(),
290            status: "CREATE_COMPLETE".to_string(),
291            resources,
292            parameters,
293            tags,
294            created_at: Utc::now(),
295            updated_at: None,
296            description: parsed.description,
297            notification_arns: notification_arns.clone(),
298        };
299
300        {
301            let mut state = self.state.write();
302            state.stacks.insert(stack_name.clone(), stack);
303        }
304
305        Self::send_stack_notification(
306            &self.deps.delivery,
307            &notification_arns,
308            stack_name,
309            &stack_id,
310            "CREATE_COMPLETE",
311        );
312
313        Ok(AwsResponse::xml(
314            StatusCode::OK,
315            xml_responses::create_stack_response(&stack_id, &req.request_id),
316        ))
317    }
318
319    fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
320        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
321            AwsServiceError::aws_error(
322                StatusCode::BAD_REQUEST,
323                "ValidationError",
324                "StackName is required",
325            )
326        })?;
327
328        let mut state = self.state.write();
329
330        // Find stack by name or stack ID
331        let stack = state.stacks.values_mut().find(|s| {
332            (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
333        });
334
335        if let Some(stack) = stack {
336            let stack_id = stack.stack_id.clone();
337            let stack_name_for_notif = stack.name.clone();
338            let notification_arns = stack.notification_arns.clone();
339            let resources: Vec<_> = stack.resources.clone();
340
341            // Build the provisioner while we still have the stack_id
342            // Drop the write lock temporarily so the provisioner can read state
343            drop(state);
344            let provisioner = self.provisioner(&stack_id);
345
346            // Delete resources in reverse order
347            for resource in resources.iter().rev() {
348                let _ = provisioner.delete_resource(resource);
349            }
350
351            // Re-acquire the write lock to update stack status
352            let mut state = self.state.write();
353            if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
354                stack.status = "DELETE_COMPLETE".to_string();
355                stack.resources.clear();
356            }
357            drop(state);
358
359            Self::send_stack_notification(
360                &self.deps.delivery,
361                &notification_arns,
362                &stack_name_for_notif,
363                &stack_id,
364                "DELETE_COMPLETE",
365            );
366        }
367
368        Ok(AwsResponse::xml(
369            StatusCode::OK,
370            xml_responses::delete_stack_response(&req.request_id),
371        ))
372    }
373
374    fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
375        let stack_name = Self::get_param(req, "StackName");
376
377        let state = self.state.read();
378        let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
379            state
380                .stacks
381                .values()
382                .filter(|s| {
383                    (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
384                })
385                .cloned()
386                .collect()
387        } else {
388            state
389                .stacks
390                .values()
391                .filter(|s| s.status != "DELETE_COMPLETE")
392                .cloned()
393                .collect()
394        };
395
396        if let Some(ref name) = stack_name {
397            if stacks.is_empty() {
398                return Err(AwsServiceError::aws_error(
399                    StatusCode::BAD_REQUEST,
400                    "ValidationError",
401                    format!("Stack with id {name} does not exist"),
402                ));
403            }
404        }
405
406        Ok(AwsResponse::xml(
407            StatusCode::OK,
408            xml_responses::describe_stacks_response(&stacks, &req.request_id),
409        ))
410    }
411
412    fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
413        let state = self.state.read();
414        let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
415
416        Ok(AwsResponse::xml(
417            StatusCode::OK,
418            xml_responses::list_stacks_response(&stacks, &req.request_id),
419        ))
420    }
421
422    fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
423        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
424            AwsServiceError::aws_error(
425                StatusCode::BAD_REQUEST,
426                "ValidationError",
427                "StackName is required",
428            )
429        })?;
430
431        let state = self.state.read();
432        let stack = state
433            .stacks
434            .values()
435            .find(|s| {
436                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
437            })
438            .ok_or_else(|| {
439                AwsServiceError::aws_error(
440                    StatusCode::BAD_REQUEST,
441                    "ValidationError",
442                    format!("Stack [{stack_name}] does not exist"),
443                )
444            })?;
445
446        Ok(AwsResponse::xml(
447            StatusCode::OK,
448            xml_responses::list_stack_resources_response(&stack.resources, &req.request_id),
449        ))
450    }
451
452    fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
453        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
454            AwsServiceError::aws_error(
455                StatusCode::BAD_REQUEST,
456                "ValidationError",
457                "StackName is required",
458            )
459        })?;
460
461        let state = self.state.read();
462        let stack = state
463            .stacks
464            .values()
465            .find(|s| {
466                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
467            })
468            .ok_or_else(|| {
469                AwsServiceError::aws_error(
470                    StatusCode::BAD_REQUEST,
471                    "ValidationError",
472                    format!("Stack [{stack_name}] does not exist"),
473                )
474            })?;
475
476        Ok(AwsResponse::xml(
477            StatusCode::OK,
478            xml_responses::describe_stack_resources_response(
479                &stack.resources,
480                &stack.name,
481                &req.request_id,
482            ),
483        ))
484    }
485
486    fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
487        let input = UpdateStackInput::from_params(req)?;
488        let parsed =
489            template::parse_template(&input.template_body, &input.parameters).map_err(|e| {
490                AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
491            })?;
492
493        // Get stack_id before write lock for the provisioner
494        let found_stack_id = {
495            let state = self.state.read();
496            state
497                .stacks
498                .values()
499                .find(|s| {
500                    (s.name == input.stack_name || s.stack_id == input.stack_name)
501                        && s.status != "DELETE_COMPLETE"
502                })
503                .map(|s| s.stack_id.clone())
504                .unwrap_or_default()
505        };
506
507        let provisioner = self.provisioner(&found_stack_id);
508
509        let mut state = self.state.write();
510        let stack = state
511            .stacks
512            .values_mut()
513            .find(|s| {
514                (s.name == input.stack_name || s.stack_id == input.stack_name)
515                    && s.status != "DELETE_COMPLETE"
516            })
517            .ok_or_else(|| {
518                AwsServiceError::aws_error(
519                    StatusCode::BAD_REQUEST,
520                    "ValidationError",
521                    format!("Stack [{}] does not exist", input.stack_name),
522                )
523            })?;
524
525        let update_result = apply_resource_updates(
526            stack,
527            &parsed.resources,
528            &input.template_body,
529            &input.parameters,
530            &provisioner,
531        );
532
533        let stack_id = stack.stack_id.clone();
534        stack.template = input.template_body.clone();
535        stack.status = if update_result.is_err() {
536            "UPDATE_FAILED".to_string()
537        } else {
538            "UPDATE_COMPLETE".to_string()
539        };
540        stack.parameters = input.parameters;
541        if !input.tags.is_empty() {
542            stack.tags = input.tags;
543        }
544        stack.updated_at = Some(Utc::now());
545        stack.description = parsed.description;
546        if !input.notification_arns.is_empty() {
547            stack.notification_arns = input.notification_arns.clone();
548        }
549        let notification_arns = stack.notification_arns.clone();
550        let stack_name_for_notif = stack.name.clone();
551
552        if let Err(error_msg) = update_result {
553            drop(state);
554            Self::send_stack_notification(
555                &self.deps.delivery,
556                &notification_arns,
557                &stack_name_for_notif,
558                &stack_id,
559                "UPDATE_FAILED",
560            );
561            return Err(AwsServiceError::aws_error(
562                StatusCode::BAD_REQUEST,
563                "ValidationError",
564                error_msg,
565            ));
566        }
567
568        drop(state);
569        Self::send_stack_notification(
570            &self.deps.delivery,
571            &notification_arns,
572            &stack_name_for_notif,
573            &stack_id,
574            "UPDATE_COMPLETE",
575        );
576
577        Ok(AwsResponse::xml(
578            StatusCode::OK,
579            xml_responses::update_stack_response(&stack_id, &req.request_id),
580        ))
581    }
582
583    fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
584        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
585            AwsServiceError::aws_error(
586                StatusCode::BAD_REQUEST,
587                "ValidationError",
588                "StackName is required",
589            )
590        })?;
591
592        let state = self.state.read();
593        let stack = state
594            .stacks
595            .values()
596            .find(|s| {
597                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
598            })
599            .ok_or_else(|| {
600                AwsServiceError::aws_error(
601                    StatusCode::BAD_REQUEST,
602                    "ValidationError",
603                    format!("Stack [{stack_name}] does not exist"),
604                )
605            })?;
606
607        Ok(AwsResponse::xml(
608            StatusCode::OK,
609            xml_responses::get_template_response(&stack.template, &req.request_id),
610        ))
611    }
612}
613
614#[async_trait]
615impl AwsService for CloudFormationService {
616    fn service_name(&self) -> &str {
617        "cloudformation"
618    }
619
620    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
621        match req.action.as_str() {
622            "CreateStack" => self.create_stack(&req),
623            "DeleteStack" => self.delete_stack(&req),
624            "DescribeStacks" => self.describe_stacks(&req),
625            "ListStacks" => self.list_stacks(&req),
626            "ListStackResources" => self.list_stack_resources(&req),
627            "DescribeStackResources" => self.describe_stack_resources(&req),
628            "UpdateStack" => self.update_stack(&req),
629            "GetTemplate" => self.get_template(&req),
630            _ => Err(AwsServiceError::action_not_implemented(
631                "cloudformation",
632                &req.action,
633            )),
634        }
635    }
636
637    fn supported_actions(&self) -> &[&str] {
638        &[
639            "CreateStack",
640            "DeleteStack",
641            "DescribeStacks",
642            "ListStacks",
643            "ListStackResources",
644            "DescribeStackResources",
645            "UpdateStack",
646            "GetTemplate",
647        ]
648    }
649}
650
651/// Parsed + validated inputs for `UpdateStack`.
652struct UpdateStackInput {
653    stack_name: String,
654    template_body: String,
655    parameters: HashMap<String, String>,
656    tags: HashMap<String, String>,
657    notification_arns: Vec<String>,
658}
659
660impl UpdateStackInput {
661    fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
662        let params = CloudFormationService::get_all_params(req);
663
664        let stack_name = params
665            .get("StackName")
666            .ok_or_else(|| {
667                AwsServiceError::aws_error(
668                    StatusCode::BAD_REQUEST,
669                    "ValidationError",
670                    "StackName is required",
671                )
672            })?
673            .to_string();
674
675        let template_body = params
676            .get("TemplateBody")
677            .ok_or_else(|| {
678                AwsServiceError::aws_error(
679                    StatusCode::BAD_REQUEST,
680                    "ValidationError",
681                    "TemplateBody is required",
682                )
683            })?
684            .to_string();
685
686        Ok(Self {
687            stack_name,
688            template_body,
689            parameters: CloudFormationService::extract_parameters(&params),
690            tags: CloudFormationService::extract_tags(&params),
691            notification_arns: CloudFormationService::extract_notification_arns(&params),
692        })
693    }
694}
695
696/// Apply resource updates: delete removed resources, create new ones.
697/// Returns Err(msg) if any resource operation fails.
698fn apply_resource_updates(
699    stack: &mut crate::state::Stack,
700    new_resource_defs: &[template::ResourceDefinition],
701    template_body: &str,
702    parameters: &HashMap<String, String>,
703    provisioner: &crate::resource_provisioner::ResourceProvisioner,
704) -> Result<(), String> {
705    let old_logical_ids: std::collections::HashSet<String> = stack
706        .resources
707        .iter()
708        .map(|r| r.logical_id.clone())
709        .collect();
710    let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
711        .iter()
712        .map(|r| r.logical_id.clone())
713        .collect();
714
715    // Delete resources no longer in template
716    let to_remove: Vec<_> = stack
717        .resources
718        .iter()
719        .filter(|r| !new_logical_ids.contains(&r.logical_id))
720        .cloned()
721        .collect();
722    for resource in &to_remove {
723        let _ = provisioner.delete_resource(resource);
724    }
725    stack
726        .resources
727        .retain(|r| new_logical_ids.contains(&r.logical_id));
728
729    // Build physical ID map from existing resources
730    let mut physical_ids: HashMap<String, String> = stack
731        .resources
732        .iter()
733        .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
734        .collect();
735
736    // Create new resources
737    for resource_def in new_resource_defs {
738        if !old_logical_ids.contains(&resource_def.logical_id) {
739            let resolved_def = template::resolve_resource_properties(
740                resource_def,
741                template_body,
742                parameters,
743                &physical_ids,
744            )
745            .map_err(|e| {
746                format!(
747                    "Failed to resolve resource {}: {e}",
748                    resource_def.logical_id
749                )
750            })?;
751
752            match provisioner.create_resource(&resolved_def) {
753                Ok(stack_resource) => {
754                    physical_ids.insert(
755                        stack_resource.logical_id.clone(),
756                        stack_resource.physical_id.clone(),
757                    );
758                    stack.resources.push(stack_resource);
759                }
760                Err(e) => {
761                    tracing::warn!(
762                        "Failed to create resource {} during update: {e}",
763                        resource_def.logical_id
764                    );
765                    return Err(format!(
766                        "Failed to create resource {}: {e}",
767                        resource_def.logical_id
768                    ));
769                }
770            }
771        }
772    }
773
774    Ok(())
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use crate::state::CloudFormationState;
781    use http::HeaderMap;
782    use parking_lot::RwLock;
783    use std::sync::Arc;
784
785    fn make_service() -> CloudFormationService {
786        let cf_state = Arc::new(RwLock::new(CloudFormationState::new(
787            "123456789012",
788            "us-east-1",
789        )));
790        let deps = CloudFormationDeps {
791            sqs: Arc::new(RwLock::new(fakecloud_sqs::state::SqsState::new(
792                "123456789012",
793                "us-east-1",
794                "http://localhost:4566",
795            ))),
796            sns: Arc::new(RwLock::new(fakecloud_sns::state::SnsState::new(
797                "123456789012",
798                "us-east-1",
799                "http://localhost:4566",
800            ))),
801            ssm: Arc::new(RwLock::new(fakecloud_ssm::state::SsmState::new(
802                "123456789012",
803                "us-east-1",
804            ))),
805            iam: Arc::new(RwLock::new(fakecloud_iam::state::IamState::new(
806                "123456789012",
807            ))),
808            s3: Arc::new(RwLock::new(fakecloud_s3::state::S3State::new(
809                "123456789012",
810                "us-east-1",
811            ))),
812            eventbridge: Arc::new(RwLock::new(
813                fakecloud_eventbridge::state::EventBridgeState::new("123456789012", "us-east-1"),
814            )),
815            dynamodb: Arc::new(RwLock::new(fakecloud_dynamodb::state::DynamoDbState::new(
816                "123456789012",
817                "us-east-1",
818            ))),
819            logs: Arc::new(RwLock::new(fakecloud_logs::state::LogsState::new(
820                "123456789012",
821                "us-east-1",
822            ))),
823            delivery: Arc::new(DeliveryBus::new()),
824        };
825        CloudFormationService::new(cf_state, deps)
826    }
827
828    fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
829        AwsRequest {
830            service: "cloudformation".to_string(),
831            action: action.to_string(),
832            region: "us-east-1".to_string(),
833            account_id: "123456789012".to_string(),
834            request_id: "test-request-id".to_string(),
835            headers: HeaderMap::new(),
836            query_params: params,
837            body: bytes::Bytes::new(),
838            path_segments: vec![],
839            raw_path: "/".to_string(),
840            raw_query: String::new(),
841            method: http::Method::POST,
842            is_query_protocol: true,
843            access_key_id: None,
844        }
845    }
846
847    #[test]
848    fn update_stack_sets_failed_status_on_resource_error() {
849        let svc = make_service();
850
851        // Create a stack with just a queue
852        let mut create_params = HashMap::new();
853        create_params.insert("StackName".to_string(), "test-stack".to_string());
854        create_params.insert(
855            "TemplateBody".to_string(),
856            r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
857        );
858        let req = make_request("CreateStack", create_params);
859        let result = svc.create_stack(&req);
860        assert!(result.is_ok());
861
862        // Update stack adding an SNS subscription with a non-existent topic
863        let mut update_params = HashMap::new();
864        update_params.insert("StackName".to_string(), "test-stack".to_string());
865        update_params.insert(
866            "TemplateBody".to_string(),
867            r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}},"BadSub":{"Type":"AWS::SNS::Subscription","Properties":{"TopicArn":"arn:aws:sns:us-east-1:123456789012:nope","Protocol":"sqs","Endpoint":"arn:aws:sqs:us-east-1:123456789012:q1"}}}}"#.to_string(),
868        );
869        let req = make_request("UpdateStack", update_params);
870        let result = svc.update_stack(&req);
871
872        // Should return an error
873        assert!(result.is_err());
874
875        // Stack status should be UPDATE_FAILED
876        let state = svc.state.read();
877        let stack = state.stacks.get("test-stack").unwrap();
878        assert_eq!(stack.status, "UPDATE_FAILED");
879    }
880
881    #[test]
882    fn create_stack_resolves_ref_to_physical_id() {
883        let svc = make_service();
884
885        // Template where subscription Refs the topic
886        let template = r#"{
887            "Resources": {
888                "MyTopic": {
889                    "Type": "AWS::SNS::Topic",
890                    "Properties": { "TopicName": "ref-test-topic" }
891                },
892                "MySub": {
893                    "Type": "AWS::SNS::Subscription",
894                    "Properties": {
895                        "TopicArn": { "Ref": "MyTopic" },
896                        "Protocol": "sqs",
897                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
898                    }
899                }
900            }
901        }"#;
902
903        let mut params = HashMap::new();
904        params.insert("StackName".to_string(), "ref-stack".to_string());
905        params.insert("TemplateBody".to_string(), template.to_string());
906        let req = make_request("CreateStack", params);
907        let result = svc.create_stack(&req);
908        assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
909
910        // Verify both resources were created
911        let state = svc.state.read();
912        let stack = state.stacks.get("ref-stack").unwrap();
913        assert_eq!(stack.resources.len(), 2);
914        assert_eq!(stack.status, "CREATE_COMPLETE");
915
916        // The subscription's physical ID should be an ARN (not just "MyTopic")
917        let sub = stack
918            .resources
919            .iter()
920            .find(|r| r.logical_id == "MySub")
921            .unwrap();
922        assert!(
923            sub.physical_id.contains("ref-test-topic"),
924            "Subscription physical ID should reference the topic ARN, got: {}",
925            sub.physical_id
926        );
927    }
928}