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    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    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 mutates = matches!(
676            req.action.as_str(),
677            "CreateStack" | "DeleteStack" | "UpdateStack"
678        );
679        let result = match req.action.as_str() {
680            "CreateStack" => self.create_stack(&req),
681            "DeleteStack" => self.delete_stack(&req),
682            "DescribeStacks" => self.describe_stacks(&req),
683            "ListStacks" => self.list_stacks(&req),
684            "ListStackResources" => self.list_stack_resources(&req),
685            "DescribeStackResources" => self.describe_stack_resources(&req),
686            "UpdateStack" => self.update_stack(&req),
687            "GetTemplate" => self.get_template(&req),
688            _ => Err(AwsServiceError::action_not_implemented(
689                "cloudformation",
690                &req.action,
691            )),
692        };
693        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
694            self.save_snapshot().await;
695        }
696        result
697    }
698
699    fn supported_actions(&self) -> &[&str] {
700        &[
701            "CreateStack",
702            "DeleteStack",
703            "DescribeStacks",
704            "ListStacks",
705            "ListStackResources",
706            "DescribeStackResources",
707            "UpdateStack",
708            "GetTemplate",
709        ]
710    }
711}
712
713/// Parsed + validated inputs for `UpdateStack`.
714struct UpdateStackInput {
715    stack_name: String,
716    template_body: String,
717    parameters: HashMap<String, String>,
718    tags: HashMap<String, String>,
719    notification_arns: Vec<String>,
720}
721
722impl UpdateStackInput {
723    fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
724        let params = CloudFormationService::get_all_params(req);
725
726        let stack_name = params
727            .get("StackName")
728            .ok_or_else(|| {
729                AwsServiceError::aws_error(
730                    StatusCode::BAD_REQUEST,
731                    "ValidationError",
732                    "StackName is required",
733                )
734            })?
735            .to_string();
736
737        let template_body = params
738            .get("TemplateBody")
739            .ok_or_else(|| {
740                AwsServiceError::aws_error(
741                    StatusCode::BAD_REQUEST,
742                    "ValidationError",
743                    "TemplateBody is required",
744                )
745            })?
746            .to_string();
747
748        Ok(Self {
749            stack_name,
750            template_body,
751            parameters: CloudFormationService::extract_parameters(&params),
752            tags: CloudFormationService::extract_tags(&params),
753            notification_arns: CloudFormationService::extract_notification_arns(&params),
754        })
755    }
756}
757
758/// Apply resource updates: delete removed resources, create new ones.
759/// Returns Err(msg) if any resource operation fails.
760fn apply_resource_updates(
761    stack: &mut crate::state::Stack,
762    new_resource_defs: &[template::ResourceDefinition],
763    template_body: &str,
764    parameters: &HashMap<String, String>,
765    provisioner: &crate::resource_provisioner::ResourceProvisioner,
766) -> Result<(), String> {
767    let old_logical_ids: std::collections::HashSet<String> = stack
768        .resources
769        .iter()
770        .map(|r| r.logical_id.clone())
771        .collect();
772    let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
773        .iter()
774        .map(|r| r.logical_id.clone())
775        .collect();
776
777    // Delete resources no longer in template
778    let to_remove: Vec<_> = stack
779        .resources
780        .iter()
781        .filter(|r| !new_logical_ids.contains(&r.logical_id))
782        .cloned()
783        .collect();
784    for resource in &to_remove {
785        let _ = provisioner.delete_resource(resource);
786    }
787    stack
788        .resources
789        .retain(|r| new_logical_ids.contains(&r.logical_id));
790
791    // Build physical ID map from existing resources
792    let mut physical_ids: HashMap<String, String> = stack
793        .resources
794        .iter()
795        .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
796        .collect();
797
798    // Create new resources
799    for resource_def in new_resource_defs {
800        if !old_logical_ids.contains(&resource_def.logical_id) {
801            let resolved_def = template::resolve_resource_properties(
802                resource_def,
803                template_body,
804                parameters,
805                &physical_ids,
806            )
807            .map_err(|e| {
808                format!(
809                    "Failed to resolve resource {}: {e}",
810                    resource_def.logical_id
811                )
812            })?;
813
814            match provisioner.create_resource(&resolved_def) {
815                Ok(stack_resource) => {
816                    physical_ids.insert(
817                        stack_resource.logical_id.clone(),
818                        stack_resource.physical_id.clone(),
819                    );
820                    stack.resources.push(stack_resource);
821                }
822                Err(e) => {
823                    tracing::warn!(
824                        "Failed to create resource {} during update: {e}",
825                        resource_def.logical_id
826                    );
827                    return Err(format!(
828                        "Failed to create resource {}: {e}",
829                        resource_def.logical_id
830                    ));
831                }
832            }
833        }
834    }
835
836    Ok(())
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use http::HeaderMap;
843    use parking_lot::RwLock;
844    use std::sync::Arc;
845
846    fn make_service() -> CloudFormationService {
847        let cf_state = Arc::new(RwLock::new(
848            fakecloud_core::multi_account::MultiAccountState::new(
849                "123456789012",
850                "us-east-1",
851                "http://localhost:4566",
852            ),
853        ));
854        let deps = CloudFormationDeps {
855            sqs: Arc::new(RwLock::new(
856                fakecloud_core::multi_account::MultiAccountState::new(
857                    "123456789012",
858                    "us-east-1",
859                    "http://localhost:4566",
860                ),
861            )),
862            sns: Arc::new(RwLock::new(
863                fakecloud_core::multi_account::MultiAccountState::new(
864                    "123456789012",
865                    "us-east-1",
866                    "http://localhost:4566",
867                ),
868            )),
869            ssm: Arc::new(RwLock::new(
870                fakecloud_core::multi_account::MultiAccountState::new(
871                    "123456789012",
872                    "us-east-1",
873                    "http://localhost:4566",
874                ),
875            )),
876            iam: Arc::new(RwLock::new(
877                fakecloud_core::multi_account::MultiAccountState::new(
878                    "123456789012",
879                    "us-east-1",
880                    "",
881                ),
882            )),
883            s3: Arc::new(RwLock::new(
884                fakecloud_core::multi_account::MultiAccountState::new(
885                    "123456789012",
886                    "us-east-1",
887                    "",
888                ),
889            )),
890            eventbridge: Arc::new(RwLock::new(
891                fakecloud_core::multi_account::MultiAccountState::new(
892                    "123456789012",
893                    "us-east-1",
894                    "",
895                ),
896            )),
897            dynamodb: Arc::new(RwLock::new(
898                fakecloud_core::multi_account::MultiAccountState::new(
899                    "123456789012",
900                    "us-east-1",
901                    "",
902                ),
903            )),
904            logs: Arc::new(RwLock::new(
905                fakecloud_core::multi_account::MultiAccountState::new(
906                    "123456789012",
907                    "us-east-1",
908                    "",
909                ),
910            )),
911            delivery: Arc::new(DeliveryBus::new()),
912        };
913        CloudFormationService::new(cf_state, deps)
914    }
915
916    fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
917        AwsRequest {
918            service: "cloudformation".to_string(),
919            action: action.to_string(),
920            region: "us-east-1".to_string(),
921            account_id: "123456789012".to_string(),
922            request_id: "test-request-id".to_string(),
923            headers: HeaderMap::new(),
924            query_params: params,
925            body: bytes::Bytes::new(),
926            path_segments: vec![],
927            raw_path: "/".to_string(),
928            raw_query: String::new(),
929            method: http::Method::POST,
930            is_query_protocol: true,
931            access_key_id: None,
932            principal: None,
933        }
934    }
935
936    #[test]
937    fn update_stack_sets_failed_status_on_resource_error() {
938        let svc = make_service();
939
940        // Create a stack with just a queue
941        let mut create_params = HashMap::new();
942        create_params.insert("StackName".to_string(), "test-stack".to_string());
943        create_params.insert(
944            "TemplateBody".to_string(),
945            r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
946        );
947        let req = make_request("CreateStack", create_params);
948        let result = svc.create_stack(&req);
949        assert!(result.is_ok());
950
951        // Update stack adding an SNS subscription with a non-existent topic
952        let mut update_params = HashMap::new();
953        update_params.insert("StackName".to_string(), "test-stack".to_string());
954        update_params.insert(
955            "TemplateBody".to_string(),
956            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(),
957        );
958        let req = make_request("UpdateStack", update_params);
959        let result = svc.update_stack(&req);
960
961        // Should return an error
962        assert!(result.is_err());
963
964        // Stack status should be UPDATE_FAILED
965        let accounts = svc.state.read();
966        let state = accounts.get("123456789012").unwrap();
967        let stack = state.stacks.get("test-stack").unwrap();
968        assert_eq!(stack.status, "UPDATE_FAILED");
969    }
970
971    #[test]
972    fn create_stack_resolves_ref_to_physical_id() {
973        let svc = make_service();
974
975        // Template where subscription Refs the topic
976        let template = r#"{
977            "Resources": {
978                "MyTopic": {
979                    "Type": "AWS::SNS::Topic",
980                    "Properties": { "TopicName": "ref-test-topic" }
981                },
982                "MySub": {
983                    "Type": "AWS::SNS::Subscription",
984                    "Properties": {
985                        "TopicArn": { "Ref": "MyTopic" },
986                        "Protocol": "sqs",
987                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
988                    }
989                }
990            }
991        }"#;
992
993        let mut params = HashMap::new();
994        params.insert("StackName".to_string(), "ref-stack".to_string());
995        params.insert("TemplateBody".to_string(), template.to_string());
996        let req = make_request("CreateStack", params);
997        let result = svc.create_stack(&req);
998        assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
999
1000        // Verify both resources were created
1001        let accounts = svc.state.read();
1002        let state = accounts.get("123456789012").unwrap();
1003        let stack = state.stacks.get("ref-stack").unwrap();
1004        assert_eq!(stack.resources.len(), 2);
1005        assert_eq!(stack.status, "CREATE_COMPLETE");
1006
1007        // The subscription's physical ID should be an ARN (not just "MyTopic")
1008        let sub = stack
1009            .resources
1010            .iter()
1011            .find(|r| r.logical_id == "MySub")
1012            .unwrap();
1013        assert!(
1014            sub.physical_id.contains("ref-test-topic"),
1015            "Subscription physical ID should reference the topic ARN, got: {}",
1016            sub.physical_id
1017        );
1018    }
1019
1020    // ── Service error paths ──
1021
1022    #[test]
1023    fn create_stack_missing_name_errors() {
1024        let svc = make_service();
1025        let mut params = HashMap::new();
1026        params.insert("TemplateBody".to_string(), "{}".to_string());
1027        let req = make_request("CreateStack", params);
1028        assert!(svc.create_stack(&req).is_err());
1029    }
1030
1031    #[test]
1032    fn create_stack_missing_template_errors() {
1033        let svc = make_service();
1034        let mut params = HashMap::new();
1035        params.insert("StackName".to_string(), "s".to_string());
1036        let req = make_request("CreateStack", params);
1037        assert!(svc.create_stack(&req).is_err());
1038    }
1039
1040    #[test]
1041    fn create_stack_duplicate_errors() {
1042        let svc = make_service();
1043        let mut params = HashMap::new();
1044        params.insert("StackName".to_string(), "dup".to_string());
1045        params.insert(
1046            "TemplateBody".to_string(),
1047            r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
1048                .to_string(),
1049        );
1050        let req = make_request("CreateStack", params.clone());
1051        svc.create_stack(&req).unwrap();
1052        let req = make_request("CreateStack", params);
1053        assert!(svc.create_stack(&req).is_err());
1054    }
1055
1056    #[test]
1057    fn create_stack_invalid_template_errors() {
1058        let svc = make_service();
1059        let mut params = HashMap::new();
1060        params.insert("StackName".to_string(), "bad".to_string());
1061        params.insert("TemplateBody".to_string(), "not json".to_string());
1062        let req = make_request("CreateStack", params);
1063        assert!(svc.create_stack(&req).is_err());
1064    }
1065
1066    #[test]
1067    fn delete_stack_unknown_is_noop() {
1068        let svc = make_service();
1069        let mut params = HashMap::new();
1070        params.insert("StackName".to_string(), "ghost".to_string());
1071        let req = make_request("DeleteStack", params);
1072        assert!(svc.delete_stack(&req).is_ok());
1073    }
1074
1075    #[test]
1076    fn describe_stacks_nonexistent_errors() {
1077        let svc = make_service();
1078        let mut params = HashMap::new();
1079        params.insert("StackName".to_string(), "ghost".to_string());
1080        let req = make_request("DescribeStacks", params);
1081        assert!(svc.describe_stacks(&req).is_err());
1082    }
1083
1084    #[test]
1085    fn describe_stacks_empty_returns_all() {
1086        let svc = make_service();
1087        let req = make_request("DescribeStacks", HashMap::new());
1088        let resp = svc.describe_stacks(&req).unwrap();
1089        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1090        assert!(b.contains("DescribeStacksResult"));
1091    }
1092
1093    #[test]
1094    fn list_stacks_empty_returns_ok() {
1095        let svc = make_service();
1096        let req = make_request("ListStacks", HashMap::new());
1097        let resp = svc.list_stacks(&req).unwrap();
1098        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1099        assert!(b.contains("ListStacksResult"));
1100    }
1101
1102    #[test]
1103    fn list_stack_resources_missing_name_errors() {
1104        let svc = make_service();
1105        let req = make_request("ListStackResources", HashMap::new());
1106        assert!(svc.list_stack_resources(&req).is_err());
1107    }
1108
1109    #[test]
1110    fn list_stack_resources_unknown_stack_errors() {
1111        let svc = make_service();
1112        let mut params = HashMap::new();
1113        params.insert("StackName".to_string(), "ghost".to_string());
1114        let req = make_request("ListStackResources", params);
1115        assert!(svc.list_stack_resources(&req).is_err());
1116    }
1117
1118    #[test]
1119    fn describe_stack_resources_missing_name_errors() {
1120        let svc = make_service();
1121        let req = make_request("DescribeStackResources", HashMap::new());
1122        assert!(svc.describe_stack_resources(&req).is_err());
1123    }
1124
1125    #[test]
1126    fn get_template_missing_name_errors() {
1127        let svc = make_service();
1128        let req = make_request("GetTemplate", HashMap::new());
1129        assert!(svc.get_template(&req).is_err());
1130    }
1131
1132    #[test]
1133    fn get_template_unknown_stack_errors() {
1134        let svc = make_service();
1135        let mut params = HashMap::new();
1136        params.insert("StackName".to_string(), "ghost".to_string());
1137        let req = make_request("GetTemplate", params);
1138        assert!(svc.get_template(&req).is_err());
1139    }
1140
1141    #[test]
1142    fn update_stack_missing_name_errors() {
1143        let svc = make_service();
1144        let mut params = HashMap::new();
1145        params.insert("TemplateBody".to_string(), "{}".to_string());
1146        let req = make_request("UpdateStack", params);
1147        assert!(svc.update_stack(&req).is_err());
1148    }
1149
1150    #[test]
1151    fn update_stack_unknown_stack_errors() {
1152        let svc = make_service();
1153        let mut params = HashMap::new();
1154        params.insert("StackName".to_string(), "ghost".to_string());
1155        params.insert(
1156            "TemplateBody".to_string(),
1157            r#"{"Resources":{}}"#.to_string(),
1158        );
1159        let req = make_request("UpdateStack", params);
1160        assert!(svc.update_stack(&req).is_err());
1161    }
1162}