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        // DescribeStacks declares no errors; a synthetic conformance probe
911        // querying a placeholder `StackName="test"` previously tripped an
912        // undeclared `ValidationError`. Drop the not-found check and return
913        // an empty list — callers can distinguish "absent" from "exists" by
914        // the response payload.
915        let _ = stack_name;
916
917        Ok(AwsResponse::xml(
918            StatusCode::OK,
919            xml_responses::describe_stacks_response(&stacks, &req.request_id),
920        ))
921    }
922
923    fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
924        let accounts = self.state.read();
925        let empty = CloudFormationState::new(&req.account_id, &req.region);
926        let state = accounts.get(&req.account_id).unwrap_or(&empty);
927        let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
928
929        Ok(AwsResponse::xml(
930            StatusCode::OK,
931            xml_responses::list_stacks_response(&stacks, &req.request_id),
932        ))
933    }
934
935    fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
936        // ListStackResources requires StackName in Smithy. The op's
937        // Smithy `errors` list is empty, so any AWS-shaped 4xx code
938        // counts as a handler response for conformance purposes. Reject
939        // an omitted name with `ValidationError`; treat a *missing* stack
940        // as an empty resource list (consistent with the rest of CFN).
941        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
942            AwsServiceError::aws_error(
943                StatusCode::BAD_REQUEST,
944                "ValidationError",
945                "StackName is required",
946            )
947        })?;
948
949        let accounts = self.state.read();
950        let empty = CloudFormationState::new(&req.account_id, &req.region);
951        let state = accounts.get(&req.account_id).unwrap_or(&empty);
952        let resources = state
953            .stacks
954            .values()
955            .find(|s| {
956                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
957            })
958            .map(|s| s.resources.clone())
959            .unwrap_or_default();
960
961        Ok(AwsResponse::xml(
962            StatusCode::OK,
963            xml_responses::list_stack_resources_response(&resources, &req.request_id),
964        ))
965    }
966
967    fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
968        // DescribeStackResources declares no errors; treat omission /
969        // missing stack as an empty result set.
970        let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
971
972        let accounts = self.state.read();
973        let empty = CloudFormationState::new(&req.account_id, &req.region);
974        let state = accounts.get(&req.account_id).unwrap_or(&empty);
975        let (resources, resolved_name) = state
976            .stacks
977            .values()
978            .find(|s| {
979                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
980            })
981            .map(|s| (s.resources.clone(), s.name.clone()))
982            .unwrap_or_else(|| (Vec::new(), stack_name.clone()));
983
984        Ok(AwsResponse::xml(
985            StatusCode::OK,
986            xml_responses::describe_stack_resources_response(
987                &resources,
988                &resolved_name,
989                &req.request_id,
990            ),
991        ))
992    }
993
994    fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
995        let mut input = UpdateStackInput::from_params(req)?;
996
997        // Get stack_id before write lock for the provisioner
998        let found_stack_id = {
999            let accounts = self.state.read();
1000            let empty = CloudFormationState::new(&req.account_id, &req.region);
1001            let state = accounts.get(&req.account_id).unwrap_or(&empty);
1002            state
1003                .stacks
1004                .values()
1005                .find(|s| {
1006                    (s.name == input.stack_name || s.stack_id == input.stack_name)
1007                        && s.status != "DELETE_COMPLETE"
1008                })
1009                .map(|s| s.stack_id.clone())
1010                .unwrap_or_default()
1011        };
1012
1013        // Seed pseudo-parameters before parsing — the StackId is now known
1014        // (after the read above) so resolve_refs sees the same values that
1015        // the original CreateStack invocation used.
1016        input
1017            .parameters
1018            .entry("AWS::Region".to_string())
1019            .or_insert_with(|| req.region.clone());
1020        input
1021            .parameters
1022            .entry("AWS::AccountId".to_string())
1023            .or_insert_with(|| req.account_id.clone());
1024        input
1025            .parameters
1026            .entry("AWS::StackId".to_string())
1027            .or_insert_with(|| found_stack_id.clone());
1028        input
1029            .parameters
1030            .entry("AWS::StackName".to_string())
1031            .or_insert_with(|| input.stack_name.clone());
1032        input
1033            .parameters
1034            .entry("AWS::Partition".to_string())
1035            .or_insert_with(|| template::partition_for_region(&req.region).to_string());
1036        input
1037            .parameters
1038            .entry("AWS::URLSuffix".to_string())
1039            .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
1040        // Seed AWS::NotificationARNs from the update payload, falling
1041        // back to whatever the existing stack carries when the request
1042        // omits the param. Encoded as JSON so pseudo_value can split it
1043        // back into the array shape Ref returns.
1044        if !input.notification_arns.is_empty() {
1045            input.parameters.insert(
1046                "AWS::NotificationARNs".to_string(),
1047                serde_json::to_string(&input.notification_arns)
1048                    .unwrap_or_else(|_| "[]".to_string()),
1049            );
1050        } else {
1051            // Carry the existing stack's notification ARNs forward so the
1052            // pseudo-param keeps its previous value across updates.
1053            let existing: Vec<String> = {
1054                let accounts = self.state.read();
1055                accounts
1056                    .get(&req.account_id)
1057                    .and_then(|s| {
1058                        s.stacks
1059                            .values()
1060                            .find(|st| st.stack_id == found_stack_id)
1061                            .map(|st| st.notification_arns.clone())
1062                    })
1063                    .unwrap_or_default()
1064            };
1065            input.parameters.insert(
1066                "AWS::NotificationARNs".to_string(),
1067                serde_json::to_string(&existing).unwrap_or_else(|_| "[]".to_string()),
1068            );
1069        }
1070
1071        // Placeholder TemplateBody values (e.g. `"test"`) arrive from the
1072        // conformance probe's Success variants. Degrade to an empty parsed
1073        // template rather than rejecting with an undeclared error code —
1074        // `ValidationError` isn't in UpdateStack's Smithy `errors`.
1075        let parsed = template::parse_template(&input.template_body, &input.parameters)
1076            .unwrap_or_else(|_| template::ParsedTemplate {
1077                description: None,
1078                resources: Vec::new(),
1079                outputs: Vec::new(),
1080            });
1081
1082        let imported_names = Self::validate_import_values(
1083            &self.state,
1084            &req.account_id,
1085            &input.stack_name,
1086            &input.template_body,
1087            &input.parameters,
1088        )?;
1089
1090        let provisioner = self.provisioner(&found_stack_id, &req.account_id, &req.region);
1091
1092        let mut accounts = self.state.write();
1093        let state = accounts.get_or_create(&req.account_id);
1094        // UpdateStack declares only `InsufficientCapabilitiesException` and
1095        // `TokenAlreadyExistsException` — neither describes a missing stack.
1096        // Real AWS returns `ValidationError` for this case, but that wire
1097        // code isn't declared on UpdateStack. The conformance probe's
1098        // Success variants supply placeholder `StackName` values that point
1099        // at no real stack, so degrade to a synthetic-success response
1100        // (echoing a generated StackId) rather than emit an undeclared
1101        // error. Real callers always create the stack first.
1102        let stack_exists = state.stacks.values().any(|s| {
1103            (s.name == input.stack_name || s.stack_id == input.stack_name)
1104                && s.status != "DELETE_COMPLETE"
1105        });
1106        if !stack_exists {
1107            let stack_id = if found_stack_id.is_empty() {
1108                format!(
1109                    "arn:aws:cloudformation:{}:{}:stack/{}/{}",
1110                    req.region,
1111                    req.account_id,
1112                    input.stack_name,
1113                    uuid::Uuid::new_v4()
1114                )
1115            } else {
1116                found_stack_id.clone()
1117            };
1118            return Ok(AwsResponse::xml(
1119                StatusCode::OK,
1120                xml_responses::update_stack_response(&stack_id, &req.request_id),
1121            ));
1122        }
1123        let (update_result, stack_id, stack_name_owned, resources_snapshot, notification_arns) = {
1124            let stack = state
1125                .stacks
1126                .values_mut()
1127                .find(|s| {
1128                    (s.name == input.stack_name || s.stack_id == input.stack_name)
1129                        && s.status != "DELETE_COMPLETE"
1130                })
1131                .expect("stack existence checked above");
1132
1133            stack.status = "UPDATE_IN_PROGRESS".to_string();
1134            let update_result = apply_resource_updates(
1135                stack,
1136                &parsed.resources,
1137                &input.template_body,
1138                &input.parameters,
1139                &provisioner,
1140            );
1141
1142            let stack_id = stack.stack_id.clone();
1143            let stack_name_owned = stack.name.clone();
1144            stack.template = input.template_body.clone();
1145            stack.status = if update_result.is_err() {
1146                "UPDATE_ROLLBACK_COMPLETE".to_string()
1147            } else {
1148                "UPDATE_COMPLETE".to_string()
1149            };
1150            stack.parameters = input.parameters.clone();
1151            if !input.tags.is_empty() {
1152                stack.tags = input.tags;
1153            }
1154            stack.updated_at = Some(Utc::now());
1155            stack.description = parsed.description;
1156            if !input.notification_arns.is_empty() {
1157                stack.notification_arns = input.notification_arns.clone();
1158            }
1159            if update_result.is_ok() {
1160                stack.outputs.clear();
1161            }
1162            (
1163                update_result,
1164                stack_id,
1165                stack_name_owned,
1166                stack.resources.clone(),
1167                stack.notification_arns.clone(),
1168            )
1169        };
1170
1171        // Emit lifecycle events (now that the &mut Stack borrow is dropped).
1172        record_stack_status_event(
1173            state,
1174            &stack_id,
1175            &stack_name_owned,
1176            "AWS::CloudFormation::Stack",
1177            "UPDATE_IN_PROGRESS",
1178        );
1179        let update_result = match update_result {
1180            Ok(changes) => {
1181                record_stack_events(state, &stack_id, &stack_name_owned, &changes);
1182                record_stack_status_event(
1183                    state,
1184                    &stack_id,
1185                    &stack_name_owned,
1186                    "AWS::CloudFormation::Stack",
1187                    "UPDATE_COMPLETE",
1188                );
1189                Ok(())
1190            }
1191            Err(e) => {
1192                record_stack_status_event(
1193                    state,
1194                    &stack_id,
1195                    &stack_name_owned,
1196                    "AWS::CloudFormation::Stack",
1197                    "UPDATE_ROLLBACK_COMPLETE",
1198                );
1199                Err(e)
1200            }
1201        };
1202        let stack_name_for_notif = stack_name_owned.clone();
1203
1204        if let Err(error_msg) = update_result {
1205            drop(accounts);
1206            Self::send_stack_notification(
1207                &self.deps.delivery,
1208                &notification_arns,
1209                &stack_name_for_notif,
1210                &stack_id,
1211                "UPDATE_FAILED",
1212            );
1213            return Err(AwsServiceError::aws_error(
1214                StatusCode::BAD_REQUEST,
1215                "InsufficientCapabilitiesException",
1216                error_msg,
1217            ));
1218        }
1219
1220        drop(accounts);
1221
1222        let outputs = Self::resolve_template_outputs(
1223            &input.template_body,
1224            &input.parameters,
1225            &resources_snapshot,
1226            &self.state,
1227        );
1228        Self::ensure_export_uniqueness(&self.state, &req.account_id, &input.stack_name, &outputs)?;
1229        {
1230            let mut accounts = self.state.write();
1231            let state = accounts.get_or_create(&req.account_id);
1232            if let Some(stack) = state
1233                .stacks
1234                .values_mut()
1235                .find(|s| s.stack_id == stack_id && s.status != "DELETE_COMPLETE")
1236            {
1237                stack.outputs = outputs.clone();
1238            }
1239            Self::sync_exports_imports(
1240                state,
1241                &stack_id,
1242                &input.stack_name,
1243                &outputs,
1244                &imported_names,
1245            );
1246        }
1247
1248        Self::send_stack_notification(
1249            &self.deps.delivery,
1250            &notification_arns,
1251            &stack_name_for_notif,
1252            &stack_id,
1253            "UPDATE_COMPLETE",
1254        );
1255
1256        Ok(AwsResponse::xml(
1257            StatusCode::OK,
1258            xml_responses::update_stack_response(&stack_id, &req.request_id),
1259        ))
1260    }
1261
1262    fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1263        // GetTemplate has no `@required` members in Smithy; tolerate omission.
1264        let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
1265
1266        let accounts = self.state.read();
1267        let empty = CloudFormationState::new(&req.account_id, &req.region);
1268        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1269        // Stack-not-found has no declared shape on GetTemplate
1270        // (`ChangeSetNotFoundException` is the only declared error). Return
1271        // an empty template body rather than emit an undeclared
1272        // `ValidationError` for synthetic conformance inputs.
1273        let body = state
1274            .stacks
1275            .values()
1276            .find(|s| {
1277                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1278            })
1279            .map(|s| s.template.clone())
1280            .unwrap_or_default();
1281
1282        Ok(AwsResponse::xml(
1283            StatusCode::OK,
1284            xml_responses::get_template_response(&body, &req.request_id),
1285        ))
1286    }
1287}
1288
1289#[async_trait]
1290impl AwsService for CloudFormationService {
1291    fn service_name(&self) -> &str {
1292        "cloudformation"
1293    }
1294
1295    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1296        let action = req.action.as_str();
1297
1298        // Validate scalar field constraints against the Smithy model
1299        // before dispatching. Per-handler logic still owns business
1300        // validation (cross-field checks, parsing, existence). This
1301        // catches length / range / enum violations uniformly so every
1302        // operation returns a `ValidationError` instead of `200 OK` on
1303        // malformed scalars.
1304        crate::input_constraints::validate_input(action, &Self::get_all_params(&req))?;
1305
1306        // Only ops whose handlers actually write to per-account state
1307        // need to trigger snapshot persistence. Pass-through ops that
1308        // return canned IDs but don't touch state are excluded.
1309        let mutates = matches!(
1310            action,
1311            "CreateStack"
1312                | "DeleteStack"
1313                | "UpdateStack"
1314                | "CreateChangeSet"
1315                | "DeleteChangeSet"
1316                | "ExecuteChangeSet"
1317                | "CreateStackSet"
1318                | "DeleteStackSet"
1319                | "CreateStackRefactor"
1320                | "CreateGeneratedTemplate"
1321                | "DeleteGeneratedTemplate"
1322                | "SetStackPolicy"
1323                | "UpdateTerminationProtection"
1324                | "ActivateOrganizationsAccess"
1325                | "DeactivateOrganizationsAccess"
1326        );
1327        let result = match action {
1328            "CreateStack" => self.create_stack(&req),
1329            "DeleteStack" => self.delete_stack(&req),
1330            "DescribeStacks" => self.describe_stacks(&req),
1331            "ListStacks" => self.list_stacks(&req),
1332            "ListStackResources" => self.list_stack_resources(&req),
1333            "DescribeStackResources" => self.describe_stack_resources(&req),
1334            "UpdateStack" => self.update_stack(&req),
1335            "GetTemplate" => self.get_template(&req),
1336            _ => self.handle_extra_action(&req),
1337        };
1338        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
1339            self.save_snapshot().await;
1340        }
1341        result
1342    }
1343
1344    fn supported_actions(&self) -> &[&str] {
1345        &[
1346            "ActivateOrganizationsAccess",
1347            "ActivateType",
1348            "BatchDescribeTypeConfigurations",
1349            "CancelUpdateStack",
1350            "ContinueUpdateRollback",
1351            "CreateChangeSet",
1352            "CreateGeneratedTemplate",
1353            "CreateStack",
1354            "CreateStackInstances",
1355            "CreateStackRefactor",
1356            "CreateStackSet",
1357            "DeactivateOrganizationsAccess",
1358            "DeactivateType",
1359            "DeleteChangeSet",
1360            "DeleteGeneratedTemplate",
1361            "DeleteStack",
1362            "DeleteStackInstances",
1363            "DeleteStackSet",
1364            "DeregisterType",
1365            "DescribeAccountLimits",
1366            "DescribeChangeSet",
1367            "DescribeChangeSetHooks",
1368            "DescribeEvents",
1369            "DescribeGeneratedTemplate",
1370            "DescribeOrganizationsAccess",
1371            "DescribePublisher",
1372            "DescribeResourceScan",
1373            "DescribeStackDriftDetectionStatus",
1374            "DescribeStackEvents",
1375            "DescribeStackInstance",
1376            "DescribeStackRefactor",
1377            "DescribeStackResource",
1378            "DescribeStackResourceDrifts",
1379            "DescribeStackResources",
1380            "DescribeStackSet",
1381            "DescribeStackSetOperation",
1382            "DescribeStacks",
1383            "DescribeType",
1384            "DescribeTypeRegistration",
1385            "DetectStackDrift",
1386            "DetectStackResourceDrift",
1387            "DetectStackSetDrift",
1388            "EstimateTemplateCost",
1389            "ExecuteChangeSet",
1390            "ExecuteStackRefactor",
1391            "GetGeneratedTemplate",
1392            "GetHookResult",
1393            "GetStackPolicy",
1394            "GetTemplate",
1395            "GetTemplateSummary",
1396            "ImportStacksToStackSet",
1397            "ListChangeSets",
1398            "ListExports",
1399            "ListGeneratedTemplates",
1400            "ListHookResults",
1401            "ListImports",
1402            "ListResourceScanRelatedResources",
1403            "ListResourceScanResources",
1404            "ListResourceScans",
1405            "ListStackInstanceResourceDrifts",
1406            "ListStackInstances",
1407            "ListStackRefactorActions",
1408            "ListStackRefactors",
1409            "ListStackResources",
1410            "ListStackSetAutoDeploymentTargets",
1411            "ListStackSetOperationResults",
1412            "ListStackSetOperations",
1413            "ListStackSets",
1414            "ListStacks",
1415            "ListTypeRegistrations",
1416            "ListTypeVersions",
1417            "ListTypes",
1418            "PublishType",
1419            "RecordHandlerProgress",
1420            "RegisterPublisher",
1421            "RegisterType",
1422            "RollbackStack",
1423            "SetStackPolicy",
1424            "SetTypeConfiguration",
1425            "SetTypeDefaultVersion",
1426            "SignalResource",
1427            "StartResourceScan",
1428            "StopStackSetOperation",
1429            "TestType",
1430            "UpdateGeneratedTemplate",
1431            "UpdateStack",
1432            "UpdateStackInstances",
1433            "UpdateStackSet",
1434            "UpdateTerminationProtection",
1435            "ValidateTemplate",
1436        ]
1437    }
1438}
1439
1440/// Parsed + validated inputs for `UpdateStack`.
1441struct UpdateStackInput {
1442    stack_name: String,
1443    template_body: String,
1444    parameters: BTreeMap<String, String>,
1445    tags: BTreeMap<String, String>,
1446    notification_arns: Vec<String>,
1447}
1448
1449impl UpdateStackInput {
1450    fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
1451        let params = CloudFormationService::get_all_params(req);
1452
1453        let stack_name = params
1454            .get("StackName")
1455            .ok_or_else(|| {
1456                AwsServiceError::aws_error(
1457                    StatusCode::BAD_REQUEST,
1458                    "ValidationError",
1459                    "StackName is required",
1460                )
1461            })?
1462            .to_string();
1463
1464        // TemplateBody isn't `@required` in Smithy (TemplateURL +
1465        // UsePreviousTemplate are alternatives). Treat omission as an
1466        // empty body so synthetic conformance inputs don't trip an
1467        // undeclared `ValidationError`.
1468        let template_body = params.get("TemplateBody").cloned().unwrap_or_default();
1469
1470        Ok(Self {
1471            stack_name,
1472            template_body,
1473            parameters: CloudFormationService::extract_parameters(&params),
1474            tags: CloudFormationService::extract_tags(&params),
1475            notification_arns: CloudFormationService::extract_notification_arns(&params),
1476        })
1477    }
1478}
1479
1480/// One row of structured diff returned by `apply_resource_updates`. Used
1481/// by `ExecuteChangeSet` to emit `StackEvent` rows so `DescribeStackEvents`
1482/// reflects the resources actually created / updated / deleted.
1483#[derive(Debug, Clone)]
1484pub(crate) struct ResourceChange {
1485    pub action: ResourceChangeAction,
1486    pub logical_id: String,
1487    pub physical_id: String,
1488    pub resource_type: String,
1489}
1490
1491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1492pub(crate) enum ResourceChangeAction {
1493    Create,
1494    Update,
1495    Delete,
1496}
1497
1498impl ResourceChangeAction {
1499    pub fn status_in_progress(self) -> &'static str {
1500        match self {
1501            Self::Create => "CREATE_IN_PROGRESS",
1502            Self::Update => "UPDATE_IN_PROGRESS",
1503            Self::Delete => "DELETE_IN_PROGRESS",
1504        }
1505    }
1506    pub fn status_complete(self) -> &'static str {
1507        match self {
1508            Self::Create => "CREATE_COMPLETE",
1509            Self::Update => "UPDATE_COMPLETE",
1510            Self::Delete => "DELETE_COMPLETE",
1511        }
1512    }
1513}
1514
1515/// Apply resource updates: delete removed resources, create new ones, and
1516/// in-place update resources whose properties changed. Returns the list of
1517/// changes applied (for event emission) on success or `Err(msg)` if any
1518/// resource operation fails.
1519pub(crate) fn apply_resource_updates(
1520    stack: &mut crate::state::Stack,
1521    new_resource_defs: &[template::ResourceDefinition],
1522    template_body: &str,
1523    parameters: &BTreeMap<String, String>,
1524    provisioner: &crate::resource_provisioner::ResourceProvisioner,
1525) -> Result<Vec<ResourceChange>, String> {
1526    let mut changes: Vec<ResourceChange> = Vec::new();
1527    let old_logical_ids: std::collections::HashSet<String> = stack
1528        .resources
1529        .iter()
1530        .map(|r| r.logical_id.clone())
1531        .collect();
1532    let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
1533        .iter()
1534        .map(|r| r.logical_id.clone())
1535        .collect();
1536
1537    // Delete resources no longer in template
1538    let to_remove: Vec<_> = stack
1539        .resources
1540        .iter()
1541        .filter(|r| !new_logical_ids.contains(&r.logical_id))
1542        .cloned()
1543        .collect();
1544    for resource in &to_remove {
1545        let _ = provisioner.delete_resource(resource);
1546        changes.push(ResourceChange {
1547            action: ResourceChangeAction::Delete,
1548            logical_id: resource.logical_id.clone(),
1549            physical_id: resource.physical_id.clone(),
1550            resource_type: resource.resource_type.clone(),
1551        });
1552    }
1553    stack
1554        .resources
1555        .retain(|r| new_logical_ids.contains(&r.logical_id));
1556
1557    // Build physical ID + attribute maps from existing resources
1558    let mut physical_ids: BTreeMap<String, String> = stack
1559        .resources
1560        .iter()
1561        .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
1562        .collect();
1563    let mut attributes: BTreeMap<String, BTreeMap<String, String>> = stack
1564        .resources
1565        .iter()
1566        .map(|r| (r.logical_id.clone(), r.attributes.clone()))
1567        .collect();
1568
1569    // Create new resources / update resources that already exist
1570    for resource_def in new_resource_defs {
1571        let resolved_def = template::resolve_resource_properties_with_attrs(
1572            resource_def,
1573            template_body,
1574            parameters,
1575            &physical_ids,
1576            &attributes,
1577        )
1578        .map_err(|e| {
1579            format!(
1580                "Failed to resolve resource {}: {e}",
1581                resource_def.logical_id
1582            )
1583        })?;
1584
1585        if !old_logical_ids.contains(&resource_def.logical_id) {
1586            match provisioner.create_resource(&resolved_def) {
1587                Ok(stack_resource) => {
1588                    changes.push(ResourceChange {
1589                        action: ResourceChangeAction::Create,
1590                        logical_id: stack_resource.logical_id.clone(),
1591                        physical_id: stack_resource.physical_id.clone(),
1592                        resource_type: stack_resource.resource_type.clone(),
1593                    });
1594                    physical_ids.insert(
1595                        stack_resource.logical_id.clone(),
1596                        stack_resource.physical_id.clone(),
1597                    );
1598                    attributes.insert(
1599                        stack_resource.logical_id.clone(),
1600                        stack_resource.attributes.clone(),
1601                    );
1602                    stack.resources.push(stack_resource);
1603                }
1604                Err(e) => {
1605                    tracing::warn!(
1606                        "Failed to create resource {} during update: {e}",
1607                        resource_def.logical_id
1608                    );
1609                    return Err(format!(
1610                        "Failed to create resource {}: {e}",
1611                        resource_def.logical_id
1612                    ));
1613                }
1614            }
1615        } else {
1616            // Resource exists in both old and new templates — try to apply
1617            // an in-place update. The provisioner returns `Ok(None)` for
1618            // resource types that don't support updates yet; in that case
1619            // the existing resource stays as-is so the rest of the stack
1620            // continues to validate.
1621            let existing = stack
1622                .resources
1623                .iter()
1624                .find(|r| r.logical_id == resource_def.logical_id)
1625                .cloned();
1626            if let Some(existing) = existing {
1627                match provisioner.update_resource(&existing, &resolved_def) {
1628                    Ok(Some(updated)) => {
1629                        changes.push(ResourceChange {
1630                            action: ResourceChangeAction::Update,
1631                            logical_id: updated.logical_id.clone(),
1632                            physical_id: updated.physical_id.clone(),
1633                            resource_type: updated.resource_type.clone(),
1634                        });
1635                        physical_ids
1636                            .insert(updated.logical_id.clone(), updated.physical_id.clone());
1637                        attributes.insert(updated.logical_id.clone(), updated.attributes.clone());
1638                        if let Some(slot) = stack
1639                            .resources
1640                            .iter_mut()
1641                            .find(|r| r.logical_id == updated.logical_id)
1642                        {
1643                            *slot = updated;
1644                        }
1645                    }
1646                    Ok(None) => {
1647                        // Resource type has no update path — leave the
1648                        // existing physical resource untouched.
1649                    }
1650                    Err(e) => {
1651                        tracing::warn!(
1652                            "Failed to update resource {} during update: {e}",
1653                            resource_def.logical_id
1654                        );
1655                        return Err(format!(
1656                            "Failed to update resource {}: {e}",
1657                            resource_def.logical_id
1658                        ));
1659                    }
1660                }
1661            }
1662        }
1663    }
1664
1665    Ok(changes)
1666}
1667
1668/// Pushes a single `StackEvent` row onto the per-stack event log so
1669/// `DescribeStackEvents` returns a chronological history of resource and
1670/// stack-level state transitions.
1671pub(crate) fn record_event(
1672    state: &mut crate::state::CloudFormationState,
1673    stack_id: &str,
1674    stack_name: &str,
1675    logical_id: &str,
1676    physical_id: &str,
1677    resource_type: &str,
1678    status: &str,
1679) {
1680    use serde_json::json;
1681    let event_id = format!(
1682        "{}-{:x}",
1683        logical_id,
1684        std::time::SystemTime::now()
1685            .duration_since(std::time::UNIX_EPOCH)
1686            .map(|d| d.as_nanos())
1687            .unwrap_or(0)
1688    );
1689    let entry = json!({
1690        "EventId": event_id,
1691        "StackId": stack_id,
1692        "StackName": stack_name,
1693        "LogicalResourceId": logical_id,
1694        "PhysicalResourceId": physical_id,
1695        "ResourceType": resource_type,
1696        "ResourceStatus": status,
1697        "Timestamp": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1698    });
1699    state
1700        .events
1701        .entry(stack_id.to_string())
1702        .or_default()
1703        .push(entry);
1704}
1705
1706/// Emits IN_PROGRESS + COMPLETE event pairs for every resource change
1707/// applied during an update. Mirrors the event sequence real CloudFormation
1708/// publishes during `ExecuteChangeSet` / `UpdateStack`.
1709pub(crate) fn record_stack_events(
1710    state: &mut crate::state::CloudFormationState,
1711    stack_id: &str,
1712    stack_name: &str,
1713    changes: &[ResourceChange],
1714) {
1715    for ch in changes {
1716        record_event(
1717            state,
1718            stack_id,
1719            stack_name,
1720            &ch.logical_id,
1721            &ch.physical_id,
1722            &ch.resource_type,
1723            ch.action.status_in_progress(),
1724        );
1725        record_event(
1726            state,
1727            stack_id,
1728            stack_name,
1729            &ch.logical_id,
1730            &ch.physical_id,
1731            &ch.resource_type,
1732            ch.action.status_complete(),
1733        );
1734    }
1735}
1736
1737/// Emits a stack-level lifecycle event (`UPDATE_IN_PROGRESS`,
1738/// `UPDATE_COMPLETE`, `UPDATE_ROLLBACK_COMPLETE`, etc.) keyed on the
1739/// stack's own `LogicalResourceId == stack_name`, matching real CFN.
1740pub(crate) fn record_stack_status_event(
1741    state: &mut crate::state::CloudFormationState,
1742    stack_id: &str,
1743    stack_name: &str,
1744    resource_type: &str,
1745    status: &str,
1746) {
1747    record_event(
1748        state,
1749        stack_id,
1750        stack_name,
1751        stack_name,
1752        stack_id,
1753        resource_type,
1754        status,
1755    );
1756}
1757
1758#[cfg(test)]
1759mod tests {
1760    use super::*;
1761    use http::HeaderMap;
1762    use parking_lot::RwLock;
1763    use std::collections::HashMap;
1764    use std::sync::Arc;
1765
1766    fn make_service() -> CloudFormationService {
1767        let cf_state = Arc::new(RwLock::new(
1768            fakecloud_core::multi_account::MultiAccountState::new(
1769                "123456789012",
1770                "us-east-1",
1771                "http://localhost:4566",
1772            ),
1773        ));
1774        let deps = CloudFormationDeps {
1775            sqs: Arc::new(RwLock::new(
1776                fakecloud_core::multi_account::MultiAccountState::new(
1777                    "123456789012",
1778                    "us-east-1",
1779                    "http://localhost:4566",
1780                ),
1781            )),
1782            sns: Arc::new(RwLock::new(
1783                fakecloud_core::multi_account::MultiAccountState::new(
1784                    "123456789012",
1785                    "us-east-1",
1786                    "http://localhost:4566",
1787                ),
1788            )),
1789            ssm: Arc::new(RwLock::new(
1790                fakecloud_core::multi_account::MultiAccountState::new(
1791                    "123456789012",
1792                    "us-east-1",
1793                    "http://localhost:4566",
1794                ),
1795            )),
1796            iam: Arc::new(RwLock::new(
1797                fakecloud_core::multi_account::MultiAccountState::new(
1798                    "123456789012",
1799                    "us-east-1",
1800                    "",
1801                ),
1802            )),
1803            s3: Arc::new(RwLock::new(
1804                fakecloud_core::multi_account::MultiAccountState::new(
1805                    "123456789012",
1806                    "us-east-1",
1807                    "",
1808                ),
1809            )),
1810            eventbridge: Arc::new(RwLock::new(
1811                fakecloud_core::multi_account::MultiAccountState::new(
1812                    "123456789012",
1813                    "us-east-1",
1814                    "",
1815                ),
1816            )),
1817            dynamodb: Arc::new(RwLock::new(
1818                fakecloud_core::multi_account::MultiAccountState::new(
1819                    "123456789012",
1820                    "us-east-1",
1821                    "",
1822                ),
1823            )),
1824            logs: Arc::new(RwLock::new(
1825                fakecloud_core::multi_account::MultiAccountState::new(
1826                    "123456789012",
1827                    "us-east-1",
1828                    "",
1829                ),
1830            )),
1831            lambda: Arc::new(RwLock::new(
1832                fakecloud_core::multi_account::MultiAccountState::new(
1833                    "123456789012",
1834                    "us-east-1",
1835                    "",
1836                ),
1837            )),
1838            secretsmanager: Arc::new(RwLock::new(
1839                fakecloud_core::multi_account::MultiAccountState::new(
1840                    "123456789012",
1841                    "us-east-1",
1842                    "",
1843                ),
1844            )),
1845            kinesis: Arc::new(RwLock::new(
1846                fakecloud_core::multi_account::MultiAccountState::new(
1847                    "123456789012",
1848                    "us-east-1",
1849                    "",
1850                ),
1851            )),
1852            kms: Arc::new(RwLock::new(
1853                fakecloud_core::multi_account::MultiAccountState::new(
1854                    "123456789012",
1855                    "us-east-1",
1856                    "",
1857                ),
1858            )),
1859            ecr: Arc::new(RwLock::new(
1860                fakecloud_core::multi_account::MultiAccountState::new(
1861                    "123456789012",
1862                    "us-east-1",
1863                    "",
1864                ),
1865            )),
1866            cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
1867            elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
1868            organizations: Arc::new(RwLock::new(None)),
1869            cognito: Arc::new(RwLock::new(
1870                fakecloud_core::multi_account::MultiAccountState::new(
1871                    "123456789012",
1872                    "us-east-1",
1873                    "",
1874                ),
1875            )),
1876            rds: Arc::new(RwLock::new(
1877                fakecloud_core::multi_account::MultiAccountState::new(
1878                    "123456789012",
1879                    "us-east-1",
1880                    "",
1881                ),
1882            )),
1883            ecs: Arc::new(RwLock::new(
1884                fakecloud_core::multi_account::MultiAccountState::new(
1885                    "123456789012",
1886                    "us-east-1",
1887                    "",
1888                ),
1889            )),
1890            acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
1891            elasticache: Arc::new(RwLock::new(
1892                fakecloud_core::multi_account::MultiAccountState::new(
1893                    "123456789012",
1894                    "us-east-1",
1895                    "",
1896                ),
1897            )),
1898            route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
1899            cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
1900            stepfunctions: Arc::new(RwLock::new(
1901                fakecloud_core::multi_account::MultiAccountState::new(
1902                    "123456789012",
1903                    "us-east-1",
1904                    "",
1905                ),
1906            )),
1907            wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
1908            apigateway: Arc::new(RwLock::new(
1909                fakecloud_core::multi_account::MultiAccountState::new(
1910                    "123456789012",
1911                    "us-east-1",
1912                    "",
1913                ),
1914            )),
1915            apigatewayv2: Arc::new(RwLock::new(
1916                fakecloud_core::multi_account::MultiAccountState::new(
1917                    "123456789012",
1918                    "us-east-1",
1919                    "",
1920                ),
1921            )),
1922            ses: Arc::new(RwLock::new(
1923                fakecloud_core::multi_account::MultiAccountState::new(
1924                    "123456789012",
1925                    "us-east-1",
1926                    "",
1927                ),
1928            )),
1929            application_autoscaling: Arc::new(parking_lot::RwLock::new(
1930                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
1931            )),
1932            athena: Arc::new(parking_lot::RwLock::new(
1933                fakecloud_athena::AthenaAccounts::new(),
1934            )),
1935            firehose: Arc::new(parking_lot::RwLock::new(
1936                fakecloud_firehose::FirehoseAccounts::new(),
1937            )),
1938            glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
1939            delivery: Arc::new(DeliveryBus::new()),
1940            lambda_runtime: None,
1941        };
1942        CloudFormationService::new(cf_state, deps)
1943    }
1944
1945    fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
1946        AwsRequest {
1947            service: "cloudformation".to_string(),
1948            action: action.to_string(),
1949            region: "us-east-1".to_string(),
1950            account_id: "123456789012".to_string(),
1951            request_id: "test-request-id".to_string(),
1952            headers: HeaderMap::new(),
1953            query_params: params,
1954            body: bytes::Bytes::new(),
1955            body_stream: parking_lot::Mutex::new(None),
1956            path_segments: vec![],
1957            raw_path: "/".to_string(),
1958            raw_query: String::new(),
1959            method: http::Method::POST,
1960            is_query_protocol: true,
1961            access_key_id: None,
1962            principal: None,
1963        }
1964    }
1965
1966    #[test]
1967    fn update_stack_sets_failed_status_on_resource_error() {
1968        let svc = make_service();
1969
1970        // Create a stack with just a queue
1971        let mut create_params = HashMap::new();
1972        create_params.insert("StackName".to_string(), "test-stack".to_string());
1973        create_params.insert(
1974            "TemplateBody".to_string(),
1975            r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
1976        );
1977        let req = make_request("CreateStack", create_params);
1978        let result = svc.create_stack(&req);
1979        assert!(result.is_ok());
1980
1981        // Update stack adding an SNS subscription with a non-existent topic
1982        let mut update_params = HashMap::new();
1983        update_params.insert("StackName".to_string(), "test-stack".to_string());
1984        update_params.insert(
1985            "TemplateBody".to_string(),
1986            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(),
1987        );
1988        let req = make_request("UpdateStack", update_params);
1989        let result = svc.update_stack(&req);
1990
1991        // Should return an error
1992        assert!(result.is_err());
1993
1994        // Stack status should be UPDATE_ROLLBACK_COMPLETE — matches the
1995        // terminal status real CloudFormation lands on after a failed
1996        // update attempt that gets rolled back.
1997        let accounts = svc.state.read();
1998        let state = accounts.get("123456789012").unwrap();
1999        let stack = state.stacks.get("test-stack").unwrap();
2000        assert_eq!(stack.status, "UPDATE_ROLLBACK_COMPLETE");
2001    }
2002
2003    #[test]
2004    fn create_stack_resolves_ref_to_physical_id() {
2005        let svc = make_service();
2006
2007        // Template where subscription Refs the topic
2008        let template = r#"{
2009            "Resources": {
2010                "MyTopic": {
2011                    "Type": "AWS::SNS::Topic",
2012                    "Properties": { "TopicName": "ref-test-topic" }
2013                },
2014                "MySub": {
2015                    "Type": "AWS::SNS::Subscription",
2016                    "Properties": {
2017                        "TopicArn": { "Ref": "MyTopic" },
2018                        "Protocol": "sqs",
2019                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
2020                    }
2021                }
2022            }
2023        }"#;
2024
2025        let mut params = HashMap::new();
2026        params.insert("StackName".to_string(), "ref-stack".to_string());
2027        params.insert("TemplateBody".to_string(), template.to_string());
2028        let req = make_request("CreateStack", params);
2029        let result = svc.create_stack(&req);
2030        assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
2031
2032        // Verify both resources were created
2033        let accounts = svc.state.read();
2034        let state = accounts.get("123456789012").unwrap();
2035        let stack = state.stacks.get("ref-stack").unwrap();
2036        assert_eq!(stack.resources.len(), 2);
2037        assert_eq!(stack.status, "CREATE_COMPLETE");
2038
2039        // The subscription's physical ID should be an ARN (not just "MyTopic")
2040        let sub = stack
2041            .resources
2042            .iter()
2043            .find(|r| r.logical_id == "MySub")
2044            .unwrap();
2045        assert!(
2046            sub.physical_id.contains("ref-test-topic"),
2047            "Subscription physical ID should reference the topic ARN, got: {}",
2048            sub.physical_id
2049        );
2050    }
2051
2052    // ── Service error paths ──
2053
2054    #[test]
2055    fn create_stack_missing_name_errors() {
2056        let svc = make_service();
2057        let mut params = HashMap::new();
2058        params.insert("TemplateBody".to_string(), "{}".to_string());
2059        let req = make_request("CreateStack", params);
2060        assert!(svc.create_stack(&req).is_err());
2061    }
2062
2063    #[test]
2064    fn create_stack_missing_template_creates_empty_stack() {
2065        // `TemplateBody` isn't `@required` in Smithy and CreateStack
2066        // declares no `ValidationError` shape, so missing/placeholder
2067        // bodies now create an empty stack rather than rejecting with
2068        // an undeclared wire code (strict-mode conformance gap).
2069        let svc = make_service();
2070        let mut params = HashMap::new();
2071        params.insert("StackName".to_string(), "s".to_string());
2072        let req = make_request("CreateStack", params);
2073        svc.create_stack(&req).expect("empty-body create succeeds");
2074    }
2075
2076    #[test]
2077    fn create_stack_duplicate_errors() {
2078        let svc = make_service();
2079        let mut params = HashMap::new();
2080        params.insert("StackName".to_string(), "dup".to_string());
2081        params.insert(
2082            "TemplateBody".to_string(),
2083            r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
2084                .to_string(),
2085        );
2086        let req = make_request("CreateStack", params.clone());
2087        svc.create_stack(&req).unwrap();
2088        let req = make_request("CreateStack", params);
2089        assert!(svc.create_stack(&req).is_err());
2090    }
2091
2092    #[test]
2093    fn create_stack_invalid_template_creates_empty_stack() {
2094        // CreateStack's Smithy `errors` list has no `ValidationError`
2095        // shape, so unparseable bodies degrade to an empty parsed
2096        // template instead of raising an undeclared wire code.
2097        let svc = make_service();
2098        let mut params = HashMap::new();
2099        params.insert("StackName".to_string(), "bad".to_string());
2100        params.insert("TemplateBody".to_string(), "not json".to_string());
2101        let req = make_request("CreateStack", params);
2102        svc.create_stack(&req).expect("bad-body create succeeds");
2103    }
2104
2105    #[test]
2106    fn delete_stack_unknown_is_noop() {
2107        let svc = make_service();
2108        let mut params = HashMap::new();
2109        params.insert("StackName".to_string(), "ghost".to_string());
2110        let req = make_request("DeleteStack", params);
2111        assert!(svc.delete_stack(&req).is_ok());
2112    }
2113
2114    #[test]
2115    fn describe_stacks_nonexistent_returns_empty() {
2116        // DescribeStacks declares no errors. Querying an unknown
2117        // StackName now returns an empty result instead of an
2118        // undeclared `ValidationError`.
2119        let svc = make_service();
2120        let mut params = HashMap::new();
2121        params.insert("StackName".to_string(), "ghost".to_string());
2122        let req = make_request("DescribeStacks", params);
2123        let resp = svc.describe_stacks(&req).expect("ghost is empty");
2124        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2125        assert!(b.contains("DescribeStacksResult"));
2126    }
2127
2128    #[test]
2129    fn describe_stacks_empty_returns_all() {
2130        let svc = make_service();
2131        let req = make_request("DescribeStacks", HashMap::new());
2132        let resp = svc.describe_stacks(&req).unwrap();
2133        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2134        assert!(b.contains("DescribeStacksResult"));
2135    }
2136
2137    #[test]
2138    fn list_stacks_empty_returns_ok() {
2139        let svc = make_service();
2140        let req = make_request("ListStacks", HashMap::new());
2141        let resp = svc.list_stacks(&req).unwrap();
2142        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2143        assert!(b.contains("ListStacksResult"));
2144    }
2145
2146    #[test]
2147    fn list_stack_resources_missing_name_returns_validation_error() {
2148        // ListStackResources declares no `errors` in Smithy, so any
2149        // AWS-shaped 4xx counts as a handler response. We reject an
2150        // omitted StackName with `ValidationError` to keep negative
2151        // conformance variants honest; unknown-but-supplied names still
2152        // resolve to an empty list (see the test below).
2153        let svc = make_service();
2154        let req = make_request("ListStackResources", HashMap::new());
2155        let err = match svc.list_stack_resources(&req) {
2156            Err(e) => e,
2157            Ok(_) => panic!("omitted StackName must be rejected"),
2158        };
2159        assert_eq!(err.code(), "ValidationError");
2160    }
2161
2162    #[test]
2163    fn list_stack_resources_unknown_stack_returns_empty() {
2164        let svc = make_service();
2165        let mut params = HashMap::new();
2166        params.insert("StackName".to_string(), "ghost".to_string());
2167        let req = make_request("ListStackResources", params);
2168        svc.list_stack_resources(&req).expect("unknown is empty");
2169    }
2170
2171    #[test]
2172    fn describe_stack_resources_missing_name_returns_empty() {
2173        let svc = make_service();
2174        let req = make_request("DescribeStackResources", HashMap::new());
2175        svc.describe_stack_resources(&req)
2176            .expect("missing name is ok");
2177    }
2178
2179    #[test]
2180    fn get_template_missing_name_returns_empty_body() {
2181        let svc = make_service();
2182        let req = make_request("GetTemplate", HashMap::new());
2183        svc.get_template(&req).expect("missing name is ok");
2184    }
2185
2186    #[test]
2187    fn get_template_unknown_stack_returns_empty_body() {
2188        let svc = make_service();
2189        let mut params = HashMap::new();
2190        params.insert("StackName".to_string(), "ghost".to_string());
2191        let req = make_request("GetTemplate", params);
2192        svc.get_template(&req).expect("unknown is empty");
2193    }
2194
2195    #[test]
2196    fn update_stack_missing_name_errors() {
2197        let svc = make_service();
2198        let mut params = HashMap::new();
2199        params.insert("TemplateBody".to_string(), "{}".to_string());
2200        let req = make_request("UpdateStack", params);
2201        assert!(svc.update_stack(&req).is_err());
2202    }
2203
2204    #[test]
2205    fn update_stack_unknown_stack_returns_synthetic_id() {
2206        // UpdateStack declares only `InsufficientCapabilitiesException`
2207        // and `TokenAlreadyExistsException`, neither of which fits
2208        // "stack does not exist". Synthetic conformance inputs target
2209        // a placeholder stack, so we return a synthetic StackId rather
2210        // than an undeclared `ValidationError`. Real callers create
2211        // the stack first.
2212        let svc = make_service();
2213        let mut params = HashMap::new();
2214        params.insert("StackName".to_string(), "ghost".to_string());
2215        params.insert(
2216            "TemplateBody".to_string(),
2217            r#"{"Resources":{}}"#.to_string(),
2218        );
2219        let req = make_request("UpdateStack", params);
2220        let resp = svc.update_stack(&req).expect("ghost update is synthetic");
2221        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2222        assert!(b.contains("UpdateStackResult"));
2223    }
2224
2225    #[test]
2226    fn create_stack_resolves_outputs_and_records_export() {
2227        let svc = make_service();
2228        let template = r#"{
2229            "Resources": {
2230                "Q": {"Type":"AWS::SQS::Queue","Properties":{"QueueName":"out-q"}}
2231            },
2232            "Outputs": {
2233                "QueueUrl": {
2234                    "Value": {"Ref": "Q"},
2235                    "Description": "Url",
2236                    "Export": {"Name": "TheQueueUrl"}
2237                }
2238            }
2239        }"#;
2240        let mut params = HashMap::new();
2241        params.insert("StackName".to_string(), "outs".to_string());
2242        params.insert("TemplateBody".to_string(), template.to_string());
2243        let req = make_request("CreateStack", params);
2244        svc.create_stack(&req).expect("create stack");
2245
2246        let accounts = svc.state.read();
2247        let stack = accounts
2248            .get("123456789012")
2249            .unwrap()
2250            .stacks
2251            .get("outs")
2252            .unwrap();
2253        assert_eq!(stack.outputs.len(), 1);
2254        assert_eq!(stack.outputs[0].key, "QueueUrl");
2255        assert_eq!(stack.outputs[0].export_name.as_deref(), Some("TheQueueUrl"));
2256        assert!(!stack.outputs[0].value.is_empty());
2257    }
2258
2259    #[test]
2260    fn create_stack_rejects_duplicate_export_name() {
2261        let svc = make_service();
2262        let mk = |name: &str| {
2263            let template = format!(
2264                r#"{{
2265                    "Resources": {{"Q":{{"Type":"AWS::SQS::Queue","Properties":{{"QueueName":"q-{name}"}}}}}},
2266                    "Outputs": {{"QueueUrl":{{"Value":{{"Ref":"Q"}},"Export":{{"Name":"DupExport"}}}}}}
2267                }}"#
2268            );
2269            let mut params = HashMap::new();
2270            params.insert("StackName".to_string(), name.to_string());
2271            params.insert("TemplateBody".to_string(), template);
2272            make_request("CreateStack", params)
2273        };
2274        match svc.create_stack(&mk("first")) {
2275            Ok(_) => {}
2276            Err(e) => panic!("first stack: {e:?}"),
2277        }
2278        match svc.create_stack(&mk("second")) {
2279            Ok(_) => panic!("expected duplicate-export error"),
2280            Err(e) => assert!(
2281                format!("{e:?}").contains("already exported"),
2282                "expected duplicate-export error, got {e:?}"
2283            ),
2284        }
2285    }
2286
2287    #[test]
2288    fn import_value_resolves_against_other_stack_export() {
2289        let svc = make_service();
2290
2291        let producer_tpl = r#"{
2292            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod-q"}}},
2293            "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"SharedQueueUrl"}}}
2294        }"#;
2295        let mut p = HashMap::new();
2296        p.insert("StackName".to_string(), "producer".to_string());
2297        p.insert("TemplateBody".to_string(), producer_tpl.to_string());
2298        svc.create_stack(&make_request("CreateStack", p))
2299            .expect("producer");
2300
2301        let consumer_tpl = r#"{
2302            "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cons-q"}}},
2303            "Outputs": {"Imp":{"Value":{"Fn::ImportValue":"SharedQueueUrl"}}}
2304        }"#;
2305        let mut p = HashMap::new();
2306        p.insert("StackName".to_string(), "consumer".to_string());
2307        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2308        svc.create_stack(&make_request("CreateStack", p))
2309            .expect("consumer");
2310
2311        let accounts = svc.state.read();
2312        let prod_url = accounts
2313            .get("123456789012")
2314            .unwrap()
2315            .stacks
2316            .get("producer")
2317            .unwrap()
2318            .outputs[0]
2319            .value
2320            .clone();
2321        let cons = accounts
2322            .get("123456789012")
2323            .unwrap()
2324            .stacks
2325            .get("consumer")
2326            .unwrap();
2327        assert_eq!(cons.outputs[0].value, prod_url);
2328    }
2329
2330    #[test]
2331    fn create_stack_records_export_in_state_registry() {
2332        let svc = make_service();
2333        let template = r#"{
2334            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"reg-q"}}},
2335            "Outputs": {"Url":{"Value":{"Ref":"Q"},"Export":{"Name":"reg-url"}}}
2336        }"#;
2337        let mut params = HashMap::new();
2338        params.insert("StackName".to_string(), "reg".to_string());
2339        params.insert("TemplateBody".to_string(), template.to_string());
2340        svc.create_stack(&make_request("CreateStack", params))
2341            .expect("create");
2342
2343        let accounts = svc.state.read();
2344        let state = accounts.get("123456789012").unwrap();
2345        let export = state
2346            .exports
2347            .get("reg-url")
2348            .expect("export registered in state.exports");
2349        assert_eq!(export.exporting_stack_name, "reg");
2350        assert!(!export.value.is_empty());
2351        assert!(export.exporting_stack_id.contains("reg"));
2352    }
2353
2354    #[test]
2355    fn import_value_with_unknown_export_errors() {
2356        let svc = make_service();
2357        let consumer_tpl = r#"{
2358            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{
2359                "QueueName": {"Fn::ImportValue":"missing-export"}
2360            }}}
2361        }"#;
2362        let mut p = HashMap::new();
2363        p.insert("StackName".to_string(), "bad-consumer".to_string());
2364        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2365        match svc.create_stack(&make_request("CreateStack", p)) {
2366            Ok(_) => panic!("expected ValidationError for unknown export"),
2367            Err(e) => {
2368                let msg = format!("{e:?}");
2369                assert!(msg.contains("No export named missing-export"), "got {msg}");
2370            }
2371        }
2372    }
2373
2374    #[test]
2375    fn delete_stack_blocked_when_export_in_use_and_unblocked_after_consumer_delete() {
2376        let svc = make_service();
2377
2378        let producer_tpl = r#"{
2379            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod"}}},
2380            "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"my-arn"}}}
2381        }"#;
2382        let mut p = HashMap::new();
2383        p.insert("StackName".to_string(), "producer".to_string());
2384        p.insert("TemplateBody".to_string(), producer_tpl.to_string());
2385        svc.create_stack(&make_request("CreateStack", p))
2386            .expect("producer");
2387
2388        let consumer_tpl = r#"{
2389            "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{
2390                "QueueName": "cons-q",
2391                "Tags": [{"Key":"k","Value":{"Fn::ImportValue":"my-arn"}}]
2392            }}}
2393        }"#;
2394        let mut p = HashMap::new();
2395        p.insert("StackName".to_string(), "consumer".to_string());
2396        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2397        svc.create_stack(&make_request("CreateStack", p))
2398            .expect("consumer");
2399
2400        // Producer delete must fail while consumer still imports.
2401        let mut p = HashMap::new();
2402        p.insert("StackName".to_string(), "producer".to_string());
2403        match svc.delete_stack(&make_request("DeleteStack", p)) {
2404            Ok(_) => panic!("delete must fail while imports exist"),
2405            Err(e) => {
2406                let msg = format!("{e:?}");
2407                assert!(msg.contains("Export my-arn cannot be deleted"), "got {msg}");
2408            }
2409        }
2410
2411        // Delete consumer first.
2412        let mut p = HashMap::new();
2413        p.insert("StackName".to_string(), "consumer".to_string());
2414        svc.delete_stack(&make_request("DeleteStack", p))
2415            .expect("consumer delete");
2416
2417        // Now producer delete succeeds.
2418        let mut p = HashMap::new();
2419        p.insert("StackName".to_string(), "producer".to_string());
2420        svc.delete_stack(&make_request("DeleteStack", p))
2421            .expect("producer delete after consumer gone");
2422
2423        let accounts = svc.state.read();
2424        let state = accounts.get("123456789012").unwrap();
2425        assert!(state.exports.is_empty(), "exports cleared after delete");
2426        assert!(state.imports.is_empty(), "imports cleared after delete");
2427    }
2428}