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            path_segments: vec![],
1023            raw_path: "/".to_string(),
1024            raw_query: String::new(),
1025            method: http::Method::POST,
1026            is_query_protocol: true,
1027            access_key_id: None,
1028            principal: None,
1029        }
1030    }
1031
1032    #[test]
1033    fn update_stack_sets_failed_status_on_resource_error() {
1034        let svc = make_service();
1035
1036        // Create a stack with just a queue
1037        let mut create_params = HashMap::new();
1038        create_params.insert("StackName".to_string(), "test-stack".to_string());
1039        create_params.insert(
1040            "TemplateBody".to_string(),
1041            r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
1042        );
1043        let req = make_request("CreateStack", create_params);
1044        let result = svc.create_stack(&req);
1045        assert!(result.is_ok());
1046
1047        // Update stack adding an SNS subscription with a non-existent topic
1048        let mut update_params = HashMap::new();
1049        update_params.insert("StackName".to_string(), "test-stack".to_string());
1050        update_params.insert(
1051            "TemplateBody".to_string(),
1052            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(),
1053        );
1054        let req = make_request("UpdateStack", update_params);
1055        let result = svc.update_stack(&req);
1056
1057        // Should return an error
1058        assert!(result.is_err());
1059
1060        // Stack status should be UPDATE_FAILED
1061        let accounts = svc.state.read();
1062        let state = accounts.get("123456789012").unwrap();
1063        let stack = state.stacks.get("test-stack").unwrap();
1064        assert_eq!(stack.status, "UPDATE_FAILED");
1065    }
1066
1067    #[test]
1068    fn create_stack_resolves_ref_to_physical_id() {
1069        let svc = make_service();
1070
1071        // Template where subscription Refs the topic
1072        let template = r#"{
1073            "Resources": {
1074                "MyTopic": {
1075                    "Type": "AWS::SNS::Topic",
1076                    "Properties": { "TopicName": "ref-test-topic" }
1077                },
1078                "MySub": {
1079                    "Type": "AWS::SNS::Subscription",
1080                    "Properties": {
1081                        "TopicArn": { "Ref": "MyTopic" },
1082                        "Protocol": "sqs",
1083                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
1084                    }
1085                }
1086            }
1087        }"#;
1088
1089        let mut params = HashMap::new();
1090        params.insert("StackName".to_string(), "ref-stack".to_string());
1091        params.insert("TemplateBody".to_string(), template.to_string());
1092        let req = make_request("CreateStack", params);
1093        let result = svc.create_stack(&req);
1094        assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
1095
1096        // Verify both resources were created
1097        let accounts = svc.state.read();
1098        let state = accounts.get("123456789012").unwrap();
1099        let stack = state.stacks.get("ref-stack").unwrap();
1100        assert_eq!(stack.resources.len(), 2);
1101        assert_eq!(stack.status, "CREATE_COMPLETE");
1102
1103        // The subscription's physical ID should be an ARN (not just "MyTopic")
1104        let sub = stack
1105            .resources
1106            .iter()
1107            .find(|r| r.logical_id == "MySub")
1108            .unwrap();
1109        assert!(
1110            sub.physical_id.contains("ref-test-topic"),
1111            "Subscription physical ID should reference the topic ARN, got: {}",
1112            sub.physical_id
1113        );
1114    }
1115
1116    // ── Service error paths ──
1117
1118    #[test]
1119    fn create_stack_missing_name_errors() {
1120        let svc = make_service();
1121        let mut params = HashMap::new();
1122        params.insert("TemplateBody".to_string(), "{}".to_string());
1123        let req = make_request("CreateStack", params);
1124        assert!(svc.create_stack(&req).is_err());
1125    }
1126
1127    #[test]
1128    fn create_stack_missing_template_errors() {
1129        let svc = make_service();
1130        let mut params = HashMap::new();
1131        params.insert("StackName".to_string(), "s".to_string());
1132        let req = make_request("CreateStack", params);
1133        assert!(svc.create_stack(&req).is_err());
1134    }
1135
1136    #[test]
1137    fn create_stack_duplicate_errors() {
1138        let svc = make_service();
1139        let mut params = HashMap::new();
1140        params.insert("StackName".to_string(), "dup".to_string());
1141        params.insert(
1142            "TemplateBody".to_string(),
1143            r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
1144                .to_string(),
1145        );
1146        let req = make_request("CreateStack", params.clone());
1147        svc.create_stack(&req).unwrap();
1148        let req = make_request("CreateStack", params);
1149        assert!(svc.create_stack(&req).is_err());
1150    }
1151
1152    #[test]
1153    fn create_stack_invalid_template_errors() {
1154        let svc = make_service();
1155        let mut params = HashMap::new();
1156        params.insert("StackName".to_string(), "bad".to_string());
1157        params.insert("TemplateBody".to_string(), "not json".to_string());
1158        let req = make_request("CreateStack", params);
1159        assert!(svc.create_stack(&req).is_err());
1160    }
1161
1162    #[test]
1163    fn delete_stack_unknown_is_noop() {
1164        let svc = make_service();
1165        let mut params = HashMap::new();
1166        params.insert("StackName".to_string(), "ghost".to_string());
1167        let req = make_request("DeleteStack", params);
1168        assert!(svc.delete_stack(&req).is_ok());
1169    }
1170
1171    #[test]
1172    fn describe_stacks_nonexistent_errors() {
1173        let svc = make_service();
1174        let mut params = HashMap::new();
1175        params.insert("StackName".to_string(), "ghost".to_string());
1176        let req = make_request("DescribeStacks", params);
1177        assert!(svc.describe_stacks(&req).is_err());
1178    }
1179
1180    #[test]
1181    fn describe_stacks_empty_returns_all() {
1182        let svc = make_service();
1183        let req = make_request("DescribeStacks", HashMap::new());
1184        let resp = svc.describe_stacks(&req).unwrap();
1185        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1186        assert!(b.contains("DescribeStacksResult"));
1187    }
1188
1189    #[test]
1190    fn list_stacks_empty_returns_ok() {
1191        let svc = make_service();
1192        let req = make_request("ListStacks", HashMap::new());
1193        let resp = svc.list_stacks(&req).unwrap();
1194        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1195        assert!(b.contains("ListStacksResult"));
1196    }
1197
1198    #[test]
1199    fn list_stack_resources_missing_name_errors() {
1200        let svc = make_service();
1201        let req = make_request("ListStackResources", HashMap::new());
1202        assert!(svc.list_stack_resources(&req).is_err());
1203    }
1204
1205    #[test]
1206    fn list_stack_resources_unknown_stack_errors() {
1207        let svc = make_service();
1208        let mut params = HashMap::new();
1209        params.insert("StackName".to_string(), "ghost".to_string());
1210        let req = make_request("ListStackResources", params);
1211        assert!(svc.list_stack_resources(&req).is_err());
1212    }
1213
1214    #[test]
1215    fn describe_stack_resources_missing_name_errors() {
1216        let svc = make_service();
1217        let req = make_request("DescribeStackResources", HashMap::new());
1218        assert!(svc.describe_stack_resources(&req).is_err());
1219    }
1220
1221    #[test]
1222    fn get_template_missing_name_errors() {
1223        let svc = make_service();
1224        let req = make_request("GetTemplate", HashMap::new());
1225        assert!(svc.get_template(&req).is_err());
1226    }
1227
1228    #[test]
1229    fn get_template_unknown_stack_errors() {
1230        let svc = make_service();
1231        let mut params = HashMap::new();
1232        params.insert("StackName".to_string(), "ghost".to_string());
1233        let req = make_request("GetTemplate", params);
1234        assert!(svc.get_template(&req).is_err());
1235    }
1236
1237    #[test]
1238    fn update_stack_missing_name_errors() {
1239        let svc = make_service();
1240        let mut params = HashMap::new();
1241        params.insert("TemplateBody".to_string(), "{}".to_string());
1242        let req = make_request("UpdateStack", params);
1243        assert!(svc.update_stack(&req).is_err());
1244    }
1245
1246    #[test]
1247    fn update_stack_unknown_stack_errors() {
1248        let svc = make_service();
1249        let mut params = HashMap::new();
1250        params.insert("StackName".to_string(), "ghost".to_string());
1251        params.insert(
1252            "TemplateBody".to_string(),
1253            r#"{"Resources":{}}"#.to_string(),
1254        );
1255        let req = make_request("UpdateStack", params);
1256        assert!(svc.update_stack(&req).is_err());
1257    }
1258}