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