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