Skip to main content

fakecloud_cloudformation/
service.rs

1use async_trait::async_trait;
2use chrono::Utc;
3use http::StatusCode;
4use std::collections::{BTreeMap, BTreeSet};
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::{S3Store, SnapshotHook, 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        "AWS::EC2::VPC" => &["VpcId", "CidrBlock"],
51        "AWS::EC2::Subnet" => &["SubnetId", "AvailabilityZone", "VpcId", "CidrBlock"],
52        "AWS::EC2::SecurityGroup" => &["GroupId", "VpcId"],
53        "AWS::EC2::InternetGateway" => &["InternetGatewayId"],
54        "AWS::EC2::RouteTable" => &["RouteTableId"],
55        _ => &[],
56    }
57}
58
59/// Map a CloudFormation resource type (`AWS::<Service>::<Resource>`) to the
60/// snapshot-hook key for its owning service (the keys of
61/// `CloudFormationDeps::snapshot_hooks`). The key is the service segment, with
62/// aliases for the few services whose CloudFormation namespace differs from
63/// their fakecloud service name (e.g. `Events` -> `eventbridge`).
64///
65/// `AWS::S3::*` is intentionally absent: S3 buckets persist through the
66/// `S3Store` write-through path in the provisioner, not a whole-state snapshot
67/// hook. Returns `None` for malformed types and any service whose state is not
68/// snapshot-backed (those CFN resources have no persistence path either way).
69fn service_key_for_type(resource_type: &str) -> Option<&'static str> {
70    let mut parts = resource_type.split("::");
71    let vendor = parts.next()?;
72    let service = parts.next()?;
73    // A real resource type has three segments; a 2-part string (or fewer) is
74    // not one.
75    parts.next()?;
76    if vendor != "AWS" {
77        return None;
78    }
79    Some(match service {
80        "Lambda" => "lambda",
81        "SecretsManager" => "secretsmanager",
82        "SQS" => "sqs",
83        "SNS" => "sns",
84        "DynamoDB" => "dynamodb",
85        "StepFunctions" => "stepfunctions",
86        "Events" => "eventbridge",
87        "SSM" => "ssm",
88        "Logs" => "logs",
89        "KMS" => "kms",
90        "Kinesis" => "kinesis",
91        "SES" => "ses",
92        "Cognito" => "cognito",
93        "RDS" => "rds",
94        "ElastiCache" => "elasticache",
95        "ECR" => "ecr",
96        "ECS" => "ecs",
97        "CloudWatch" => "cloudwatch",
98        "ApiGateway" => "apigateway",
99        "ApiGatewayV2" => "apigatewayv2",
100        "Bedrock" => "bedrock",
101        "Scheduler" => "scheduler",
102        "IAM" => "iam",
103        // Services whose CloudFormation namespace differs from their fakecloud
104        // service name, and which were missing from this table: their
105        // provisioner mutates snapshot-backed state directly, so CFN-created
106        // (and CFN-deleted) resources vanished on restart while their
107        // direct-API equivalents persisted (#1766 class). Each has a registered
108        // snapshot hook in the server.
109        "CertificateManager" => "acm",
110        "ElasticLoadBalancingV2" => "elbv2",
111        "CloudFront" => "cloudfront",
112        "Route53" => "route53",
113        "KinesisFirehose" => "firehose",
114        "Glue" => "glue",
115        "WAFv2" => "wafv2",
116        "Athena" => "athena",
117        "Organizations" => "organizations",
118        // EC2 and ApplicationAutoScaling were wired into the provisioner after
119        // this table was last audited (EC2: 2026-06-25 #1957;
120        // application-autoscaling: persistence sweep), so their CFN-created VPC
121        // / Subnet / SecurityGroup / scalable-target state mutated in memory and
122        // vanished on restart (#1766 class). Both have a registered snapshot
123        // hook in the server.
124        "EC2" => "ec2",
125        "AutoScaling" => "autoscaling",
126        "Batch" => "batch",
127        "ApplicationAutoScaling" => "application-autoscaling",
128        _ => return None,
129    })
130}
131
132/// Persist each distinct snapshot-backed service touched by a stack op, once.
133///
134/// `resource_types` is the set of `resource_type` strings the op created /
135/// updated / deleted. The CloudFormation provisioner mutates services' shared
136/// state directly and never triggers their snapshot path; this writes that
137/// state through to disk afterwards, so a CFN-provisioned (or CFN-deleted)
138/// resource survives a restart. Services with no registered hook (memory mode,
139/// or non-snapshot-backed services) are skipped.
140async fn persist_touched_services<I>(
141    hooks: &BTreeMap<&'static str, SnapshotHook>,
142    resource_types: I,
143) where
144    I: IntoIterator<Item = String>,
145{
146    if hooks.is_empty() {
147        return;
148    }
149    let mut keys: BTreeSet<&'static str> = BTreeSet::new();
150    for ty in resource_types {
151        if let Some(key) = service_key_for_type(&ty) {
152            keys.insert(key);
153        }
154    }
155    for key in keys {
156        if let Some(hook) = hooks.get(key) {
157            hook().await;
158        }
159    }
160}
161
162/// Multi-pass provisioning for all resources in a parsed template.
163///
164/// Resources may `Ref` each other in either direction, and JSON object
165/// iteration order isn't stable, so a single forward pass isn't enough
166/// to resolve them. We loop: each pass tries every pending resource, and
167/// any resource whose `Ref` targets are still unknown just stays pending
168/// for the next pass. When no pass makes progress we report the first
169/// pending failure and rollback.
170pub(crate) fn provision_stack_resources(
171    provisioner: &ResourceProvisioner,
172    resource_defs: &[template::ResourceDefinition],
173    template_body: &str,
174    parameters: &BTreeMap<String, String>,
175    imports: &BTreeMap<String, String>,
176) -> Result<Vec<StackResource>, AwsServiceError> {
177    let mut resources = Vec::new();
178    let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
179    let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
180    // Seed the work list in dependency order so a referenced resource is
181    // provisioned before its referrer and `Ref`/`GetAtt`/`Fn::Sub` resolve to
182    // physical ids. The fixed-point retry loop below still recovers from any
183    // residual ordering the graph can't express.
184    let order = template::dependency_order(template_body, parameters, resource_defs);
185    let mut pending: Vec<&template::ResourceDefinition> =
186        order.iter().map(|&i| &resource_defs[i]).collect();
187    let max_passes = pending.len() + 1;
188
189    for _ in 0..max_passes {
190        if pending.is_empty() {
191            break;
192        }
193        let mut still_pending = Vec::new();
194        let mut made_progress = false;
195
196        for resource_def in pending {
197            let resolved_def = template::resolve_resource_properties_with_attrs(
198                resource_def,
199                template_body,
200                parameters,
201                &physical_ids,
202                &attributes,
203                imports,
204            )
205            .map_err(|e| {
206                // `ValidationError` isn't declared on CreateStack/UpdateStack;
207                // surface template resolve failures through the closest declared
208                // shape (`InsufficientCapabilitiesException`) instead.
209                AwsServiceError::aws_error(
210                    StatusCode::BAD_REQUEST,
211                    "InsufficientCapabilitiesException",
212                    e,
213                )
214            })?;
215
216            match provisioner.create_resource(&resolved_def) {
217                Ok(mut stack_resource) => {
218                    physical_ids.insert(
219                        stack_resource.logical_id.clone(),
220                        stack_resource.physical_id.clone(),
221                    );
222                    // Start with the eagerly-captured attribute set, then
223                    // overlay anything the provisioner can resolve from
224                    // live state (e.g. attributes that depend on side-effects
225                    // recorded after the create handler returned).
226                    let mut attr_map = stack_resource.attributes.clone();
227                    for attr in well_known_attributes_for(&stack_resource.resource_type) {
228                        if attr_map.contains_key(*attr) {
229                            continue;
230                        }
231                        if let Some(v) = provisioner.get_att(&stack_resource, attr) {
232                            attr_map.insert((*attr).to_string(), v);
233                        }
234                    }
235                    attributes.insert(stack_resource.logical_id.clone(), attr_map.clone());
236                    // Persist the live-resolved attributes onto the stored
237                    // resource so later readers — notably resolve_template_outputs,
238                    // which reads StackResource.attributes directly without the
239                    // overlay — see the full GetAtt set, not just the eager subset.
240                    stack_resource.attributes = attr_map;
241                    resources.push(stack_resource);
242                    made_progress = true;
243                }
244                Err(_) => still_pending.push(resource_def),
245            }
246        }
247
248        pending = still_pending;
249        if !made_progress && !pending.is_empty() {
250            // No progress — report the first failure and rollback anything
251            // we already created.
252            let resource_def = pending[0];
253            let resolved_def = template::resolve_resource_properties_with_attrs(
254                resource_def,
255                template_body,
256                parameters,
257                &physical_ids,
258                &attributes,
259                imports,
260            )
261            .unwrap_or_else(|_| resource_def.clone());
262            let err = provisioner.create_resource(&resolved_def).unwrap_err();
263            for r in &resources {
264                let _ = provisioner.delete_resource(r);
265            }
266            return Err(AwsServiceError::aws_error(
267                StatusCode::BAD_REQUEST,
268                "ValidationError",
269                format!(
270                    "Failed to create resource {}: {err}",
271                    resource_def.logical_id
272                ),
273            ));
274        }
275    }
276
277    Ok(resources)
278}
279
280/// State references for every service CloudFormation can provision resources in.
281pub struct CloudFormationDeps {
282    pub sqs: SharedSqsState,
283    pub sns: SharedSnsState,
284    pub ssm: SharedSsmState,
285    pub iam: SharedIamState,
286    pub s3: SharedS3State,
287    pub eventbridge: SharedEventBridgeState,
288    pub dynamodb: SharedDynamoDbState,
289    pub logs: SharedLogsState,
290    pub lambda: fakecloud_lambda::SharedLambdaState,
291    pub secretsmanager: fakecloud_secretsmanager::SharedSecretsManagerState,
292    pub kinesis: fakecloud_kinesis::SharedKinesisState,
293    pub kms: fakecloud_kms::SharedKmsState,
294    pub ecr: fakecloud_ecr::SharedEcrState,
295    pub cloudwatch: fakecloud_cloudwatch::SharedCloudWatchState,
296    pub elbv2: fakecloud_elbv2::SharedElbv2State,
297    pub organizations: fakecloud_organizations::SharedOrganizationsState,
298    pub cognito: fakecloud_cognito::SharedCognitoState,
299    pub rds: fakecloud_rds::SharedRdsState,
300    pub ec2: fakecloud_ec2::SharedEc2State,
301    pub autoscaling: fakecloud_autoscaling::SharedAutoScalingState,
302    pub batch: fakecloud_batch::SharedBatchState,
303    pub ecs: fakecloud_ecs::SharedEcsState,
304    pub acm: fakecloud_acm::SharedAcmState,
305    pub elasticache: fakecloud_elasticache::SharedElastiCacheState,
306    pub route53: fakecloud_route53::SharedRoute53State,
307    pub cloudfront: fakecloud_cloudfront::SharedCloudFrontState,
308    pub stepfunctions: fakecloud_stepfunctions::SharedStepFunctionsState,
309    pub wafv2: fakecloud_wafv2::SharedWafv2State,
310    pub apigateway: fakecloud_apigateway::SharedApiGatewayState,
311    pub apigatewayv2: fakecloud_apigatewayv2::SharedApiGatewayV2State,
312    pub ses: fakecloud_ses::SharedSesState,
313    pub application_autoscaling:
314        fakecloud_application_autoscaling::SharedApplicationAutoScalingState,
315    pub athena: fakecloud_athena::SharedAthenaState,
316    pub firehose: fakecloud_firehose::SharedFirehoseState,
317    pub glue: fakecloud_glue::SharedGlueState,
318    pub delivery: Arc<DeliveryBus>,
319    /// Lambda container runtime, when Docker/Podman is available. Used to
320    /// pre-pull the runtime image of a CFN-provisioned `AWS::Lambda::Function`
321    /// in the background so its first Invoke doesn't pay the cold-pull cost
322    /// (the #1539 timeout, through the CloudFormation door). `None` when no
323    /// runtime is configured — provisioning still works, the first Invoke just
324    /// falls back to a cold pull.
325    pub lambda_runtime: Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
326}
327
328pub struct CloudFormationService {
329    pub(crate) state: SharedCloudFormationState,
330    pub(crate) deps: CloudFormationDeps,
331    snapshot_store: Option<Arc<dyn SnapshotStore>>,
332    snapshot_lock: Arc<AsyncMutex<()>>,
333    /// Fine-grained S3 disk store. CFN bucket create/delete writes through this
334    /// (the same path the real `CreateBucket`/`DeleteBucket` use) instead of
335    /// only mutating the in-memory map, so a CFN-provisioned bucket survives a
336    /// restart. Defaults to a `MemoryS3Store` (no-op); the server wires the
337    /// real store via `with_s3_store` once it has been built.
338    s3_store: Arc<dyn S3Store>,
339    /// Whole-state snapshot persist hooks keyed by service name (see
340    /// `service_key_for_type`). After a stack op the handler invokes the hook
341    /// for each touched service so a CFN-provisioned (or CFN-deleted) resource
342    /// is written through to disk, the same persistence a direct mutating API
343    /// call would trigger. Empty by default (memory mode, or no services
344    /// wired); the server populates it via `with_snapshot_hooks`.
345    snapshot_hooks: BTreeMap<&'static str, SnapshotHook>,
346}
347
348/// Everything the async CreateStack provisioning task needs to provision
349/// resources and flip the stack to its terminal status. Bundled into one
350/// owned struct so it can move into a detached `tokio::spawn`.
351struct CreateStackContext {
352    state: SharedCloudFormationState,
353    delivery: Arc<DeliveryBus>,
354    snapshot_store: Option<Arc<dyn SnapshotStore>>,
355    snapshot_lock: Arc<AsyncMutex<()>>,
356    snapshot_hooks: BTreeMap<&'static str, SnapshotHook>,
357    provisioner: ResourceProvisioner,
358    account_id: String,
359    stack_name: String,
360    stack_id: String,
361    template_body: String,
362    parameters: BTreeMap<String, String>,
363    notification_arns: Vec<String>,
364    imported_names: Vec<String>,
365    resource_defs: Vec<template::ResourceDefinition>,
366}
367
368impl CloudFormationService {
369    pub fn new(state: SharedCloudFormationState, deps: CloudFormationDeps) -> Self {
370        Self {
371            state,
372            deps,
373            snapshot_store: None,
374            snapshot_lock: Arc::new(AsyncMutex::new(())),
375            s3_store: Arc::new(fakecloud_persistence::s3::MemoryS3Store::new()),
376            snapshot_hooks: BTreeMap::new(),
377        }
378    }
379
380    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
381        self.snapshot_store = Some(store);
382        self
383    }
384
385    /// Wire the fine-grained S3 disk store so CFN-provisioned buckets are
386    /// written through to disk (see `ResourceProvisioner::s3_store`).
387    pub fn with_s3_store(mut self, store: Arc<dyn S3Store>) -> Self {
388        self.s3_store = store;
389        self
390    }
391
392    /// Register the per-service snapshot persist hooks (keyed by service name)
393    /// used to persist every snapshot-backed service a stack op touches.
394    pub fn with_snapshot_hooks(mut self, hooks: BTreeMap<&'static str, SnapshotHook>) -> Self {
395        self.snapshot_hooks = hooks;
396        self
397    }
398
399    async fn save_snapshot(&self) {
400        let Some(store) = self.snapshot_store.clone() else {
401            return;
402        };
403        let _guard = self.snapshot_lock.lock().await;
404        let snapshot = CloudFormationSnapshot {
405            schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
406            state: None,
407            accounts: Some(self.state.read().clone()),
408        };
409        let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
410            let bytes = serde_json::to_vec(&snapshot)
411                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
412            store.save(&bytes)
413        })
414        .await;
415        match join {
416            Ok(Ok(())) => {}
417            Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
418            Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
419        }
420    }
421
422    pub(crate) fn provisioner(
423        &self,
424        stack_id: &str,
425        account_id: &str,
426        region: &str,
427    ) -> ResourceProvisioner {
428        ResourceProvisioner {
429            sqs_state: self.deps.sqs.clone(),
430            sns_state: self.deps.sns.clone(),
431            ssm_state: self.deps.ssm.clone(),
432            iam_state: self.deps.iam.clone(),
433            s3_state: self.deps.s3.clone(),
434            eventbridge_state: self.deps.eventbridge.clone(),
435            dynamodb_state: self.deps.dynamodb.clone(),
436            logs_state: self.deps.logs.clone(),
437            lambda_state: self.deps.lambda.clone(),
438            secretsmanager_state: self.deps.secretsmanager.clone(),
439            kinesis_state: self.deps.kinesis.clone(),
440            kms_state: self.deps.kms.clone(),
441            ecr_state: self.deps.ecr.clone(),
442            cloudwatch_state: self.deps.cloudwatch.clone(),
443            elbv2_state: self.deps.elbv2.clone(),
444            organizations_state: self.deps.organizations.clone(),
445            cognito_state: self.deps.cognito.clone(),
446            rds_state: self.deps.rds.clone(),
447            ec2_state: self.deps.ec2.clone(),
448            autoscaling_state: self.deps.autoscaling.clone(),
449            batch_state: self.deps.batch.clone(),
450            ecs_state: self.deps.ecs.clone(),
451            acm_state: self.deps.acm.clone(),
452            elasticache_state: self.deps.elasticache.clone(),
453            route53_state: self.deps.route53.clone(),
454            cloudfront_state: self.deps.cloudfront.clone(),
455            stepfunctions_state: self.deps.stepfunctions.clone(),
456            wafv2_state: self.deps.wafv2.clone(),
457            apigateway_state: self.deps.apigateway.clone(),
458            apigatewayv2_state: self.deps.apigatewayv2.clone(),
459            ses_state: self.deps.ses.clone(),
460            app_autoscaling_state: self.deps.application_autoscaling.clone(),
461            athena_state: self.deps.athena.clone(),
462            firehose_state: self.deps.firehose.clone(),
463            glue_state: self.deps.glue.clone(),
464            cloudformation_state: self.state.clone(),
465            delivery: self.deps.delivery.clone(),
466            lambda_runtime: self.deps.lambda_runtime.clone(),
467            s3_store: self.s3_store.clone(),
468            account_id: account_id.to_string(),
469            region: region.to_string(),
470            stack_id: stack_id.to_string(),
471        }
472    }
473
474    fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
475        // Check query params first (for Query protocol)
476        if let Some(v) = req.query_params.get(key) {
477            return Some(v.clone());
478        }
479        // Then check form-encoded body
480        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
481        body_params.get(key).cloned()
482    }
483
484    pub(crate) fn get_all_params(req: &AwsRequest) -> BTreeMap<String, String> {
485        let mut params: BTreeMap<String, String> = req.query_params.clone().into_iter().collect();
486        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
487        for (k, v) in body_params {
488            params.entry(k).or_insert(v);
489        }
490        params
491    }
492
493    pub(crate) fn extract_tags(params: &BTreeMap<String, String>) -> BTreeMap<String, String> {
494        let mut tags = BTreeMap::new();
495        for i in 1.. {
496            let key_param = format!("Tags.member.{i}.Key");
497            let value_param = format!("Tags.member.{i}.Value");
498            match (params.get(&key_param), params.get(&value_param)) {
499                (Some(k), Some(v)) => {
500                    tags.insert(k.clone(), v.clone());
501                }
502                _ => break,
503            }
504        }
505        tags
506    }
507
508    pub(crate) fn extract_parameters(
509        params: &BTreeMap<String, String>,
510    ) -> BTreeMap<String, String> {
511        let mut result = BTreeMap::new();
512        for i in 1.. {
513            let key_param = format!("Parameters.member.{i}.ParameterKey");
514            let value_param = format!("Parameters.member.{i}.ParameterValue");
515            match (params.get(&key_param), params.get(&value_param)) {
516                (Some(k), Some(v)) => {
517                    result.insert(k.clone(), v.clone());
518                }
519                _ => break,
520            }
521        }
522        result
523    }
524
525    /// Fill in declared `Parameters.<Name>.Default` for any parameter the
526    /// caller didn't supply. Without this a `Ref` to an omitted-but-defaulted
527    /// parameter baked the bare parameter name instead of its default -- common
528    /// in hand-written CFN and `aws cloudformation deploy` without
529    /// `--parameter-overrides` (bug-audit 2026-06-20, 1.6).
530    pub(crate) fn merge_parameter_defaults(
531        parameters: &mut BTreeMap<String, String>,
532        template_body: &str,
533    ) {
534        let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
535            match serde_json::from_str(template_body) {
536                Ok(v) => v,
537                Err(_) => return,
538            }
539        } else {
540            match serde_yaml::from_str(template_body) {
541                Ok(v) => v,
542                Err(_) => return,
543            }
544        };
545        let Some(decls) = value.get("Parameters").and_then(|v| v.as_object()) else {
546            return;
547        };
548        for (name, spec) in decls {
549            if parameters.contains_key(name) {
550                continue;
551            }
552            if let Some(default) = spec.get("Default") {
553                let s = default
554                    .as_str()
555                    .map(|s| s.to_string())
556                    .unwrap_or_else(|| default.to_string());
557                parameters.insert(name.clone(), s);
558            }
559        }
560    }
561
562    pub(crate) fn extract_notification_arns(params: &BTreeMap<String, String>) -> Vec<String> {
563        let mut arns = Vec::new();
564        for i in 1.. {
565            let key = format!("NotificationARNs.member.{i}");
566            match params.get(&key) {
567                Some(arn) => arns.push(arn.clone()),
568                None => break,
569            }
570        }
571        arns
572    }
573
574    fn send_stack_notification(
575        delivery: &DeliveryBus,
576        notification_arns: &[String],
577        stack_name: &str,
578        stack_id: &str,
579        status: &str,
580    ) {
581        if notification_arns.is_empty() {
582            return;
583        }
584        let message = format!(
585            "StackId='{}'\nTimestamp='{}'\nEventId='{}'\nLogicalResourceId='{}'\nResourceStatus='{}'\nResourceType='AWS::CloudFormation::Stack'\nStackName='{}'",
586            stack_id,
587            chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
588            uuid::Uuid::new_v4(),
589            stack_name,
590            status,
591            stack_name,
592        );
593        for arn in notification_arns {
594            delivery.publish_to_sns(arn, &message, Some("AWS CloudFormation Notification"));
595        }
596    }
597
598    /// Build a Fn::ImportValue lookup map from the account-level
599    /// `state.exports` registry. `skip_stack` removes any export owned by
600    /// the named stack — used during update so a stack doesn't import its
601    /// own previous-revision export.
602    pub(crate) fn collect_account_imports(
603        state: &SharedCloudFormationState,
604        account_id: &str,
605        skip_stack: Option<&str>,
606    ) -> BTreeMap<String, String> {
607        let mut imports = BTreeMap::new();
608        let accounts = state.read();
609        let Some(state) = accounts.get(account_id) else {
610            return imports;
611        };
612        for (name, export) in &state.exports {
613            if matches!(skip_stack, Some(skip) if skip == export.exporting_stack_name) {
614                continue;
615            }
616            imports.insert(name.clone(), export.value.clone());
617        }
618        imports
619    }
620
621    /// Pre-validate every `Fn::ImportValue` site in `template_body` —
622    /// return a `ValidationError` listing any export names that aren't
623    /// known in the account. Mirrors CloudFormation's behavior of
624    /// failing the create/update before any resource is provisioned.
625    fn validate_import_values(
626        state: &SharedCloudFormationState,
627        account_id: &str,
628        stack_name: &str,
629        template_body: &str,
630        parameters: &BTreeMap<String, String>,
631    ) -> Result<Vec<String>, AwsServiceError> {
632        let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
633            match serde_json::from_str(template_body) {
634                Ok(v) => v,
635                Err(_) => return Ok(Vec::new()),
636            }
637        } else {
638            match serde_yaml::from_str(template_body) {
639                Ok(v) => v,
640                Err(_) => return Ok(Vec::new()),
641            }
642        };
643        let names = template::collect_import_value_names(&value, parameters);
644        let known = Self::collect_account_imports(state, account_id, Some(stack_name));
645        for n in &names {
646            if !known.contains_key(n) {
647                // CreateStack and UpdateStack both declare
648                // `InsufficientCapabilitiesException`; reuse it for the
649                // "missing imported export" pre-flight so the wire code
650                // matches a Smithy-declared shape on both ops.
651                return Err(AwsServiceError::aws_error(
652                    StatusCode::BAD_REQUEST,
653                    "InsufficientCapabilitiesException",
654                    format!("No export named {n} found."),
655                ));
656            }
657        }
658        Ok(names)
659    }
660
661    /// Sync `state.exports` and `state.imports` after a stack create or
662    /// update. Removes any exports / imports the stack used to own and
663    /// re-adds the current-revision set.
664    pub(crate) fn sync_exports_imports(
665        state: &mut CloudFormationState,
666        stack_id: &str,
667        stack_name: &str,
668        outputs: &[state::StackOutput],
669        imported_names: &[String],
670    ) {
671        // 1. Drop any prior exports owned by this stack.
672        let stale_exports: Vec<String> = state
673            .exports
674            .iter()
675            .filter(|(_, e)| e.exporting_stack_name == stack_name)
676            .map(|(k, _)| k.clone())
677            .collect();
678        for k in stale_exports {
679            state.exports.remove(&k);
680        }
681        // 2. Drop any prior imports recorded against this stack.
682        for entries in state.imports.values_mut() {
683            entries.retain(|s| s != stack_name);
684        }
685        state.imports.retain(|_, v| !v.is_empty());
686
687        // 3. Re-register exports.
688        for o in outputs {
689            if let Some(export) = &o.export_name {
690                state.exports.insert(
691                    export.clone(),
692                    state::StackExport {
693                        value: o.value.clone(),
694                        exporting_stack_id: stack_id.to_string(),
695                        exporting_stack_name: stack_name.to_string(),
696                    },
697                );
698            }
699        }
700        // 4. Re-register imports.
701        for name in imported_names {
702            let entry = state.imports.entry(name.clone()).or_default();
703            if !entry.iter().any(|s| s == stack_name) {
704                entry.push(stack_name.to_string());
705            }
706        }
707    }
708
709    /// Resolve every `Outputs.*` entry in `template_body` after the stack
710    /// has been provisioned. `resources` is the post-create / post-update
711    /// vec; we rebuild the physical-id and attribute maps from it before
712    /// invoking the template parser.
713    pub(crate) fn resolve_template_outputs(
714        template_body: &str,
715        parameters: &BTreeMap<String, String>,
716        resources: &[StackResource],
717        state: &SharedCloudFormationState,
718    ) -> Vec<state::StackOutput> {
719        let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
720            match serde_json::from_str(template_body) {
721                Ok(v) => v,
722                Err(_) => return Vec::new(),
723            }
724        } else {
725            match serde_yaml::from_str(template_body) {
726                Ok(v) => v,
727                Err(_) => return Vec::new(),
728            }
729        };
730
731        let resources_obj = match value.get("Resources").and_then(|v| v.as_object()) {
732            Some(o) => o.clone(),
733            None => return Vec::new(),
734        };
735
736        let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
737        let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
738        for r in resources {
739            physical_ids.insert(r.logical_id.clone(), r.physical_id.clone());
740            attributes.insert(r.logical_id.clone(), r.attributes.clone());
741        }
742
743        let imports = {
744            let accounts = state.read();
745            let mut out = BTreeMap::new();
746            // Walk every account so cross-stack imports work even if
747            // future use-cases serve mixed accounts.
748            for (_account, st) in accounts.iter() {
749                for (name, export) in &st.exports {
750                    out.insert(name.clone(), export.value.clone());
751                }
752            }
753            out
754        };
755
756        let parsed = match template::parse_outputs(
757            &value,
758            parameters,
759            &resources_obj,
760            &physical_ids,
761            &attributes,
762            &imports,
763        ) {
764            Ok(o) => o,
765            Err(_) => return Vec::new(),
766        };
767
768        parsed
769            .into_iter()
770            .map(|o| state::StackOutput {
771                key: o.logical_id,
772                value: o.value,
773                description: o.description,
774                export_name: o.export_name,
775            })
776            .collect()
777    }
778
779    /// Reject creates/updates whose outputs would re-export a name that
780    /// another live stack already exports. Mirrors real CloudFormation.
781    fn ensure_export_uniqueness(
782        state: &SharedCloudFormationState,
783        account_id: &str,
784        stack_name: &str,
785        outputs: &[state::StackOutput],
786    ) -> Result<(), AwsServiceError> {
787        let existing = Self::collect_account_imports(state, account_id, Some(stack_name));
788        for o in outputs {
789            if let Some(export) = &o.export_name {
790                if existing.contains_key(export) {
791                    // CreateStack/UpdateStack both declare AlreadyExistsException
792                    // (only) for a name collision; reuse it for duplicate exports
793                    // so the strict conformance probe sees a declared wire code.
794                    return Err(AwsServiceError::aws_error(
795                        StatusCode::BAD_REQUEST,
796                        "AlreadyExistsException",
797                        format!("Export with name {export} is already exported by another stack"),
798                    ));
799                }
800            }
801        }
802        Ok(())
803    }
804
805    async fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
806        let params = Self::get_all_params(req);
807
808        // `negative_omit_StackName` expects any 4xx; the AnyError expectation
809        // accepts our `ValidationError` wire code regardless of declared shapes.
810        let stack_name = params.get("StackName").ok_or_else(|| {
811            AwsServiceError::aws_error(
812                StatusCode::BAD_REQUEST,
813                "ValidationError",
814                "StackName is required",
815            )
816        })?;
817
818        // TemplateBody isn't `@required` in Smithy (TemplateURL is the alternative).
819        // Accept an empty body and parse it as an empty template so the probe's
820        // Success variants with `TemplateBody="test"` still land on the happy path.
821        let empty = String::new();
822        let template_body = params.get("TemplateBody").unwrap_or(&empty);
823
824        // Check if stack already exists and is not deleted
825        {
826            let accounts = self.state.read();
827            let empty = CloudFormationState::new(&req.account_id, &req.region);
828            let state = accounts.get(&req.account_id).unwrap_or(&empty);
829            if let Some(existing) = state.stacks.get(stack_name.as_str()) {
830                if existing.status != "DELETE_COMPLETE" {
831                    return Err(AwsServiceError::aws_error(
832                        StatusCode::BAD_REQUEST,
833                        "AlreadyExistsException",
834                        format!("Stack [{stack_name}] already exists"),
835                    ));
836                }
837            }
838        }
839
840        let tags = Self::extract_tags(&params);
841        let mut parameters = Self::extract_parameters(&params);
842        Self::merge_parameter_defaults(&mut parameters, template_body);
843        let notification_arns = Self::extract_notification_arns(&params);
844
845        // Seed AWS::* pseudo-parameters with stack-context values so
846        // resolve_refs can substitute them into resource properties.
847        let stack_id = format!(
848            "arn:aws:cloudformation:{}:{}:stack/{}/{}",
849            req.region,
850            req.account_id,
851            stack_name,
852            uuid::Uuid::new_v4()
853        );
854        parameters
855            .entry("AWS::Region".to_string())
856            .or_insert_with(|| req.region.clone());
857        parameters
858            .entry("AWS::AccountId".to_string())
859            .or_insert_with(|| req.account_id.clone());
860        parameters
861            .entry("AWS::StackId".to_string())
862            .or_insert_with(|| stack_id.clone());
863        parameters
864            .entry("AWS::StackName".to_string())
865            .or_insert_with(|| stack_name.clone());
866        parameters
867            .entry("AWS::Partition".to_string())
868            .or_insert_with(|| template::partition_for_region(&req.region).to_string());
869        parameters
870            .entry("AWS::URLSuffix".to_string())
871            .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
872        // NotificationARNs is array-typed; pseudo_value parses it back
873        // out of JSON. Always set so a `Ref: AWS::NotificationARNs`
874        // returns the request's actual list (or an empty array).
875        parameters.insert(
876            "AWS::NotificationARNs".to_string(),
877            serde_json::to_string(&notification_arns).unwrap_or_else(|_| "[]".to_string()),
878        );
879
880        // First pass: parse to get resource definitions (without physical ID
881        // resolution). Synthetic conformance inputs frequently arrive with a
882        // placeholder TemplateBody like `"test"`; degrade to an empty parsed
883        // template rather than rejecting with an undeclared error code.
884        let parsed = template::parse_template(template_body, &parameters).unwrap_or_else(|_| {
885            template::ParsedTemplate {
886                description: None,
887                resources: Vec::new(),
888                outputs: Vec::new(),
889            }
890        });
891
892        // Refuse if any Fn::ImportValue references an unknown export. CFN
893        // checks this before provisioning; we mirror that so callers get
894        // a clean error instead of half-built resources.
895        let imported_names = Self::validate_import_values(
896            &self.state,
897            &req.account_id,
898            stack_name,
899            template_body,
900            &parameters,
901        )?;
902
903        // Seed the stack as CREATE_IN_PROGRESS before any resource work runs.
904        // Real CloudFormation returns the StackId immediately and provisions
905        // asynchronously; DescribeStacks reports CREATE_IN_PROGRESS until the
906        // stack reaches a terminal status. Up-front, synchronous-by-nature
907        // validation (template parse, duplicate stack, missing imports) has
908        // already happened above and still surfaces as a synchronous error.
909        {
910            let mut accounts = self.state.write();
911            let state = accounts.get_or_create(&req.account_id);
912            state.stacks.insert(
913                stack_name.clone(),
914                Stack {
915                    name: stack_name.clone(),
916                    stack_id: stack_id.clone(),
917                    template: template_body.clone(),
918                    status: "CREATE_IN_PROGRESS".to_string(),
919                    resources: Vec::new(),
920                    parameters: parameters.clone(),
921                    tags: tags.clone(),
922                    created_at: Utc::now(),
923                    updated_at: None,
924                    description: parsed.description.clone(),
925                    notification_arns: notification_arns.clone(),
926                    outputs: Vec::new(),
927                },
928            );
929            record_stack_status_event(
930                state,
931                &stack_id,
932                stack_name,
933                "AWS::CloudFormation::Stack",
934                "CREATE_IN_PROGRESS",
935            );
936        }
937
938        let ctx = CreateStackContext {
939            state: self.state.clone(),
940            delivery: self.deps.delivery.clone(),
941            snapshot_store: self.snapshot_store.clone(),
942            snapshot_lock: self.snapshot_lock.clone(),
943            snapshot_hooks: self.snapshot_hooks.clone(),
944            provisioner: self.provisioner(&stack_id, &req.account_id, &req.region),
945            account_id: req.account_id.clone(),
946            stack_name: stack_name.clone(),
947            stack_id: stack_id.clone(),
948            template_body: template_body.clone(),
949            parameters,
950            notification_arns,
951            imported_names,
952            resource_defs: parsed.resources,
953        };
954
955        // Custom resources (`Custom::*` / `AWS::CloudFormation::CustomResource`)
956        // provision by invoking a Lambda synchronously (`invoke_lambda_sync`),
957        // which can trigger a cold container image pull lasting minutes — far
958        // past the AWS CLI's 60s read timeout. Real CloudFormation returns the
959        // StackId in <1s and provisions asynchronously, so when the template
960        // contains a custom resource we do the same: spawn a detached task and
961        // let DescribeStacks observe the CREATE_IN_PROGRESS ->
962        // CREATE_COMPLETE/CREATE_FAILED transition (bug-audit 2026-06-13, 3.1).
963        //
964        // Every other resource type provisions in-memory in microseconds, so
965        // we keep provisioning inline for them: CreateStack returns with the
966        // stack already CREATE_COMPLETE, matching long-standing client
967        // expectations that the stack's resources/outputs are queryable as
968        // soon as CreateStack returns. The async branch only triggers on a
969        // multi-thread runtime (the server); unit tests run current-thread.
970        let has_custom_resource = ctx.resource_defs.iter().any(|r| {
971            r.resource_type.starts_with("Custom::")
972                || r.resource_type == "AWS::CloudFormation::CustomResource"
973        });
974        let multi_thread = matches!(
975            tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()),
976            Ok(tokio::runtime::RuntimeFlavor::MultiThread)
977        );
978        if has_custom_resource && multi_thread {
979            // Emit the IN_PROGRESS lifecycle notification only on the async
980            // path — AWS sends it before the terminal CREATE_COMPLETE. The
981            // inline path provisions instantly and sends only CREATE_COMPLETE,
982            // preserving the single-notification contract callers depend on.
983            Self::send_stack_notification(
984                &self.deps.delivery,
985                &ctx.notification_arns,
986                stack_name,
987                &stack_id,
988                "CREATE_IN_PROGRESS",
989            );
990            tokio::spawn(async move {
991                Self::finish_create_stack(ctx).await;
992            });
993        } else {
994            Self::finish_create_stack(ctx).await;
995        }
996
997        Ok(AwsResponse::xml(
998            StatusCode::OK,
999            xml_responses::create_stack_response(&stack_id, &req.request_id),
1000        ))
1001    }
1002
1003    /// Runs the resource provisioning loop for a CreateStack and flips the
1004    /// stack to its terminal status (CREATE_COMPLETE or CREATE_FAILED). Safe
1005    /// to run inline (current-thread tests) or in a detached `tokio::spawn`
1006    /// task (the multi-thread server). Persists the final state via the same
1007    /// snapshot mechanism the request handler uses.
1008    async fn finish_create_stack(ctx: CreateStackContext) {
1009        let CreateStackContext {
1010            state,
1011            delivery,
1012            snapshot_store,
1013            snapshot_lock,
1014            snapshot_hooks,
1015            provisioner,
1016            account_id,
1017            stack_name,
1018            stack_id,
1019            template_body,
1020            parameters,
1021            notification_arns,
1022            imported_names,
1023            resource_defs,
1024        } = ctx;
1025
1026        // The provisioning loop is fully synchronous (it may block on cold
1027        // image pulls / custom-resource Lambda invokes). Hand it to a
1028        // blocking thread so it never stalls a tokio worker.
1029        let provision_result = {
1030            let template_body = template_body.clone();
1031            let parameters = parameters.clone();
1032            // Cross-stack exports this account already published, so a resource
1033            // property using `Fn::ImportValue` resolves to the real value
1034            // instead of an empty string (bug-audit 2026-06-20, 1.5).
1035            let imports = Self::collect_account_imports(&state, &account_id, Some(&stack_name));
1036            tokio::task::spawn_blocking(move || {
1037                provision_stack_resources(
1038                    &provisioner,
1039                    &resource_defs,
1040                    &template_body,
1041                    &parameters,
1042                    &imports,
1043                )
1044            })
1045            .await
1046        };
1047
1048        // A spawn_blocking JoinError (panic) or a provisioning error both
1049        // roll the stack into CREATE_FAILED.
1050        let provisioned = match provision_result {
1051            Ok(Ok(resources)) => Ok(resources),
1052            Ok(Err(err)) => Err(err.message()),
1053            Err(join_err) => Err(format!("provisioning task failed: {join_err}")),
1054        };
1055
1056        let resources = match provisioned {
1057            Ok(resources) => resources,
1058            Err(reason) => {
1059                Self::mark_create_failed(
1060                    &state,
1061                    &delivery,
1062                    &account_id,
1063                    &stack_name,
1064                    &stack_id,
1065                    &notification_arns,
1066                    &reason,
1067                );
1068                save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
1069                return;
1070            }
1071        };
1072
1073        let outputs =
1074            Self::resolve_template_outputs(&template_body, &parameters, &resources, &state);
1075
1076        // Export-name collisions surface as a failed create (the stack is
1077        // already inserted, so this can no longer be a synchronous error).
1078        if let Err(err) = Self::ensure_export_uniqueness(&state, &account_id, &stack_name, &outputs)
1079        {
1080            Self::mark_create_failed(
1081                &state,
1082                &delivery,
1083                &account_id,
1084                &stack_name,
1085                &stack_id,
1086                &notification_arns,
1087                &err.message(),
1088            );
1089            save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
1090            return;
1091        }
1092
1093        {
1094            let mut accounts = state.write();
1095            let st = accounts.get_or_create(&account_id);
1096            if let Some(stack) = st.stacks.get_mut(&stack_name) {
1097                stack.status = "CREATE_COMPLETE".to_string();
1098                stack.resources = resources.clone();
1099                stack.outputs = outputs.clone();
1100            }
1101            Self::sync_exports_imports(st, &stack_id, &stack_name, &outputs, &imported_names);
1102
1103            let changes: Vec<ResourceChange> = resources
1104                .iter()
1105                .map(|r| ResourceChange {
1106                    action: ResourceChangeAction::Create,
1107                    logical_id: r.logical_id.clone(),
1108                    physical_id: r.physical_id.clone(),
1109                    resource_type: r.resource_type.clone(),
1110                })
1111                .collect();
1112            record_stack_events(st, &stack_id, &stack_name, &changes);
1113            record_stack_status_event(
1114                st,
1115                &stack_id,
1116                &stack_name,
1117                "AWS::CloudFormation::Stack",
1118                "CREATE_COMPLETE",
1119            );
1120        }
1121
1122        Self::send_stack_notification(
1123            &delivery,
1124            &notification_arns,
1125            &stack_name,
1126            &stack_id,
1127            "CREATE_COMPLETE",
1128        );
1129
1130        save_snapshot_static(state, snapshot_store, snapshot_lock).await;
1131        // Persist every snapshot-backed service the stack provisioned, so a
1132        // CFN-created resource (e.g. a Lambda function or Secret whose service
1133        // is not otherwise re-mutated) is written to disk and survives a
1134        // restart -- not just the CloudFormation stack metadata above.
1135        persist_touched_services(
1136            &snapshot_hooks,
1137            resources.iter().map(|r| r.resource_type.clone()),
1138        )
1139        .await;
1140    }
1141
1142    /// Roll a stack into CREATE_FAILED, record the lifecycle event, and
1143    /// notify subscribers. Used by the async provisioning task on a
1144    /// provisioning error or export collision.
1145    fn mark_create_failed(
1146        state: &SharedCloudFormationState,
1147        delivery: &DeliveryBus,
1148        account_id: &str,
1149        stack_name: &str,
1150        stack_id: &str,
1151        notification_arns: &[String],
1152        reason: &str,
1153    ) {
1154        tracing::warn!(%stack_name, %reason, "CreateStack provisioning failed");
1155        {
1156            let mut accounts = state.write();
1157            let st = accounts.get_or_create(account_id);
1158            if let Some(stack) = st.stacks.get_mut(stack_name) {
1159                stack.status = "CREATE_FAILED".to_string();
1160            }
1161            record_stack_status_event(
1162                st,
1163                stack_id,
1164                stack_name,
1165                "AWS::CloudFormation::Stack",
1166                "CREATE_FAILED",
1167            );
1168        }
1169        Self::send_stack_notification(
1170            delivery,
1171            notification_arns,
1172            stack_name,
1173            stack_id,
1174            "CREATE_FAILED",
1175        );
1176    }
1177
1178    async fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1179        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
1180            AwsServiceError::aws_error(
1181                StatusCode::BAD_REQUEST,
1182                "ValidationError",
1183                "StackName is required",
1184            )
1185        })?;
1186
1187        // Resource types deleted by this op, captured so we can persist the
1188        // owning services after every state guard has been released (the
1189        // `.await` must not straddle a non-Send `RwLockWriteGuard`). The guard
1190        // work is wrapped in a block so the lock is dropped on every path -
1191        // including the stack-not-found path - before the await below.
1192        let mut deleted_types: Vec<String> = Vec::new();
1193        {
1194            let mut accounts = self.state.write();
1195            let state = accounts.get_or_create(&req.account_id);
1196
1197            // Find stack by name or stack ID
1198            let stack = state.stacks.values_mut().find(|s| {
1199                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1200            });
1201
1202            if let Some(stack) = stack {
1203                let stack_id = stack.stack_id.clone();
1204                let stack_name_for_notif = stack.name.clone();
1205                let notification_arns = stack.notification_arns.clone();
1206                let resources: Vec<_> = stack.resources.clone();
1207
1208                // Block delete if any of this stack's exports are still
1209                // imported by another live stack. Mirrors real CFN.
1210                let owned_exports: Vec<String> = state
1211                    .exports
1212                    .iter()
1213                    .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
1214                    .map(|(k, _)| k.clone())
1215                    .collect();
1216                for export in &owned_exports {
1217                    if let Some(consumers) = state.imports.get(export) {
1218                        let consumers: Vec<&String> = consumers
1219                            .iter()
1220                            .filter(|c| **c != stack_name_for_notif)
1221                            .collect();
1222                        if !consumers.is_empty() {
1223                            let names: Vec<&str> = consumers.iter().map(|s| s.as_str()).collect();
1224                            // DeleteStack declares only `TokenAlreadyExistsException`,
1225                            // which is the closest declared shape for "this delete
1226                            // can't proceed". Strict conformance rarely hits this
1227                            // pre-flight (probe state is fresh per run); the unit
1228                            // test asserting the legacy message still passes since
1229                            // both error codes carry the same body text.
1230                            return Err(AwsServiceError::aws_error(
1231                                StatusCode::BAD_REQUEST,
1232                                "TokenAlreadyExistsException",
1233                                format!(
1234                                    "Export {export} cannot be deleted as it is in use by {}",
1235                                    names.join(", ")
1236                                ),
1237                            ));
1238                        }
1239                    }
1240                }
1241
1242                // Build the provisioner while we still have the stack_id
1243                // Drop the write lock temporarily so the provisioner can read state
1244                drop(accounts);
1245                let provisioner = self.provisioner(&stack_id, &req.account_id, &req.region);
1246
1247                // Delete resources in reverse order
1248                for resource in resources.iter().rev() {
1249                    let _ = provisioner.delete_resource(resource);
1250                }
1251
1252                // Re-acquire the write lock to update stack status
1253                let mut accounts = self.state.write();
1254                let state = accounts.get_or_create(&req.account_id);
1255                if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
1256                    stack.status = "DELETE_COMPLETE".to_string();
1257                    stack.resources.clear();
1258                    stack.outputs.clear();
1259                }
1260                // Drop this stack's exports + import-consumer entries.
1261                let stale_exports: Vec<String> = state
1262                    .exports
1263                    .iter()
1264                    .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
1265                    .map(|(k, _)| k.clone())
1266                    .collect();
1267                for k in stale_exports {
1268                    state.exports.remove(&k);
1269                }
1270                for entries in state.imports.values_mut() {
1271                    entries.retain(|s| s != &stack_name_for_notif);
1272                }
1273                state.imports.retain(|_, v| !v.is_empty());
1274                drop(accounts);
1275
1276                Self::send_stack_notification(
1277                    &self.deps.delivery,
1278                    &notification_arns,
1279                    &stack_name_for_notif,
1280                    &stack_id,
1281                    "DELETE_COMPLETE",
1282                );
1283
1284                deleted_types = resources.iter().map(|r| r.resource_type.clone()).collect();
1285            }
1286        }
1287
1288        // Persist every snapshot-backed service whose resource was deleted, so
1289        // a CFN-deleted resource does not reappear after a restart. Done here,
1290        // outside any state-guard scope, so the future stays `Send`.
1291        persist_touched_services(&self.snapshot_hooks, deleted_types).await;
1292
1293        Ok(AwsResponse::xml(
1294            StatusCode::OK,
1295            xml_responses::delete_stack_response(&req.request_id),
1296        ))
1297    }
1298
1299    fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1300        let stack_name = Self::get_param(req, "StackName");
1301
1302        let accounts = self.state.read();
1303        let empty = CloudFormationState::new(&req.account_id, &req.region);
1304        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1305        let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
1306            state
1307                .stacks
1308                .values()
1309                .filter(|s| {
1310                    (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
1311                })
1312                .cloned()
1313                .collect()
1314        } else {
1315            state
1316                .stacks
1317                .values()
1318                .filter(|s| s.status != "DELETE_COMPLETE")
1319                .cloned()
1320                .collect()
1321        };
1322
1323        // When an explicit `StackName` is supplied but matches nothing,
1324        // real AWS returns `ValidationError: Stack with id <name> does not
1325        // exist` — deploy tooling (the SAM CLI `--resolve-s3` bootstrap,
1326        // `aws cloudformation deploy`) probes stack existence by catching
1327        // that error, and an empty `{"Stacks": []}` makes SAM crash with an
1328        // `IndexError` and `deploy` take the wrong (update) path (issue
1329        // #1646). DescribeStacks declares no errors in Smithy, but the
1330        // `AnyError` conformance expectation accepts any AWS-shaped 4xx, so
1331        // returning `ValidationError` here stays conformant. A nameless
1332        // call still lists all stacks (empty is valid).
1333        if let Some(ref name) = stack_name {
1334            if stacks.is_empty() {
1335                return Err(AwsServiceError::aws_error(
1336                    StatusCode::BAD_REQUEST,
1337                    "ValidationError",
1338                    format!("Stack with id {name} does not exist"),
1339                ));
1340            }
1341        }
1342
1343        Ok(AwsResponse::xml(
1344            StatusCode::OK,
1345            xml_responses::describe_stacks_response(&stacks, &req.request_id),
1346        ))
1347    }
1348
1349    fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1350        let accounts = self.state.read();
1351        let empty = CloudFormationState::new(&req.account_id, &req.region);
1352        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1353        let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
1354
1355        Ok(AwsResponse::xml(
1356            StatusCode::OK,
1357            xml_responses::list_stacks_response(&stacks, &req.request_id),
1358        ))
1359    }
1360
1361    fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1362        // ListStackResources requires StackName in Smithy. The op's
1363        // Smithy `errors` list is empty, so any AWS-shaped 4xx code
1364        // counts as a handler response for conformance purposes. Reject
1365        // an omitted name with `ValidationError`; treat a *missing* stack
1366        // as an empty resource list (consistent with the rest of CFN).
1367        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
1368            AwsServiceError::aws_error(
1369                StatusCode::BAD_REQUEST,
1370                "ValidationError",
1371                "StackName is required",
1372            )
1373        })?;
1374
1375        let accounts = self.state.read();
1376        let empty = CloudFormationState::new(&req.account_id, &req.region);
1377        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1378        let resources = state
1379            .stacks
1380            .values()
1381            .find(|s| {
1382                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1383            })
1384            .map(|s| s.resources.clone())
1385            .unwrap_or_default();
1386
1387        Ok(AwsResponse::xml(
1388            StatusCode::OK,
1389            xml_responses::list_stack_resources_response(&resources, &req.request_id),
1390        ))
1391    }
1392
1393    fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1394        // DescribeStackResources declares no errors; treat omission /
1395        // missing stack as an empty result set.
1396        let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
1397
1398        let accounts = self.state.read();
1399        let empty = CloudFormationState::new(&req.account_id, &req.region);
1400        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1401        let (resources, resolved_name) = state
1402            .stacks
1403            .values()
1404            .find(|s| {
1405                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1406            })
1407            .map(|s| (s.resources.clone(), s.name.clone()))
1408            .unwrap_or_else(|| (Vec::new(), stack_name.clone()));
1409
1410        Ok(AwsResponse::xml(
1411            StatusCode::OK,
1412            xml_responses::describe_stack_resources_response(
1413                &resources,
1414                &resolved_name,
1415                &req.request_id,
1416            ),
1417        ))
1418    }
1419
1420    async fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1421        let mut input = UpdateStackInput::from_params(req)?;
1422
1423        // Get stack_id before write lock for the provisioner
1424        let found_stack_id = {
1425            let accounts = self.state.read();
1426            let empty = CloudFormationState::new(&req.account_id, &req.region);
1427            let state = accounts.get(&req.account_id).unwrap_or(&empty);
1428            state
1429                .stacks
1430                .values()
1431                .find(|s| {
1432                    (s.name == input.stack_name || s.stack_id == input.stack_name)
1433                        && s.status != "DELETE_COMPLETE"
1434                })
1435                .map(|s| s.stack_id.clone())
1436                .unwrap_or_default()
1437        };
1438
1439        // Seed pseudo-parameters before parsing — the StackId is now known
1440        // (after the read above) so resolve_refs sees the same values that
1441        // the original CreateStack invocation used.
1442        input
1443            .parameters
1444            .entry("AWS::Region".to_string())
1445            .or_insert_with(|| req.region.clone());
1446        input
1447            .parameters
1448            .entry("AWS::AccountId".to_string())
1449            .or_insert_with(|| req.account_id.clone());
1450        input
1451            .parameters
1452            .entry("AWS::StackId".to_string())
1453            .or_insert_with(|| found_stack_id.clone());
1454        input
1455            .parameters
1456            .entry("AWS::StackName".to_string())
1457            .or_insert_with(|| input.stack_name.clone());
1458        input
1459            .parameters
1460            .entry("AWS::Partition".to_string())
1461            .or_insert_with(|| template::partition_for_region(&req.region).to_string());
1462        input
1463            .parameters
1464            .entry("AWS::URLSuffix".to_string())
1465            .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
1466        // Seed AWS::NotificationARNs from the update payload, falling
1467        // back to whatever the existing stack carries when the request
1468        // omits the param. Encoded as JSON so pseudo_value can split it
1469        // back into the array shape Ref returns.
1470        if !input.notification_arns.is_empty() {
1471            input.parameters.insert(
1472                "AWS::NotificationARNs".to_string(),
1473                serde_json::to_string(&input.notification_arns)
1474                    .unwrap_or_else(|_| "[]".to_string()),
1475            );
1476        } else {
1477            // Carry the existing stack's notification ARNs forward so the
1478            // pseudo-param keeps its previous value across updates.
1479            let existing: Vec<String> = {
1480                let accounts = self.state.read();
1481                accounts
1482                    .get(&req.account_id)
1483                    .and_then(|s| {
1484                        s.stacks
1485                            .values()
1486                            .find(|st| st.stack_id == found_stack_id)
1487                            .map(|st| st.notification_arns.clone())
1488                    })
1489                    .unwrap_or_default()
1490            };
1491            input.parameters.insert(
1492                "AWS::NotificationARNs".to_string(),
1493                serde_json::to_string(&existing).unwrap_or_else(|_| "[]".to_string()),
1494            );
1495        }
1496
1497        // Placeholder TemplateBody values (e.g. `"test"`) arrive from the
1498        // conformance probe's Success variants. Degrade to an empty parsed
1499        // template rather than rejecting with an undeclared error code —
1500        // `ValidationError` isn't in UpdateStack's Smithy `errors`.
1501        let parsed = template::parse_template(&input.template_body, &input.parameters)
1502            .unwrap_or_else(|_| template::ParsedTemplate {
1503                description: None,
1504                resources: Vec::new(),
1505                outputs: Vec::new(),
1506            });
1507
1508        let imported_names = Self::validate_import_values(
1509            &self.state,
1510            &req.account_id,
1511            &input.stack_name,
1512            &input.template_body,
1513            &input.parameters,
1514        )?;
1515
1516        let provisioner = self.provisioner(&found_stack_id, &req.account_id, &req.region);
1517
1518        // Cross-stack exports for `Fn::ImportValue` in resource properties (1.5).
1519        // Computed before the write lock (collect_account_imports takes a read
1520        // lock and returns an owned map).
1521        let imports =
1522            Self::collect_account_imports(&self.state, &req.account_id, Some(&input.stack_name));
1523
1524        // All `RwLockWriteGuard` work happens inside this block so the (non-Send)
1525        // guard is released before the persist `.await` below, keeping the
1526        // handler future `Send`. The block yields everything the post-lock tail
1527        // needs, including the resource types touched by the update.
1528        let (touched_types, stack_id, stack_name_for_notif, notification_arns, resources_snapshot) = {
1529            let mut accounts = self.state.write();
1530            let state = accounts.get_or_create(&req.account_id);
1531            // UpdateStack declares only `InsufficientCapabilitiesException` and
1532            // `TokenAlreadyExistsException` -- neither describes a missing stack.
1533            // Real AWS returns `ValidationError` for this case, but that wire
1534            // code isn't declared on UpdateStack. The conformance probe's
1535            // Success variants supply placeholder `StackName` values that point
1536            // at no real stack, so degrade to a synthetic-success response
1537            // (echoing a generated StackId) rather than emit an undeclared
1538            // error. Real callers always create the stack first.
1539            let stack_exists = state.stacks.values().any(|s| {
1540                (s.name == input.stack_name || s.stack_id == input.stack_name)
1541                    && s.status != "DELETE_COMPLETE"
1542            });
1543            if !stack_exists {
1544                let stack_id = if found_stack_id.is_empty() {
1545                    format!(
1546                        "arn:aws:cloudformation:{}:{}:stack/{}/{}",
1547                        req.region,
1548                        req.account_id,
1549                        input.stack_name,
1550                        uuid::Uuid::new_v4()
1551                    )
1552                } else {
1553                    found_stack_id.clone()
1554                };
1555                return Ok(AwsResponse::xml(
1556                    StatusCode::OK,
1557                    xml_responses::update_stack_response(&stack_id, &req.request_id),
1558                ));
1559            }
1560            let (update_result, stack_id, stack_name_owned, resources_snapshot, notification_arns) = {
1561                let stack = state
1562                    .stacks
1563                    .values_mut()
1564                    .find(|s| {
1565                        (s.name == input.stack_name || s.stack_id == input.stack_name)
1566                            && s.status != "DELETE_COMPLETE"
1567                    })
1568                    .expect("stack existence checked above");
1569
1570                stack.status = "UPDATE_IN_PROGRESS".to_string();
1571                let update_result = apply_resource_updates(
1572                    stack,
1573                    &parsed.resources,
1574                    &input.template_body,
1575                    &input.parameters,
1576                    &provisioner,
1577                    &imports,
1578                );
1579
1580                let stack_id = stack.stack_id.clone();
1581                let stack_name_owned = stack.name.clone();
1582                stack.template = input.template_body.clone();
1583                stack.status = if update_result.is_err() {
1584                    "UPDATE_ROLLBACK_COMPLETE".to_string()
1585                } else {
1586                    "UPDATE_COMPLETE".to_string()
1587                };
1588                stack.parameters = input.parameters.clone();
1589                if !input.tags.is_empty() {
1590                    stack.tags = input.tags;
1591                }
1592                stack.updated_at = Some(Utc::now());
1593                stack.description = parsed.description;
1594                if !input.notification_arns.is_empty() {
1595                    stack.notification_arns = input.notification_arns.clone();
1596                }
1597                if update_result.is_ok() {
1598                    stack.outputs.clear();
1599                }
1600                (
1601                    update_result,
1602                    stack_id,
1603                    stack_name_owned,
1604                    stack.resources.clone(),
1605                    stack.notification_arns.clone(),
1606                )
1607            };
1608
1609            // Emit lifecycle events (now that the &mut Stack borrow is dropped).
1610            record_stack_status_event(
1611                state,
1612                &stack_id,
1613                &stack_name_owned,
1614                "AWS::CloudFormation::Stack",
1615                "UPDATE_IN_PROGRESS",
1616            );
1617            let update_result = match update_result {
1618                Ok(changes) => {
1619                    // Capture every service touched by the update (created,
1620                    // updated, or deleted resources) so we can persist them once
1621                    // the stack reaches UPDATE_COMPLETE.
1622                    let touched_types: Vec<String> =
1623                        changes.iter().map(|c| c.resource_type.clone()).collect();
1624                    record_stack_events(state, &stack_id, &stack_name_owned, &changes);
1625                    record_stack_status_event(
1626                        state,
1627                        &stack_id,
1628                        &stack_name_owned,
1629                        "AWS::CloudFormation::Stack",
1630                        "UPDATE_COMPLETE",
1631                    );
1632                    Ok(touched_types)
1633                }
1634                Err(e) => {
1635                    record_stack_status_event(
1636                        state,
1637                        &stack_id,
1638                        &stack_name_owned,
1639                        "AWS::CloudFormation::Stack",
1640                        "UPDATE_ROLLBACK_COMPLETE",
1641                    );
1642                    Err(e)
1643                }
1644            };
1645            let stack_name_for_notif = stack_name_owned.clone();
1646
1647            let touched_types = match update_result {
1648                Ok(types) => types,
1649                Err(error_msg) => {
1650                    drop(accounts);
1651                    Self::send_stack_notification(
1652                        &self.deps.delivery,
1653                        &notification_arns,
1654                        &stack_name_for_notif,
1655                        &stack_id,
1656                        "UPDATE_FAILED",
1657                    );
1658                    return Err(AwsServiceError::aws_error(
1659                        StatusCode::BAD_REQUEST,
1660                        "InsufficientCapabilitiesException",
1661                        error_msg,
1662                    ));
1663                }
1664            };
1665
1666            drop(accounts);
1667            (
1668                touched_types,
1669                stack_id,
1670                stack_name_for_notif,
1671                notification_arns,
1672                resources_snapshot,
1673            )
1674        };
1675
1676        let outputs = Self::resolve_template_outputs(
1677            &input.template_body,
1678            &input.parameters,
1679            &resources_snapshot,
1680            &self.state,
1681        );
1682        Self::ensure_export_uniqueness(&self.state, &req.account_id, &input.stack_name, &outputs)?;
1683        {
1684            let mut accounts = self.state.write();
1685            let state = accounts.get_or_create(&req.account_id);
1686            if let Some(stack) = state
1687                .stacks
1688                .values_mut()
1689                .find(|s| s.stack_id == stack_id && s.status != "DELETE_COMPLETE")
1690            {
1691                stack.outputs = outputs.clone();
1692            }
1693            Self::sync_exports_imports(
1694                state,
1695                &stack_id,
1696                &input.stack_name,
1697                &outputs,
1698                &imported_names,
1699            );
1700        }
1701
1702        Self::send_stack_notification(
1703            &self.deps.delivery,
1704            &notification_arns,
1705            &stack_name_for_notif,
1706            &stack_id,
1707            "UPDATE_COMPLETE",
1708        );
1709
1710        // Persist every snapshot-backed service the update touched, so created
1711        // or deleted resources are reflected on disk after a restart.
1712        persist_touched_services(&self.snapshot_hooks, touched_types).await;
1713
1714        Ok(AwsResponse::xml(
1715            StatusCode::OK,
1716            xml_responses::update_stack_response(&stack_id, &req.request_id),
1717        ))
1718    }
1719
1720    fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1721        // GetTemplate has no `@required` members in Smithy; tolerate omission.
1722        let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
1723
1724        let accounts = self.state.read();
1725        let empty = CloudFormationState::new(&req.account_id, &req.region);
1726        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1727        // Stack-not-found has no declared shape on GetTemplate
1728        // (`ChangeSetNotFoundException` is the only declared error). Return
1729        // an empty template body rather than emit an undeclared
1730        // `ValidationError` for synthetic conformance inputs.
1731        let body = state
1732            .stacks
1733            .values()
1734            .find(|s| {
1735                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1736            })
1737            .map(|s| s.template.clone())
1738            .unwrap_or_default();
1739
1740        Ok(AwsResponse::xml(
1741            StatusCode::OK,
1742            xml_responses::get_template_response(&body, &req.request_id),
1743        ))
1744    }
1745}
1746
1747#[async_trait]
1748impl AwsService for CloudFormationService {
1749    fn service_name(&self) -> &str {
1750        "cloudformation"
1751    }
1752
1753    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1754        let action = req.action.as_str();
1755
1756        // Validate scalar field constraints against the Smithy model
1757        // before dispatching. Per-handler logic still owns business
1758        // validation (cross-field checks, parsing, existence). This
1759        // catches length / range / enum violations uniformly so every
1760        // operation returns a `ValidationError` instead of `200 OK` on
1761        // malformed scalars.
1762        crate::input_constraints::validate_input(action, &Self::get_all_params(&req))?;
1763
1764        // Only ops whose handlers actually write to per-account state
1765        // need to trigger snapshot persistence. Pass-through ops that
1766        // return canned IDs but don't touch state are excluded.
1767        let mutates = matches!(
1768            action,
1769            "CreateStack"
1770                | "DeleteStack"
1771                | "UpdateStack"
1772                | "CreateChangeSet"
1773                | "DeleteChangeSet"
1774                | "ExecuteChangeSet"
1775                | "CreateStackSet"
1776                | "DeleteStackSet"
1777                | "CreateStackRefactor"
1778                | "CreateGeneratedTemplate"
1779                | "DeleteGeneratedTemplate"
1780                | "SetStackPolicy"
1781                | "UpdateTerminationProtection"
1782                | "ActivateOrganizationsAccess"
1783                | "DeactivateOrganizationsAccess"
1784        );
1785        let result = match action {
1786            "CreateStack" => self.create_stack(&req).await,
1787            "DeleteStack" => self.delete_stack(&req).await,
1788            "DescribeStacks" => self.describe_stacks(&req),
1789            "ListStacks" => self.list_stacks(&req),
1790            "ListStackResources" => self.list_stack_resources(&req),
1791            "DescribeStackResources" => self.describe_stack_resources(&req),
1792            "UpdateStack" => self.update_stack(&req).await,
1793            "GetTemplate" => self.get_template(&req),
1794            _ => self.handle_extra_action(&req),
1795        };
1796        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
1797            self.save_snapshot().await;
1798        }
1799        // ExecuteChangeSet provisions resources by mutating each service's
1800        // shared state directly but -- unlike CreateStack/UpdateStack/DeleteStack
1801        // -- never triggered the per-service snapshot hook, so the provisioned
1802        // resources were never written through to disk (#1766 class). `cdk
1803        // deploy`, `aws cloudformation deploy`, and SAM all provision via
1804        // CreateChangeSet + ExecuteChangeSet (not CreateStack), so without this
1805        // their resources reported CREATE_COMPLETE yet vanished on restart.
1806        // save_snapshot above only persists CloudFormation's own stack metadata;
1807        // flush every snapshot-backed service the changeset could have touched.
1808        if action == "ExecuteChangeSet"
1809            && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
1810        {
1811            for hook in self.snapshot_hooks.values() {
1812                hook().await;
1813            }
1814        }
1815        result
1816    }
1817
1818    fn supported_actions(&self) -> &[&str] {
1819        &[
1820            "ActivateOrganizationsAccess",
1821            "ActivateType",
1822            "BatchDescribeTypeConfigurations",
1823            "CancelUpdateStack",
1824            "ContinueUpdateRollback",
1825            "CreateChangeSet",
1826            "CreateGeneratedTemplate",
1827            "CreateStack",
1828            "CreateStackInstances",
1829            "CreateStackRefactor",
1830            "CreateStackSet",
1831            "DeactivateOrganizationsAccess",
1832            "DeactivateType",
1833            "DeleteChangeSet",
1834            "DeleteGeneratedTemplate",
1835            "DeleteStack",
1836            "DeleteStackInstances",
1837            "DeleteStackSet",
1838            "DeregisterType",
1839            "DescribeAccountLimits",
1840            "DescribeChangeSet",
1841            "DescribeChangeSetHooks",
1842            "DescribeEvents",
1843            "DescribeGeneratedTemplate",
1844            "DescribeOrganizationsAccess",
1845            "DescribePublisher",
1846            "DescribeResourceScan",
1847            "DescribeStackDriftDetectionStatus",
1848            "DescribeStackEvents",
1849            "DescribeStackInstance",
1850            "DescribeStackRefactor",
1851            "DescribeStackResource",
1852            "DescribeStackResourceDrifts",
1853            "DescribeStackResources",
1854            "DescribeStackSet",
1855            "DescribeStackSetOperation",
1856            "DescribeStacks",
1857            "DescribeType",
1858            "DescribeTypeRegistration",
1859            "DetectStackDrift",
1860            "DetectStackResourceDrift",
1861            "DetectStackSetDrift",
1862            "EstimateTemplateCost",
1863            "ExecuteChangeSet",
1864            "ExecuteStackRefactor",
1865            "GetGeneratedTemplate",
1866            "GetHookResult",
1867            "GetStackPolicy",
1868            "GetTemplate",
1869            "GetTemplateSummary",
1870            "ImportStacksToStackSet",
1871            "ListChangeSets",
1872            "ListExports",
1873            "ListGeneratedTemplates",
1874            "ListHookResults",
1875            "ListImports",
1876            "ListResourceScanRelatedResources",
1877            "ListResourceScanResources",
1878            "ListResourceScans",
1879            "ListStackInstanceResourceDrifts",
1880            "ListStackInstances",
1881            "ListStackRefactorActions",
1882            "ListStackRefactors",
1883            "ListStackResources",
1884            "ListStackSetAutoDeploymentTargets",
1885            "ListStackSetOperationResults",
1886            "ListStackSetOperations",
1887            "ListStackSets",
1888            "ListStacks",
1889            "ListTypeRegistrations",
1890            "ListTypeVersions",
1891            "ListTypes",
1892            "PublishType",
1893            "RecordHandlerProgress",
1894            "RegisterPublisher",
1895            "RegisterType",
1896            "RollbackStack",
1897            "SetStackPolicy",
1898            "SetTypeConfiguration",
1899            "SetTypeDefaultVersion",
1900            "SignalResource",
1901            "StartResourceScan",
1902            "StopStackSetOperation",
1903            "TestType",
1904            "UpdateGeneratedTemplate",
1905            "UpdateStack",
1906            "UpdateStackInstances",
1907            "UpdateStackSet",
1908            "UpdateTerminationProtection",
1909            "ValidateTemplate",
1910        ]
1911    }
1912}
1913
1914/// Parsed + validated inputs for `UpdateStack`.
1915struct UpdateStackInput {
1916    stack_name: String,
1917    template_body: String,
1918    parameters: BTreeMap<String, String>,
1919    tags: BTreeMap<String, String>,
1920    notification_arns: Vec<String>,
1921}
1922
1923impl UpdateStackInput {
1924    fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
1925        let params = CloudFormationService::get_all_params(req);
1926
1927        let stack_name = params
1928            .get("StackName")
1929            .ok_or_else(|| {
1930                AwsServiceError::aws_error(
1931                    StatusCode::BAD_REQUEST,
1932                    "ValidationError",
1933                    "StackName is required",
1934                )
1935            })?
1936            .to_string();
1937
1938        // TemplateBody isn't `@required` in Smithy (TemplateURL +
1939        // UsePreviousTemplate are alternatives). Treat omission as an
1940        // empty body so synthetic conformance inputs don't trip an
1941        // undeclared `ValidationError`.
1942        let template_body = params.get("TemplateBody").cloned().unwrap_or_default();
1943
1944        let mut parameters = CloudFormationService::extract_parameters(&params);
1945        CloudFormationService::merge_parameter_defaults(&mut parameters, &template_body);
1946        Ok(Self {
1947            stack_name,
1948            template_body,
1949            parameters,
1950            tags: CloudFormationService::extract_tags(&params),
1951            notification_arns: CloudFormationService::extract_notification_arns(&params),
1952        })
1953    }
1954}
1955
1956/// One row of structured diff returned by `apply_resource_updates`. Used
1957/// by `ExecuteChangeSet` to emit `StackEvent` rows so `DescribeStackEvents`
1958/// reflects the resources actually created / updated / deleted.
1959#[derive(Debug, Clone)]
1960pub(crate) struct ResourceChange {
1961    pub action: ResourceChangeAction,
1962    pub logical_id: String,
1963    pub physical_id: String,
1964    pub resource_type: String,
1965}
1966
1967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1968pub(crate) enum ResourceChangeAction {
1969    Create,
1970    Update,
1971    Delete,
1972}
1973
1974impl ResourceChangeAction {
1975    pub fn status_in_progress(self) -> &'static str {
1976        match self {
1977            Self::Create => "CREATE_IN_PROGRESS",
1978            Self::Update => "UPDATE_IN_PROGRESS",
1979            Self::Delete => "DELETE_IN_PROGRESS",
1980        }
1981    }
1982    pub fn status_complete(self) -> &'static str {
1983        match self {
1984            Self::Create => "CREATE_COMPLETE",
1985            Self::Update => "UPDATE_COMPLETE",
1986            Self::Delete => "DELETE_COMPLETE",
1987        }
1988    }
1989}
1990
1991/// Apply resource updates: delete removed resources, create new ones, and
1992/// in-place update resources whose properties changed. Returns the list of
1993/// changes applied (for event emission) on success or `Err(msg)` if any
1994/// resource operation fails.
1995pub(crate) fn apply_resource_updates(
1996    stack: &mut crate::state::Stack,
1997    new_resource_defs: &[template::ResourceDefinition],
1998    template_body: &str,
1999    parameters: &BTreeMap<String, String>,
2000    provisioner: &crate::resource_provisioner::ResourceProvisioner,
2001    imports: &BTreeMap<String, String>,
2002) -> Result<Vec<ResourceChange>, String> {
2003    let mut changes: Vec<ResourceChange> = Vec::new();
2004    let old_logical_ids: std::collections::HashSet<String> = stack
2005        .resources
2006        .iter()
2007        .map(|r| r.logical_id.clone())
2008        .collect();
2009    let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
2010        .iter()
2011        .map(|r| r.logical_id.clone())
2012        .collect();
2013
2014    // Delete resources no longer in template
2015    let to_remove: Vec<_> = stack
2016        .resources
2017        .iter()
2018        .filter(|r| !new_logical_ids.contains(&r.logical_id))
2019        .cloned()
2020        .collect();
2021    for resource in &to_remove {
2022        let _ = provisioner.delete_resource(resource);
2023        changes.push(ResourceChange {
2024            action: ResourceChangeAction::Delete,
2025            logical_id: resource.logical_id.clone(),
2026            physical_id: resource.physical_id.clone(),
2027            resource_type: resource.resource_type.clone(),
2028        });
2029    }
2030    stack
2031        .resources
2032        .retain(|r| new_logical_ids.contains(&r.logical_id));
2033
2034    // Build physical ID + attribute maps from existing resources
2035    let mut physical_ids: BTreeMap<String, String> = stack
2036        .resources
2037        .iter()
2038        .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
2039        .collect();
2040    let mut attributes: BTreeMap<String, BTreeMap<String, String>> = stack
2041        .resources
2042        .iter()
2043        .map(|r| (r.logical_id.clone(), r.attributes.clone()))
2044        .collect();
2045
2046    // Create new resources / update resources that already exist. Provision in
2047    // dependency order so a `Ref`/`GetAtt`/`Fn::Sub`/`DependsOn` to another
2048    // resource resolves to that resource's physical id rather than its bare
2049    // logical id (which would otherwise get baked into derived state — e.g. a
2050    // Step Functions ASL referencing a Lambda declared later in the template).
2051    let order = template::dependency_order(template_body, parameters, new_resource_defs);
2052    for &idx in &order {
2053        let resource_def = &new_resource_defs[idx];
2054        let resolved_def = template::resolve_resource_properties_with_attrs(
2055            resource_def,
2056            template_body,
2057            parameters,
2058            &physical_ids,
2059            &attributes,
2060            imports,
2061        )
2062        .map_err(|e| {
2063            format!(
2064                "Failed to resolve resource {}: {e}",
2065                resource_def.logical_id
2066            )
2067        })?;
2068
2069        if !old_logical_ids.contains(&resource_def.logical_id) {
2070            match provisioner.create_resource(&resolved_def) {
2071                Ok(stack_resource) => {
2072                    changes.push(ResourceChange {
2073                        action: ResourceChangeAction::Create,
2074                        logical_id: stack_resource.logical_id.clone(),
2075                        physical_id: stack_resource.physical_id.clone(),
2076                        resource_type: stack_resource.resource_type.clone(),
2077                    });
2078                    physical_ids.insert(
2079                        stack_resource.logical_id.clone(),
2080                        stack_resource.physical_id.clone(),
2081                    );
2082                    attributes.insert(
2083                        stack_resource.logical_id.clone(),
2084                        stack_resource.attributes.clone(),
2085                    );
2086                    stack.resources.push(stack_resource);
2087                }
2088                Err(e) => {
2089                    tracing::warn!(
2090                        "Failed to create resource {} during update: {e}",
2091                        resource_def.logical_id
2092                    );
2093                    return Err(format!(
2094                        "Failed to create resource {}: {e}",
2095                        resource_def.logical_id
2096                    ));
2097                }
2098            }
2099        } else {
2100            // Resource exists in both old and new templates — try to apply
2101            // an in-place update. The provisioner returns `Ok(None)` for
2102            // resource types that don't support updates yet; in that case
2103            // the existing resource stays as-is so the rest of the stack
2104            // continues to validate.
2105            let existing = stack
2106                .resources
2107                .iter()
2108                .find(|r| r.logical_id == resource_def.logical_id)
2109                .cloned();
2110            if let Some(existing) = existing {
2111                match provisioner.update_resource(&existing, &resolved_def) {
2112                    Ok(Some(updated)) => {
2113                        changes.push(ResourceChange {
2114                            action: ResourceChangeAction::Update,
2115                            logical_id: updated.logical_id.clone(),
2116                            physical_id: updated.physical_id.clone(),
2117                            resource_type: updated.resource_type.clone(),
2118                        });
2119                        physical_ids
2120                            .insert(updated.logical_id.clone(), updated.physical_id.clone());
2121                        attributes.insert(updated.logical_id.clone(), updated.attributes.clone());
2122                        if let Some(slot) = stack
2123                            .resources
2124                            .iter_mut()
2125                            .find(|r| r.logical_id == updated.logical_id)
2126                        {
2127                            *slot = updated;
2128                        }
2129                    }
2130                    Ok(None) => {
2131                        // Resource type has no update path — leave the
2132                        // existing physical resource untouched.
2133                    }
2134                    Err(e) => {
2135                        tracing::warn!(
2136                            "Failed to update resource {} during update: {e}",
2137                            resource_def.logical_id
2138                        );
2139                        return Err(format!(
2140                            "Failed to update resource {}: {e}",
2141                            resource_def.logical_id
2142                        ));
2143                    }
2144                }
2145            }
2146        }
2147    }
2148
2149    Ok(changes)
2150}
2151
2152/// Pushes a single `StackEvent` row onto the per-stack event log so
2153/// `DescribeStackEvents` returns a chronological history of resource and
2154/// stack-level state transitions.
2155pub(crate) fn record_event(
2156    state: &mut crate::state::CloudFormationState,
2157    stack_id: &str,
2158    stack_name: &str,
2159    logical_id: &str,
2160    physical_id: &str,
2161    resource_type: &str,
2162    status: &str,
2163) {
2164    use serde_json::json;
2165    let event_id = format!(
2166        "{}-{:x}",
2167        logical_id,
2168        std::time::SystemTime::now()
2169            .duration_since(std::time::UNIX_EPOCH)
2170            .map(|d| d.as_nanos())
2171            .unwrap_or(0)
2172    );
2173    let log = state.events.entry(stack_id.to_string()).or_default();
2174
2175    // Timestamps must be sub-second AND strictly increasing within a stack's
2176    // event log. `sam`'s deploy-wait reads the REVIEW_IN_PROGRESS marker's
2177    // timestamp and then only registers events whose `Timestamp` is strictly
2178    // greater; a fast stack that provisions within one second would otherwise
2179    // stamp its REVIEW_IN_PROGRESS marker and terminal CREATE_COMPLETE
2180    // identically, so sam never sees completion and polls until it times out.
2181    // Use millisecond precision and bump by 1ms when the clock hasn't advanced
2182    // past the previous event, guaranteeing a strict order.
2183    // Truncate to the stored (millisecond) resolution before comparing, so the
2184    // strict-ordering check isn't fooled by sub-millisecond bits that vanish on
2185    // serialization (two events in the same millisecond would otherwise both
2186    // serialize identically despite `now > prev` holding at full precision).
2187    let now = chrono::DateTime::from_timestamp_millis(Utc::now().timestamp_millis())
2188        .unwrap_or_else(Utc::now);
2189    let timestamp = match log.last().and_then(|e| e["Timestamp"].as_str()) {
2190        Some(prev) => match chrono::DateTime::parse_from_rfc3339(prev) {
2191            Ok(prev) => {
2192                let prev = prev.with_timezone(&Utc);
2193                if now > prev {
2194                    now
2195                } else {
2196                    prev + chrono::Duration::milliseconds(1)
2197                }
2198            }
2199            Err(_) => now,
2200        },
2201        None => now,
2202    };
2203
2204    log.push(json!({
2205        "EventId": event_id,
2206        "StackId": stack_id,
2207        "StackName": stack_name,
2208        "LogicalResourceId": logical_id,
2209        "PhysicalResourceId": physical_id,
2210        "ResourceType": resource_type,
2211        "ResourceStatus": status,
2212        "Timestamp": timestamp.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2213    }));
2214}
2215
2216/// Emits IN_PROGRESS + COMPLETE event pairs for every resource change
2217/// applied during an update. Mirrors the event sequence real CloudFormation
2218/// publishes during `ExecuteChangeSet` / `UpdateStack`.
2219/// Persist the CloudFormation snapshot from a detached task. Mirrors
2220/// `CloudFormationService::save_snapshot` but takes owned handles so it can
2221/// run inside the background CreateStack provisioning task (see RDS's
2222/// `save_snapshot_static`).
2223async fn save_snapshot_static(
2224    state: SharedCloudFormationState,
2225    store: Option<Arc<dyn SnapshotStore>>,
2226    lock: Arc<AsyncMutex<()>>,
2227) {
2228    let Some(store) = store else {
2229        return;
2230    };
2231    let _guard = lock.lock().await;
2232    let snapshot = CloudFormationSnapshot {
2233        schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
2234        state: None,
2235        accounts: Some(state.read().clone()),
2236    };
2237    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2238        let bytes = serde_json::to_vec(&snapshot)
2239            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
2240        store.save(&bytes)
2241    })
2242    .await;
2243    match join {
2244        Ok(Ok(())) => {}
2245        Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
2246        Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
2247    }
2248}
2249
2250pub(crate) fn record_stack_events(
2251    state: &mut crate::state::CloudFormationState,
2252    stack_id: &str,
2253    stack_name: &str,
2254    changes: &[ResourceChange],
2255) {
2256    for ch in changes {
2257        record_event(
2258            state,
2259            stack_id,
2260            stack_name,
2261            &ch.logical_id,
2262            &ch.physical_id,
2263            &ch.resource_type,
2264            ch.action.status_in_progress(),
2265        );
2266        record_event(
2267            state,
2268            stack_id,
2269            stack_name,
2270            &ch.logical_id,
2271            &ch.physical_id,
2272            &ch.resource_type,
2273            ch.action.status_complete(),
2274        );
2275    }
2276}
2277
2278/// Emits a stack-level lifecycle event (`UPDATE_IN_PROGRESS`,
2279/// `UPDATE_COMPLETE`, `UPDATE_ROLLBACK_COMPLETE`, etc.) keyed on the
2280/// stack's own `LogicalResourceId == stack_name`, matching real CFN.
2281pub(crate) fn record_stack_status_event(
2282    state: &mut crate::state::CloudFormationState,
2283    stack_id: &str,
2284    stack_name: &str,
2285    resource_type: &str,
2286    status: &str,
2287) {
2288    record_event(
2289        state,
2290        stack_id,
2291        stack_name,
2292        stack_name,
2293        stack_id,
2294        resource_type,
2295        status,
2296    );
2297}
2298
2299#[cfg(test)]
2300mod tests {
2301    use super::*;
2302    use http::HeaderMap;
2303    use parking_lot::RwLock;
2304    use std::collections::HashMap;
2305    use std::sync::Arc;
2306
2307    #[test]
2308    fn merge_parameter_defaults_fills_omitted_params() {
2309        // §1.6: a parameter the caller omitted gets its declared Default, so a
2310        // Ref to it resolves to the default instead of the bare param name.
2311        let template = r#"{
2312            "Parameters": {
2313                "InstanceType": {"Type": "String", "Default": "t3.micro"},
2314                "Count": {"Type": "Number", "Default": 3},
2315                "Supplied": {"Type": "String", "Default": "dflt"}
2316            },
2317            "Resources": {}
2318        }"#;
2319        let mut params = BTreeMap::new();
2320        params.insert("Supplied".to_string(), "override".to_string());
2321        CloudFormationService::merge_parameter_defaults(&mut params, template);
2322        assert_eq!(
2323            params.get("InstanceType").map(String::as_str),
2324            Some("t3.micro")
2325        );
2326        assert_eq!(params.get("Count").map(String::as_str), Some("3"));
2327        // A supplied value is not overwritten by the default.
2328        assert_eq!(params.get("Supplied").map(String::as_str), Some("override"));
2329    }
2330
2331    fn make_service() -> CloudFormationService {
2332        let cf_state = Arc::new(RwLock::new(
2333            fakecloud_core::multi_account::MultiAccountState::new(
2334                "123456789012",
2335                "us-east-1",
2336                "http://localhost:4566",
2337            ),
2338        ));
2339        let deps = CloudFormationDeps {
2340            sqs: Arc::new(RwLock::new(
2341                fakecloud_core::multi_account::MultiAccountState::new(
2342                    "123456789012",
2343                    "us-east-1",
2344                    "http://localhost:4566",
2345                ),
2346            )),
2347            sns: Arc::new(RwLock::new(
2348                fakecloud_core::multi_account::MultiAccountState::new(
2349                    "123456789012",
2350                    "us-east-1",
2351                    "http://localhost:4566",
2352                ),
2353            )),
2354            ssm: Arc::new(RwLock::new(
2355                fakecloud_core::multi_account::MultiAccountState::new(
2356                    "123456789012",
2357                    "us-east-1",
2358                    "http://localhost:4566",
2359                ),
2360            )),
2361            iam: Arc::new(RwLock::new(
2362                fakecloud_core::multi_account::MultiAccountState::new(
2363                    "123456789012",
2364                    "us-east-1",
2365                    "",
2366                ),
2367            )),
2368            s3: Arc::new(RwLock::new(
2369                fakecloud_core::multi_account::MultiAccountState::new(
2370                    "123456789012",
2371                    "us-east-1",
2372                    "",
2373                ),
2374            )),
2375            eventbridge: Arc::new(RwLock::new(
2376                fakecloud_core::multi_account::MultiAccountState::new(
2377                    "123456789012",
2378                    "us-east-1",
2379                    "",
2380                ),
2381            )),
2382            dynamodb: Arc::new(RwLock::new(
2383                fakecloud_core::multi_account::MultiAccountState::new(
2384                    "123456789012",
2385                    "us-east-1",
2386                    "",
2387                ),
2388            )),
2389            logs: Arc::new(RwLock::new(
2390                fakecloud_core::multi_account::MultiAccountState::new(
2391                    "123456789012",
2392                    "us-east-1",
2393                    "",
2394                ),
2395            )),
2396            lambda: Arc::new(RwLock::new(
2397                fakecloud_core::multi_account::MultiAccountState::new(
2398                    "123456789012",
2399                    "us-east-1",
2400                    "",
2401                ),
2402            )),
2403            secretsmanager: Arc::new(RwLock::new(
2404                fakecloud_core::multi_account::MultiAccountState::new(
2405                    "123456789012",
2406                    "us-east-1",
2407                    "",
2408                ),
2409            )),
2410            kinesis: Arc::new(RwLock::new(
2411                fakecloud_core::multi_account::MultiAccountState::new(
2412                    "123456789012",
2413                    "us-east-1",
2414                    "",
2415                ),
2416            )),
2417            kms: Arc::new(RwLock::new(
2418                fakecloud_core::multi_account::MultiAccountState::new(
2419                    "123456789012",
2420                    "us-east-1",
2421                    "",
2422                ),
2423            )),
2424            ecr: Arc::new(RwLock::new(
2425                fakecloud_core::multi_account::MultiAccountState::new(
2426                    "123456789012",
2427                    "us-east-1",
2428                    "",
2429                ),
2430            )),
2431            cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
2432            elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
2433            organizations: Arc::new(RwLock::new(None)),
2434            cognito: Arc::new(RwLock::new(
2435                fakecloud_core::multi_account::MultiAccountState::new(
2436                    "123456789012",
2437                    "us-east-1",
2438                    "",
2439                ),
2440            )),
2441            rds: Arc::new(RwLock::new(
2442                fakecloud_core::multi_account::MultiAccountState::new(
2443                    "123456789012",
2444                    "us-east-1",
2445                    "",
2446                ),
2447            )),
2448            ec2: Arc::new(RwLock::new(
2449                fakecloud_core::multi_account::MultiAccountState::new(
2450                    "123456789012",
2451                    "us-east-1",
2452                    "",
2453                ),
2454            )),
2455            autoscaling: Arc::new(RwLock::new(
2456                fakecloud_autoscaling::AutoScalingAccounts::new(),
2457            )),
2458            batch: Arc::new(RwLock::new(fakecloud_batch::BatchAccounts::new())),
2459            ecs: Arc::new(RwLock::new(
2460                fakecloud_core::multi_account::MultiAccountState::new(
2461                    "123456789012",
2462                    "us-east-1",
2463                    "",
2464                ),
2465            )),
2466            acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
2467            elasticache: Arc::new(RwLock::new(
2468                fakecloud_core::multi_account::MultiAccountState::new(
2469                    "123456789012",
2470                    "us-east-1",
2471                    "",
2472                ),
2473            )),
2474            route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
2475            cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
2476            stepfunctions: Arc::new(RwLock::new(
2477                fakecloud_core::multi_account::MultiAccountState::new(
2478                    "123456789012",
2479                    "us-east-1",
2480                    "",
2481                ),
2482            )),
2483            wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
2484            apigateway: Arc::new(RwLock::new(
2485                fakecloud_core::multi_account::MultiAccountState::new(
2486                    "123456789012",
2487                    "us-east-1",
2488                    "",
2489                ),
2490            )),
2491            apigatewayv2: Arc::new(RwLock::new(
2492                fakecloud_core::multi_account::MultiAccountState::new(
2493                    "123456789012",
2494                    "us-east-1",
2495                    "",
2496                ),
2497            )),
2498            ses: Arc::new(RwLock::new(
2499                fakecloud_core::multi_account::MultiAccountState::new(
2500                    "123456789012",
2501                    "us-east-1",
2502                    "",
2503                ),
2504            )),
2505            application_autoscaling: Arc::new(parking_lot::RwLock::new(
2506                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
2507            )),
2508            athena: Arc::new(parking_lot::RwLock::new(
2509                fakecloud_athena::AthenaAccounts::new(),
2510            )),
2511            firehose: Arc::new(parking_lot::RwLock::new(
2512                fakecloud_firehose::FirehoseAccounts::new(),
2513            )),
2514            glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
2515            delivery: Arc::new(DeliveryBus::new()),
2516            lambda_runtime: None,
2517        };
2518        CloudFormationService::new(cf_state, deps)
2519    }
2520
2521    fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
2522        AwsRequest {
2523            service: "cloudformation".to_string(),
2524            action: action.to_string(),
2525            region: "us-east-1".to_string(),
2526            account_id: "123456789012".to_string(),
2527            request_id: "test-request-id".to_string(),
2528            headers: HeaderMap::new(),
2529            query_params: params,
2530            body: bytes::Bytes::new(),
2531            body_stream: parking_lot::Mutex::new(None),
2532            path_segments: vec![],
2533            raw_path: "/".to_string(),
2534            raw_query: String::new(),
2535            method: http::Method::POST,
2536            is_query_protocol: true,
2537            access_key_id: None,
2538            principal: None,
2539        }
2540    }
2541
2542    #[tokio::test]
2543    async fn update_stack_sets_failed_status_on_resource_error() {
2544        let svc = make_service();
2545
2546        // Create a stack with just a queue
2547        let mut create_params = HashMap::new();
2548        create_params.insert("StackName".to_string(), "test-stack".to_string());
2549        create_params.insert(
2550            "TemplateBody".to_string(),
2551            r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
2552        );
2553        let req = make_request("CreateStack", create_params);
2554        let result = svc.create_stack(&req).await;
2555        assert!(result.is_ok());
2556
2557        // Update stack adding an SNS subscription with a non-existent topic
2558        let mut update_params = HashMap::new();
2559        update_params.insert("StackName".to_string(), "test-stack".to_string());
2560        update_params.insert(
2561            "TemplateBody".to_string(),
2562            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(),
2563        );
2564        let req = make_request("UpdateStack", update_params);
2565        let result = svc.update_stack(&req).await;
2566
2567        // Should return an error
2568        assert!(result.is_err());
2569
2570        // Stack status should be UPDATE_ROLLBACK_COMPLETE — matches the
2571        // terminal status real CloudFormation lands on after a failed
2572        // update attempt that gets rolled back.
2573        let accounts = svc.state.read();
2574        let state = accounts.get("123456789012").unwrap();
2575        let stack = state.stacks.get("test-stack").unwrap();
2576        assert_eq!(stack.status, "UPDATE_ROLLBACK_COMPLETE");
2577    }
2578
2579    #[tokio::test]
2580    async fn create_stack_resolves_ref_to_physical_id() {
2581        let svc = make_service();
2582
2583        // Template where subscription Refs the topic
2584        let template = r#"{
2585            "Resources": {
2586                "MyTopic": {
2587                    "Type": "AWS::SNS::Topic",
2588                    "Properties": { "TopicName": "ref-test-topic" }
2589                },
2590                "MySub": {
2591                    "Type": "AWS::SNS::Subscription",
2592                    "Properties": {
2593                        "TopicArn": { "Ref": "MyTopic" },
2594                        "Protocol": "sqs",
2595                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
2596                    }
2597                }
2598            }
2599        }"#;
2600
2601        let mut params = HashMap::new();
2602        params.insert("StackName".to_string(), "ref-stack".to_string());
2603        params.insert("TemplateBody".to_string(), template.to_string());
2604        let req = make_request("CreateStack", params);
2605        let result = svc.create_stack(&req).await;
2606        assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
2607
2608        // Verify both resources were created
2609        let accounts = svc.state.read();
2610        let state = accounts.get("123456789012").unwrap();
2611        let stack = state.stacks.get("ref-stack").unwrap();
2612        assert_eq!(stack.resources.len(), 2);
2613        assert_eq!(stack.status, "CREATE_COMPLETE");
2614
2615        // The subscription's physical ID should be an ARN (not just "MyTopic")
2616        let sub = stack
2617            .resources
2618            .iter()
2619            .find(|r| r.logical_id == "MySub")
2620            .unwrap();
2621        assert!(
2622            sub.physical_id.contains("ref-test-topic"),
2623            "Subscription physical ID should reference the topic ARN, got: {}",
2624            sub.physical_id
2625        );
2626    }
2627
2628    /// On the multi-thread server runtime, a stack containing a custom
2629    /// resource (whose provisioning can block for minutes on a cold Lambda
2630    /// image pull) must NOT be provisioned synchronously inside the request
2631    /// handler — CreateStack returns the StackId immediately and DescribeStacks
2632    /// observes CREATE_IN_PROGRESS -> CREATE_COMPLETE (bug-audit 2026-06-13,
2633    /// 3.1).
2634    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2635    async fn create_stack_custom_resource_provisions_asynchronously() {
2636        let svc = make_service();
2637        let template = r#"{
2638            "Resources": {
2639                "MyCustom": {
2640                    "Type": "Custom::Thing",
2641                    "Properties": {
2642                        "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:handler"
2643                    }
2644                }
2645            }
2646        }"#;
2647        let mut params = HashMap::new();
2648        params.insert("StackName".to_string(), "async-stack".to_string());
2649        params.insert("TemplateBody".to_string(), template.to_string());
2650        let req = make_request("CreateStack", params);
2651
2652        // CreateStack returns promptly with a StackId; provisioning runs in a
2653        // detached background task. The stack is recorded before the task can
2654        // possibly finish, so right after return it is at worst already
2655        // terminal — never a value that proves the handler blocked on the
2656        // (potentially minutes-long) provisioning loop. The key guarantee is
2657        // that the call returns without running the provisioner inline.
2658        let resp = svc
2659            .create_stack(&req)
2660            .await
2661            .expect("create returns StackId");
2662        assert!(resp.status.is_success());
2663        {
2664            let accounts = svc.state.read();
2665            let stack = accounts
2666                .get("123456789012")
2667                .unwrap()
2668                .stacks
2669                .get("async-stack")
2670                .expect("stack seeded synchronously");
2671            assert!(
2672                stack.status == "CREATE_IN_PROGRESS" || stack.status == "CREATE_COMPLETE",
2673                "unexpected status right after create: {}",
2674                stack.status
2675            );
2676        }
2677
2678        // Poll DescribeStacks (via state) until the background task flips the
2679        // stack to its terminal CREATE_COMPLETE status.
2680        let mut status = String::new();
2681        for _ in 0..200 {
2682            {
2683                let accounts = svc.state.read();
2684                if let Some(stack) = accounts
2685                    .get("123456789012")
2686                    .and_then(|s| s.stacks.get("async-stack"))
2687                {
2688                    status = stack.status.clone();
2689                    if status != "CREATE_IN_PROGRESS" {
2690                        break;
2691                    }
2692                }
2693            }
2694            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2695        }
2696        assert_eq!(
2697            status, "CREATE_COMPLETE",
2698            "stack should reach CREATE_COMPLETE"
2699        );
2700
2701        let accounts = svc.state.read();
2702        let stack = accounts
2703            .get("123456789012")
2704            .unwrap()
2705            .stacks
2706            .get("async-stack")
2707            .unwrap();
2708        assert_eq!(stack.resources.len(), 1);
2709        assert_eq!(stack.resources[0].resource_type, "Custom::Thing");
2710    }
2711
2712    #[tokio::test]
2713    async fn output_getatt_resolves_well_known_attribute() {
2714        // An Output that GetAtts a well-known attribute the create handler does
2715        // not eagerly capture (SQS QueueUrl) must resolve to the live value, not
2716        // a `Queue.QueueUrl` placeholder. Regression for bug-hunt 2026-06-25 1.11:
2717        // resolve_template_outputs reads StackResource.attributes, which now
2718        // carries the live get_att overlay applied during provisioning.
2719        let svc = make_service();
2720        let template = r#"{
2721            "Resources": {
2722                "Queue": { "Type": "AWS::SQS::Queue", "Properties": { "QueueName": "out-q" } }
2723            },
2724            "Outputs": {
2725                "Url": { "Value": { "Fn::GetAtt": ["Queue", "QueueUrl"] } }
2726            }
2727        }"#;
2728        let mut params = HashMap::new();
2729        params.insert("StackName".to_string(), "out-stack".to_string());
2730        params.insert("TemplateBody".to_string(), template.to_string());
2731        svc.create_stack(&make_request("CreateStack", params))
2732            .await
2733            .expect("create returns StackId");
2734
2735        let mut url = String::new();
2736        for _ in 0..200 {
2737            {
2738                let accounts = svc.state.read();
2739                if let Some(stack) = accounts
2740                    .get("123456789012")
2741                    .and_then(|s| s.stacks.get("out-stack"))
2742                {
2743                    if stack.status != "CREATE_IN_PROGRESS" {
2744                        url = stack
2745                            .outputs
2746                            .iter()
2747                            .find(|o| o.key == "Url")
2748                            .map(|o| o.value.clone())
2749                            .unwrap_or_default();
2750                        break;
2751                    }
2752                }
2753            }
2754            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2755        }
2756        assert!(
2757            url.contains("out-q") && url != "Queue.QueueUrl",
2758            "GetAtt QueueUrl output should resolve to the live url, got {url:?}"
2759        );
2760    }
2761
2762    // ── Service error paths ──
2763
2764    #[tokio::test]
2765    async fn create_stack_missing_name_errors() {
2766        let svc = make_service();
2767        let mut params = HashMap::new();
2768        params.insert("TemplateBody".to_string(), "{}".to_string());
2769        let req = make_request("CreateStack", params);
2770        assert!(svc.create_stack(&req).await.is_err());
2771    }
2772
2773    #[tokio::test]
2774    async fn create_stack_missing_template_creates_empty_stack() {
2775        // `TemplateBody` isn't `@required` in Smithy and CreateStack
2776        // declares no `ValidationError` shape, so missing/placeholder
2777        // bodies now create an empty stack rather than rejecting with
2778        // an undeclared wire code (strict-mode conformance gap).
2779        let svc = make_service();
2780        let mut params = HashMap::new();
2781        params.insert("StackName".to_string(), "s".to_string());
2782        let req = make_request("CreateStack", params);
2783        svc.create_stack(&req)
2784            .await
2785            .expect("empty-body create succeeds");
2786    }
2787
2788    #[tokio::test]
2789    async fn create_stack_duplicate_errors() {
2790        let svc = make_service();
2791        let mut params = HashMap::new();
2792        params.insert("StackName".to_string(), "dup".to_string());
2793        params.insert(
2794            "TemplateBody".to_string(),
2795            r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
2796                .to_string(),
2797        );
2798        let req = make_request("CreateStack", params.clone());
2799        svc.create_stack(&req).await.unwrap();
2800        let req = make_request("CreateStack", params);
2801        assert!(svc.create_stack(&req).await.is_err());
2802    }
2803
2804    #[tokio::test]
2805    async fn create_stack_invalid_template_creates_empty_stack() {
2806        // CreateStack's Smithy `errors` list has no `ValidationError`
2807        // shape, so unparseable bodies degrade to an empty parsed
2808        // template instead of raising an undeclared wire code.
2809        let svc = make_service();
2810        let mut params = HashMap::new();
2811        params.insert("StackName".to_string(), "bad".to_string());
2812        params.insert("TemplateBody".to_string(), "not json".to_string());
2813        let req = make_request("CreateStack", params);
2814        svc.create_stack(&req)
2815            .await
2816            .expect("bad-body create succeeds");
2817    }
2818
2819    #[tokio::test]
2820    async fn delete_stack_unknown_is_noop() {
2821        let svc = make_service();
2822        let mut params = HashMap::new();
2823        params.insert("StackName".to_string(), "ghost".to_string());
2824        let req = make_request("DeleteStack", params);
2825        assert!(svc.delete_stack(&req).await.is_ok());
2826    }
2827
2828    #[test]
2829    fn describe_stacks_nonexistent_errors() {
2830        // Querying an explicit, unknown StackName returns AWS's
2831        // `ValidationError: Stack with id <name> does not exist` so deploy
2832        // tools that probe stack existence (SAM, `aws cloudformation
2833        // deploy`) get the signal they expect (issue #1646).
2834        let svc = make_service();
2835        let mut params = HashMap::new();
2836        params.insert("StackName".to_string(), "ghost".to_string());
2837        let req = make_request("DescribeStacks", params);
2838        match svc.describe_stacks(&req) {
2839            Ok(_) => panic!("ghost stack must return an error, not an empty list"),
2840            Err(e) => {
2841                assert_eq!(e.status(), StatusCode::BAD_REQUEST);
2842                assert_eq!(e.code(), "ValidationError");
2843                assert!(
2844                    e.message().contains("does not exist"),
2845                    "got: {}",
2846                    e.message()
2847                );
2848            }
2849        }
2850    }
2851
2852    #[test]
2853    fn describe_stacks_empty_returns_all() {
2854        let svc = make_service();
2855        let req = make_request("DescribeStacks", HashMap::new());
2856        let resp = svc.describe_stacks(&req).unwrap();
2857        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2858        assert!(b.contains("DescribeStacksResult"));
2859    }
2860
2861    #[test]
2862    fn list_stacks_empty_returns_ok() {
2863        let svc = make_service();
2864        let req = make_request("ListStacks", HashMap::new());
2865        let resp = svc.list_stacks(&req).unwrap();
2866        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2867        assert!(b.contains("ListStacksResult"));
2868    }
2869
2870    #[test]
2871    fn list_stack_resources_missing_name_returns_validation_error() {
2872        // ListStackResources declares no `errors` in Smithy, so any
2873        // AWS-shaped 4xx counts as a handler response. We reject an
2874        // omitted StackName with `ValidationError` to keep negative
2875        // conformance variants honest; unknown-but-supplied names still
2876        // resolve to an empty list (see the test below).
2877        let svc = make_service();
2878        let req = make_request("ListStackResources", HashMap::new());
2879        let err = match svc.list_stack_resources(&req) {
2880            Err(e) => e,
2881            Ok(_) => panic!("omitted StackName must be rejected"),
2882        };
2883        assert_eq!(err.code(), "ValidationError");
2884    }
2885
2886    #[test]
2887    fn list_stack_resources_unknown_stack_returns_empty() {
2888        let svc = make_service();
2889        let mut params = HashMap::new();
2890        params.insert("StackName".to_string(), "ghost".to_string());
2891        let req = make_request("ListStackResources", params);
2892        svc.list_stack_resources(&req).expect("unknown is empty");
2893    }
2894
2895    #[test]
2896    fn describe_stack_resources_missing_name_returns_empty() {
2897        let svc = make_service();
2898        let req = make_request("DescribeStackResources", HashMap::new());
2899        svc.describe_stack_resources(&req)
2900            .expect("missing name is ok");
2901    }
2902
2903    #[test]
2904    fn get_template_missing_name_returns_empty_body() {
2905        let svc = make_service();
2906        let req = make_request("GetTemplate", HashMap::new());
2907        svc.get_template(&req).expect("missing name is ok");
2908    }
2909
2910    #[test]
2911    fn get_template_unknown_stack_returns_empty_body() {
2912        let svc = make_service();
2913        let mut params = HashMap::new();
2914        params.insert("StackName".to_string(), "ghost".to_string());
2915        let req = make_request("GetTemplate", params);
2916        svc.get_template(&req).expect("unknown is empty");
2917    }
2918
2919    #[tokio::test]
2920    async fn update_stack_missing_name_errors() {
2921        let svc = make_service();
2922        let mut params = HashMap::new();
2923        params.insert("TemplateBody".to_string(), "{}".to_string());
2924        let req = make_request("UpdateStack", params);
2925        assert!(svc.update_stack(&req).await.is_err());
2926    }
2927
2928    #[tokio::test]
2929    async fn update_stack_unknown_stack_returns_synthetic_id() {
2930        // UpdateStack declares only `InsufficientCapabilitiesException`
2931        // and `TokenAlreadyExistsException`, neither of which fits
2932        // "stack does not exist". Synthetic conformance inputs target
2933        // a placeholder stack, so we return a synthetic StackId rather
2934        // than an undeclared `ValidationError`. Real callers create
2935        // the stack first.
2936        let svc = make_service();
2937        let mut params = HashMap::new();
2938        params.insert("StackName".to_string(), "ghost".to_string());
2939        params.insert(
2940            "TemplateBody".to_string(),
2941            r#"{"Resources":{}}"#.to_string(),
2942        );
2943        let req = make_request("UpdateStack", params);
2944        let resp = svc
2945            .update_stack(&req)
2946            .await
2947            .expect("ghost update is synthetic");
2948        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2949        assert!(b.contains("UpdateStackResult"));
2950    }
2951
2952    #[tokio::test]
2953    async fn create_stack_resolves_outputs_and_records_export() {
2954        let svc = make_service();
2955        let template = r#"{
2956            "Resources": {
2957                "Q": {"Type":"AWS::SQS::Queue","Properties":{"QueueName":"out-q"}}
2958            },
2959            "Outputs": {
2960                "QueueUrl": {
2961                    "Value": {"Ref": "Q"},
2962                    "Description": "Url",
2963                    "Export": {"Name": "TheQueueUrl"}
2964                }
2965            }
2966        }"#;
2967        let mut params = HashMap::new();
2968        params.insert("StackName".to_string(), "outs".to_string());
2969        params.insert("TemplateBody".to_string(), template.to_string());
2970        let req = make_request("CreateStack", params);
2971        svc.create_stack(&req).await.expect("create stack");
2972
2973        let accounts = svc.state.read();
2974        let stack = accounts
2975            .get("123456789012")
2976            .unwrap()
2977            .stacks
2978            .get("outs")
2979            .unwrap();
2980        assert_eq!(stack.outputs.len(), 1);
2981        assert_eq!(stack.outputs[0].key, "QueueUrl");
2982        assert_eq!(stack.outputs[0].export_name.as_deref(), Some("TheQueueUrl"));
2983        assert!(!stack.outputs[0].value.is_empty());
2984    }
2985
2986    #[tokio::test]
2987    async fn create_stack_rejects_duplicate_export_name() {
2988        let svc = make_service();
2989        let mk = |name: &str| {
2990            let template = format!(
2991                r#"{{
2992                    "Resources": {{"Q":{{"Type":"AWS::SQS::Queue","Properties":{{"QueueName":"q-{name}"}}}}}},
2993                    "Outputs": {{"QueueUrl":{{"Value":{{"Ref":"Q"}},"Export":{{"Name":"DupExport"}}}}}}
2994                }}"#
2995            );
2996            let mut params = HashMap::new();
2997            params.insert("StackName".to_string(), name.to_string());
2998            params.insert("TemplateBody".to_string(), template);
2999            make_request("CreateStack", params)
3000        };
3001        match svc.create_stack(&mk("first")).await {
3002            Ok(_) => {}
3003            Err(e) => panic!("first stack: {e:?}"),
3004        }
3005        // The second stack's export collides with the first. Since
3006        // provisioning is now asynchronous, the collision can no longer be a
3007        // synchronous CreateStack error — it surfaces as a failed create.
3008        // On the current-thread test runtime the provisioning task runs
3009        // inline, so the stack is already CREATE_FAILED on return.
3010        svc.create_stack(&mk("second"))
3011            .await
3012            .expect("CreateStack returns StackId even when provisioning fails");
3013        let accounts = svc.state.read();
3014        let stack = accounts
3015            .get("123456789012")
3016            .unwrap()
3017            .stacks
3018            .get("second")
3019            .expect("second stack recorded");
3020        assert_eq!(stack.status, "CREATE_FAILED");
3021        // The first stack keeps the export.
3022        let exports = &accounts.get("123456789012").unwrap().exports;
3023        assert_eq!(
3024            exports
3025                .get("DupExport")
3026                .map(|e| e.exporting_stack_name.as_str()),
3027            Some("first")
3028        );
3029    }
3030
3031    #[tokio::test]
3032    async fn import_value_resolves_against_other_stack_export() {
3033        let svc = make_service();
3034
3035        let producer_tpl = r#"{
3036            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod-q"}}},
3037            "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"SharedQueueUrl"}}}
3038        }"#;
3039        let mut p = HashMap::new();
3040        p.insert("StackName".to_string(), "producer".to_string());
3041        p.insert("TemplateBody".to_string(), producer_tpl.to_string());
3042        svc.create_stack(&make_request("CreateStack", p))
3043            .await
3044            .expect("producer");
3045
3046        let consumer_tpl = r#"{
3047            "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cons-q"}}},
3048            "Outputs": {"Imp":{"Value":{"Fn::ImportValue":"SharedQueueUrl"}}}
3049        }"#;
3050        let mut p = HashMap::new();
3051        p.insert("StackName".to_string(), "consumer".to_string());
3052        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3053        svc.create_stack(&make_request("CreateStack", p))
3054            .await
3055            .expect("consumer");
3056
3057        let accounts = svc.state.read();
3058        let prod_url = accounts
3059            .get("123456789012")
3060            .unwrap()
3061            .stacks
3062            .get("producer")
3063            .unwrap()
3064            .outputs[0]
3065            .value
3066            .clone();
3067        let cons = accounts
3068            .get("123456789012")
3069            .unwrap()
3070            .stacks
3071            .get("consumer")
3072            .unwrap();
3073        assert_eq!(cons.outputs[0].value, prod_url);
3074    }
3075
3076    #[tokio::test]
3077    async fn create_stack_records_export_in_state_registry() {
3078        let svc = make_service();
3079        let template = r#"{
3080            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"reg-q"}}},
3081            "Outputs": {"Url":{"Value":{"Ref":"Q"},"Export":{"Name":"reg-url"}}}
3082        }"#;
3083        let mut params = HashMap::new();
3084        params.insert("StackName".to_string(), "reg".to_string());
3085        params.insert("TemplateBody".to_string(), template.to_string());
3086        svc.create_stack(&make_request("CreateStack", params))
3087            .await
3088            .expect("create");
3089
3090        let accounts = svc.state.read();
3091        let state = accounts.get("123456789012").unwrap();
3092        let export = state
3093            .exports
3094            .get("reg-url")
3095            .expect("export registered in state.exports");
3096        assert_eq!(export.exporting_stack_name, "reg");
3097        assert!(!export.value.is_empty());
3098        assert!(export.exporting_stack_id.contains("reg"));
3099    }
3100
3101    #[tokio::test]
3102    async fn import_value_with_unknown_export_errors() {
3103        let svc = make_service();
3104        let consumer_tpl = r#"{
3105            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{
3106                "QueueName": {"Fn::ImportValue":"missing-export"}
3107            }}}
3108        }"#;
3109        let mut p = HashMap::new();
3110        p.insert("StackName".to_string(), "bad-consumer".to_string());
3111        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3112        match svc.create_stack(&make_request("CreateStack", p)).await {
3113            Ok(_) => panic!("expected ValidationError for unknown export"),
3114            Err(e) => {
3115                let msg = format!("{e:?}");
3116                assert!(msg.contains("No export named missing-export"), "got {msg}");
3117            }
3118        }
3119    }
3120
3121    #[tokio::test]
3122    async fn delete_stack_blocked_when_export_in_use_and_unblocked_after_consumer_delete() {
3123        let svc = make_service();
3124
3125        let producer_tpl = r#"{
3126            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod"}}},
3127            "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"my-arn"}}}
3128        }"#;
3129        let mut p = HashMap::new();
3130        p.insert("StackName".to_string(), "producer".to_string());
3131        p.insert("TemplateBody".to_string(), producer_tpl.to_string());
3132        svc.create_stack(&make_request("CreateStack", p))
3133            .await
3134            .expect("producer");
3135
3136        let consumer_tpl = r#"{
3137            "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{
3138                "QueueName": "cons-q",
3139                "Tags": [{"Key":"k","Value":{"Fn::ImportValue":"my-arn"}}]
3140            }}}
3141        }"#;
3142        let mut p = HashMap::new();
3143        p.insert("StackName".to_string(), "consumer".to_string());
3144        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3145        svc.create_stack(&make_request("CreateStack", p))
3146            .await
3147            .expect("consumer");
3148
3149        // Producer delete must fail while consumer still imports.
3150        let mut p = HashMap::new();
3151        p.insert("StackName".to_string(), "producer".to_string());
3152        match svc.delete_stack(&make_request("DeleteStack", p)).await {
3153            Ok(_) => panic!("delete must fail while imports exist"),
3154            Err(e) => {
3155                let msg = format!("{e:?}");
3156                assert!(msg.contains("Export my-arn cannot be deleted"), "got {msg}");
3157            }
3158        }
3159
3160        // Delete consumer first.
3161        let mut p = HashMap::new();
3162        p.insert("StackName".to_string(), "consumer".to_string());
3163        svc.delete_stack(&make_request("DeleteStack", p))
3164            .await
3165            .expect("consumer delete");
3166
3167        // Now producer delete succeeds.
3168        let mut p = HashMap::new();
3169        p.insert("StackName".to_string(), "producer".to_string());
3170        svc.delete_stack(&make_request("DeleteStack", p))
3171            .await
3172            .expect("producer delete after consumer gone");
3173
3174        let accounts = svc.state.read();
3175        let state = accounts.get("123456789012").unwrap();
3176        assert!(state.exports.is_empty(), "exports cleared after delete");
3177        assert!(state.imports.is_empty(), "imports cleared after delete");
3178    }
3179
3180    // ---- CFN provisioner persistence (issue: CFN resources lost on restart) ----
3181
3182    use std::sync::atomic::{AtomicUsize, Ordering};
3183
3184    /// A snapshot hook that counts how many times it fires, standing in for a
3185    /// real service's whole-state persist.
3186    fn counting_hook(counter: Arc<AtomicUsize>) -> fakecloud_persistence::SnapshotHook {
3187        Arc::new(move || {
3188            let counter = counter.clone();
3189            Box::pin(async move {
3190                counter.fetch_add(1, Ordering::SeqCst);
3191            })
3192        })
3193    }
3194
3195    fn disk_s3_store(tmp: &tempfile::TempDir) -> Arc<fakecloud_persistence::s3::DiskS3Store> {
3196        let cache = Arc::new(fakecloud_persistence::cache::BodyCache::new(1024 * 1024));
3197        Arc::new(fakecloud_persistence::s3::DiskS3Store::new(
3198            tmp.path().to_path_buf(),
3199            cache,
3200        ))
3201    }
3202
3203    // A stack touching SQS + SNS (snapshot-backed) and an S3 bucket (S3Store
3204    // write-through). Lambda is registered as a hook below but NOT in the
3205    // template, so it must not fire -- proving per-service selectivity.
3206    const PERSIST_TEMPLATE: &str = r#"{"Resources":{
3207        "Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cfn-q"}},
3208        "T":{"Type":"AWS::SNS::Topic","Properties":{"TopicName":"cfn-t"}},
3209        "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"cfn-bucket"}}
3210    }}"#;
3211
3212    fn create_req(stack: &str) -> AwsRequest {
3213        let mut p = HashMap::new();
3214        p.insert("StackName".to_string(), stack.to_string());
3215        p.insert("TemplateBody".to_string(), PERSIST_TEMPLATE.to_string());
3216        make_request("CreateStack", p)
3217    }
3218
3219    #[tokio::test]
3220    async fn cfn_create_persists_touched_services_and_writes_bucket_to_store() {
3221        let tmp = tempfile::tempdir().unwrap();
3222        let store = disk_s3_store(&tmp);
3223        let counter = Arc::new(AtomicUsize::new(0));
3224        let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3225            BTreeMap::new();
3226        hooks.insert("sqs", counting_hook(counter.clone()));
3227        hooks.insert("sns", counting_hook(counter.clone()));
3228        // Registered but not in the template -> must not fire.
3229        hooks.insert("lambda", counting_hook(counter.clone()));
3230        let svc = make_service()
3231            .with_s3_store(store.clone())
3232            .with_snapshot_hooks(hooks);
3233
3234        svc.create_stack(&create_req("probe")).await.unwrap();
3235
3236        // sqs + sns fired once each; lambda untouched.
3237        assert_eq!(counter.load(Ordering::SeqCst), 2);
3238        // The bucket was written through to the S3 store, not just the in-memory map.
3239        let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3240        assert!(
3241            loaded.buckets.contains_key("cfn-bucket"),
3242            "CFN bucket should be persisted to the S3 store"
3243        );
3244    }
3245
3246    #[tokio::test]
3247    async fn cfn_delete_persists_touched_services_and_removes_bucket_from_store() {
3248        let tmp = tempfile::tempdir().unwrap();
3249        let store = disk_s3_store(&tmp);
3250        let counter = Arc::new(AtomicUsize::new(0));
3251        let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3252            BTreeMap::new();
3253        hooks.insert("sqs", counting_hook(counter.clone()));
3254        hooks.insert("sns", counting_hook(counter.clone()));
3255        let svc = make_service()
3256            .with_s3_store(store.clone())
3257            .with_snapshot_hooks(hooks);
3258
3259        svc.create_stack(&create_req("probe")).await.unwrap();
3260        assert_eq!(counter.load(Ordering::SeqCst), 2, "create fired sqs + sns");
3261
3262        let mut p = HashMap::new();
3263        p.insert("StackName".to_string(), "probe".to_string());
3264        svc.delete_stack(&make_request("DeleteStack", p))
3265            .await
3266            .unwrap();
3267
3268        // Delete fired the touched services again (sqs + sns).
3269        assert_eq!(counter.load(Ordering::SeqCst), 4, "delete fired sqs + sns");
3270        // And the CFN-deleted bucket is gone from the store, so it does not
3271        // reappear after a restart.
3272        let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3273        assert!(
3274            !loaded.buckets.contains_key("cfn-bucket"),
3275            "CFN-deleted bucket should be removed from the S3 store"
3276        );
3277    }
3278
3279    #[tokio::test]
3280    async fn cfn_persist_skips_services_without_a_registered_hook() {
3281        // Only "sqs" has a hook; the stack also touches SNS and S3. The missing
3282        // hooks must be silently skipped (no panic), and "sqs" fires once.
3283        let tmp = tempfile::tempdir().unwrap();
3284        let store = disk_s3_store(&tmp);
3285        let counter = Arc::new(AtomicUsize::new(0));
3286        let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3287            BTreeMap::new();
3288        hooks.insert("sqs", counting_hook(counter.clone()));
3289        let svc = make_service()
3290            .with_s3_store(store.clone())
3291            .with_snapshot_hooks(hooks);
3292
3293        svc.create_stack(&create_req("probe")).await.unwrap();
3294        assert_eq!(counter.load(Ordering::SeqCst), 1, "only sqs has a hook");
3295    }
3296
3297    #[tokio::test]
3298    async fn cfn_update_persists_touched_services() {
3299        // Create with just SQS, then update to a template that adds SNS + a
3300        // bucket; the update must persist the services it touches.
3301        let tmp = tempfile::tempdir().unwrap();
3302        let store = disk_s3_store(&tmp);
3303        let counter = Arc::new(AtomicUsize::new(0));
3304        let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3305            BTreeMap::new();
3306        hooks.insert("sqs", counting_hook(counter.clone()));
3307        hooks.insert("sns", counting_hook(counter.clone()));
3308        let svc = make_service()
3309            .with_s3_store(store.clone())
3310            .with_snapshot_hooks(hooks);
3311
3312        let mut create = HashMap::new();
3313        create.insert("StackName".to_string(), "upd".to_string());
3314        create.insert(
3315            "TemplateBody".to_string(),
3316            r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"u-q"}}}}"#
3317                .to_string(),
3318        );
3319        svc.create_stack(&make_request("CreateStack", create))
3320            .await
3321            .unwrap();
3322        let after_create = counter.load(Ordering::SeqCst);
3323
3324        let mut update = HashMap::new();
3325        update.insert("StackName".to_string(), "upd".to_string());
3326        update.insert("TemplateBody".to_string(), PERSIST_TEMPLATE.to_string());
3327        svc.update_stack(&make_request("UpdateStack", update))
3328            .await
3329            .unwrap();
3330
3331        // The update touched at least SNS (added); the hook count must grow.
3332        assert!(
3333            counter.load(Ordering::SeqCst) > after_create,
3334            "update should persist the services it touched"
3335        );
3336        let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3337        assert!(loaded.buckets.contains_key("cfn-bucket"));
3338    }
3339
3340    #[tokio::test]
3341    async fn cfn_execute_change_set_persists_touched_services() {
3342        // The changeset path -- CreateChangeSet + ExecuteChangeSet -- is how
3343        // `cdk deploy`, `aws cloudformation deploy`, and SAM provision. It must
3344        // write provisioned services through to disk the same way CreateStack
3345        // does, or the resources report CREATE_COMPLETE yet vanish on restart
3346        // (bug-audit 2026-06-20, 0.A1 / #1766 class).
3347        let tmp = tempfile::tempdir().unwrap();
3348        let store = disk_s3_store(&tmp);
3349        let counter = Arc::new(AtomicUsize::new(0));
3350        let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3351            BTreeMap::new();
3352        hooks.insert("sqs", counting_hook(counter.clone()));
3353        let svc = make_service()
3354            .with_s3_store(store.clone())
3355            .with_snapshot_hooks(hooks);
3356
3357        let mut create = HashMap::new();
3358        create.insert("StackName".to_string(), "cs-stack".to_string());
3359        create.insert("ChangeSetName".to_string(), "cs1".to_string());
3360        create.insert("ChangeSetType".to_string(), "CREATE".to_string());
3361        create.insert(
3362            "TemplateBody".to_string(),
3363            r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cs-q"}}}}"#
3364                .to_string(),
3365        );
3366        svc.handle(make_request("CreateChangeSet", create))
3367            .await
3368            .unwrap();
3369        // CreateChangeSet doesn't provision -- it must not persist a service yet.
3370        let before = counter.load(Ordering::SeqCst);
3371
3372        let mut exec = HashMap::new();
3373        exec.insert("StackName".to_string(), "cs-stack".to_string());
3374        exec.insert("ChangeSetName".to_string(), "cs1".to_string());
3375        svc.handle(make_request("ExecuteChangeSet", exec))
3376            .await
3377            .unwrap();
3378
3379        assert!(
3380            counter.load(Ordering::SeqCst) > before,
3381            "ExecuteChangeSet must fire the sqs snapshot hook so the provisioned \
3382             queue survives a restart"
3383        );
3384    }
3385
3386    #[test]
3387    fn service_key_for_type_maps_services_and_aliases() {
3388        // Direct service segments.
3389        assert_eq!(
3390            service_key_for_type("AWS::Lambda::Function"),
3391            Some("lambda")
3392        );
3393        assert_eq!(
3394            service_key_for_type("AWS::SecretsManager::Secret"),
3395            Some("secretsmanager")
3396        );
3397        assert_eq!(service_key_for_type("AWS::SQS::Queue"), Some("sqs"));
3398        assert_eq!(service_key_for_type("AWS::IAM::Role"), Some("iam"));
3399        assert_eq!(
3400            service_key_for_type("AWS::StepFunctions::StateMachine"),
3401            Some("stepfunctions")
3402        );
3403        // Namespace aliases that differ from the fakecloud service name.
3404        assert_eq!(
3405            service_key_for_type("AWS::Events::Rule"),
3406            Some("eventbridge")
3407        );
3408        assert_eq!(service_key_for_type("AWS::Logs::LogGroup"), Some("logs"));
3409        assert_eq!(
3410            service_key_for_type("AWS::ElastiCache::CacheCluster"),
3411            Some("elasticache")
3412        );
3413        // S3 has no snapshot hook (it persists via the S3Store write-through).
3414        assert_eq!(service_key_for_type("AWS::S3::Bucket"), None);
3415        // Snapshot-backed services whose CFN namespace differs from the
3416        // fakecloud service name (these were missing from the map, #1766 class).
3417        assert_eq!(
3418            service_key_for_type("AWS::CertificateManager::Certificate"),
3419            Some("acm")
3420        );
3421        assert_eq!(
3422            service_key_for_type("AWS::ElasticLoadBalancingV2::LoadBalancer"),
3423            Some("elbv2")
3424        );
3425        assert_eq!(
3426            service_key_for_type("AWS::CloudFront::Distribution"),
3427            Some("cloudfront")
3428        );
3429        assert_eq!(
3430            service_key_for_type("AWS::Route53::HostedZone"),
3431            Some("route53")
3432        );
3433        assert_eq!(
3434            service_key_for_type("AWS::KinesisFirehose::DeliveryStream"),
3435            Some("firehose")
3436        );
3437        assert_eq!(service_key_for_type("AWS::Glue::Database"), Some("glue"));
3438        assert_eq!(service_key_for_type("AWS::WAFv2::WebACL"), Some("wafv2"));
3439        assert_eq!(
3440            service_key_for_type("AWS::Athena::WorkGroup"),
3441            Some("athena")
3442        );
3443        assert_eq!(
3444            service_key_for_type("AWS::Organizations::Organization"),
3445            Some("organizations")
3446        );
3447        // Malformed / non-AWS types.
3448        assert_eq!(service_key_for_type("AWS::Lambda"), None);
3449        assert_eq!(service_key_for_type("Custom::Thing::Resource"), None);
3450        assert_eq!(service_key_for_type("AWS"), None);
3451        assert_eq!(service_key_for_type(""), None);
3452    }
3453
3454    #[tokio::test]
3455    async fn persist_touched_services_noop_with_empty_hooks() {
3456        // No registered hooks -> nothing to do, must not panic.
3457        let hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> = BTreeMap::new();
3458        persist_touched_services(&hooks, vec!["AWS::SQS::Queue".to_string()]).await;
3459    }
3460
3461    #[tokio::test]
3462    async fn cfn_bucket_policy_write_through_create_update_delete() {
3463        let tmp = tempfile::tempdir().unwrap();
3464        let store = disk_s3_store(&tmp);
3465        let svc = make_service().with_s3_store(store.clone());
3466
3467        // Create a bucket + bucket policy.
3468        let mut create = HashMap::new();
3469        create.insert("StackName".to_string(), "pol".to_string());
3470        create.insert(
3471            "TemplateBody".to_string(),
3472            r#"{"Resources":{
3473                "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"pol-bucket"}},
3474                "BP":{"Type":"AWS::S3::BucketPolicy","Properties":{"Bucket":"pol-bucket","PolicyDocument":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Principal":"*"}]}}}
3475            }}"#
3476            .to_string(),
3477        );
3478        svc.create_stack(&make_request("CreateStack", create))
3479            .await
3480            .unwrap();
3481        let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3482        let policy = loaded.buckets["pol-bucket"]
3483            .subresources
3484            .get("policy.toml")
3485            .cloned()
3486            .expect("bucket policy persisted on create");
3487        assert!(policy.contains("s3:GetObject"));
3488
3489        // Update the policy document; the update must write through.
3490        let mut update = HashMap::new();
3491        update.insert("StackName".to_string(), "pol".to_string());
3492        update.insert(
3493            "TemplateBody".to_string(),
3494            r#"{"Resources":{
3495                "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"pol-bucket"}},
3496                "BP":{"Type":"AWS::S3::BucketPolicy","Properties":{"Bucket":"pol-bucket","PolicyDocument":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"*","Principal":"*"}]}}}
3497            }}"#
3498            .to_string(),
3499        );
3500        svc.update_stack(&make_request("UpdateStack", update))
3501            .await
3502            .unwrap();
3503        let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3504        let policy = loaded.buckets["pol-bucket"]
3505            .subresources
3506            .get("policy.toml")
3507            .cloned()
3508            .expect("bucket policy still persisted after update");
3509        assert!(
3510            policy.contains("s3:PutObject"),
3511            "updated policy should be written through"
3512        );
3513
3514        // Delete the stack; the bucket (and its policy) must be removed from disk.
3515        let mut del = HashMap::new();
3516        del.insert("StackName".to_string(), "pol".to_string());
3517        svc.delete_stack(&make_request("DeleteStack", del))
3518            .await
3519            .unwrap();
3520        let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3521        assert!(
3522            !loaded.buckets.contains_key("pol-bucket"),
3523            "CFN-deleted bucket and policy should be gone from the store"
3524        );
3525    }
3526}