Skip to main content

fakecloud_cloudformation/
service.rs

1use async_trait::async_trait;
2use chrono::Utc;
3use http::StatusCode;
4use std::collections::BTreeMap;
5use std::sync::Arc;
6
7use fakecloud_core::delivery::DeliveryBus;
8use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
9use fakecloud_dynamodb::SharedDynamoDbState;
10use fakecloud_eventbridge::SharedEventBridgeState;
11use fakecloud_iam::SharedIamState;
12use fakecloud_logs::SharedLogsState;
13use fakecloud_persistence::SnapshotStore;
14use fakecloud_s3::SharedS3State;
15use fakecloud_sns::SharedSnsState;
16use fakecloud_sqs::SharedSqsState;
17use fakecloud_ssm::SharedSsmState;
18use tokio::sync::Mutex as AsyncMutex;
19
20use crate::resource_provisioner::ResourceProvisioner;
21use crate::state;
22use crate::state::{
23    CloudFormationSnapshot, CloudFormationState, SharedCloudFormationState, Stack, StackResource,
24    CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
25};
26use crate::template;
27use crate::xml_responses;
28
29/// Canonical `Fn::GetAtt` attribute names per resource type. Used to
30/// supplement the eagerly-captured `ProvisionResult::attributes` with
31/// live-state lookups via `ResourceProvisioner::get_att`. Resources not
32/// listed here just use whatever the create handler captured.
33fn well_known_attributes_for(resource_type: &str) -> &'static [&'static str] {
34    match resource_type {
35        "AWS::S3::Bucket" => &[
36            "Arn",
37            "DomainName",
38            "RegionalDomainName",
39            "DualStackDomainName",
40            "WebsiteURL",
41        ],
42        "AWS::Lambda::Function" => &["Arn", "FunctionUrl", "Version"],
43        "AWS::IAM::Role" => &["Arn", "RoleId"],
44        "AWS::SQS::Queue" => &["Arn", "QueueName", "QueueUrl"],
45        "AWS::SNS::Topic" => &["TopicArn", "TopicName"],
46        "AWS::DynamoDB::Table" => &["Arn", "StreamArn"],
47        "AWS::KMS::Key" => &["Arn", "KeyId"],
48        "AWS::SecretsManager::Secret" => &["Arn", "Id"],
49        "AWS::CloudFront::Distribution" => &["DomainName", "Id"],
50        _ => &[],
51    }
52}
53
54/// Multi-pass provisioning for all resources in a parsed template.
55///
56/// Resources may `Ref` each other in either direction, and JSON object
57/// iteration order isn't stable, so a single forward pass isn't enough
58/// to resolve them. We loop: each pass tries every pending resource, and
59/// any resource whose `Ref` targets are still unknown just stays pending
60/// for the next pass. When no pass makes progress we report the first
61/// pending failure and rollback.
62pub(crate) fn provision_stack_resources(
63    provisioner: &ResourceProvisioner,
64    resource_defs: &[template::ResourceDefinition],
65    template_body: &str,
66    parameters: &BTreeMap<String, String>,
67) -> Result<Vec<StackResource>, AwsServiceError> {
68    let mut resources = Vec::new();
69    let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
70    let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
71    // Seed the work list in dependency order so a referenced resource is
72    // provisioned before its referrer and `Ref`/`GetAtt`/`Fn::Sub` resolve to
73    // physical ids. The fixed-point retry loop below still recovers from any
74    // residual ordering the graph can't express.
75    let order = template::dependency_order(template_body, parameters, resource_defs);
76    let mut pending: Vec<&template::ResourceDefinition> =
77        order.iter().map(|&i| &resource_defs[i]).collect();
78    let max_passes = pending.len() + 1;
79
80    for _ in 0..max_passes {
81        if pending.is_empty() {
82            break;
83        }
84        let mut still_pending = Vec::new();
85        let mut made_progress = false;
86
87        for resource_def in pending {
88            let resolved_def = template::resolve_resource_properties_with_attrs(
89                resource_def,
90                template_body,
91                parameters,
92                &physical_ids,
93                &attributes,
94            )
95            .map_err(|e| {
96                // `ValidationError` isn't declared on CreateStack/UpdateStack;
97                // surface template resolve failures through the closest declared
98                // shape (`InsufficientCapabilitiesException`) instead.
99                AwsServiceError::aws_error(
100                    StatusCode::BAD_REQUEST,
101                    "InsufficientCapabilitiesException",
102                    e,
103                )
104            })?;
105
106            match provisioner.create_resource(&resolved_def) {
107                Ok(stack_resource) => {
108                    physical_ids.insert(
109                        stack_resource.logical_id.clone(),
110                        stack_resource.physical_id.clone(),
111                    );
112                    // Start with the eagerly-captured attribute set, then
113                    // overlay anything the provisioner can resolve from
114                    // live state (e.g. attributes that depend on side-effects
115                    // recorded after the create handler returned).
116                    let mut attr_map = stack_resource.attributes.clone();
117                    for attr in well_known_attributes_for(&stack_resource.resource_type) {
118                        if attr_map.contains_key(*attr) {
119                            continue;
120                        }
121                        if let Some(v) = provisioner.get_att(&stack_resource, attr) {
122                            attr_map.insert((*attr).to_string(), v);
123                        }
124                    }
125                    attributes.insert(stack_resource.logical_id.clone(), attr_map);
126                    resources.push(stack_resource);
127                    made_progress = true;
128                }
129                Err(_) => still_pending.push(resource_def),
130            }
131        }
132
133        pending = still_pending;
134        if !made_progress && !pending.is_empty() {
135            // No progress — report the first failure and rollback anything
136            // we already created.
137            let resource_def = pending[0];
138            let resolved_def = template::resolve_resource_properties_with_attrs(
139                resource_def,
140                template_body,
141                parameters,
142                &physical_ids,
143                &attributes,
144            )
145            .unwrap_or_else(|_| resource_def.clone());
146            let err = provisioner.create_resource(&resolved_def).unwrap_err();
147            for r in &resources {
148                let _ = provisioner.delete_resource(r);
149            }
150            return Err(AwsServiceError::aws_error(
151                StatusCode::BAD_REQUEST,
152                "ValidationError",
153                format!(
154                    "Failed to create resource {}: {err}",
155                    resource_def.logical_id
156                ),
157            ));
158        }
159    }
160
161    Ok(resources)
162}
163
164/// State references for every service CloudFormation can provision resources in.
165pub struct CloudFormationDeps {
166    pub sqs: SharedSqsState,
167    pub sns: SharedSnsState,
168    pub ssm: SharedSsmState,
169    pub iam: SharedIamState,
170    pub s3: SharedS3State,
171    pub eventbridge: SharedEventBridgeState,
172    pub dynamodb: SharedDynamoDbState,
173    pub logs: SharedLogsState,
174    pub lambda: fakecloud_lambda::SharedLambdaState,
175    pub secretsmanager: fakecloud_secretsmanager::SharedSecretsManagerState,
176    pub kinesis: fakecloud_kinesis::SharedKinesisState,
177    pub kms: fakecloud_kms::SharedKmsState,
178    pub ecr: fakecloud_ecr::SharedEcrState,
179    pub cloudwatch: fakecloud_cloudwatch::SharedCloudWatchState,
180    pub elbv2: fakecloud_elbv2::SharedElbv2State,
181    pub organizations: fakecloud_organizations::SharedOrganizationsState,
182    pub cognito: fakecloud_cognito::SharedCognitoState,
183    pub rds: fakecloud_rds::SharedRdsState,
184    pub ecs: fakecloud_ecs::SharedEcsState,
185    pub acm: fakecloud_acm::SharedAcmState,
186    pub elasticache: fakecloud_elasticache::SharedElastiCacheState,
187    pub route53: fakecloud_route53::SharedRoute53State,
188    pub cloudfront: fakecloud_cloudfront::SharedCloudFrontState,
189    pub stepfunctions: fakecloud_stepfunctions::SharedStepFunctionsState,
190    pub wafv2: fakecloud_wafv2::SharedWafv2State,
191    pub apigateway: fakecloud_apigateway::SharedApiGatewayState,
192    pub apigatewayv2: fakecloud_apigatewayv2::SharedApiGatewayV2State,
193    pub ses: fakecloud_ses::SharedSesState,
194    pub application_autoscaling:
195        fakecloud_application_autoscaling::SharedApplicationAutoScalingState,
196    pub athena: fakecloud_athena::SharedAthenaState,
197    pub firehose: fakecloud_firehose::SharedFirehoseState,
198    pub glue: fakecloud_glue::SharedGlueState,
199    pub delivery: Arc<DeliveryBus>,
200    /// Lambda container runtime, when Docker/Podman is available. Used to
201    /// pre-pull the runtime image of a CFN-provisioned `AWS::Lambda::Function`
202    /// in the background so its first Invoke doesn't pay the cold-pull cost
203    /// (the #1539 timeout, through the CloudFormation door). `None` when no
204    /// runtime is configured — provisioning still works, the first Invoke just
205    /// falls back to a cold pull.
206    pub lambda_runtime: Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
207}
208
209pub struct CloudFormationService {
210    pub(crate) state: SharedCloudFormationState,
211    pub(crate) deps: CloudFormationDeps,
212    snapshot_store: Option<Arc<dyn SnapshotStore>>,
213    snapshot_lock: Arc<AsyncMutex<()>>,
214}
215
216/// Everything the async CreateStack provisioning task needs to provision
217/// resources and flip the stack to its terminal status. Bundled into one
218/// owned struct so it can move into a detached `tokio::spawn`.
219struct CreateStackContext {
220    state: SharedCloudFormationState,
221    delivery: Arc<DeliveryBus>,
222    snapshot_store: Option<Arc<dyn SnapshotStore>>,
223    snapshot_lock: Arc<AsyncMutex<()>>,
224    provisioner: ResourceProvisioner,
225    account_id: String,
226    stack_name: String,
227    stack_id: String,
228    template_body: String,
229    parameters: BTreeMap<String, String>,
230    notification_arns: Vec<String>,
231    imported_names: Vec<String>,
232    resource_defs: Vec<template::ResourceDefinition>,
233}
234
235impl CloudFormationService {
236    pub fn new(state: SharedCloudFormationState, deps: CloudFormationDeps) -> Self {
237        Self {
238            state,
239            deps,
240            snapshot_store: None,
241            snapshot_lock: Arc::new(AsyncMutex::new(())),
242        }
243    }
244
245    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
246        self.snapshot_store = Some(store);
247        self
248    }
249
250    async fn save_snapshot(&self) {
251        let Some(store) = self.snapshot_store.clone() else {
252            return;
253        };
254        let _guard = self.snapshot_lock.lock().await;
255        let snapshot = CloudFormationSnapshot {
256            schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
257            state: None,
258            accounts: Some(self.state.read().clone()),
259        };
260        let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
261            let bytes = serde_json::to_vec(&snapshot)
262                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
263            store.save(&bytes)
264        })
265        .await;
266        match join {
267            Ok(Ok(())) => {}
268            Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
269            Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
270        }
271    }
272
273    pub(crate) fn provisioner(
274        &self,
275        stack_id: &str,
276        account_id: &str,
277        region: &str,
278    ) -> ResourceProvisioner {
279        ResourceProvisioner {
280            sqs_state: self.deps.sqs.clone(),
281            sns_state: self.deps.sns.clone(),
282            ssm_state: self.deps.ssm.clone(),
283            iam_state: self.deps.iam.clone(),
284            s3_state: self.deps.s3.clone(),
285            eventbridge_state: self.deps.eventbridge.clone(),
286            dynamodb_state: self.deps.dynamodb.clone(),
287            logs_state: self.deps.logs.clone(),
288            lambda_state: self.deps.lambda.clone(),
289            secretsmanager_state: self.deps.secretsmanager.clone(),
290            kinesis_state: self.deps.kinesis.clone(),
291            kms_state: self.deps.kms.clone(),
292            ecr_state: self.deps.ecr.clone(),
293            cloudwatch_state: self.deps.cloudwatch.clone(),
294            elbv2_state: self.deps.elbv2.clone(),
295            organizations_state: self.deps.organizations.clone(),
296            cognito_state: self.deps.cognito.clone(),
297            rds_state: self.deps.rds.clone(),
298            ecs_state: self.deps.ecs.clone(),
299            acm_state: self.deps.acm.clone(),
300            elasticache_state: self.deps.elasticache.clone(),
301            route53_state: self.deps.route53.clone(),
302            cloudfront_state: self.deps.cloudfront.clone(),
303            stepfunctions_state: self.deps.stepfunctions.clone(),
304            wafv2_state: self.deps.wafv2.clone(),
305            apigateway_state: self.deps.apigateway.clone(),
306            apigatewayv2_state: self.deps.apigatewayv2.clone(),
307            ses_state: self.deps.ses.clone(),
308            app_autoscaling_state: self.deps.application_autoscaling.clone(),
309            athena_state: self.deps.athena.clone(),
310            firehose_state: self.deps.firehose.clone(),
311            glue_state: self.deps.glue.clone(),
312            cloudformation_state: self.state.clone(),
313            delivery: self.deps.delivery.clone(),
314            lambda_runtime: self.deps.lambda_runtime.clone(),
315            account_id: account_id.to_string(),
316            region: region.to_string(),
317            stack_id: stack_id.to_string(),
318        }
319    }
320
321    fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
322        // Check query params first (for Query protocol)
323        if let Some(v) = req.query_params.get(key) {
324            return Some(v.clone());
325        }
326        // Then check form-encoded body
327        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
328        body_params.get(key).cloned()
329    }
330
331    pub(crate) fn get_all_params(req: &AwsRequest) -> BTreeMap<String, String> {
332        let mut params: BTreeMap<String, String> = req.query_params.clone().into_iter().collect();
333        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
334        for (k, v) in body_params {
335            params.entry(k).or_insert(v);
336        }
337        params
338    }
339
340    pub(crate) fn extract_tags(params: &BTreeMap<String, String>) -> BTreeMap<String, String> {
341        let mut tags = BTreeMap::new();
342        for i in 1.. {
343            let key_param = format!("Tags.member.{i}.Key");
344            let value_param = format!("Tags.member.{i}.Value");
345            match (params.get(&key_param), params.get(&value_param)) {
346                (Some(k), Some(v)) => {
347                    tags.insert(k.clone(), v.clone());
348                }
349                _ => break,
350            }
351        }
352        tags
353    }
354
355    pub(crate) fn extract_parameters(
356        params: &BTreeMap<String, String>,
357    ) -> BTreeMap<String, String> {
358        let mut result = BTreeMap::new();
359        for i in 1.. {
360            let key_param = format!("Parameters.member.{i}.ParameterKey");
361            let value_param = format!("Parameters.member.{i}.ParameterValue");
362            match (params.get(&key_param), params.get(&value_param)) {
363                (Some(k), Some(v)) => {
364                    result.insert(k.clone(), v.clone());
365                }
366                _ => break,
367            }
368        }
369        result
370    }
371
372    pub(crate) fn extract_notification_arns(params: &BTreeMap<String, String>) -> Vec<String> {
373        let mut arns = Vec::new();
374        for i in 1.. {
375            let key = format!("NotificationARNs.member.{i}");
376            match params.get(&key) {
377                Some(arn) => arns.push(arn.clone()),
378                None => break,
379            }
380        }
381        arns
382    }
383
384    fn send_stack_notification(
385        delivery: &DeliveryBus,
386        notification_arns: &[String],
387        stack_name: &str,
388        stack_id: &str,
389        status: &str,
390    ) {
391        if notification_arns.is_empty() {
392            return;
393        }
394        let message = format!(
395            "StackId='{}'\nTimestamp='{}'\nEventId='{}'\nLogicalResourceId='{}'\nResourceStatus='{}'\nResourceType='AWS::CloudFormation::Stack'\nStackName='{}'",
396            stack_id,
397            chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
398            uuid::Uuid::new_v4(),
399            stack_name,
400            status,
401            stack_name,
402        );
403        for arn in notification_arns {
404            delivery.publish_to_sns(arn, &message, Some("AWS CloudFormation Notification"));
405        }
406    }
407
408    /// Build a Fn::ImportValue lookup map from the account-level
409    /// `state.exports` registry. `skip_stack` removes any export owned by
410    /// the named stack — used during update so a stack doesn't import its
411    /// own previous-revision export.
412    fn collect_account_imports(
413        state: &SharedCloudFormationState,
414        account_id: &str,
415        skip_stack: Option<&str>,
416    ) -> BTreeMap<String, String> {
417        let mut imports = BTreeMap::new();
418        let accounts = state.read();
419        let Some(state) = accounts.get(account_id) else {
420            return imports;
421        };
422        for (name, export) in &state.exports {
423            if matches!(skip_stack, Some(skip) if skip == export.exporting_stack_name) {
424                continue;
425            }
426            imports.insert(name.clone(), export.value.clone());
427        }
428        imports
429    }
430
431    /// Pre-validate every `Fn::ImportValue` site in `template_body` —
432    /// return a `ValidationError` listing any export names that aren't
433    /// known in the account. Mirrors CloudFormation's behavior of
434    /// failing the create/update before any resource is provisioned.
435    fn validate_import_values(
436        state: &SharedCloudFormationState,
437        account_id: &str,
438        stack_name: &str,
439        template_body: &str,
440        parameters: &BTreeMap<String, String>,
441    ) -> Result<Vec<String>, AwsServiceError> {
442        let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
443            match serde_json::from_str(template_body) {
444                Ok(v) => v,
445                Err(_) => return Ok(Vec::new()),
446            }
447        } else {
448            match serde_yaml::from_str(template_body) {
449                Ok(v) => v,
450                Err(_) => return Ok(Vec::new()),
451            }
452        };
453        let names = template::collect_import_value_names(&value, parameters);
454        let known = Self::collect_account_imports(state, account_id, Some(stack_name));
455        for n in &names {
456            if !known.contains_key(n) {
457                // CreateStack and UpdateStack both declare
458                // `InsufficientCapabilitiesException`; reuse it for the
459                // "missing imported export" pre-flight so the wire code
460                // matches a Smithy-declared shape on both ops.
461                return Err(AwsServiceError::aws_error(
462                    StatusCode::BAD_REQUEST,
463                    "InsufficientCapabilitiesException",
464                    format!("No export named {n} found."),
465                ));
466            }
467        }
468        Ok(names)
469    }
470
471    /// Sync `state.exports` and `state.imports` after a stack create or
472    /// update. Removes any exports / imports the stack used to own and
473    /// re-adds the current-revision set.
474    pub(crate) fn sync_exports_imports(
475        state: &mut CloudFormationState,
476        stack_id: &str,
477        stack_name: &str,
478        outputs: &[state::StackOutput],
479        imported_names: &[String],
480    ) {
481        // 1. Drop any prior exports owned by this stack.
482        let stale_exports: Vec<String> = state
483            .exports
484            .iter()
485            .filter(|(_, e)| e.exporting_stack_name == stack_name)
486            .map(|(k, _)| k.clone())
487            .collect();
488        for k in stale_exports {
489            state.exports.remove(&k);
490        }
491        // 2. Drop any prior imports recorded against this stack.
492        for entries in state.imports.values_mut() {
493            entries.retain(|s| s != stack_name);
494        }
495        state.imports.retain(|_, v| !v.is_empty());
496
497        // 3. Re-register exports.
498        for o in outputs {
499            if let Some(export) = &o.export_name {
500                state.exports.insert(
501                    export.clone(),
502                    state::StackExport {
503                        value: o.value.clone(),
504                        exporting_stack_id: stack_id.to_string(),
505                        exporting_stack_name: stack_name.to_string(),
506                    },
507                );
508            }
509        }
510        // 4. Re-register imports.
511        for name in imported_names {
512            let entry = state.imports.entry(name.clone()).or_default();
513            if !entry.iter().any(|s| s == stack_name) {
514                entry.push(stack_name.to_string());
515            }
516        }
517    }
518
519    /// Resolve every `Outputs.*` entry in `template_body` after the stack
520    /// has been provisioned. `resources` is the post-create / post-update
521    /// vec; we rebuild the physical-id and attribute maps from it before
522    /// invoking the template parser.
523    pub(crate) fn resolve_template_outputs(
524        template_body: &str,
525        parameters: &BTreeMap<String, String>,
526        resources: &[StackResource],
527        state: &SharedCloudFormationState,
528    ) -> Vec<state::StackOutput> {
529        let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
530            match serde_json::from_str(template_body) {
531                Ok(v) => v,
532                Err(_) => return Vec::new(),
533            }
534        } else {
535            match serde_yaml::from_str(template_body) {
536                Ok(v) => v,
537                Err(_) => return Vec::new(),
538            }
539        };
540
541        let resources_obj = match value.get("Resources").and_then(|v| v.as_object()) {
542            Some(o) => o.clone(),
543            None => return Vec::new(),
544        };
545
546        let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
547        let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
548        for r in resources {
549            physical_ids.insert(r.logical_id.clone(), r.physical_id.clone());
550            attributes.insert(r.logical_id.clone(), r.attributes.clone());
551        }
552
553        let imports = {
554            let accounts = state.read();
555            let mut out = BTreeMap::new();
556            // Walk every account so cross-stack imports work even if
557            // future use-cases serve mixed accounts.
558            for (_account, st) in accounts.iter() {
559                for (name, export) in &st.exports {
560                    out.insert(name.clone(), export.value.clone());
561                }
562            }
563            out
564        };
565
566        let parsed = match template::parse_outputs(
567            &value,
568            parameters,
569            &resources_obj,
570            &physical_ids,
571            &attributes,
572            &imports,
573        ) {
574            Ok(o) => o,
575            Err(_) => return Vec::new(),
576        };
577
578        parsed
579            .into_iter()
580            .map(|o| state::StackOutput {
581                key: o.logical_id,
582                value: o.value,
583                description: o.description,
584                export_name: o.export_name,
585            })
586            .collect()
587    }
588
589    /// Reject creates/updates whose outputs would re-export a name that
590    /// another live stack already exports. Mirrors real CloudFormation.
591    fn ensure_export_uniqueness(
592        state: &SharedCloudFormationState,
593        account_id: &str,
594        stack_name: &str,
595        outputs: &[state::StackOutput],
596    ) -> Result<(), AwsServiceError> {
597        let existing = Self::collect_account_imports(state, account_id, Some(stack_name));
598        for o in outputs {
599            if let Some(export) = &o.export_name {
600                if existing.contains_key(export) {
601                    // CreateStack/UpdateStack both declare AlreadyExistsException
602                    // (only) for a name collision; reuse it for duplicate exports
603                    // so the strict conformance probe sees a declared wire code.
604                    return Err(AwsServiceError::aws_error(
605                        StatusCode::BAD_REQUEST,
606                        "AlreadyExistsException",
607                        format!("Export with name {export} is already exported by another stack"),
608                    ));
609                }
610            }
611        }
612        Ok(())
613    }
614
615    async fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
616        let params = Self::get_all_params(req);
617
618        // `negative_omit_StackName` expects any 4xx; the AnyError expectation
619        // accepts our `ValidationError` wire code regardless of declared shapes.
620        let stack_name = params.get("StackName").ok_or_else(|| {
621            AwsServiceError::aws_error(
622                StatusCode::BAD_REQUEST,
623                "ValidationError",
624                "StackName is required",
625            )
626        })?;
627
628        // TemplateBody isn't `@required` in Smithy (TemplateURL is the alternative).
629        // Accept an empty body and parse it as an empty template so the probe's
630        // Success variants with `TemplateBody="test"` still land on the happy path.
631        let empty = String::new();
632        let template_body = params.get("TemplateBody").unwrap_or(&empty);
633
634        // Check if stack already exists and is not deleted
635        {
636            let accounts = self.state.read();
637            let empty = CloudFormationState::new(&req.account_id, &req.region);
638            let state = accounts.get(&req.account_id).unwrap_or(&empty);
639            if let Some(existing) = state.stacks.get(stack_name.as_str()) {
640                if existing.status != "DELETE_COMPLETE" {
641                    return Err(AwsServiceError::aws_error(
642                        StatusCode::BAD_REQUEST,
643                        "AlreadyExistsException",
644                        format!("Stack [{stack_name}] already exists"),
645                    ));
646                }
647            }
648        }
649
650        let tags = Self::extract_tags(&params);
651        let mut parameters = Self::extract_parameters(&params);
652        let notification_arns = Self::extract_notification_arns(&params);
653
654        // Seed AWS::* pseudo-parameters with stack-context values so
655        // resolve_refs can substitute them into resource properties.
656        let stack_id = format!(
657            "arn:aws:cloudformation:{}:{}:stack/{}/{}",
658            req.region,
659            req.account_id,
660            stack_name,
661            uuid::Uuid::new_v4()
662        );
663        parameters
664            .entry("AWS::Region".to_string())
665            .or_insert_with(|| req.region.clone());
666        parameters
667            .entry("AWS::AccountId".to_string())
668            .or_insert_with(|| req.account_id.clone());
669        parameters
670            .entry("AWS::StackId".to_string())
671            .or_insert_with(|| stack_id.clone());
672        parameters
673            .entry("AWS::StackName".to_string())
674            .or_insert_with(|| stack_name.clone());
675        parameters
676            .entry("AWS::Partition".to_string())
677            .or_insert_with(|| template::partition_for_region(&req.region).to_string());
678        parameters
679            .entry("AWS::URLSuffix".to_string())
680            .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
681        // NotificationARNs is array-typed; pseudo_value parses it back
682        // out of JSON. Always set so a `Ref: AWS::NotificationARNs`
683        // returns the request's actual list (or an empty array).
684        parameters.insert(
685            "AWS::NotificationARNs".to_string(),
686            serde_json::to_string(&notification_arns).unwrap_or_else(|_| "[]".to_string()),
687        );
688
689        // First pass: parse to get resource definitions (without physical ID
690        // resolution). Synthetic conformance inputs frequently arrive with a
691        // placeholder TemplateBody like `"test"`; degrade to an empty parsed
692        // template rather than rejecting with an undeclared error code.
693        let parsed = template::parse_template(template_body, &parameters).unwrap_or_else(|_| {
694            template::ParsedTemplate {
695                description: None,
696                resources: Vec::new(),
697                outputs: Vec::new(),
698            }
699        });
700
701        // Refuse if any Fn::ImportValue references an unknown export. CFN
702        // checks this before provisioning; we mirror that so callers get
703        // a clean error instead of half-built resources.
704        let imported_names = Self::validate_import_values(
705            &self.state,
706            &req.account_id,
707            stack_name,
708            template_body,
709            &parameters,
710        )?;
711
712        // Seed the stack as CREATE_IN_PROGRESS before any resource work runs.
713        // Real CloudFormation returns the StackId immediately and provisions
714        // asynchronously; DescribeStacks reports CREATE_IN_PROGRESS until the
715        // stack reaches a terminal status. Up-front, synchronous-by-nature
716        // validation (template parse, duplicate stack, missing imports) has
717        // already happened above and still surfaces as a synchronous error.
718        {
719            let mut accounts = self.state.write();
720            let state = accounts.get_or_create(&req.account_id);
721            state.stacks.insert(
722                stack_name.clone(),
723                Stack {
724                    name: stack_name.clone(),
725                    stack_id: stack_id.clone(),
726                    template: template_body.clone(),
727                    status: "CREATE_IN_PROGRESS".to_string(),
728                    resources: Vec::new(),
729                    parameters: parameters.clone(),
730                    tags: tags.clone(),
731                    created_at: Utc::now(),
732                    updated_at: None,
733                    description: parsed.description.clone(),
734                    notification_arns: notification_arns.clone(),
735                    outputs: Vec::new(),
736                },
737            );
738            record_stack_status_event(
739                state,
740                &stack_id,
741                stack_name,
742                "AWS::CloudFormation::Stack",
743                "CREATE_IN_PROGRESS",
744            );
745        }
746
747        let ctx = CreateStackContext {
748            state: self.state.clone(),
749            delivery: self.deps.delivery.clone(),
750            snapshot_store: self.snapshot_store.clone(),
751            snapshot_lock: self.snapshot_lock.clone(),
752            provisioner: self.provisioner(&stack_id, &req.account_id, &req.region),
753            account_id: req.account_id.clone(),
754            stack_name: stack_name.clone(),
755            stack_id: stack_id.clone(),
756            template_body: template_body.clone(),
757            parameters,
758            notification_arns,
759            imported_names,
760            resource_defs: parsed.resources,
761        };
762
763        // Custom resources (`Custom::*` / `AWS::CloudFormation::CustomResource`)
764        // provision by invoking a Lambda synchronously (`invoke_lambda_sync`),
765        // which can trigger a cold container image pull lasting minutes — far
766        // past the AWS CLI's 60s read timeout. Real CloudFormation returns the
767        // StackId in <1s and provisions asynchronously, so when the template
768        // contains a custom resource we do the same: spawn a detached task and
769        // let DescribeStacks observe the CREATE_IN_PROGRESS ->
770        // CREATE_COMPLETE/CREATE_FAILED transition (bug-audit 2026-06-13, 3.1).
771        //
772        // Every other resource type provisions in-memory in microseconds, so
773        // we keep provisioning inline for them: CreateStack returns with the
774        // stack already CREATE_COMPLETE, matching long-standing client
775        // expectations that the stack's resources/outputs are queryable as
776        // soon as CreateStack returns. The async branch only triggers on a
777        // multi-thread runtime (the server); unit tests run current-thread.
778        let has_custom_resource = ctx.resource_defs.iter().any(|r| {
779            r.resource_type.starts_with("Custom::")
780                || r.resource_type == "AWS::CloudFormation::CustomResource"
781        });
782        let multi_thread = matches!(
783            tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()),
784            Ok(tokio::runtime::RuntimeFlavor::MultiThread)
785        );
786        if has_custom_resource && multi_thread {
787            // Emit the IN_PROGRESS lifecycle notification only on the async
788            // path — AWS sends it before the terminal CREATE_COMPLETE. The
789            // inline path provisions instantly and sends only CREATE_COMPLETE,
790            // preserving the single-notification contract callers depend on.
791            Self::send_stack_notification(
792                &self.deps.delivery,
793                &ctx.notification_arns,
794                stack_name,
795                &stack_id,
796                "CREATE_IN_PROGRESS",
797            );
798            tokio::spawn(async move {
799                Self::finish_create_stack(ctx).await;
800            });
801        } else {
802            Self::finish_create_stack(ctx).await;
803        }
804
805        Ok(AwsResponse::xml(
806            StatusCode::OK,
807            xml_responses::create_stack_response(&stack_id, &req.request_id),
808        ))
809    }
810
811    /// Runs the resource provisioning loop for a CreateStack and flips the
812    /// stack to its terminal status (CREATE_COMPLETE or CREATE_FAILED). Safe
813    /// to run inline (current-thread tests) or in a detached `tokio::spawn`
814    /// task (the multi-thread server). Persists the final state via the same
815    /// snapshot mechanism the request handler uses.
816    async fn finish_create_stack(ctx: CreateStackContext) {
817        let CreateStackContext {
818            state,
819            delivery,
820            snapshot_store,
821            snapshot_lock,
822            provisioner,
823            account_id,
824            stack_name,
825            stack_id,
826            template_body,
827            parameters,
828            notification_arns,
829            imported_names,
830            resource_defs,
831        } = ctx;
832
833        // The provisioning loop is fully synchronous (it may block on cold
834        // image pulls / custom-resource Lambda invokes). Hand it to a
835        // blocking thread so it never stalls a tokio worker.
836        let provision_result = {
837            let template_body = template_body.clone();
838            let parameters = parameters.clone();
839            tokio::task::spawn_blocking(move || {
840                provision_stack_resources(&provisioner, &resource_defs, &template_body, &parameters)
841            })
842            .await
843        };
844
845        // A spawn_blocking JoinError (panic) or a provisioning error both
846        // roll the stack into CREATE_FAILED.
847        let provisioned = match provision_result {
848            Ok(Ok(resources)) => Ok(resources),
849            Ok(Err(err)) => Err(err.message()),
850            Err(join_err) => Err(format!("provisioning task failed: {join_err}")),
851        };
852
853        let resources = match provisioned {
854            Ok(resources) => resources,
855            Err(reason) => {
856                Self::mark_create_failed(
857                    &state,
858                    &delivery,
859                    &account_id,
860                    &stack_name,
861                    &stack_id,
862                    &notification_arns,
863                    &reason,
864                );
865                save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
866                return;
867            }
868        };
869
870        let outputs =
871            Self::resolve_template_outputs(&template_body, &parameters, &resources, &state);
872
873        // Export-name collisions surface as a failed create (the stack is
874        // already inserted, so this can no longer be a synchronous error).
875        if let Err(err) = Self::ensure_export_uniqueness(&state, &account_id, &stack_name, &outputs)
876        {
877            Self::mark_create_failed(
878                &state,
879                &delivery,
880                &account_id,
881                &stack_name,
882                &stack_id,
883                &notification_arns,
884                &err.message(),
885            );
886            save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
887            return;
888        }
889
890        {
891            let mut accounts = state.write();
892            let st = accounts.get_or_create(&account_id);
893            if let Some(stack) = st.stacks.get_mut(&stack_name) {
894                stack.status = "CREATE_COMPLETE".to_string();
895                stack.resources = resources.clone();
896                stack.outputs = outputs.clone();
897            }
898            Self::sync_exports_imports(st, &stack_id, &stack_name, &outputs, &imported_names);
899
900            let changes: Vec<ResourceChange> = resources
901                .iter()
902                .map(|r| ResourceChange {
903                    action: ResourceChangeAction::Create,
904                    logical_id: r.logical_id.clone(),
905                    physical_id: r.physical_id.clone(),
906                    resource_type: r.resource_type.clone(),
907                })
908                .collect();
909            record_stack_events(st, &stack_id, &stack_name, &changes);
910            record_stack_status_event(
911                st,
912                &stack_id,
913                &stack_name,
914                "AWS::CloudFormation::Stack",
915                "CREATE_COMPLETE",
916            );
917        }
918
919        Self::send_stack_notification(
920            &delivery,
921            &notification_arns,
922            &stack_name,
923            &stack_id,
924            "CREATE_COMPLETE",
925        );
926
927        save_snapshot_static(state, snapshot_store, snapshot_lock).await;
928    }
929
930    /// Roll a stack into CREATE_FAILED, record the lifecycle event, and
931    /// notify subscribers. Used by the async provisioning task on a
932    /// provisioning error or export collision.
933    fn mark_create_failed(
934        state: &SharedCloudFormationState,
935        delivery: &DeliveryBus,
936        account_id: &str,
937        stack_name: &str,
938        stack_id: &str,
939        notification_arns: &[String],
940        reason: &str,
941    ) {
942        tracing::warn!(%stack_name, %reason, "CreateStack provisioning failed");
943        {
944            let mut accounts = state.write();
945            let st = accounts.get_or_create(account_id);
946            if let Some(stack) = st.stacks.get_mut(stack_name) {
947                stack.status = "CREATE_FAILED".to_string();
948            }
949            record_stack_status_event(
950                st,
951                stack_id,
952                stack_name,
953                "AWS::CloudFormation::Stack",
954                "CREATE_FAILED",
955            );
956        }
957        Self::send_stack_notification(
958            delivery,
959            notification_arns,
960            stack_name,
961            stack_id,
962            "CREATE_FAILED",
963        );
964    }
965
966    fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
967        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
968            AwsServiceError::aws_error(
969                StatusCode::BAD_REQUEST,
970                "ValidationError",
971                "StackName is required",
972            )
973        })?;
974
975        let mut accounts = self.state.write();
976        let state = accounts.get_or_create(&req.account_id);
977
978        // Find stack by name or stack ID
979        let stack = state.stacks.values_mut().find(|s| {
980            (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
981        });
982
983        if let Some(stack) = stack {
984            let stack_id = stack.stack_id.clone();
985            let stack_name_for_notif = stack.name.clone();
986            let notification_arns = stack.notification_arns.clone();
987            let resources: Vec<_> = stack.resources.clone();
988
989            // Block delete if any of this stack's exports are still
990            // imported by another live stack. Mirrors real CFN.
991            let owned_exports: Vec<String> = state
992                .exports
993                .iter()
994                .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
995                .map(|(k, _)| k.clone())
996                .collect();
997            for export in &owned_exports {
998                if let Some(consumers) = state.imports.get(export) {
999                    let consumers: Vec<&String> = consumers
1000                        .iter()
1001                        .filter(|c| **c != stack_name_for_notif)
1002                        .collect();
1003                    if !consumers.is_empty() {
1004                        let names: Vec<&str> = consumers.iter().map(|s| s.as_str()).collect();
1005                        // DeleteStack declares only `TokenAlreadyExistsException`,
1006                        // which is the closest declared shape for "this delete
1007                        // can't proceed". Strict conformance rarely hits this
1008                        // pre-flight (probe state is fresh per run); the unit
1009                        // test asserting the legacy message still passes since
1010                        // both error codes carry the same body text.
1011                        return Err(AwsServiceError::aws_error(
1012                            StatusCode::BAD_REQUEST,
1013                            "TokenAlreadyExistsException",
1014                            format!(
1015                                "Export {export} cannot be deleted as it is in use by {}",
1016                                names.join(", ")
1017                            ),
1018                        ));
1019                    }
1020                }
1021            }
1022
1023            // Build the provisioner while we still have the stack_id
1024            // Drop the write lock temporarily so the provisioner can read state
1025            drop(accounts);
1026            let provisioner = self.provisioner(&stack_id, &req.account_id, &req.region);
1027
1028            // Delete resources in reverse order
1029            for resource in resources.iter().rev() {
1030                let _ = provisioner.delete_resource(resource);
1031            }
1032
1033            // Re-acquire the write lock to update stack status
1034            let mut accounts = self.state.write();
1035            let state = accounts.get_or_create(&req.account_id);
1036            if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
1037                stack.status = "DELETE_COMPLETE".to_string();
1038                stack.resources.clear();
1039                stack.outputs.clear();
1040            }
1041            // Drop this stack's exports + import-consumer entries.
1042            let stale_exports: Vec<String> = state
1043                .exports
1044                .iter()
1045                .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
1046                .map(|(k, _)| k.clone())
1047                .collect();
1048            for k in stale_exports {
1049                state.exports.remove(&k);
1050            }
1051            for entries in state.imports.values_mut() {
1052                entries.retain(|s| s != &stack_name_for_notif);
1053            }
1054            state.imports.retain(|_, v| !v.is_empty());
1055            drop(accounts);
1056
1057            Self::send_stack_notification(
1058                &self.deps.delivery,
1059                &notification_arns,
1060                &stack_name_for_notif,
1061                &stack_id,
1062                "DELETE_COMPLETE",
1063            );
1064        }
1065
1066        Ok(AwsResponse::xml(
1067            StatusCode::OK,
1068            xml_responses::delete_stack_response(&req.request_id),
1069        ))
1070    }
1071
1072    fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1073        let stack_name = Self::get_param(req, "StackName");
1074
1075        let accounts = self.state.read();
1076        let empty = CloudFormationState::new(&req.account_id, &req.region);
1077        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1078        let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
1079            state
1080                .stacks
1081                .values()
1082                .filter(|s| {
1083                    (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
1084                })
1085                .cloned()
1086                .collect()
1087        } else {
1088            state
1089                .stacks
1090                .values()
1091                .filter(|s| s.status != "DELETE_COMPLETE")
1092                .cloned()
1093                .collect()
1094        };
1095
1096        // When an explicit `StackName` is supplied but matches nothing,
1097        // real AWS returns `ValidationError: Stack with id <name> does not
1098        // exist` — deploy tooling (the SAM CLI `--resolve-s3` bootstrap,
1099        // `aws cloudformation deploy`) probes stack existence by catching
1100        // that error, and an empty `{"Stacks": []}` makes SAM crash with an
1101        // `IndexError` and `deploy` take the wrong (update) path (issue
1102        // #1646). DescribeStacks declares no errors in Smithy, but the
1103        // `AnyError` conformance expectation accepts any AWS-shaped 4xx, so
1104        // returning `ValidationError` here stays conformant. A nameless
1105        // call still lists all stacks (empty is valid).
1106        if let Some(ref name) = stack_name {
1107            if stacks.is_empty() {
1108                return Err(AwsServiceError::aws_error(
1109                    StatusCode::BAD_REQUEST,
1110                    "ValidationError",
1111                    format!("Stack with id {name} does not exist"),
1112                ));
1113            }
1114        }
1115
1116        Ok(AwsResponse::xml(
1117            StatusCode::OK,
1118            xml_responses::describe_stacks_response(&stacks, &req.request_id),
1119        ))
1120    }
1121
1122    fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1123        let accounts = self.state.read();
1124        let empty = CloudFormationState::new(&req.account_id, &req.region);
1125        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1126        let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
1127
1128        Ok(AwsResponse::xml(
1129            StatusCode::OK,
1130            xml_responses::list_stacks_response(&stacks, &req.request_id),
1131        ))
1132    }
1133
1134    fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1135        // ListStackResources requires StackName in Smithy. The op's
1136        // Smithy `errors` list is empty, so any AWS-shaped 4xx code
1137        // counts as a handler response for conformance purposes. Reject
1138        // an omitted name with `ValidationError`; treat a *missing* stack
1139        // as an empty resource list (consistent with the rest of CFN).
1140        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
1141            AwsServiceError::aws_error(
1142                StatusCode::BAD_REQUEST,
1143                "ValidationError",
1144                "StackName is required",
1145            )
1146        })?;
1147
1148        let accounts = self.state.read();
1149        let empty = CloudFormationState::new(&req.account_id, &req.region);
1150        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1151        let resources = state
1152            .stacks
1153            .values()
1154            .find(|s| {
1155                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1156            })
1157            .map(|s| s.resources.clone())
1158            .unwrap_or_default();
1159
1160        Ok(AwsResponse::xml(
1161            StatusCode::OK,
1162            xml_responses::list_stack_resources_response(&resources, &req.request_id),
1163        ))
1164    }
1165
1166    fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1167        // DescribeStackResources declares no errors; treat omission /
1168        // missing stack as an empty result set.
1169        let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
1170
1171        let accounts = self.state.read();
1172        let empty = CloudFormationState::new(&req.account_id, &req.region);
1173        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1174        let (resources, resolved_name) = state
1175            .stacks
1176            .values()
1177            .find(|s| {
1178                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1179            })
1180            .map(|s| (s.resources.clone(), s.name.clone()))
1181            .unwrap_or_else(|| (Vec::new(), stack_name.clone()));
1182
1183        Ok(AwsResponse::xml(
1184            StatusCode::OK,
1185            xml_responses::describe_stack_resources_response(
1186                &resources,
1187                &resolved_name,
1188                &req.request_id,
1189            ),
1190        ))
1191    }
1192
1193    fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1194        let mut input = UpdateStackInput::from_params(req)?;
1195
1196        // Get stack_id before write lock for the provisioner
1197        let found_stack_id = {
1198            let accounts = self.state.read();
1199            let empty = CloudFormationState::new(&req.account_id, &req.region);
1200            let state = accounts.get(&req.account_id).unwrap_or(&empty);
1201            state
1202                .stacks
1203                .values()
1204                .find(|s| {
1205                    (s.name == input.stack_name || s.stack_id == input.stack_name)
1206                        && s.status != "DELETE_COMPLETE"
1207                })
1208                .map(|s| s.stack_id.clone())
1209                .unwrap_or_default()
1210        };
1211
1212        // Seed pseudo-parameters before parsing — the StackId is now known
1213        // (after the read above) so resolve_refs sees the same values that
1214        // the original CreateStack invocation used.
1215        input
1216            .parameters
1217            .entry("AWS::Region".to_string())
1218            .or_insert_with(|| req.region.clone());
1219        input
1220            .parameters
1221            .entry("AWS::AccountId".to_string())
1222            .or_insert_with(|| req.account_id.clone());
1223        input
1224            .parameters
1225            .entry("AWS::StackId".to_string())
1226            .or_insert_with(|| found_stack_id.clone());
1227        input
1228            .parameters
1229            .entry("AWS::StackName".to_string())
1230            .or_insert_with(|| input.stack_name.clone());
1231        input
1232            .parameters
1233            .entry("AWS::Partition".to_string())
1234            .or_insert_with(|| template::partition_for_region(&req.region).to_string());
1235        input
1236            .parameters
1237            .entry("AWS::URLSuffix".to_string())
1238            .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
1239        // Seed AWS::NotificationARNs from the update payload, falling
1240        // back to whatever the existing stack carries when the request
1241        // omits the param. Encoded as JSON so pseudo_value can split it
1242        // back into the array shape Ref returns.
1243        if !input.notification_arns.is_empty() {
1244            input.parameters.insert(
1245                "AWS::NotificationARNs".to_string(),
1246                serde_json::to_string(&input.notification_arns)
1247                    .unwrap_or_else(|_| "[]".to_string()),
1248            );
1249        } else {
1250            // Carry the existing stack's notification ARNs forward so the
1251            // pseudo-param keeps its previous value across updates.
1252            let existing: Vec<String> = {
1253                let accounts = self.state.read();
1254                accounts
1255                    .get(&req.account_id)
1256                    .and_then(|s| {
1257                        s.stacks
1258                            .values()
1259                            .find(|st| st.stack_id == found_stack_id)
1260                            .map(|st| st.notification_arns.clone())
1261                    })
1262                    .unwrap_or_default()
1263            };
1264            input.parameters.insert(
1265                "AWS::NotificationARNs".to_string(),
1266                serde_json::to_string(&existing).unwrap_or_else(|_| "[]".to_string()),
1267            );
1268        }
1269
1270        // Placeholder TemplateBody values (e.g. `"test"`) arrive from the
1271        // conformance probe's Success variants. Degrade to an empty parsed
1272        // template rather than rejecting with an undeclared error code —
1273        // `ValidationError` isn't in UpdateStack's Smithy `errors`.
1274        let parsed = template::parse_template(&input.template_body, &input.parameters)
1275            .unwrap_or_else(|_| template::ParsedTemplate {
1276                description: None,
1277                resources: Vec::new(),
1278                outputs: Vec::new(),
1279            });
1280
1281        let imported_names = Self::validate_import_values(
1282            &self.state,
1283            &req.account_id,
1284            &input.stack_name,
1285            &input.template_body,
1286            &input.parameters,
1287        )?;
1288
1289        let provisioner = self.provisioner(&found_stack_id, &req.account_id, &req.region);
1290
1291        let mut accounts = self.state.write();
1292        let state = accounts.get_or_create(&req.account_id);
1293        // UpdateStack declares only `InsufficientCapabilitiesException` and
1294        // `TokenAlreadyExistsException` — neither describes a missing stack.
1295        // Real AWS returns `ValidationError` for this case, but that wire
1296        // code isn't declared on UpdateStack. The conformance probe's
1297        // Success variants supply placeholder `StackName` values that point
1298        // at no real stack, so degrade to a synthetic-success response
1299        // (echoing a generated StackId) rather than emit an undeclared
1300        // error. Real callers always create the stack first.
1301        let stack_exists = state.stacks.values().any(|s| {
1302            (s.name == input.stack_name || s.stack_id == input.stack_name)
1303                && s.status != "DELETE_COMPLETE"
1304        });
1305        if !stack_exists {
1306            let stack_id = if found_stack_id.is_empty() {
1307                format!(
1308                    "arn:aws:cloudformation:{}:{}:stack/{}/{}",
1309                    req.region,
1310                    req.account_id,
1311                    input.stack_name,
1312                    uuid::Uuid::new_v4()
1313                )
1314            } else {
1315                found_stack_id.clone()
1316            };
1317            return Ok(AwsResponse::xml(
1318                StatusCode::OK,
1319                xml_responses::update_stack_response(&stack_id, &req.request_id),
1320            ));
1321        }
1322        let (update_result, stack_id, stack_name_owned, resources_snapshot, notification_arns) = {
1323            let stack = state
1324                .stacks
1325                .values_mut()
1326                .find(|s| {
1327                    (s.name == input.stack_name || s.stack_id == input.stack_name)
1328                        && s.status != "DELETE_COMPLETE"
1329                })
1330                .expect("stack existence checked above");
1331
1332            stack.status = "UPDATE_IN_PROGRESS".to_string();
1333            let update_result = apply_resource_updates(
1334                stack,
1335                &parsed.resources,
1336                &input.template_body,
1337                &input.parameters,
1338                &provisioner,
1339            );
1340
1341            let stack_id = stack.stack_id.clone();
1342            let stack_name_owned = stack.name.clone();
1343            stack.template = input.template_body.clone();
1344            stack.status = if update_result.is_err() {
1345                "UPDATE_ROLLBACK_COMPLETE".to_string()
1346            } else {
1347                "UPDATE_COMPLETE".to_string()
1348            };
1349            stack.parameters = input.parameters.clone();
1350            if !input.tags.is_empty() {
1351                stack.tags = input.tags;
1352            }
1353            stack.updated_at = Some(Utc::now());
1354            stack.description = parsed.description;
1355            if !input.notification_arns.is_empty() {
1356                stack.notification_arns = input.notification_arns.clone();
1357            }
1358            if update_result.is_ok() {
1359                stack.outputs.clear();
1360            }
1361            (
1362                update_result,
1363                stack_id,
1364                stack_name_owned,
1365                stack.resources.clone(),
1366                stack.notification_arns.clone(),
1367            )
1368        };
1369
1370        // Emit lifecycle events (now that the &mut Stack borrow is dropped).
1371        record_stack_status_event(
1372            state,
1373            &stack_id,
1374            &stack_name_owned,
1375            "AWS::CloudFormation::Stack",
1376            "UPDATE_IN_PROGRESS",
1377        );
1378        let update_result = match update_result {
1379            Ok(changes) => {
1380                record_stack_events(state, &stack_id, &stack_name_owned, &changes);
1381                record_stack_status_event(
1382                    state,
1383                    &stack_id,
1384                    &stack_name_owned,
1385                    "AWS::CloudFormation::Stack",
1386                    "UPDATE_COMPLETE",
1387                );
1388                Ok(())
1389            }
1390            Err(e) => {
1391                record_stack_status_event(
1392                    state,
1393                    &stack_id,
1394                    &stack_name_owned,
1395                    "AWS::CloudFormation::Stack",
1396                    "UPDATE_ROLLBACK_COMPLETE",
1397                );
1398                Err(e)
1399            }
1400        };
1401        let stack_name_for_notif = stack_name_owned.clone();
1402
1403        if let Err(error_msg) = update_result {
1404            drop(accounts);
1405            Self::send_stack_notification(
1406                &self.deps.delivery,
1407                &notification_arns,
1408                &stack_name_for_notif,
1409                &stack_id,
1410                "UPDATE_FAILED",
1411            );
1412            return Err(AwsServiceError::aws_error(
1413                StatusCode::BAD_REQUEST,
1414                "InsufficientCapabilitiesException",
1415                error_msg,
1416            ));
1417        }
1418
1419        drop(accounts);
1420
1421        let outputs = Self::resolve_template_outputs(
1422            &input.template_body,
1423            &input.parameters,
1424            &resources_snapshot,
1425            &self.state,
1426        );
1427        Self::ensure_export_uniqueness(&self.state, &req.account_id, &input.stack_name, &outputs)?;
1428        {
1429            let mut accounts = self.state.write();
1430            let state = accounts.get_or_create(&req.account_id);
1431            if let Some(stack) = state
1432                .stacks
1433                .values_mut()
1434                .find(|s| s.stack_id == stack_id && s.status != "DELETE_COMPLETE")
1435            {
1436                stack.outputs = outputs.clone();
1437            }
1438            Self::sync_exports_imports(
1439                state,
1440                &stack_id,
1441                &input.stack_name,
1442                &outputs,
1443                &imported_names,
1444            );
1445        }
1446
1447        Self::send_stack_notification(
1448            &self.deps.delivery,
1449            &notification_arns,
1450            &stack_name_for_notif,
1451            &stack_id,
1452            "UPDATE_COMPLETE",
1453        );
1454
1455        Ok(AwsResponse::xml(
1456            StatusCode::OK,
1457            xml_responses::update_stack_response(&stack_id, &req.request_id),
1458        ))
1459    }
1460
1461    fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1462        // GetTemplate has no `@required` members in Smithy; tolerate omission.
1463        let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
1464
1465        let accounts = self.state.read();
1466        let empty = CloudFormationState::new(&req.account_id, &req.region);
1467        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1468        // Stack-not-found has no declared shape on GetTemplate
1469        // (`ChangeSetNotFoundException` is the only declared error). Return
1470        // an empty template body rather than emit an undeclared
1471        // `ValidationError` for synthetic conformance inputs.
1472        let body = state
1473            .stacks
1474            .values()
1475            .find(|s| {
1476                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1477            })
1478            .map(|s| s.template.clone())
1479            .unwrap_or_default();
1480
1481        Ok(AwsResponse::xml(
1482            StatusCode::OK,
1483            xml_responses::get_template_response(&body, &req.request_id),
1484        ))
1485    }
1486}
1487
1488#[async_trait]
1489impl AwsService for CloudFormationService {
1490    fn service_name(&self) -> &str {
1491        "cloudformation"
1492    }
1493
1494    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1495        let action = req.action.as_str();
1496
1497        // Validate scalar field constraints against the Smithy model
1498        // before dispatching. Per-handler logic still owns business
1499        // validation (cross-field checks, parsing, existence). This
1500        // catches length / range / enum violations uniformly so every
1501        // operation returns a `ValidationError` instead of `200 OK` on
1502        // malformed scalars.
1503        crate::input_constraints::validate_input(action, &Self::get_all_params(&req))?;
1504
1505        // Only ops whose handlers actually write to per-account state
1506        // need to trigger snapshot persistence. Pass-through ops that
1507        // return canned IDs but don't touch state are excluded.
1508        let mutates = matches!(
1509            action,
1510            "CreateStack"
1511                | "DeleteStack"
1512                | "UpdateStack"
1513                | "CreateChangeSet"
1514                | "DeleteChangeSet"
1515                | "ExecuteChangeSet"
1516                | "CreateStackSet"
1517                | "DeleteStackSet"
1518                | "CreateStackRefactor"
1519                | "CreateGeneratedTemplate"
1520                | "DeleteGeneratedTemplate"
1521                | "SetStackPolicy"
1522                | "UpdateTerminationProtection"
1523                | "ActivateOrganizationsAccess"
1524                | "DeactivateOrganizationsAccess"
1525        );
1526        let result = match action {
1527            "CreateStack" => self.create_stack(&req).await,
1528            "DeleteStack" => self.delete_stack(&req),
1529            "DescribeStacks" => self.describe_stacks(&req),
1530            "ListStacks" => self.list_stacks(&req),
1531            "ListStackResources" => self.list_stack_resources(&req),
1532            "DescribeStackResources" => self.describe_stack_resources(&req),
1533            "UpdateStack" => self.update_stack(&req),
1534            "GetTemplate" => self.get_template(&req),
1535            _ => self.handle_extra_action(&req),
1536        };
1537        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
1538            self.save_snapshot().await;
1539        }
1540        result
1541    }
1542
1543    fn supported_actions(&self) -> &[&str] {
1544        &[
1545            "ActivateOrganizationsAccess",
1546            "ActivateType",
1547            "BatchDescribeTypeConfigurations",
1548            "CancelUpdateStack",
1549            "ContinueUpdateRollback",
1550            "CreateChangeSet",
1551            "CreateGeneratedTemplate",
1552            "CreateStack",
1553            "CreateStackInstances",
1554            "CreateStackRefactor",
1555            "CreateStackSet",
1556            "DeactivateOrganizationsAccess",
1557            "DeactivateType",
1558            "DeleteChangeSet",
1559            "DeleteGeneratedTemplate",
1560            "DeleteStack",
1561            "DeleteStackInstances",
1562            "DeleteStackSet",
1563            "DeregisterType",
1564            "DescribeAccountLimits",
1565            "DescribeChangeSet",
1566            "DescribeChangeSetHooks",
1567            "DescribeEvents",
1568            "DescribeGeneratedTemplate",
1569            "DescribeOrganizationsAccess",
1570            "DescribePublisher",
1571            "DescribeResourceScan",
1572            "DescribeStackDriftDetectionStatus",
1573            "DescribeStackEvents",
1574            "DescribeStackInstance",
1575            "DescribeStackRefactor",
1576            "DescribeStackResource",
1577            "DescribeStackResourceDrifts",
1578            "DescribeStackResources",
1579            "DescribeStackSet",
1580            "DescribeStackSetOperation",
1581            "DescribeStacks",
1582            "DescribeType",
1583            "DescribeTypeRegistration",
1584            "DetectStackDrift",
1585            "DetectStackResourceDrift",
1586            "DetectStackSetDrift",
1587            "EstimateTemplateCost",
1588            "ExecuteChangeSet",
1589            "ExecuteStackRefactor",
1590            "GetGeneratedTemplate",
1591            "GetHookResult",
1592            "GetStackPolicy",
1593            "GetTemplate",
1594            "GetTemplateSummary",
1595            "ImportStacksToStackSet",
1596            "ListChangeSets",
1597            "ListExports",
1598            "ListGeneratedTemplates",
1599            "ListHookResults",
1600            "ListImports",
1601            "ListResourceScanRelatedResources",
1602            "ListResourceScanResources",
1603            "ListResourceScans",
1604            "ListStackInstanceResourceDrifts",
1605            "ListStackInstances",
1606            "ListStackRefactorActions",
1607            "ListStackRefactors",
1608            "ListStackResources",
1609            "ListStackSetAutoDeploymentTargets",
1610            "ListStackSetOperationResults",
1611            "ListStackSetOperations",
1612            "ListStackSets",
1613            "ListStacks",
1614            "ListTypeRegistrations",
1615            "ListTypeVersions",
1616            "ListTypes",
1617            "PublishType",
1618            "RecordHandlerProgress",
1619            "RegisterPublisher",
1620            "RegisterType",
1621            "RollbackStack",
1622            "SetStackPolicy",
1623            "SetTypeConfiguration",
1624            "SetTypeDefaultVersion",
1625            "SignalResource",
1626            "StartResourceScan",
1627            "StopStackSetOperation",
1628            "TestType",
1629            "UpdateGeneratedTemplate",
1630            "UpdateStack",
1631            "UpdateStackInstances",
1632            "UpdateStackSet",
1633            "UpdateTerminationProtection",
1634            "ValidateTemplate",
1635        ]
1636    }
1637}
1638
1639/// Parsed + validated inputs for `UpdateStack`.
1640struct UpdateStackInput {
1641    stack_name: String,
1642    template_body: String,
1643    parameters: BTreeMap<String, String>,
1644    tags: BTreeMap<String, String>,
1645    notification_arns: Vec<String>,
1646}
1647
1648impl UpdateStackInput {
1649    fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
1650        let params = CloudFormationService::get_all_params(req);
1651
1652        let stack_name = params
1653            .get("StackName")
1654            .ok_or_else(|| {
1655                AwsServiceError::aws_error(
1656                    StatusCode::BAD_REQUEST,
1657                    "ValidationError",
1658                    "StackName is required",
1659                )
1660            })?
1661            .to_string();
1662
1663        // TemplateBody isn't `@required` in Smithy (TemplateURL +
1664        // UsePreviousTemplate are alternatives). Treat omission as an
1665        // empty body so synthetic conformance inputs don't trip an
1666        // undeclared `ValidationError`.
1667        let template_body = params.get("TemplateBody").cloned().unwrap_or_default();
1668
1669        Ok(Self {
1670            stack_name,
1671            template_body,
1672            parameters: CloudFormationService::extract_parameters(&params),
1673            tags: CloudFormationService::extract_tags(&params),
1674            notification_arns: CloudFormationService::extract_notification_arns(&params),
1675        })
1676    }
1677}
1678
1679/// One row of structured diff returned by `apply_resource_updates`. Used
1680/// by `ExecuteChangeSet` to emit `StackEvent` rows so `DescribeStackEvents`
1681/// reflects the resources actually created / updated / deleted.
1682#[derive(Debug, Clone)]
1683pub(crate) struct ResourceChange {
1684    pub action: ResourceChangeAction,
1685    pub logical_id: String,
1686    pub physical_id: String,
1687    pub resource_type: String,
1688}
1689
1690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1691pub(crate) enum ResourceChangeAction {
1692    Create,
1693    Update,
1694    Delete,
1695}
1696
1697impl ResourceChangeAction {
1698    pub fn status_in_progress(self) -> &'static str {
1699        match self {
1700            Self::Create => "CREATE_IN_PROGRESS",
1701            Self::Update => "UPDATE_IN_PROGRESS",
1702            Self::Delete => "DELETE_IN_PROGRESS",
1703        }
1704    }
1705    pub fn status_complete(self) -> &'static str {
1706        match self {
1707            Self::Create => "CREATE_COMPLETE",
1708            Self::Update => "UPDATE_COMPLETE",
1709            Self::Delete => "DELETE_COMPLETE",
1710        }
1711    }
1712}
1713
1714/// Apply resource updates: delete removed resources, create new ones, and
1715/// in-place update resources whose properties changed. Returns the list of
1716/// changes applied (for event emission) on success or `Err(msg)` if any
1717/// resource operation fails.
1718pub(crate) fn apply_resource_updates(
1719    stack: &mut crate::state::Stack,
1720    new_resource_defs: &[template::ResourceDefinition],
1721    template_body: &str,
1722    parameters: &BTreeMap<String, String>,
1723    provisioner: &crate::resource_provisioner::ResourceProvisioner,
1724) -> Result<Vec<ResourceChange>, String> {
1725    let mut changes: Vec<ResourceChange> = Vec::new();
1726    let old_logical_ids: std::collections::HashSet<String> = stack
1727        .resources
1728        .iter()
1729        .map(|r| r.logical_id.clone())
1730        .collect();
1731    let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
1732        .iter()
1733        .map(|r| r.logical_id.clone())
1734        .collect();
1735
1736    // Delete resources no longer in template
1737    let to_remove: Vec<_> = stack
1738        .resources
1739        .iter()
1740        .filter(|r| !new_logical_ids.contains(&r.logical_id))
1741        .cloned()
1742        .collect();
1743    for resource in &to_remove {
1744        let _ = provisioner.delete_resource(resource);
1745        changes.push(ResourceChange {
1746            action: ResourceChangeAction::Delete,
1747            logical_id: resource.logical_id.clone(),
1748            physical_id: resource.physical_id.clone(),
1749            resource_type: resource.resource_type.clone(),
1750        });
1751    }
1752    stack
1753        .resources
1754        .retain(|r| new_logical_ids.contains(&r.logical_id));
1755
1756    // Build physical ID + attribute maps from existing resources
1757    let mut physical_ids: BTreeMap<String, String> = stack
1758        .resources
1759        .iter()
1760        .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
1761        .collect();
1762    let mut attributes: BTreeMap<String, BTreeMap<String, String>> = stack
1763        .resources
1764        .iter()
1765        .map(|r| (r.logical_id.clone(), r.attributes.clone()))
1766        .collect();
1767
1768    // Create new resources / update resources that already exist. Provision in
1769    // dependency order so a `Ref`/`GetAtt`/`Fn::Sub`/`DependsOn` to another
1770    // resource resolves to that resource's physical id rather than its bare
1771    // logical id (which would otherwise get baked into derived state — e.g. a
1772    // Step Functions ASL referencing a Lambda declared later in the template).
1773    let order = template::dependency_order(template_body, parameters, new_resource_defs);
1774    for &idx in &order {
1775        let resource_def = &new_resource_defs[idx];
1776        let resolved_def = template::resolve_resource_properties_with_attrs(
1777            resource_def,
1778            template_body,
1779            parameters,
1780            &physical_ids,
1781            &attributes,
1782        )
1783        .map_err(|e| {
1784            format!(
1785                "Failed to resolve resource {}: {e}",
1786                resource_def.logical_id
1787            )
1788        })?;
1789
1790        if !old_logical_ids.contains(&resource_def.logical_id) {
1791            match provisioner.create_resource(&resolved_def) {
1792                Ok(stack_resource) => {
1793                    changes.push(ResourceChange {
1794                        action: ResourceChangeAction::Create,
1795                        logical_id: stack_resource.logical_id.clone(),
1796                        physical_id: stack_resource.physical_id.clone(),
1797                        resource_type: stack_resource.resource_type.clone(),
1798                    });
1799                    physical_ids.insert(
1800                        stack_resource.logical_id.clone(),
1801                        stack_resource.physical_id.clone(),
1802                    );
1803                    attributes.insert(
1804                        stack_resource.logical_id.clone(),
1805                        stack_resource.attributes.clone(),
1806                    );
1807                    stack.resources.push(stack_resource);
1808                }
1809                Err(e) => {
1810                    tracing::warn!(
1811                        "Failed to create resource {} during update: {e}",
1812                        resource_def.logical_id
1813                    );
1814                    return Err(format!(
1815                        "Failed to create resource {}: {e}",
1816                        resource_def.logical_id
1817                    ));
1818                }
1819            }
1820        } else {
1821            // Resource exists in both old and new templates — try to apply
1822            // an in-place update. The provisioner returns `Ok(None)` for
1823            // resource types that don't support updates yet; in that case
1824            // the existing resource stays as-is so the rest of the stack
1825            // continues to validate.
1826            let existing = stack
1827                .resources
1828                .iter()
1829                .find(|r| r.logical_id == resource_def.logical_id)
1830                .cloned();
1831            if let Some(existing) = existing {
1832                match provisioner.update_resource(&existing, &resolved_def) {
1833                    Ok(Some(updated)) => {
1834                        changes.push(ResourceChange {
1835                            action: ResourceChangeAction::Update,
1836                            logical_id: updated.logical_id.clone(),
1837                            physical_id: updated.physical_id.clone(),
1838                            resource_type: updated.resource_type.clone(),
1839                        });
1840                        physical_ids
1841                            .insert(updated.logical_id.clone(), updated.physical_id.clone());
1842                        attributes.insert(updated.logical_id.clone(), updated.attributes.clone());
1843                        if let Some(slot) = stack
1844                            .resources
1845                            .iter_mut()
1846                            .find(|r| r.logical_id == updated.logical_id)
1847                        {
1848                            *slot = updated;
1849                        }
1850                    }
1851                    Ok(None) => {
1852                        // Resource type has no update path — leave the
1853                        // existing physical resource untouched.
1854                    }
1855                    Err(e) => {
1856                        tracing::warn!(
1857                            "Failed to update resource {} during update: {e}",
1858                            resource_def.logical_id
1859                        );
1860                        return Err(format!(
1861                            "Failed to update resource {}: {e}",
1862                            resource_def.logical_id
1863                        ));
1864                    }
1865                }
1866            }
1867        }
1868    }
1869
1870    Ok(changes)
1871}
1872
1873/// Pushes a single `StackEvent` row onto the per-stack event log so
1874/// `DescribeStackEvents` returns a chronological history of resource and
1875/// stack-level state transitions.
1876pub(crate) fn record_event(
1877    state: &mut crate::state::CloudFormationState,
1878    stack_id: &str,
1879    stack_name: &str,
1880    logical_id: &str,
1881    physical_id: &str,
1882    resource_type: &str,
1883    status: &str,
1884) {
1885    use serde_json::json;
1886    let event_id = format!(
1887        "{}-{:x}",
1888        logical_id,
1889        std::time::SystemTime::now()
1890            .duration_since(std::time::UNIX_EPOCH)
1891            .map(|d| d.as_nanos())
1892            .unwrap_or(0)
1893    );
1894    let log = state.events.entry(stack_id.to_string()).or_default();
1895
1896    // Timestamps must be sub-second AND strictly increasing within a stack's
1897    // event log. `sam`'s deploy-wait reads the REVIEW_IN_PROGRESS marker's
1898    // timestamp and then only registers events whose `Timestamp` is strictly
1899    // greater; a fast stack that provisions within one second would otherwise
1900    // stamp its REVIEW_IN_PROGRESS marker and terminal CREATE_COMPLETE
1901    // identically, so sam never sees completion and polls until it times out.
1902    // Use millisecond precision and bump by 1ms when the clock hasn't advanced
1903    // past the previous event, guaranteeing a strict order.
1904    // Truncate to the stored (millisecond) resolution before comparing, so the
1905    // strict-ordering check isn't fooled by sub-millisecond bits that vanish on
1906    // serialization (two events in the same millisecond would otherwise both
1907    // serialize identically despite `now > prev` holding at full precision).
1908    let now = chrono::DateTime::from_timestamp_millis(Utc::now().timestamp_millis())
1909        .unwrap_or_else(Utc::now);
1910    let timestamp = match log.last().and_then(|e| e["Timestamp"].as_str()) {
1911        Some(prev) => match chrono::DateTime::parse_from_rfc3339(prev) {
1912            Ok(prev) => {
1913                let prev = prev.with_timezone(&Utc);
1914                if now > prev {
1915                    now
1916                } else {
1917                    prev + chrono::Duration::milliseconds(1)
1918                }
1919            }
1920            Err(_) => now,
1921        },
1922        None => now,
1923    };
1924
1925    log.push(json!({
1926        "EventId": event_id,
1927        "StackId": stack_id,
1928        "StackName": stack_name,
1929        "LogicalResourceId": logical_id,
1930        "PhysicalResourceId": physical_id,
1931        "ResourceType": resource_type,
1932        "ResourceStatus": status,
1933        "Timestamp": timestamp.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1934    }));
1935}
1936
1937/// Emits IN_PROGRESS + COMPLETE event pairs for every resource change
1938/// applied during an update. Mirrors the event sequence real CloudFormation
1939/// publishes during `ExecuteChangeSet` / `UpdateStack`.
1940/// Persist the CloudFormation snapshot from a detached task. Mirrors
1941/// `CloudFormationService::save_snapshot` but takes owned handles so it can
1942/// run inside the background CreateStack provisioning task (see RDS's
1943/// `save_snapshot_static`).
1944async fn save_snapshot_static(
1945    state: SharedCloudFormationState,
1946    store: Option<Arc<dyn SnapshotStore>>,
1947    lock: Arc<AsyncMutex<()>>,
1948) {
1949    let Some(store) = store else {
1950        return;
1951    };
1952    let _guard = lock.lock().await;
1953    let snapshot = CloudFormationSnapshot {
1954        schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
1955        state: None,
1956        accounts: Some(state.read().clone()),
1957    };
1958    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
1959        let bytes = serde_json::to_vec(&snapshot)
1960            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
1961        store.save(&bytes)
1962    })
1963    .await;
1964    match join {
1965        Ok(Ok(())) => {}
1966        Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
1967        Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
1968    }
1969}
1970
1971pub(crate) fn record_stack_events(
1972    state: &mut crate::state::CloudFormationState,
1973    stack_id: &str,
1974    stack_name: &str,
1975    changes: &[ResourceChange],
1976) {
1977    for ch in changes {
1978        record_event(
1979            state,
1980            stack_id,
1981            stack_name,
1982            &ch.logical_id,
1983            &ch.physical_id,
1984            &ch.resource_type,
1985            ch.action.status_in_progress(),
1986        );
1987        record_event(
1988            state,
1989            stack_id,
1990            stack_name,
1991            &ch.logical_id,
1992            &ch.physical_id,
1993            &ch.resource_type,
1994            ch.action.status_complete(),
1995        );
1996    }
1997}
1998
1999/// Emits a stack-level lifecycle event (`UPDATE_IN_PROGRESS`,
2000/// `UPDATE_COMPLETE`, `UPDATE_ROLLBACK_COMPLETE`, etc.) keyed on the
2001/// stack's own `LogicalResourceId == stack_name`, matching real CFN.
2002pub(crate) fn record_stack_status_event(
2003    state: &mut crate::state::CloudFormationState,
2004    stack_id: &str,
2005    stack_name: &str,
2006    resource_type: &str,
2007    status: &str,
2008) {
2009    record_event(
2010        state,
2011        stack_id,
2012        stack_name,
2013        stack_name,
2014        stack_id,
2015        resource_type,
2016        status,
2017    );
2018}
2019
2020#[cfg(test)]
2021mod tests {
2022    use super::*;
2023    use http::HeaderMap;
2024    use parking_lot::RwLock;
2025    use std::collections::HashMap;
2026    use std::sync::Arc;
2027
2028    fn make_service() -> CloudFormationService {
2029        let cf_state = Arc::new(RwLock::new(
2030            fakecloud_core::multi_account::MultiAccountState::new(
2031                "123456789012",
2032                "us-east-1",
2033                "http://localhost:4566",
2034            ),
2035        ));
2036        let deps = CloudFormationDeps {
2037            sqs: Arc::new(RwLock::new(
2038                fakecloud_core::multi_account::MultiAccountState::new(
2039                    "123456789012",
2040                    "us-east-1",
2041                    "http://localhost:4566",
2042                ),
2043            )),
2044            sns: Arc::new(RwLock::new(
2045                fakecloud_core::multi_account::MultiAccountState::new(
2046                    "123456789012",
2047                    "us-east-1",
2048                    "http://localhost:4566",
2049                ),
2050            )),
2051            ssm: Arc::new(RwLock::new(
2052                fakecloud_core::multi_account::MultiAccountState::new(
2053                    "123456789012",
2054                    "us-east-1",
2055                    "http://localhost:4566",
2056                ),
2057            )),
2058            iam: Arc::new(RwLock::new(
2059                fakecloud_core::multi_account::MultiAccountState::new(
2060                    "123456789012",
2061                    "us-east-1",
2062                    "",
2063                ),
2064            )),
2065            s3: Arc::new(RwLock::new(
2066                fakecloud_core::multi_account::MultiAccountState::new(
2067                    "123456789012",
2068                    "us-east-1",
2069                    "",
2070                ),
2071            )),
2072            eventbridge: Arc::new(RwLock::new(
2073                fakecloud_core::multi_account::MultiAccountState::new(
2074                    "123456789012",
2075                    "us-east-1",
2076                    "",
2077                ),
2078            )),
2079            dynamodb: Arc::new(RwLock::new(
2080                fakecloud_core::multi_account::MultiAccountState::new(
2081                    "123456789012",
2082                    "us-east-1",
2083                    "",
2084                ),
2085            )),
2086            logs: Arc::new(RwLock::new(
2087                fakecloud_core::multi_account::MultiAccountState::new(
2088                    "123456789012",
2089                    "us-east-1",
2090                    "",
2091                ),
2092            )),
2093            lambda: Arc::new(RwLock::new(
2094                fakecloud_core::multi_account::MultiAccountState::new(
2095                    "123456789012",
2096                    "us-east-1",
2097                    "",
2098                ),
2099            )),
2100            secretsmanager: Arc::new(RwLock::new(
2101                fakecloud_core::multi_account::MultiAccountState::new(
2102                    "123456789012",
2103                    "us-east-1",
2104                    "",
2105                ),
2106            )),
2107            kinesis: Arc::new(RwLock::new(
2108                fakecloud_core::multi_account::MultiAccountState::new(
2109                    "123456789012",
2110                    "us-east-1",
2111                    "",
2112                ),
2113            )),
2114            kms: Arc::new(RwLock::new(
2115                fakecloud_core::multi_account::MultiAccountState::new(
2116                    "123456789012",
2117                    "us-east-1",
2118                    "",
2119                ),
2120            )),
2121            ecr: Arc::new(RwLock::new(
2122                fakecloud_core::multi_account::MultiAccountState::new(
2123                    "123456789012",
2124                    "us-east-1",
2125                    "",
2126                ),
2127            )),
2128            cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
2129            elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
2130            organizations: Arc::new(RwLock::new(None)),
2131            cognito: Arc::new(RwLock::new(
2132                fakecloud_core::multi_account::MultiAccountState::new(
2133                    "123456789012",
2134                    "us-east-1",
2135                    "",
2136                ),
2137            )),
2138            rds: Arc::new(RwLock::new(
2139                fakecloud_core::multi_account::MultiAccountState::new(
2140                    "123456789012",
2141                    "us-east-1",
2142                    "",
2143                ),
2144            )),
2145            ecs: Arc::new(RwLock::new(
2146                fakecloud_core::multi_account::MultiAccountState::new(
2147                    "123456789012",
2148                    "us-east-1",
2149                    "",
2150                ),
2151            )),
2152            acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
2153            elasticache: Arc::new(RwLock::new(
2154                fakecloud_core::multi_account::MultiAccountState::new(
2155                    "123456789012",
2156                    "us-east-1",
2157                    "",
2158                ),
2159            )),
2160            route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
2161            cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
2162            stepfunctions: Arc::new(RwLock::new(
2163                fakecloud_core::multi_account::MultiAccountState::new(
2164                    "123456789012",
2165                    "us-east-1",
2166                    "",
2167                ),
2168            )),
2169            wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
2170            apigateway: Arc::new(RwLock::new(
2171                fakecloud_core::multi_account::MultiAccountState::new(
2172                    "123456789012",
2173                    "us-east-1",
2174                    "",
2175                ),
2176            )),
2177            apigatewayv2: Arc::new(RwLock::new(
2178                fakecloud_core::multi_account::MultiAccountState::new(
2179                    "123456789012",
2180                    "us-east-1",
2181                    "",
2182                ),
2183            )),
2184            ses: Arc::new(RwLock::new(
2185                fakecloud_core::multi_account::MultiAccountState::new(
2186                    "123456789012",
2187                    "us-east-1",
2188                    "",
2189                ),
2190            )),
2191            application_autoscaling: Arc::new(parking_lot::RwLock::new(
2192                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
2193            )),
2194            athena: Arc::new(parking_lot::RwLock::new(
2195                fakecloud_athena::AthenaAccounts::new(),
2196            )),
2197            firehose: Arc::new(parking_lot::RwLock::new(
2198                fakecloud_firehose::FirehoseAccounts::new(),
2199            )),
2200            glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
2201            delivery: Arc::new(DeliveryBus::new()),
2202            lambda_runtime: None,
2203        };
2204        CloudFormationService::new(cf_state, deps)
2205    }
2206
2207    fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
2208        AwsRequest {
2209            service: "cloudformation".to_string(),
2210            action: action.to_string(),
2211            region: "us-east-1".to_string(),
2212            account_id: "123456789012".to_string(),
2213            request_id: "test-request-id".to_string(),
2214            headers: HeaderMap::new(),
2215            query_params: params,
2216            body: bytes::Bytes::new(),
2217            body_stream: parking_lot::Mutex::new(None),
2218            path_segments: vec![],
2219            raw_path: "/".to_string(),
2220            raw_query: String::new(),
2221            method: http::Method::POST,
2222            is_query_protocol: true,
2223            access_key_id: None,
2224            principal: None,
2225        }
2226    }
2227
2228    #[tokio::test]
2229    async fn update_stack_sets_failed_status_on_resource_error() {
2230        let svc = make_service();
2231
2232        // Create a stack with just a queue
2233        let mut create_params = HashMap::new();
2234        create_params.insert("StackName".to_string(), "test-stack".to_string());
2235        create_params.insert(
2236            "TemplateBody".to_string(),
2237            r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
2238        );
2239        let req = make_request("CreateStack", create_params);
2240        let result = svc.create_stack(&req).await;
2241        assert!(result.is_ok());
2242
2243        // Update stack adding an SNS subscription with a non-existent topic
2244        let mut update_params = HashMap::new();
2245        update_params.insert("StackName".to_string(), "test-stack".to_string());
2246        update_params.insert(
2247            "TemplateBody".to_string(),
2248            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(),
2249        );
2250        let req = make_request("UpdateStack", update_params);
2251        let result = svc.update_stack(&req);
2252
2253        // Should return an error
2254        assert!(result.is_err());
2255
2256        // Stack status should be UPDATE_ROLLBACK_COMPLETE — matches the
2257        // terminal status real CloudFormation lands on after a failed
2258        // update attempt that gets rolled back.
2259        let accounts = svc.state.read();
2260        let state = accounts.get("123456789012").unwrap();
2261        let stack = state.stacks.get("test-stack").unwrap();
2262        assert_eq!(stack.status, "UPDATE_ROLLBACK_COMPLETE");
2263    }
2264
2265    #[tokio::test]
2266    async fn create_stack_resolves_ref_to_physical_id() {
2267        let svc = make_service();
2268
2269        // Template where subscription Refs the topic
2270        let template = r#"{
2271            "Resources": {
2272                "MyTopic": {
2273                    "Type": "AWS::SNS::Topic",
2274                    "Properties": { "TopicName": "ref-test-topic" }
2275                },
2276                "MySub": {
2277                    "Type": "AWS::SNS::Subscription",
2278                    "Properties": {
2279                        "TopicArn": { "Ref": "MyTopic" },
2280                        "Protocol": "sqs",
2281                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
2282                    }
2283                }
2284            }
2285        }"#;
2286
2287        let mut params = HashMap::new();
2288        params.insert("StackName".to_string(), "ref-stack".to_string());
2289        params.insert("TemplateBody".to_string(), template.to_string());
2290        let req = make_request("CreateStack", params);
2291        let result = svc.create_stack(&req).await;
2292        assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
2293
2294        // Verify both resources were created
2295        let accounts = svc.state.read();
2296        let state = accounts.get("123456789012").unwrap();
2297        let stack = state.stacks.get("ref-stack").unwrap();
2298        assert_eq!(stack.resources.len(), 2);
2299        assert_eq!(stack.status, "CREATE_COMPLETE");
2300
2301        // The subscription's physical ID should be an ARN (not just "MyTopic")
2302        let sub = stack
2303            .resources
2304            .iter()
2305            .find(|r| r.logical_id == "MySub")
2306            .unwrap();
2307        assert!(
2308            sub.physical_id.contains("ref-test-topic"),
2309            "Subscription physical ID should reference the topic ARN, got: {}",
2310            sub.physical_id
2311        );
2312    }
2313
2314    /// On the multi-thread server runtime, a stack containing a custom
2315    /// resource (whose provisioning can block for minutes on a cold Lambda
2316    /// image pull) must NOT be provisioned synchronously inside the request
2317    /// handler — CreateStack returns the StackId immediately and DescribeStacks
2318    /// observes CREATE_IN_PROGRESS -> CREATE_COMPLETE (bug-audit 2026-06-13,
2319    /// 3.1).
2320    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2321    async fn create_stack_custom_resource_provisions_asynchronously() {
2322        let svc = make_service();
2323        let template = r#"{
2324            "Resources": {
2325                "MyCustom": {
2326                    "Type": "Custom::Thing",
2327                    "Properties": {
2328                        "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:handler"
2329                    }
2330                }
2331            }
2332        }"#;
2333        let mut params = HashMap::new();
2334        params.insert("StackName".to_string(), "async-stack".to_string());
2335        params.insert("TemplateBody".to_string(), template.to_string());
2336        let req = make_request("CreateStack", params);
2337
2338        // CreateStack returns promptly with a StackId; provisioning runs in a
2339        // detached background task. The stack is recorded before the task can
2340        // possibly finish, so right after return it is at worst already
2341        // terminal — never a value that proves the handler blocked on the
2342        // (potentially minutes-long) provisioning loop. The key guarantee is
2343        // that the call returns without running the provisioner inline.
2344        let resp = svc
2345            .create_stack(&req)
2346            .await
2347            .expect("create returns StackId");
2348        assert!(resp.status.is_success());
2349        {
2350            let accounts = svc.state.read();
2351            let stack = accounts
2352                .get("123456789012")
2353                .unwrap()
2354                .stacks
2355                .get("async-stack")
2356                .expect("stack seeded synchronously");
2357            assert!(
2358                stack.status == "CREATE_IN_PROGRESS" || stack.status == "CREATE_COMPLETE",
2359                "unexpected status right after create: {}",
2360                stack.status
2361            );
2362        }
2363
2364        // Poll DescribeStacks (via state) until the background task flips the
2365        // stack to its terminal CREATE_COMPLETE status.
2366        let mut status = String::new();
2367        for _ in 0..200 {
2368            {
2369                let accounts = svc.state.read();
2370                if let Some(stack) = accounts
2371                    .get("123456789012")
2372                    .and_then(|s| s.stacks.get("async-stack"))
2373                {
2374                    status = stack.status.clone();
2375                    if status != "CREATE_IN_PROGRESS" {
2376                        break;
2377                    }
2378                }
2379            }
2380            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2381        }
2382        assert_eq!(
2383            status, "CREATE_COMPLETE",
2384            "stack should reach CREATE_COMPLETE"
2385        );
2386
2387        let accounts = svc.state.read();
2388        let stack = accounts
2389            .get("123456789012")
2390            .unwrap()
2391            .stacks
2392            .get("async-stack")
2393            .unwrap();
2394        assert_eq!(stack.resources.len(), 1);
2395        assert_eq!(stack.resources[0].resource_type, "Custom::Thing");
2396    }
2397
2398    // ── Service error paths ──
2399
2400    #[tokio::test]
2401    async fn create_stack_missing_name_errors() {
2402        let svc = make_service();
2403        let mut params = HashMap::new();
2404        params.insert("TemplateBody".to_string(), "{}".to_string());
2405        let req = make_request("CreateStack", params);
2406        assert!(svc.create_stack(&req).await.is_err());
2407    }
2408
2409    #[tokio::test]
2410    async fn create_stack_missing_template_creates_empty_stack() {
2411        // `TemplateBody` isn't `@required` in Smithy and CreateStack
2412        // declares no `ValidationError` shape, so missing/placeholder
2413        // bodies now create an empty stack rather than rejecting with
2414        // an undeclared wire code (strict-mode conformance gap).
2415        let svc = make_service();
2416        let mut params = HashMap::new();
2417        params.insert("StackName".to_string(), "s".to_string());
2418        let req = make_request("CreateStack", params);
2419        svc.create_stack(&req)
2420            .await
2421            .expect("empty-body create succeeds");
2422    }
2423
2424    #[tokio::test]
2425    async fn create_stack_duplicate_errors() {
2426        let svc = make_service();
2427        let mut params = HashMap::new();
2428        params.insert("StackName".to_string(), "dup".to_string());
2429        params.insert(
2430            "TemplateBody".to_string(),
2431            r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
2432                .to_string(),
2433        );
2434        let req = make_request("CreateStack", params.clone());
2435        svc.create_stack(&req).await.unwrap();
2436        let req = make_request("CreateStack", params);
2437        assert!(svc.create_stack(&req).await.is_err());
2438    }
2439
2440    #[tokio::test]
2441    async fn create_stack_invalid_template_creates_empty_stack() {
2442        // CreateStack's Smithy `errors` list has no `ValidationError`
2443        // shape, so unparseable bodies degrade to an empty parsed
2444        // template instead of raising an undeclared wire code.
2445        let svc = make_service();
2446        let mut params = HashMap::new();
2447        params.insert("StackName".to_string(), "bad".to_string());
2448        params.insert("TemplateBody".to_string(), "not json".to_string());
2449        let req = make_request("CreateStack", params);
2450        svc.create_stack(&req)
2451            .await
2452            .expect("bad-body create succeeds");
2453    }
2454
2455    #[test]
2456    fn delete_stack_unknown_is_noop() {
2457        let svc = make_service();
2458        let mut params = HashMap::new();
2459        params.insert("StackName".to_string(), "ghost".to_string());
2460        let req = make_request("DeleteStack", params);
2461        assert!(svc.delete_stack(&req).is_ok());
2462    }
2463
2464    #[test]
2465    fn describe_stacks_nonexistent_errors() {
2466        // Querying an explicit, unknown StackName returns AWS's
2467        // `ValidationError: Stack with id <name> does not exist` so deploy
2468        // tools that probe stack existence (SAM, `aws cloudformation
2469        // deploy`) get the signal they expect (issue #1646).
2470        let svc = make_service();
2471        let mut params = HashMap::new();
2472        params.insert("StackName".to_string(), "ghost".to_string());
2473        let req = make_request("DescribeStacks", params);
2474        match svc.describe_stacks(&req) {
2475            Ok(_) => panic!("ghost stack must return an error, not an empty list"),
2476            Err(e) => {
2477                assert_eq!(e.status(), StatusCode::BAD_REQUEST);
2478                assert_eq!(e.code(), "ValidationError");
2479                assert!(
2480                    e.message().contains("does not exist"),
2481                    "got: {}",
2482                    e.message()
2483                );
2484            }
2485        }
2486    }
2487
2488    #[test]
2489    fn describe_stacks_empty_returns_all() {
2490        let svc = make_service();
2491        let req = make_request("DescribeStacks", HashMap::new());
2492        let resp = svc.describe_stacks(&req).unwrap();
2493        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2494        assert!(b.contains("DescribeStacksResult"));
2495    }
2496
2497    #[test]
2498    fn list_stacks_empty_returns_ok() {
2499        let svc = make_service();
2500        let req = make_request("ListStacks", HashMap::new());
2501        let resp = svc.list_stacks(&req).unwrap();
2502        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2503        assert!(b.contains("ListStacksResult"));
2504    }
2505
2506    #[test]
2507    fn list_stack_resources_missing_name_returns_validation_error() {
2508        // ListStackResources declares no `errors` in Smithy, so any
2509        // AWS-shaped 4xx counts as a handler response. We reject an
2510        // omitted StackName with `ValidationError` to keep negative
2511        // conformance variants honest; unknown-but-supplied names still
2512        // resolve to an empty list (see the test below).
2513        let svc = make_service();
2514        let req = make_request("ListStackResources", HashMap::new());
2515        let err = match svc.list_stack_resources(&req) {
2516            Err(e) => e,
2517            Ok(_) => panic!("omitted StackName must be rejected"),
2518        };
2519        assert_eq!(err.code(), "ValidationError");
2520    }
2521
2522    #[test]
2523    fn list_stack_resources_unknown_stack_returns_empty() {
2524        let svc = make_service();
2525        let mut params = HashMap::new();
2526        params.insert("StackName".to_string(), "ghost".to_string());
2527        let req = make_request("ListStackResources", params);
2528        svc.list_stack_resources(&req).expect("unknown is empty");
2529    }
2530
2531    #[test]
2532    fn describe_stack_resources_missing_name_returns_empty() {
2533        let svc = make_service();
2534        let req = make_request("DescribeStackResources", HashMap::new());
2535        svc.describe_stack_resources(&req)
2536            .expect("missing name is ok");
2537    }
2538
2539    #[test]
2540    fn get_template_missing_name_returns_empty_body() {
2541        let svc = make_service();
2542        let req = make_request("GetTemplate", HashMap::new());
2543        svc.get_template(&req).expect("missing name is ok");
2544    }
2545
2546    #[test]
2547    fn get_template_unknown_stack_returns_empty_body() {
2548        let svc = make_service();
2549        let mut params = HashMap::new();
2550        params.insert("StackName".to_string(), "ghost".to_string());
2551        let req = make_request("GetTemplate", params);
2552        svc.get_template(&req).expect("unknown is empty");
2553    }
2554
2555    #[test]
2556    fn update_stack_missing_name_errors() {
2557        let svc = make_service();
2558        let mut params = HashMap::new();
2559        params.insert("TemplateBody".to_string(), "{}".to_string());
2560        let req = make_request("UpdateStack", params);
2561        assert!(svc.update_stack(&req).is_err());
2562    }
2563
2564    #[test]
2565    fn update_stack_unknown_stack_returns_synthetic_id() {
2566        // UpdateStack declares only `InsufficientCapabilitiesException`
2567        // and `TokenAlreadyExistsException`, neither of which fits
2568        // "stack does not exist". Synthetic conformance inputs target
2569        // a placeholder stack, so we return a synthetic StackId rather
2570        // than an undeclared `ValidationError`. Real callers create
2571        // the stack first.
2572        let svc = make_service();
2573        let mut params = HashMap::new();
2574        params.insert("StackName".to_string(), "ghost".to_string());
2575        params.insert(
2576            "TemplateBody".to_string(),
2577            r#"{"Resources":{}}"#.to_string(),
2578        );
2579        let req = make_request("UpdateStack", params);
2580        let resp = svc.update_stack(&req).expect("ghost update is synthetic");
2581        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2582        assert!(b.contains("UpdateStackResult"));
2583    }
2584
2585    #[tokio::test]
2586    async fn create_stack_resolves_outputs_and_records_export() {
2587        let svc = make_service();
2588        let template = r#"{
2589            "Resources": {
2590                "Q": {"Type":"AWS::SQS::Queue","Properties":{"QueueName":"out-q"}}
2591            },
2592            "Outputs": {
2593                "QueueUrl": {
2594                    "Value": {"Ref": "Q"},
2595                    "Description": "Url",
2596                    "Export": {"Name": "TheQueueUrl"}
2597                }
2598            }
2599        }"#;
2600        let mut params = HashMap::new();
2601        params.insert("StackName".to_string(), "outs".to_string());
2602        params.insert("TemplateBody".to_string(), template.to_string());
2603        let req = make_request("CreateStack", params);
2604        svc.create_stack(&req).await.expect("create stack");
2605
2606        let accounts = svc.state.read();
2607        let stack = accounts
2608            .get("123456789012")
2609            .unwrap()
2610            .stacks
2611            .get("outs")
2612            .unwrap();
2613        assert_eq!(stack.outputs.len(), 1);
2614        assert_eq!(stack.outputs[0].key, "QueueUrl");
2615        assert_eq!(stack.outputs[0].export_name.as_deref(), Some("TheQueueUrl"));
2616        assert!(!stack.outputs[0].value.is_empty());
2617    }
2618
2619    #[tokio::test]
2620    async fn create_stack_rejects_duplicate_export_name() {
2621        let svc = make_service();
2622        let mk = |name: &str| {
2623            let template = format!(
2624                r#"{{
2625                    "Resources": {{"Q":{{"Type":"AWS::SQS::Queue","Properties":{{"QueueName":"q-{name}"}}}}}},
2626                    "Outputs": {{"QueueUrl":{{"Value":{{"Ref":"Q"}},"Export":{{"Name":"DupExport"}}}}}}
2627                }}"#
2628            );
2629            let mut params = HashMap::new();
2630            params.insert("StackName".to_string(), name.to_string());
2631            params.insert("TemplateBody".to_string(), template);
2632            make_request("CreateStack", params)
2633        };
2634        match svc.create_stack(&mk("first")).await {
2635            Ok(_) => {}
2636            Err(e) => panic!("first stack: {e:?}"),
2637        }
2638        // The second stack's export collides with the first. Since
2639        // provisioning is now asynchronous, the collision can no longer be a
2640        // synchronous CreateStack error — it surfaces as a failed create.
2641        // On the current-thread test runtime the provisioning task runs
2642        // inline, so the stack is already CREATE_FAILED on return.
2643        svc.create_stack(&mk("second"))
2644            .await
2645            .expect("CreateStack returns StackId even when provisioning fails");
2646        let accounts = svc.state.read();
2647        let stack = accounts
2648            .get("123456789012")
2649            .unwrap()
2650            .stacks
2651            .get("second")
2652            .expect("second stack recorded");
2653        assert_eq!(stack.status, "CREATE_FAILED");
2654        // The first stack keeps the export.
2655        let exports = &accounts.get("123456789012").unwrap().exports;
2656        assert_eq!(
2657            exports
2658                .get("DupExport")
2659                .map(|e| e.exporting_stack_name.as_str()),
2660            Some("first")
2661        );
2662    }
2663
2664    #[tokio::test]
2665    async fn import_value_resolves_against_other_stack_export() {
2666        let svc = make_service();
2667
2668        let producer_tpl = r#"{
2669            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod-q"}}},
2670            "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"SharedQueueUrl"}}}
2671        }"#;
2672        let mut p = HashMap::new();
2673        p.insert("StackName".to_string(), "producer".to_string());
2674        p.insert("TemplateBody".to_string(), producer_tpl.to_string());
2675        svc.create_stack(&make_request("CreateStack", p))
2676            .await
2677            .expect("producer");
2678
2679        let consumer_tpl = r#"{
2680            "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cons-q"}}},
2681            "Outputs": {"Imp":{"Value":{"Fn::ImportValue":"SharedQueueUrl"}}}
2682        }"#;
2683        let mut p = HashMap::new();
2684        p.insert("StackName".to_string(), "consumer".to_string());
2685        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2686        svc.create_stack(&make_request("CreateStack", p))
2687            .await
2688            .expect("consumer");
2689
2690        let accounts = svc.state.read();
2691        let prod_url = accounts
2692            .get("123456789012")
2693            .unwrap()
2694            .stacks
2695            .get("producer")
2696            .unwrap()
2697            .outputs[0]
2698            .value
2699            .clone();
2700        let cons = accounts
2701            .get("123456789012")
2702            .unwrap()
2703            .stacks
2704            .get("consumer")
2705            .unwrap();
2706        assert_eq!(cons.outputs[0].value, prod_url);
2707    }
2708
2709    #[tokio::test]
2710    async fn create_stack_records_export_in_state_registry() {
2711        let svc = make_service();
2712        let template = r#"{
2713            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"reg-q"}}},
2714            "Outputs": {"Url":{"Value":{"Ref":"Q"},"Export":{"Name":"reg-url"}}}
2715        }"#;
2716        let mut params = HashMap::new();
2717        params.insert("StackName".to_string(), "reg".to_string());
2718        params.insert("TemplateBody".to_string(), template.to_string());
2719        svc.create_stack(&make_request("CreateStack", params))
2720            .await
2721            .expect("create");
2722
2723        let accounts = svc.state.read();
2724        let state = accounts.get("123456789012").unwrap();
2725        let export = state
2726            .exports
2727            .get("reg-url")
2728            .expect("export registered in state.exports");
2729        assert_eq!(export.exporting_stack_name, "reg");
2730        assert!(!export.value.is_empty());
2731        assert!(export.exporting_stack_id.contains("reg"));
2732    }
2733
2734    #[tokio::test]
2735    async fn import_value_with_unknown_export_errors() {
2736        let svc = make_service();
2737        let consumer_tpl = r#"{
2738            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{
2739                "QueueName": {"Fn::ImportValue":"missing-export"}
2740            }}}
2741        }"#;
2742        let mut p = HashMap::new();
2743        p.insert("StackName".to_string(), "bad-consumer".to_string());
2744        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2745        match svc.create_stack(&make_request("CreateStack", p)).await {
2746            Ok(_) => panic!("expected ValidationError for unknown export"),
2747            Err(e) => {
2748                let msg = format!("{e:?}");
2749                assert!(msg.contains("No export named missing-export"), "got {msg}");
2750            }
2751        }
2752    }
2753
2754    #[tokio::test]
2755    async fn delete_stack_blocked_when_export_in_use_and_unblocked_after_consumer_delete() {
2756        let svc = make_service();
2757
2758        let producer_tpl = r#"{
2759            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod"}}},
2760            "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"my-arn"}}}
2761        }"#;
2762        let mut p = HashMap::new();
2763        p.insert("StackName".to_string(), "producer".to_string());
2764        p.insert("TemplateBody".to_string(), producer_tpl.to_string());
2765        svc.create_stack(&make_request("CreateStack", p))
2766            .await
2767            .expect("producer");
2768
2769        let consumer_tpl = r#"{
2770            "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{
2771                "QueueName": "cons-q",
2772                "Tags": [{"Key":"k","Value":{"Fn::ImportValue":"my-arn"}}]
2773            }}}
2774        }"#;
2775        let mut p = HashMap::new();
2776        p.insert("StackName".to_string(), "consumer".to_string());
2777        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2778        svc.create_stack(&make_request("CreateStack", p))
2779            .await
2780            .expect("consumer");
2781
2782        // Producer delete must fail while consumer still imports.
2783        let mut p = HashMap::new();
2784        p.insert("StackName".to_string(), "producer".to_string());
2785        match svc.delete_stack(&make_request("DeleteStack", p)) {
2786            Ok(_) => panic!("delete must fail while imports exist"),
2787            Err(e) => {
2788                let msg = format!("{e:?}");
2789                assert!(msg.contains("Export my-arn cannot be deleted"), "got {msg}");
2790            }
2791        }
2792
2793        // Delete consumer first.
2794        let mut p = HashMap::new();
2795        p.insert("StackName".to_string(), "consumer".to_string());
2796        svc.delete_stack(&make_request("DeleteStack", p))
2797            .expect("consumer delete");
2798
2799        // Now producer delete succeeds.
2800        let mut p = HashMap::new();
2801        p.insert("StackName".to_string(), "producer".to_string());
2802        svc.delete_stack(&make_request("DeleteStack", p))
2803            .expect("producer delete after consumer gone");
2804
2805        let accounts = svc.state.read();
2806        let state = accounts.get("123456789012").unwrap();
2807        assert!(state.exports.is_empty(), "exports cleared after delete");
2808        assert!(state.imports.is_empty(), "imports cleared after delete");
2809    }
2810}