Skip to main content

fakecloud_cloudformation/
service.rs

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