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_persistence::SnapshotStore;
14use fakecloud_s3::state::SharedS3State;
15use fakecloud_sns::state::SharedSnsState;
16use fakecloud_sqs::state::SharedSqsState;
17use fakecloud_ssm::state::SharedSsmState;
18use tokio::sync::Mutex as AsyncMutex;
19
20use crate::resource_provisioner::ResourceProvisioner;
21use crate::state::{
22    CloudFormationSnapshot, CloudFormationState, SharedCloudFormationState, Stack, StackResource,
23    CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
24};
25use crate::template;
26use crate::xml_responses;
27
28/// Multi-pass provisioning for all resources in a parsed template.
29///
30/// Resources may `Ref` each other in either direction, and JSON object
31/// iteration order isn't stable, so a single forward pass isn't enough
32/// to resolve them. We loop: each pass tries every pending resource, and
33/// any resource whose `Ref` targets are still unknown just stays pending
34/// for the next pass. When no pass makes progress we report the first
35/// pending failure and rollback.
36fn provision_stack_resources(
37    provisioner: &ResourceProvisioner,
38    resource_defs: &[template::ResourceDefinition],
39    template_body: &str,
40    parameters: &HashMap<String, String>,
41) -> Result<Vec<StackResource>, AwsServiceError> {
42    let mut resources = Vec::new();
43    let mut physical_ids: HashMap<String, String> = HashMap::new();
44    let mut pending: Vec<&template::ResourceDefinition> = resource_defs.iter().collect();
45    let max_passes = pending.len() + 1;
46
47    for _ in 0..max_passes {
48        if pending.is_empty() {
49            break;
50        }
51        let mut still_pending = Vec::new();
52        let mut made_progress = false;
53
54        for resource_def in pending {
55            let resolved_def = template::resolve_resource_properties(
56                resource_def,
57                template_body,
58                parameters,
59                &physical_ids,
60            )
61            .map_err(|e| {
62                AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
63            })?;
64
65            match provisioner.create_resource(&resolved_def) {
66                Ok(stack_resource) => {
67                    physical_ids.insert(
68                        stack_resource.logical_id.clone(),
69                        stack_resource.physical_id.clone(),
70                    );
71                    resources.push(stack_resource);
72                    made_progress = true;
73                }
74                Err(_) => still_pending.push(resource_def),
75            }
76        }
77
78        pending = still_pending;
79        if !made_progress && !pending.is_empty() {
80            // No progress — report the first failure and rollback anything
81            // we already created.
82            let resource_def = pending[0];
83            let resolved_def = template::resolve_resource_properties(
84                resource_def,
85                template_body,
86                parameters,
87                &physical_ids,
88            )
89            .unwrap_or_else(|_| resource_def.clone());
90            let err = provisioner.create_resource(&resolved_def).unwrap_err();
91            for r in &resources {
92                let _ = provisioner.delete_resource(r);
93            }
94            return Err(AwsServiceError::aws_error(
95                StatusCode::BAD_REQUEST,
96                "ValidationError",
97                format!(
98                    "Failed to create resource {}: {err}",
99                    resource_def.logical_id
100                ),
101            ));
102        }
103    }
104
105    Ok(resources)
106}
107
108/// State references for every service CloudFormation can provision resources in.
109pub struct CloudFormationDeps {
110    pub sqs: SharedSqsState,
111    pub sns: SharedSnsState,
112    pub ssm: SharedSsmState,
113    pub iam: SharedIamState,
114    pub s3: SharedS3State,
115    pub eventbridge: SharedEventBridgeState,
116    pub dynamodb: SharedDynamoDbState,
117    pub logs: SharedLogsState,
118    pub delivery: Arc<DeliveryBus>,
119}
120
121pub struct CloudFormationService {
122    pub(crate) state: SharedCloudFormationState,
123    deps: CloudFormationDeps,
124    snapshot_store: Option<Arc<dyn SnapshotStore>>,
125    snapshot_lock: Arc<AsyncMutex<()>>,
126}
127
128impl CloudFormationService {
129    pub fn new(state: SharedCloudFormationState, deps: CloudFormationDeps) -> Self {
130        Self {
131            state,
132            deps,
133            snapshot_store: None,
134            snapshot_lock: Arc::new(AsyncMutex::new(())),
135        }
136    }
137
138    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
139        self.snapshot_store = Some(store);
140        self
141    }
142
143    async fn save_snapshot(&self) {
144        let Some(store) = self.snapshot_store.clone() else {
145            return;
146        };
147        let _guard = self.snapshot_lock.lock().await;
148        let snapshot = CloudFormationSnapshot {
149            schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
150            state: None,
151            accounts: Some(self.state.read().clone()),
152        };
153        let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
154            let bytes = serde_json::to_vec(&snapshot)
155                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
156            store.save(&bytes)
157        })
158        .await;
159        match join {
160            Ok(Ok(())) => {}
161            Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
162            Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
163        }
164    }
165
166    fn provisioner(&self, stack_id: &str, account_id: &str, region: &str) -> ResourceProvisioner {
167        ResourceProvisioner {
168            sqs_state: self.deps.sqs.clone(),
169            sns_state: self.deps.sns.clone(),
170            ssm_state: self.deps.ssm.clone(),
171            iam_state: self.deps.iam.clone(),
172            s3_state: self.deps.s3.clone(),
173            eventbridge_state: self.deps.eventbridge.clone(),
174            dynamodb_state: self.deps.dynamodb.clone(),
175            logs_state: self.deps.logs.clone(),
176            delivery: self.deps.delivery.clone(),
177            account_id: account_id.to_string(),
178            region: region.to_string(),
179            stack_id: stack_id.to_string(),
180        }
181    }
182
183    fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
184        // Check query params first (for Query protocol)
185        if let Some(v) = req.query_params.get(key) {
186            return Some(v.clone());
187        }
188        // Then check form-encoded body
189        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
190        body_params.get(key).cloned()
191    }
192
193    pub(crate) fn get_all_params(req: &AwsRequest) -> HashMap<String, String> {
194        let mut params = req.query_params.clone();
195        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
196        for (k, v) in body_params {
197            params.entry(k).or_insert(v);
198        }
199        params
200    }
201
202    fn extract_tags(params: &HashMap<String, String>) -> HashMap<String, String> {
203        let mut tags = HashMap::new();
204        for i in 1.. {
205            let key_param = format!("Tags.member.{i}.Key");
206            let value_param = format!("Tags.member.{i}.Value");
207            match (params.get(&key_param), params.get(&value_param)) {
208                (Some(k), Some(v)) => {
209                    tags.insert(k.clone(), v.clone());
210                }
211                _ => break,
212            }
213        }
214        tags
215    }
216
217    fn extract_parameters(params: &HashMap<String, String>) -> HashMap<String, String> {
218        let mut result = HashMap::new();
219        for i in 1.. {
220            let key_param = format!("Parameters.member.{i}.ParameterKey");
221            let value_param = format!("Parameters.member.{i}.ParameterValue");
222            match (params.get(&key_param), params.get(&value_param)) {
223                (Some(k), Some(v)) => {
224                    result.insert(k.clone(), v.clone());
225                }
226                _ => break,
227            }
228        }
229        result
230    }
231
232    fn extract_notification_arns(params: &HashMap<String, String>) -> Vec<String> {
233        let mut arns = Vec::new();
234        for i in 1.. {
235            let key = format!("NotificationARNs.member.{i}");
236            match params.get(&key) {
237                Some(arn) => arns.push(arn.clone()),
238                None => break,
239            }
240        }
241        arns
242    }
243
244    fn send_stack_notification(
245        delivery: &DeliveryBus,
246        notification_arns: &[String],
247        stack_name: &str,
248        stack_id: &str,
249        status: &str,
250    ) {
251        if notification_arns.is_empty() {
252            return;
253        }
254        let message = format!(
255            "StackId='{}'\nTimestamp='{}'\nEventId='{}'\nLogicalResourceId='{}'\nResourceStatus='{}'\nResourceType='AWS::CloudFormation::Stack'\nStackName='{}'",
256            stack_id,
257            chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
258            uuid::Uuid::new_v4(),
259            stack_name,
260            status,
261            stack_name,
262        );
263        for arn in notification_arns {
264            delivery.publish_to_sns(arn, &message, Some("AWS CloudFormation Notification"));
265        }
266    }
267
268    fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
269        let params = Self::get_all_params(req);
270
271        let stack_name = params.get("StackName").ok_or_else(|| {
272            AwsServiceError::aws_error(
273                StatusCode::BAD_REQUEST,
274                "ValidationError",
275                "StackName is required",
276            )
277        })?;
278
279        let template_body = params.get("TemplateBody").ok_or_else(|| {
280            AwsServiceError::aws_error(
281                StatusCode::BAD_REQUEST,
282                "ValidationError",
283                "TemplateBody is required",
284            )
285        })?;
286
287        // Check if stack already exists and is not deleted
288        {
289            let accounts = self.state.read();
290            let empty = CloudFormationState::new(&req.account_id, &req.region);
291            let state = accounts.get(&req.account_id).unwrap_or(&empty);
292            if let Some(existing) = state.stacks.get(stack_name.as_str()) {
293                if existing.status != "DELETE_COMPLETE" {
294                    return Err(AwsServiceError::aws_error(
295                        StatusCode::BAD_REQUEST,
296                        "AlreadyExistsException",
297                        format!("Stack [{stack_name}] already exists"),
298                    ));
299                }
300            }
301        }
302
303        let tags = Self::extract_tags(&params);
304        let parameters = Self::extract_parameters(&params);
305        let notification_arns = Self::extract_notification_arns(&params);
306
307        // First pass: parse to get resource definitions (without physical ID resolution)
308        let parsed = template::parse_template(template_body, &parameters).map_err(|e| {
309            AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
310        })?;
311
312        let stack_id = format!(
313            "arn:aws:cloudformation:{}:{}:stack/{}/{}",
314            req.region,
315            req.account_id,
316            stack_name,
317            uuid::Uuid::new_v4()
318        );
319
320        let provisioner = self.provisioner(&stack_id, &req.account_id, &req.region);
321        let resources =
322            provision_stack_resources(&provisioner, &parsed.resources, template_body, &parameters)?;
323
324        let stack = Stack {
325            name: stack_name.clone(),
326            stack_id: stack_id.clone(),
327            template: template_body.clone(),
328            status: "CREATE_COMPLETE".to_string(),
329            resources,
330            parameters,
331            tags,
332            created_at: Utc::now(),
333            updated_at: None,
334            description: parsed.description,
335            notification_arns: notification_arns.clone(),
336        };
337
338        {
339            let mut accounts = self.state.write();
340            let state = accounts.get_or_create(&req.account_id);
341            state.stacks.insert(stack_name.clone(), stack);
342        }
343
344        Self::send_stack_notification(
345            &self.deps.delivery,
346            &notification_arns,
347            stack_name,
348            &stack_id,
349            "CREATE_COMPLETE",
350        );
351
352        Ok(AwsResponse::xml(
353            StatusCode::OK,
354            xml_responses::create_stack_response(&stack_id, &req.request_id),
355        ))
356    }
357
358    fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
359        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
360            AwsServiceError::aws_error(
361                StatusCode::BAD_REQUEST,
362                "ValidationError",
363                "StackName is required",
364            )
365        })?;
366
367        let mut accounts = self.state.write();
368        let state = accounts.get_or_create(&req.account_id);
369
370        // Find stack by name or stack ID
371        let stack = state.stacks.values_mut().find(|s| {
372            (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
373        });
374
375        if let Some(stack) = stack {
376            let stack_id = stack.stack_id.clone();
377            let stack_name_for_notif = stack.name.clone();
378            let notification_arns = stack.notification_arns.clone();
379            let resources: Vec<_> = stack.resources.clone();
380
381            // Build the provisioner while we still have the stack_id
382            // Drop the write lock temporarily so the provisioner can read state
383            drop(accounts);
384            let provisioner = self.provisioner(&stack_id, &req.account_id, &req.region);
385
386            // Delete resources in reverse order
387            for resource in resources.iter().rev() {
388                let _ = provisioner.delete_resource(resource);
389            }
390
391            // Re-acquire the write lock to update stack status
392            let mut accounts = self.state.write();
393            let state = accounts.get_or_create(&req.account_id);
394            if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
395                stack.status = "DELETE_COMPLETE".to_string();
396                stack.resources.clear();
397            }
398            drop(accounts);
399
400            Self::send_stack_notification(
401                &self.deps.delivery,
402                &notification_arns,
403                &stack_name_for_notif,
404                &stack_id,
405                "DELETE_COMPLETE",
406            );
407        }
408
409        Ok(AwsResponse::xml(
410            StatusCode::OK,
411            xml_responses::delete_stack_response(&req.request_id),
412        ))
413    }
414
415    fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
416        let stack_name = Self::get_param(req, "StackName");
417
418        let accounts = self.state.read();
419        let empty = CloudFormationState::new(&req.account_id, &req.region);
420        let state = accounts.get(&req.account_id).unwrap_or(&empty);
421        let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
422            state
423                .stacks
424                .values()
425                .filter(|s| {
426                    (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
427                })
428                .cloned()
429                .collect()
430        } else {
431            state
432                .stacks
433                .values()
434                .filter(|s| s.status != "DELETE_COMPLETE")
435                .cloned()
436                .collect()
437        };
438
439        if let Some(ref name) = stack_name {
440            if stacks.is_empty() {
441                return Err(AwsServiceError::aws_error(
442                    StatusCode::BAD_REQUEST,
443                    "ValidationError",
444                    format!("Stack with id {name} does not exist"),
445                ));
446            }
447        }
448
449        Ok(AwsResponse::xml(
450            StatusCode::OK,
451            xml_responses::describe_stacks_response(&stacks, &req.request_id),
452        ))
453    }
454
455    fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
456        let accounts = self.state.read();
457        let empty = CloudFormationState::new(&req.account_id, &req.region);
458        let state = accounts.get(&req.account_id).unwrap_or(&empty);
459        let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
460
461        Ok(AwsResponse::xml(
462            StatusCode::OK,
463            xml_responses::list_stacks_response(&stacks, &req.request_id),
464        ))
465    }
466
467    fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
468        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
469            AwsServiceError::aws_error(
470                StatusCode::BAD_REQUEST,
471                "ValidationError",
472                "StackName is required",
473            )
474        })?;
475
476        let accounts = self.state.read();
477        let empty = CloudFormationState::new(&req.account_id, &req.region);
478        let state = accounts.get(&req.account_id).unwrap_or(&empty);
479        let stack = state
480            .stacks
481            .values()
482            .find(|s| {
483                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
484            })
485            .ok_or_else(|| {
486                AwsServiceError::aws_error(
487                    StatusCode::BAD_REQUEST,
488                    "ValidationError",
489                    format!("Stack [{stack_name}] does not exist"),
490                )
491            })?;
492
493        Ok(AwsResponse::xml(
494            StatusCode::OK,
495            xml_responses::list_stack_resources_response(&stack.resources, &req.request_id),
496        ))
497    }
498
499    fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
500        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
501            AwsServiceError::aws_error(
502                StatusCode::BAD_REQUEST,
503                "ValidationError",
504                "StackName is required",
505            )
506        })?;
507
508        let accounts = self.state.read();
509        let empty = CloudFormationState::new(&req.account_id, &req.region);
510        let state = accounts.get(&req.account_id).unwrap_or(&empty);
511        let stack = state
512            .stacks
513            .values()
514            .find(|s| {
515                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
516            })
517            .ok_or_else(|| {
518                AwsServiceError::aws_error(
519                    StatusCode::BAD_REQUEST,
520                    "ValidationError",
521                    format!("Stack [{stack_name}] does not exist"),
522                )
523            })?;
524
525        Ok(AwsResponse::xml(
526            StatusCode::OK,
527            xml_responses::describe_stack_resources_response(
528                &stack.resources,
529                &stack.name,
530                &req.request_id,
531            ),
532        ))
533    }
534
535    fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
536        let input = UpdateStackInput::from_params(req)?;
537        let parsed =
538            template::parse_template(&input.template_body, &input.parameters).map_err(|e| {
539                AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
540            })?;
541
542        // Get stack_id before write lock for the provisioner
543        let found_stack_id = {
544            let accounts = self.state.read();
545            let empty = CloudFormationState::new(&req.account_id, &req.region);
546            let state = accounts.get(&req.account_id).unwrap_or(&empty);
547            state
548                .stacks
549                .values()
550                .find(|s| {
551                    (s.name == input.stack_name || s.stack_id == input.stack_name)
552                        && s.status != "DELETE_COMPLETE"
553                })
554                .map(|s| s.stack_id.clone())
555                .unwrap_or_default()
556        };
557
558        let provisioner = self.provisioner(&found_stack_id, &req.account_id, &req.region);
559
560        let mut accounts = self.state.write();
561        let state = accounts.get_or_create(&req.account_id);
562        let stack = state
563            .stacks
564            .values_mut()
565            .find(|s| {
566                (s.name == input.stack_name || s.stack_id == input.stack_name)
567                    && s.status != "DELETE_COMPLETE"
568            })
569            .ok_or_else(|| {
570                AwsServiceError::aws_error(
571                    StatusCode::BAD_REQUEST,
572                    "ValidationError",
573                    format!("Stack [{}] does not exist", input.stack_name),
574                )
575            })?;
576
577        let update_result = apply_resource_updates(
578            stack,
579            &parsed.resources,
580            &input.template_body,
581            &input.parameters,
582            &provisioner,
583        );
584
585        let stack_id = stack.stack_id.clone();
586        stack.template = input.template_body.clone();
587        stack.status = if update_result.is_err() {
588            "UPDATE_FAILED".to_string()
589        } else {
590            "UPDATE_COMPLETE".to_string()
591        };
592        stack.parameters = input.parameters;
593        if !input.tags.is_empty() {
594            stack.tags = input.tags;
595        }
596        stack.updated_at = Some(Utc::now());
597        stack.description = parsed.description;
598        if !input.notification_arns.is_empty() {
599            stack.notification_arns = input.notification_arns.clone();
600        }
601        let notification_arns = stack.notification_arns.clone();
602        let stack_name_for_notif = stack.name.clone();
603
604        if let Err(error_msg) = update_result {
605            drop(accounts);
606            Self::send_stack_notification(
607                &self.deps.delivery,
608                &notification_arns,
609                &stack_name_for_notif,
610                &stack_id,
611                "UPDATE_FAILED",
612            );
613            return Err(AwsServiceError::aws_error(
614                StatusCode::BAD_REQUEST,
615                "ValidationError",
616                error_msg,
617            ));
618        }
619
620        drop(accounts);
621        Self::send_stack_notification(
622            &self.deps.delivery,
623            &notification_arns,
624            &stack_name_for_notif,
625            &stack_id,
626            "UPDATE_COMPLETE",
627        );
628
629        Ok(AwsResponse::xml(
630            StatusCode::OK,
631            xml_responses::update_stack_response(&stack_id, &req.request_id),
632        ))
633    }
634
635    fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
636        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
637            AwsServiceError::aws_error(
638                StatusCode::BAD_REQUEST,
639                "ValidationError",
640                "StackName is required",
641            )
642        })?;
643
644        let accounts = self.state.read();
645        let empty = CloudFormationState::new(&req.account_id, &req.region);
646        let state = accounts.get(&req.account_id).unwrap_or(&empty);
647        let stack = state
648            .stacks
649            .values()
650            .find(|s| {
651                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
652            })
653            .ok_or_else(|| {
654                AwsServiceError::aws_error(
655                    StatusCode::BAD_REQUEST,
656                    "ValidationError",
657                    format!("Stack [{stack_name}] does not exist"),
658                )
659            })?;
660
661        Ok(AwsResponse::xml(
662            StatusCode::OK,
663            xml_responses::get_template_response(&stack.template, &req.request_id),
664        ))
665    }
666}
667
668#[async_trait]
669impl AwsService for CloudFormationService {
670    fn service_name(&self) -> &str {
671        "cloudformation"
672    }
673
674    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
675        let action = req.action.as_str();
676        // Only ops whose handlers actually write to per-account state
677        // need to trigger snapshot persistence. Pass-through ops that
678        // return canned IDs but don't touch state are excluded.
679        let mutates = matches!(
680            action,
681            "CreateStack"
682                | "DeleteStack"
683                | "UpdateStack"
684                | "CreateChangeSet"
685                | "DeleteChangeSet"
686                | "CreateStackSet"
687                | "DeleteStackSet"
688                | "CreateStackRefactor"
689                | "CreateGeneratedTemplate"
690                | "DeleteGeneratedTemplate"
691                | "SetStackPolicy"
692                | "UpdateTerminationProtection"
693                | "ActivateOrganizationsAccess"
694                | "DeactivateOrganizationsAccess"
695        );
696        let result = match action {
697            "CreateStack" => self.create_stack(&req),
698            "DeleteStack" => self.delete_stack(&req),
699            "DescribeStacks" => self.describe_stacks(&req),
700            "ListStacks" => self.list_stacks(&req),
701            "ListStackResources" => self.list_stack_resources(&req),
702            "DescribeStackResources" => self.describe_stack_resources(&req),
703            "UpdateStack" => self.update_stack(&req),
704            "GetTemplate" => self.get_template(&req),
705            _ => self.handle_extra_action(&req),
706        };
707        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
708            self.save_snapshot().await;
709        }
710        result
711    }
712
713    fn supported_actions(&self) -> &[&str] {
714        &[
715            "ActivateOrganizationsAccess",
716            "ActivateType",
717            "BatchDescribeTypeConfigurations",
718            "CancelUpdateStack",
719            "ContinueUpdateRollback",
720            "CreateChangeSet",
721            "CreateGeneratedTemplate",
722            "CreateStack",
723            "CreateStackInstances",
724            "CreateStackRefactor",
725            "CreateStackSet",
726            "DeactivateOrganizationsAccess",
727            "DeactivateType",
728            "DeleteChangeSet",
729            "DeleteGeneratedTemplate",
730            "DeleteStack",
731            "DeleteStackInstances",
732            "DeleteStackSet",
733            "DeregisterType",
734            "DescribeAccountLimits",
735            "DescribeChangeSet",
736            "DescribeChangeSetHooks",
737            "DescribeEvents",
738            "DescribeGeneratedTemplate",
739            "DescribeOrganizationsAccess",
740            "DescribePublisher",
741            "DescribeResourceScan",
742            "DescribeStackDriftDetectionStatus",
743            "DescribeStackEvents",
744            "DescribeStackInstance",
745            "DescribeStackRefactor",
746            "DescribeStackResource",
747            "DescribeStackResourceDrifts",
748            "DescribeStackResources",
749            "DescribeStackSet",
750            "DescribeStackSetOperation",
751            "DescribeStacks",
752            "DescribeType",
753            "DescribeTypeRegistration",
754            "DetectStackDrift",
755            "DetectStackResourceDrift",
756            "DetectStackSetDrift",
757            "EstimateTemplateCost",
758            "ExecuteChangeSet",
759            "ExecuteStackRefactor",
760            "GetGeneratedTemplate",
761            "GetHookResult",
762            "GetStackPolicy",
763            "GetTemplate",
764            "GetTemplateSummary",
765            "ImportStacksToStackSet",
766            "ListChangeSets",
767            "ListExports",
768            "ListGeneratedTemplates",
769            "ListHookResults",
770            "ListImports",
771            "ListResourceScanRelatedResources",
772            "ListResourceScanResources",
773            "ListResourceScans",
774            "ListStackInstanceResourceDrifts",
775            "ListStackInstances",
776            "ListStackRefactorActions",
777            "ListStackRefactors",
778            "ListStackResources",
779            "ListStackSetAutoDeploymentTargets",
780            "ListStackSetOperationResults",
781            "ListStackSetOperations",
782            "ListStackSets",
783            "ListStacks",
784            "ListTypeRegistrations",
785            "ListTypeVersions",
786            "ListTypes",
787            "PublishType",
788            "RecordHandlerProgress",
789            "RegisterPublisher",
790            "RegisterType",
791            "RollbackStack",
792            "SetStackPolicy",
793            "SetTypeConfiguration",
794            "SetTypeDefaultVersion",
795            "SignalResource",
796            "StartResourceScan",
797            "StopStackSetOperation",
798            "TestType",
799            "UpdateGeneratedTemplate",
800            "UpdateStack",
801            "UpdateStackInstances",
802            "UpdateStackSet",
803            "UpdateTerminationProtection",
804            "ValidateTemplate",
805        ]
806    }
807}
808
809/// Parsed + validated inputs for `UpdateStack`.
810struct UpdateStackInput {
811    stack_name: String,
812    template_body: String,
813    parameters: HashMap<String, String>,
814    tags: HashMap<String, String>,
815    notification_arns: Vec<String>,
816}
817
818impl UpdateStackInput {
819    fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
820        let params = CloudFormationService::get_all_params(req);
821
822        let stack_name = params
823            .get("StackName")
824            .ok_or_else(|| {
825                AwsServiceError::aws_error(
826                    StatusCode::BAD_REQUEST,
827                    "ValidationError",
828                    "StackName is required",
829                )
830            })?
831            .to_string();
832
833        let template_body = params
834            .get("TemplateBody")
835            .ok_or_else(|| {
836                AwsServiceError::aws_error(
837                    StatusCode::BAD_REQUEST,
838                    "ValidationError",
839                    "TemplateBody is required",
840                )
841            })?
842            .to_string();
843
844        Ok(Self {
845            stack_name,
846            template_body,
847            parameters: CloudFormationService::extract_parameters(&params),
848            tags: CloudFormationService::extract_tags(&params),
849            notification_arns: CloudFormationService::extract_notification_arns(&params),
850        })
851    }
852}
853
854/// Apply resource updates: delete removed resources, create new ones.
855/// Returns Err(msg) if any resource operation fails.
856fn apply_resource_updates(
857    stack: &mut crate::state::Stack,
858    new_resource_defs: &[template::ResourceDefinition],
859    template_body: &str,
860    parameters: &HashMap<String, String>,
861    provisioner: &crate::resource_provisioner::ResourceProvisioner,
862) -> Result<(), String> {
863    let old_logical_ids: std::collections::HashSet<String> = stack
864        .resources
865        .iter()
866        .map(|r| r.logical_id.clone())
867        .collect();
868    let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
869        .iter()
870        .map(|r| r.logical_id.clone())
871        .collect();
872
873    // Delete resources no longer in template
874    let to_remove: Vec<_> = stack
875        .resources
876        .iter()
877        .filter(|r| !new_logical_ids.contains(&r.logical_id))
878        .cloned()
879        .collect();
880    for resource in &to_remove {
881        let _ = provisioner.delete_resource(resource);
882    }
883    stack
884        .resources
885        .retain(|r| new_logical_ids.contains(&r.logical_id));
886
887    // Build physical ID map from existing resources
888    let mut physical_ids: HashMap<String, String> = stack
889        .resources
890        .iter()
891        .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
892        .collect();
893
894    // Create new resources
895    for resource_def in new_resource_defs {
896        if !old_logical_ids.contains(&resource_def.logical_id) {
897            let resolved_def = template::resolve_resource_properties(
898                resource_def,
899                template_body,
900                parameters,
901                &physical_ids,
902            )
903            .map_err(|e| {
904                format!(
905                    "Failed to resolve resource {}: {e}",
906                    resource_def.logical_id
907                )
908            })?;
909
910            match provisioner.create_resource(&resolved_def) {
911                Ok(stack_resource) => {
912                    physical_ids.insert(
913                        stack_resource.logical_id.clone(),
914                        stack_resource.physical_id.clone(),
915                    );
916                    stack.resources.push(stack_resource);
917                }
918                Err(e) => {
919                    tracing::warn!(
920                        "Failed to create resource {} during update: {e}",
921                        resource_def.logical_id
922                    );
923                    return Err(format!(
924                        "Failed to create resource {}: {e}",
925                        resource_def.logical_id
926                    ));
927                }
928            }
929        }
930    }
931
932    Ok(())
933}
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938    use http::HeaderMap;
939    use parking_lot::RwLock;
940    use std::sync::Arc;
941
942    fn make_service() -> CloudFormationService {
943        let cf_state = Arc::new(RwLock::new(
944            fakecloud_core::multi_account::MultiAccountState::new(
945                "123456789012",
946                "us-east-1",
947                "http://localhost:4566",
948            ),
949        ));
950        let deps = CloudFormationDeps {
951            sqs: Arc::new(RwLock::new(
952                fakecloud_core::multi_account::MultiAccountState::new(
953                    "123456789012",
954                    "us-east-1",
955                    "http://localhost:4566",
956                ),
957            )),
958            sns: Arc::new(RwLock::new(
959                fakecloud_core::multi_account::MultiAccountState::new(
960                    "123456789012",
961                    "us-east-1",
962                    "http://localhost:4566",
963                ),
964            )),
965            ssm: Arc::new(RwLock::new(
966                fakecloud_core::multi_account::MultiAccountState::new(
967                    "123456789012",
968                    "us-east-1",
969                    "http://localhost:4566",
970                ),
971            )),
972            iam: Arc::new(RwLock::new(
973                fakecloud_core::multi_account::MultiAccountState::new(
974                    "123456789012",
975                    "us-east-1",
976                    "",
977                ),
978            )),
979            s3: Arc::new(RwLock::new(
980                fakecloud_core::multi_account::MultiAccountState::new(
981                    "123456789012",
982                    "us-east-1",
983                    "",
984                ),
985            )),
986            eventbridge: Arc::new(RwLock::new(
987                fakecloud_core::multi_account::MultiAccountState::new(
988                    "123456789012",
989                    "us-east-1",
990                    "",
991                ),
992            )),
993            dynamodb: Arc::new(RwLock::new(
994                fakecloud_core::multi_account::MultiAccountState::new(
995                    "123456789012",
996                    "us-east-1",
997                    "",
998                ),
999            )),
1000            logs: Arc::new(RwLock::new(
1001                fakecloud_core::multi_account::MultiAccountState::new(
1002                    "123456789012",
1003                    "us-east-1",
1004                    "",
1005                ),
1006            )),
1007            delivery: Arc::new(DeliveryBus::new()),
1008        };
1009        CloudFormationService::new(cf_state, deps)
1010    }
1011
1012    fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
1013        AwsRequest {
1014            service: "cloudformation".to_string(),
1015            action: action.to_string(),
1016            region: "us-east-1".to_string(),
1017            account_id: "123456789012".to_string(),
1018            request_id: "test-request-id".to_string(),
1019            headers: HeaderMap::new(),
1020            query_params: params,
1021            body: bytes::Bytes::new(),
1022            body_stream: parking_lot::Mutex::new(None),
1023            path_segments: vec![],
1024            raw_path: "/".to_string(),
1025            raw_query: String::new(),
1026            method: http::Method::POST,
1027            is_query_protocol: true,
1028            access_key_id: None,
1029            principal: None,
1030        }
1031    }
1032
1033    #[test]
1034    fn update_stack_sets_failed_status_on_resource_error() {
1035        let svc = make_service();
1036
1037        // Create a stack with just a queue
1038        let mut create_params = HashMap::new();
1039        create_params.insert("StackName".to_string(), "test-stack".to_string());
1040        create_params.insert(
1041            "TemplateBody".to_string(),
1042            r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
1043        );
1044        let req = make_request("CreateStack", create_params);
1045        let result = svc.create_stack(&req);
1046        assert!(result.is_ok());
1047
1048        // Update stack adding an SNS subscription with a non-existent topic
1049        let mut update_params = HashMap::new();
1050        update_params.insert("StackName".to_string(), "test-stack".to_string());
1051        update_params.insert(
1052            "TemplateBody".to_string(),
1053            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(),
1054        );
1055        let req = make_request("UpdateStack", update_params);
1056        let result = svc.update_stack(&req);
1057
1058        // Should return an error
1059        assert!(result.is_err());
1060
1061        // Stack status should be UPDATE_FAILED
1062        let accounts = svc.state.read();
1063        let state = accounts.get("123456789012").unwrap();
1064        let stack = state.stacks.get("test-stack").unwrap();
1065        assert_eq!(stack.status, "UPDATE_FAILED");
1066    }
1067
1068    #[test]
1069    fn create_stack_resolves_ref_to_physical_id() {
1070        let svc = make_service();
1071
1072        // Template where subscription Refs the topic
1073        let template = r#"{
1074            "Resources": {
1075                "MyTopic": {
1076                    "Type": "AWS::SNS::Topic",
1077                    "Properties": { "TopicName": "ref-test-topic" }
1078                },
1079                "MySub": {
1080                    "Type": "AWS::SNS::Subscription",
1081                    "Properties": {
1082                        "TopicArn": { "Ref": "MyTopic" },
1083                        "Protocol": "sqs",
1084                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
1085                    }
1086                }
1087            }
1088        }"#;
1089
1090        let mut params = HashMap::new();
1091        params.insert("StackName".to_string(), "ref-stack".to_string());
1092        params.insert("TemplateBody".to_string(), template.to_string());
1093        let req = make_request("CreateStack", params);
1094        let result = svc.create_stack(&req);
1095        assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
1096
1097        // Verify both resources were created
1098        let accounts = svc.state.read();
1099        let state = accounts.get("123456789012").unwrap();
1100        let stack = state.stacks.get("ref-stack").unwrap();
1101        assert_eq!(stack.resources.len(), 2);
1102        assert_eq!(stack.status, "CREATE_COMPLETE");
1103
1104        // The subscription's physical ID should be an ARN (not just "MyTopic")
1105        let sub = stack
1106            .resources
1107            .iter()
1108            .find(|r| r.logical_id == "MySub")
1109            .unwrap();
1110        assert!(
1111            sub.physical_id.contains("ref-test-topic"),
1112            "Subscription physical ID should reference the topic ARN, got: {}",
1113            sub.physical_id
1114        );
1115    }
1116
1117    // ── Service error paths ──
1118
1119    #[test]
1120    fn create_stack_missing_name_errors() {
1121        let svc = make_service();
1122        let mut params = HashMap::new();
1123        params.insert("TemplateBody".to_string(), "{}".to_string());
1124        let req = make_request("CreateStack", params);
1125        assert!(svc.create_stack(&req).is_err());
1126    }
1127
1128    #[test]
1129    fn create_stack_missing_template_errors() {
1130        let svc = make_service();
1131        let mut params = HashMap::new();
1132        params.insert("StackName".to_string(), "s".to_string());
1133        let req = make_request("CreateStack", params);
1134        assert!(svc.create_stack(&req).is_err());
1135    }
1136
1137    #[test]
1138    fn create_stack_duplicate_errors() {
1139        let svc = make_service();
1140        let mut params = HashMap::new();
1141        params.insert("StackName".to_string(), "dup".to_string());
1142        params.insert(
1143            "TemplateBody".to_string(),
1144            r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
1145                .to_string(),
1146        );
1147        let req = make_request("CreateStack", params.clone());
1148        svc.create_stack(&req).unwrap();
1149        let req = make_request("CreateStack", params);
1150        assert!(svc.create_stack(&req).is_err());
1151    }
1152
1153    #[test]
1154    fn create_stack_invalid_template_errors() {
1155        let svc = make_service();
1156        let mut params = HashMap::new();
1157        params.insert("StackName".to_string(), "bad".to_string());
1158        params.insert("TemplateBody".to_string(), "not json".to_string());
1159        let req = make_request("CreateStack", params);
1160        assert!(svc.create_stack(&req).is_err());
1161    }
1162
1163    #[test]
1164    fn delete_stack_unknown_is_noop() {
1165        let svc = make_service();
1166        let mut params = HashMap::new();
1167        params.insert("StackName".to_string(), "ghost".to_string());
1168        let req = make_request("DeleteStack", params);
1169        assert!(svc.delete_stack(&req).is_ok());
1170    }
1171
1172    #[test]
1173    fn describe_stacks_nonexistent_errors() {
1174        let svc = make_service();
1175        let mut params = HashMap::new();
1176        params.insert("StackName".to_string(), "ghost".to_string());
1177        let req = make_request("DescribeStacks", params);
1178        assert!(svc.describe_stacks(&req).is_err());
1179    }
1180
1181    #[test]
1182    fn describe_stacks_empty_returns_all() {
1183        let svc = make_service();
1184        let req = make_request("DescribeStacks", HashMap::new());
1185        let resp = svc.describe_stacks(&req).unwrap();
1186        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1187        assert!(b.contains("DescribeStacksResult"));
1188    }
1189
1190    #[test]
1191    fn list_stacks_empty_returns_ok() {
1192        let svc = make_service();
1193        let req = make_request("ListStacks", HashMap::new());
1194        let resp = svc.list_stacks(&req).unwrap();
1195        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1196        assert!(b.contains("ListStacksResult"));
1197    }
1198
1199    #[test]
1200    fn list_stack_resources_missing_name_errors() {
1201        let svc = make_service();
1202        let req = make_request("ListStackResources", HashMap::new());
1203        assert!(svc.list_stack_resources(&req).is_err());
1204    }
1205
1206    #[test]
1207    fn list_stack_resources_unknown_stack_errors() {
1208        let svc = make_service();
1209        let mut params = HashMap::new();
1210        params.insert("StackName".to_string(), "ghost".to_string());
1211        let req = make_request("ListStackResources", params);
1212        assert!(svc.list_stack_resources(&req).is_err());
1213    }
1214
1215    #[test]
1216    fn describe_stack_resources_missing_name_errors() {
1217        let svc = make_service();
1218        let req = make_request("DescribeStackResources", HashMap::new());
1219        assert!(svc.describe_stack_resources(&req).is_err());
1220    }
1221
1222    #[test]
1223    fn get_template_missing_name_errors() {
1224        let svc = make_service();
1225        let req = make_request("GetTemplate", HashMap::new());
1226        assert!(svc.get_template(&req).is_err());
1227    }
1228
1229    #[test]
1230    fn get_template_unknown_stack_errors() {
1231        let svc = make_service();
1232        let mut params = HashMap::new();
1233        params.insert("StackName".to_string(), "ghost".to_string());
1234        let req = make_request("GetTemplate", params);
1235        assert!(svc.get_template(&req).is_err());
1236    }
1237
1238    #[test]
1239    fn update_stack_missing_name_errors() {
1240        let svc = make_service();
1241        let mut params = HashMap::new();
1242        params.insert("TemplateBody".to_string(), "{}".to_string());
1243        let req = make_request("UpdateStack", params);
1244        assert!(svc.update_stack(&req).is_err());
1245    }
1246
1247    #[test]
1248    fn update_stack_unknown_stack_errors() {
1249        let svc = make_service();
1250        let mut params = HashMap::new();
1251        params.insert("StackName".to_string(), "ghost".to_string());
1252        params.insert(
1253            "TemplateBody".to_string(),
1254            r#"{"Resources":{}}"#.to_string(),
1255        );
1256        let req = make_request("UpdateStack", params);
1257        assert!(svc.update_stack(&req).is_err());
1258    }
1259}