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