Skip to main content

fakecloud_cloudformation/
service.rs

1use async_trait::async_trait;
2use chrono::Utc;
3use http::StatusCode;
4use std::collections::BTreeMap;
5use std::sync::Arc;
6
7use fakecloud_core::delivery::DeliveryBus;
8use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
9use fakecloud_dynamodb::SharedDynamoDbState;
10use fakecloud_eventbridge::SharedEventBridgeState;
11use fakecloud_iam::SharedIamState;
12use fakecloud_logs::SharedLogsState;
13use fakecloud_persistence::SnapshotStore;
14use fakecloud_s3::SharedS3State;
15use fakecloud_sns::SharedSnsState;
16use fakecloud_sqs::SharedSqsState;
17use fakecloud_ssm::SharedSsmState;
18use tokio::sync::Mutex as AsyncMutex;
19
20use crate::resource_provisioner::ResourceProvisioner;
21use crate::state;
22use crate::state::{
23    CloudFormationSnapshot, CloudFormationState, SharedCloudFormationState, Stack, StackResource,
24    CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
25};
26use crate::template;
27use crate::xml_responses;
28
29/// Canonical `Fn::GetAtt` attribute names per resource type. Used to
30/// supplement the eagerly-captured `ProvisionResult::attributes` with
31/// live-state lookups via `ResourceProvisioner::get_att`. Resources not
32/// listed here just use whatever the create handler captured.
33fn well_known_attributes_for(resource_type: &str) -> &'static [&'static str] {
34    match resource_type {
35        "AWS::S3::Bucket" => &[
36            "Arn",
37            "DomainName",
38            "RegionalDomainName",
39            "DualStackDomainName",
40            "WebsiteURL",
41        ],
42        "AWS::Lambda::Function" => &["Arn", "FunctionUrl", "Version"],
43        "AWS::IAM::Role" => &["Arn", "RoleId"],
44        "AWS::SQS::Queue" => &["Arn", "QueueName", "QueueUrl"],
45        "AWS::SNS::Topic" => &["TopicArn", "TopicName"],
46        "AWS::DynamoDB::Table" => &["Arn", "StreamArn"],
47        "AWS::KMS::Key" => &["Arn", "KeyId"],
48        "AWS::SecretsManager::Secret" => &["Arn", "Id"],
49        "AWS::CloudFront::Distribution" => &["DomainName", "Id"],
50        _ => &[],
51    }
52}
53
54/// Multi-pass provisioning for all resources in a parsed template.
55///
56/// Resources may `Ref` each other in either direction, and JSON object
57/// iteration order isn't stable, so a single forward pass isn't enough
58/// to resolve them. We loop: each pass tries every pending resource, and
59/// any resource whose `Ref` targets are still unknown just stays pending
60/// for the next pass. When no pass makes progress we report the first
61/// pending failure and rollback.
62pub(crate) fn provision_stack_resources(
63    provisioner: &ResourceProvisioner,
64    resource_defs: &[template::ResourceDefinition],
65    template_body: &str,
66    parameters: &BTreeMap<String, String>,
67) -> Result<Vec<StackResource>, AwsServiceError> {
68    let mut resources = Vec::new();
69    let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
70    let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
71    let mut pending: Vec<&template::ResourceDefinition> = resource_defs.iter().collect();
72    let max_passes = pending.len() + 1;
73
74    for _ in 0..max_passes {
75        if pending.is_empty() {
76            break;
77        }
78        let mut still_pending = Vec::new();
79        let mut made_progress = false;
80
81        for resource_def in pending {
82            let resolved_def = template::resolve_resource_properties_with_attrs(
83                resource_def,
84                template_body,
85                parameters,
86                &physical_ids,
87                &attributes,
88            )
89            .map_err(|e| {
90                AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
91            })?;
92
93            match provisioner.create_resource(&resolved_def) {
94                Ok(stack_resource) => {
95                    physical_ids.insert(
96                        stack_resource.logical_id.clone(),
97                        stack_resource.physical_id.clone(),
98                    );
99                    // Start with the eagerly-captured attribute set, then
100                    // overlay anything the provisioner can resolve from
101                    // live state (e.g. attributes that depend on side-effects
102                    // recorded after the create handler returned).
103                    let mut attr_map = stack_resource.attributes.clone();
104                    for attr in well_known_attributes_for(&stack_resource.resource_type) {
105                        if attr_map.contains_key(*attr) {
106                            continue;
107                        }
108                        if let Some(v) = provisioner.get_att(&stack_resource, attr) {
109                            attr_map.insert((*attr).to_string(), v);
110                        }
111                    }
112                    attributes.insert(stack_resource.logical_id.clone(), attr_map);
113                    resources.push(stack_resource);
114                    made_progress = true;
115                }
116                Err(_) => still_pending.push(resource_def),
117            }
118        }
119
120        pending = still_pending;
121        if !made_progress && !pending.is_empty() {
122            // No progress — report the first failure and rollback anything
123            // we already created.
124            let resource_def = pending[0];
125            let resolved_def = template::resolve_resource_properties_with_attrs(
126                resource_def,
127                template_body,
128                parameters,
129                &physical_ids,
130                &attributes,
131            )
132            .unwrap_or_else(|_| resource_def.clone());
133            let err = provisioner.create_resource(&resolved_def).unwrap_err();
134            for r in &resources {
135                let _ = provisioner.delete_resource(r);
136            }
137            return Err(AwsServiceError::aws_error(
138                StatusCode::BAD_REQUEST,
139                "ValidationError",
140                format!(
141                    "Failed to create resource {}: {err}",
142                    resource_def.logical_id
143                ),
144            ));
145        }
146    }
147
148    Ok(resources)
149}
150
151/// State references for every service CloudFormation can provision resources in.
152pub struct CloudFormationDeps {
153    pub sqs: SharedSqsState,
154    pub sns: SharedSnsState,
155    pub ssm: SharedSsmState,
156    pub iam: SharedIamState,
157    pub s3: SharedS3State,
158    pub eventbridge: SharedEventBridgeState,
159    pub dynamodb: SharedDynamoDbState,
160    pub logs: SharedLogsState,
161    pub lambda: fakecloud_lambda::SharedLambdaState,
162    pub secretsmanager: fakecloud_secretsmanager::SharedSecretsManagerState,
163    pub kinesis: fakecloud_kinesis::SharedKinesisState,
164    pub kms: fakecloud_kms::SharedKmsState,
165    pub ecr: fakecloud_ecr::SharedEcrState,
166    pub cloudwatch: fakecloud_cloudwatch::SharedCloudWatchState,
167    pub elbv2: fakecloud_elbv2::SharedElbv2State,
168    pub organizations: fakecloud_organizations::SharedOrganizationsState,
169    pub cognito: fakecloud_cognito::SharedCognitoState,
170    pub rds: fakecloud_rds::SharedRdsState,
171    pub ecs: fakecloud_ecs::SharedEcsState,
172    pub acm: fakecloud_acm::SharedAcmState,
173    pub elasticache: fakecloud_elasticache::SharedElastiCacheState,
174    pub route53: fakecloud_route53::SharedRoute53State,
175    pub cloudfront: fakecloud_cloudfront::SharedCloudFrontState,
176    pub stepfunctions: fakecloud_stepfunctions::SharedStepFunctionsState,
177    pub wafv2: fakecloud_wafv2::SharedWafv2State,
178    pub apigateway: fakecloud_apigateway::SharedApiGatewayState,
179    pub apigatewayv2: fakecloud_apigatewayv2::SharedApiGatewayV2State,
180    pub ses: fakecloud_ses::SharedSesState,
181    pub application_autoscaling:
182        fakecloud_application_autoscaling::SharedApplicationAutoScalingState,
183    pub athena: fakecloud_athena::SharedAthenaState,
184    pub firehose: fakecloud_firehose::SharedFirehoseState,
185    pub glue: fakecloud_glue::SharedGlueState,
186    pub delivery: Arc<DeliveryBus>,
187}
188
189pub struct CloudFormationService {
190    pub(crate) state: SharedCloudFormationState,
191    pub(crate) deps: CloudFormationDeps,
192    snapshot_store: Option<Arc<dyn SnapshotStore>>,
193    snapshot_lock: Arc<AsyncMutex<()>>,
194}
195
196impl CloudFormationService {
197    pub fn new(state: SharedCloudFormationState, deps: CloudFormationDeps) -> Self {
198        Self {
199            state,
200            deps,
201            snapshot_store: None,
202            snapshot_lock: Arc::new(AsyncMutex::new(())),
203        }
204    }
205
206    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
207        self.snapshot_store = Some(store);
208        self
209    }
210
211    async fn save_snapshot(&self) {
212        let Some(store) = self.snapshot_store.clone() else {
213            return;
214        };
215        let _guard = self.snapshot_lock.lock().await;
216        let snapshot = CloudFormationSnapshot {
217            schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
218            state: None,
219            accounts: Some(self.state.read().clone()),
220        };
221        let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
222            let bytes = serde_json::to_vec(&snapshot)
223                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
224            store.save(&bytes)
225        })
226        .await;
227        match join {
228            Ok(Ok(())) => {}
229            Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
230            Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
231        }
232    }
233
234    pub(crate) fn provisioner(
235        &self,
236        stack_id: &str,
237        account_id: &str,
238        region: &str,
239    ) -> ResourceProvisioner {
240        ResourceProvisioner {
241            sqs_state: self.deps.sqs.clone(),
242            sns_state: self.deps.sns.clone(),
243            ssm_state: self.deps.ssm.clone(),
244            iam_state: self.deps.iam.clone(),
245            s3_state: self.deps.s3.clone(),
246            eventbridge_state: self.deps.eventbridge.clone(),
247            dynamodb_state: self.deps.dynamodb.clone(),
248            logs_state: self.deps.logs.clone(),
249            lambda_state: self.deps.lambda.clone(),
250            secretsmanager_state: self.deps.secretsmanager.clone(),
251            kinesis_state: self.deps.kinesis.clone(),
252            kms_state: self.deps.kms.clone(),
253            ecr_state: self.deps.ecr.clone(),
254            cloudwatch_state: self.deps.cloudwatch.clone(),
255            elbv2_state: self.deps.elbv2.clone(),
256            organizations_state: self.deps.organizations.clone(),
257            cognito_state: self.deps.cognito.clone(),
258            rds_state: self.deps.rds.clone(),
259            ecs_state: self.deps.ecs.clone(),
260            acm_state: self.deps.acm.clone(),
261            elasticache_state: self.deps.elasticache.clone(),
262            route53_state: self.deps.route53.clone(),
263            cloudfront_state: self.deps.cloudfront.clone(),
264            stepfunctions_state: self.deps.stepfunctions.clone(),
265            wafv2_state: self.deps.wafv2.clone(),
266            apigateway_state: self.deps.apigateway.clone(),
267            apigatewayv2_state: self.deps.apigatewayv2.clone(),
268            ses_state: self.deps.ses.clone(),
269            app_autoscaling_state: self.deps.application_autoscaling.clone(),
270            athena_state: self.deps.athena.clone(),
271            firehose_state: self.deps.firehose.clone(),
272            glue_state: self.deps.glue.clone(),
273            cloudformation_state: self.state.clone(),
274            delivery: self.deps.delivery.clone(),
275            account_id: account_id.to_string(),
276            region: region.to_string(),
277            stack_id: stack_id.to_string(),
278        }
279    }
280
281    fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
282        // Check query params first (for Query protocol)
283        if let Some(v) = req.query_params.get(key) {
284            return Some(v.clone());
285        }
286        // Then check form-encoded body
287        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
288        body_params.get(key).cloned()
289    }
290
291    pub(crate) fn get_all_params(req: &AwsRequest) -> BTreeMap<String, String> {
292        let mut params: BTreeMap<String, String> = req.query_params.clone().into_iter().collect();
293        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
294        for (k, v) in body_params {
295            params.entry(k).or_insert(v);
296        }
297        params
298    }
299
300    pub(crate) fn extract_tags(params: &BTreeMap<String, String>) -> BTreeMap<String, String> {
301        let mut tags = BTreeMap::new();
302        for i in 1.. {
303            let key_param = format!("Tags.member.{i}.Key");
304            let value_param = format!("Tags.member.{i}.Value");
305            match (params.get(&key_param), params.get(&value_param)) {
306                (Some(k), Some(v)) => {
307                    tags.insert(k.clone(), v.clone());
308                }
309                _ => break,
310            }
311        }
312        tags
313    }
314
315    pub(crate) fn extract_parameters(
316        params: &BTreeMap<String, String>,
317    ) -> BTreeMap<String, String> {
318        let mut result = BTreeMap::new();
319        for i in 1.. {
320            let key_param = format!("Parameters.member.{i}.ParameterKey");
321            let value_param = format!("Parameters.member.{i}.ParameterValue");
322            match (params.get(&key_param), params.get(&value_param)) {
323                (Some(k), Some(v)) => {
324                    result.insert(k.clone(), v.clone());
325                }
326                _ => break,
327            }
328        }
329        result
330    }
331
332    pub(crate) fn extract_notification_arns(params: &BTreeMap<String, String>) -> Vec<String> {
333        let mut arns = Vec::new();
334        for i in 1.. {
335            let key = format!("NotificationARNs.member.{i}");
336            match params.get(&key) {
337                Some(arn) => arns.push(arn.clone()),
338                None => break,
339            }
340        }
341        arns
342    }
343
344    fn send_stack_notification(
345        delivery: &DeliveryBus,
346        notification_arns: &[String],
347        stack_name: &str,
348        stack_id: &str,
349        status: &str,
350    ) {
351        if notification_arns.is_empty() {
352            return;
353        }
354        let message = format!(
355            "StackId='{}'\nTimestamp='{}'\nEventId='{}'\nLogicalResourceId='{}'\nResourceStatus='{}'\nResourceType='AWS::CloudFormation::Stack'\nStackName='{}'",
356            stack_id,
357            chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
358            uuid::Uuid::new_v4(),
359            stack_name,
360            status,
361            stack_name,
362        );
363        for arn in notification_arns {
364            delivery.publish_to_sns(arn, &message, Some("AWS CloudFormation Notification"));
365        }
366    }
367
368    /// Build a Fn::ImportValue lookup map from the account-level
369    /// `state.exports` registry. `skip_stack` removes any export owned by
370    /// the named stack — used during update so a stack doesn't import its
371    /// own previous-revision export.
372    fn collect_account_imports(
373        state: &SharedCloudFormationState,
374        account_id: &str,
375        skip_stack: Option<&str>,
376    ) -> BTreeMap<String, String> {
377        let mut imports = BTreeMap::new();
378        let accounts = state.read();
379        let Some(state) = accounts.get(account_id) else {
380            return imports;
381        };
382        for (name, export) in &state.exports {
383            if matches!(skip_stack, Some(skip) if skip == export.exporting_stack_name) {
384                continue;
385            }
386            imports.insert(name.clone(), export.value.clone());
387        }
388        imports
389    }
390
391    /// Pre-validate every `Fn::ImportValue` site in `template_body` —
392    /// return a `ValidationError` listing any export names that aren't
393    /// known in the account. Mirrors CloudFormation's behavior of
394    /// failing the create/update before any resource is provisioned.
395    fn validate_import_values(
396        state: &SharedCloudFormationState,
397        account_id: &str,
398        stack_name: &str,
399        template_body: &str,
400        parameters: &BTreeMap<String, String>,
401    ) -> Result<Vec<String>, AwsServiceError> {
402        let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
403            match serde_json::from_str(template_body) {
404                Ok(v) => v,
405                Err(_) => return Ok(Vec::new()),
406            }
407        } else {
408            match serde_yaml::from_str(template_body) {
409                Ok(v) => v,
410                Err(_) => return Ok(Vec::new()),
411            }
412        };
413        let names = template::collect_import_value_names(&value, parameters);
414        let known = Self::collect_account_imports(state, account_id, Some(stack_name));
415        for n in &names {
416            if !known.contains_key(n) {
417                return Err(AwsServiceError::aws_error(
418                    StatusCode::BAD_REQUEST,
419                    "ValidationError",
420                    format!("No export named {n} found."),
421                ));
422            }
423        }
424        Ok(names)
425    }
426
427    /// Sync `state.exports` and `state.imports` after a stack create or
428    /// update. Removes any exports / imports the stack used to own and
429    /// re-adds the current-revision set.
430    fn sync_exports_imports(
431        state: &mut CloudFormationState,
432        stack_id: &str,
433        stack_name: &str,
434        outputs: &[state::StackOutput],
435        imported_names: &[String],
436    ) {
437        // 1. Drop any prior exports owned by this stack.
438        let stale_exports: Vec<String> = state
439            .exports
440            .iter()
441            .filter(|(_, e)| e.exporting_stack_name == stack_name)
442            .map(|(k, _)| k.clone())
443            .collect();
444        for k in stale_exports {
445            state.exports.remove(&k);
446        }
447        // 2. Drop any prior imports recorded against this stack.
448        for entries in state.imports.values_mut() {
449            entries.retain(|s| s != stack_name);
450        }
451        state.imports.retain(|_, v| !v.is_empty());
452
453        // 3. Re-register exports.
454        for o in outputs {
455            if let Some(export) = &o.export_name {
456                state.exports.insert(
457                    export.clone(),
458                    state::StackExport {
459                        value: o.value.clone(),
460                        exporting_stack_id: stack_id.to_string(),
461                        exporting_stack_name: stack_name.to_string(),
462                    },
463                );
464            }
465        }
466        // 4. Re-register imports.
467        for name in imported_names {
468            let entry = state.imports.entry(name.clone()).or_default();
469            if !entry.iter().any(|s| s == stack_name) {
470                entry.push(stack_name.to_string());
471            }
472        }
473    }
474
475    /// Resolve every `Outputs.*` entry in `template_body` after the stack
476    /// has been provisioned. `resources` is the post-create / post-update
477    /// vec; we rebuild the physical-id and attribute maps from it before
478    /// invoking the template parser.
479    fn resolve_template_outputs(
480        template_body: &str,
481        parameters: &BTreeMap<String, String>,
482        resources: &[StackResource],
483        state: &SharedCloudFormationState,
484    ) -> Vec<state::StackOutput> {
485        let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
486            match serde_json::from_str(template_body) {
487                Ok(v) => v,
488                Err(_) => return Vec::new(),
489            }
490        } else {
491            match serde_yaml::from_str(template_body) {
492                Ok(v) => v,
493                Err(_) => return Vec::new(),
494            }
495        };
496
497        let resources_obj = match value.get("Resources").and_then(|v| v.as_object()) {
498            Some(o) => o.clone(),
499            None => return Vec::new(),
500        };
501
502        let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
503        let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
504        for r in resources {
505            physical_ids.insert(r.logical_id.clone(), r.physical_id.clone());
506            attributes.insert(r.logical_id.clone(), r.attributes.clone());
507        }
508
509        let imports = {
510            let accounts = state.read();
511            let mut out = BTreeMap::new();
512            // Walk every account so cross-stack imports work even if
513            // future use-cases serve mixed accounts.
514            for (_account, st) in accounts.iter() {
515                for (name, export) in &st.exports {
516                    out.insert(name.clone(), export.value.clone());
517                }
518            }
519            out
520        };
521
522        let parsed = match template::parse_outputs(
523            &value,
524            parameters,
525            &resources_obj,
526            &physical_ids,
527            &attributes,
528            &imports,
529        ) {
530            Ok(o) => o,
531            Err(_) => return Vec::new(),
532        };
533
534        parsed
535            .into_iter()
536            .map(|o| state::StackOutput {
537                key: o.logical_id,
538                value: o.value,
539                description: o.description,
540                export_name: o.export_name,
541            })
542            .collect()
543    }
544
545    /// Reject creates/updates whose outputs would re-export a name that
546    /// another live stack already exports. Mirrors real CloudFormation.
547    fn ensure_export_uniqueness(
548        state: &SharedCloudFormationState,
549        account_id: &str,
550        stack_name: &str,
551        outputs: &[state::StackOutput],
552    ) -> Result<(), AwsServiceError> {
553        let existing = Self::collect_account_imports(state, account_id, Some(stack_name));
554        for o in outputs {
555            if let Some(export) = &o.export_name {
556                if existing.contains_key(export) {
557                    return Err(AwsServiceError::aws_error(
558                        StatusCode::BAD_REQUEST,
559                        "ValidationError",
560                        format!("Export with name {export} is already exported by another stack"),
561                    ));
562                }
563            }
564        }
565        Ok(())
566    }
567
568    fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
569        let params = Self::get_all_params(req);
570
571        let stack_name = params.get("StackName").ok_or_else(|| {
572            AwsServiceError::aws_error(
573                StatusCode::BAD_REQUEST,
574                "ValidationError",
575                "StackName is required",
576            )
577        })?;
578
579        let template_body = params.get("TemplateBody").ok_or_else(|| {
580            AwsServiceError::aws_error(
581                StatusCode::BAD_REQUEST,
582                "ValidationError",
583                "TemplateBody is required",
584            )
585        })?;
586
587        // Check if stack already exists and is not deleted
588        {
589            let accounts = self.state.read();
590            let empty = CloudFormationState::new(&req.account_id, &req.region);
591            let state = accounts.get(&req.account_id).unwrap_or(&empty);
592            if let Some(existing) = state.stacks.get(stack_name.as_str()) {
593                if existing.status != "DELETE_COMPLETE" {
594                    return Err(AwsServiceError::aws_error(
595                        StatusCode::BAD_REQUEST,
596                        "AlreadyExistsException",
597                        format!("Stack [{stack_name}] already exists"),
598                    ));
599                }
600            }
601        }
602
603        let tags = Self::extract_tags(&params);
604        let mut parameters = Self::extract_parameters(&params);
605        let notification_arns = Self::extract_notification_arns(&params);
606
607        // Seed AWS::* pseudo-parameters with stack-context values so
608        // resolve_refs can substitute them into resource properties.
609        let stack_id = format!(
610            "arn:aws:cloudformation:{}:{}:stack/{}/{}",
611            req.region,
612            req.account_id,
613            stack_name,
614            uuid::Uuid::new_v4()
615        );
616        parameters
617            .entry("AWS::Region".to_string())
618            .or_insert_with(|| req.region.clone());
619        parameters
620            .entry("AWS::AccountId".to_string())
621            .or_insert_with(|| req.account_id.clone());
622        parameters
623            .entry("AWS::StackId".to_string())
624            .or_insert_with(|| stack_id.clone());
625        parameters
626            .entry("AWS::StackName".to_string())
627            .or_insert_with(|| stack_name.clone());
628        parameters
629            .entry("AWS::Partition".to_string())
630            .or_insert_with(|| template::partition_for_region(&req.region).to_string());
631        parameters
632            .entry("AWS::URLSuffix".to_string())
633            .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
634        // NotificationARNs is array-typed; pseudo_value parses it back
635        // out of JSON. Always set so a `Ref: AWS::NotificationARNs`
636        // returns the request's actual list (or an empty array).
637        parameters.insert(
638            "AWS::NotificationARNs".to_string(),
639            serde_json::to_string(&notification_arns).unwrap_or_else(|_| "[]".to_string()),
640        );
641
642        // First pass: parse to get resource definitions (without physical ID resolution)
643        let parsed = template::parse_template(template_body, &parameters).map_err(|e| {
644            AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
645        })?;
646
647        // Refuse if any Fn::ImportValue references an unknown export. CFN
648        // checks this before provisioning; we mirror that so callers get
649        // a clean error instead of half-built resources.
650        let imported_names = Self::validate_import_values(
651            &self.state,
652            &req.account_id,
653            stack_name,
654            template_body,
655            &parameters,
656        )?;
657
658        let provisioner = self.provisioner(&stack_id, &req.account_id, &req.region);
659        let resources =
660            provision_stack_resources(&provisioner, &parsed.resources, template_body, &parameters)?;
661
662        let outputs =
663            Self::resolve_template_outputs(template_body, &parameters, &resources, &self.state);
664
665        Self::ensure_export_uniqueness(&self.state, &req.account_id, stack_name, &outputs)?;
666
667        let stack = Stack {
668            name: stack_name.clone(),
669            stack_id: stack_id.clone(),
670            template: template_body.clone(),
671            status: "CREATE_COMPLETE".to_string(),
672            resources: resources.clone(),
673            parameters,
674            tags,
675            created_at: Utc::now(),
676            updated_at: None,
677            description: parsed.description,
678            notification_arns: notification_arns.clone(),
679            outputs: outputs.clone(),
680        };
681
682        {
683            let mut accounts = self.state.write();
684            let state = accounts.get_or_create(&req.account_id);
685            state.stacks.insert(stack_name.clone(), stack);
686            Self::sync_exports_imports(state, &stack_id, stack_name, &outputs, &imported_names);
687
688            // Emit lifecycle events for CreateStack
689            record_stack_status_event(
690                state,
691                &stack_id,
692                stack_name,
693                "AWS::CloudFormation::Stack",
694                "CREATE_IN_PROGRESS",
695            );
696            let changes: Vec<ResourceChange> = resources
697                .iter()
698                .map(|r| ResourceChange {
699                    action: ResourceChangeAction::Create,
700                    logical_id: r.logical_id.clone(),
701                    physical_id: r.physical_id.clone(),
702                    resource_type: r.resource_type.clone(),
703                })
704                .collect();
705            record_stack_events(state, &stack_id, stack_name, &changes);
706            record_stack_status_event(
707                state,
708                &stack_id,
709                stack_name,
710                "AWS::CloudFormation::Stack",
711                "CREATE_COMPLETE",
712            );
713        }
714
715        Self::send_stack_notification(
716            &self.deps.delivery,
717            &notification_arns,
718            stack_name,
719            &stack_id,
720            "CREATE_COMPLETE",
721        );
722
723        Ok(AwsResponse::xml(
724            StatusCode::OK,
725            xml_responses::create_stack_response(&stack_id, &req.request_id),
726        ))
727    }
728
729    fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
730        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
731            AwsServiceError::aws_error(
732                StatusCode::BAD_REQUEST,
733                "ValidationError",
734                "StackName is required",
735            )
736        })?;
737
738        let mut accounts = self.state.write();
739        let state = accounts.get_or_create(&req.account_id);
740
741        // Find stack by name or stack ID
742        let stack = state.stacks.values_mut().find(|s| {
743            (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
744        });
745
746        if let Some(stack) = stack {
747            let stack_id = stack.stack_id.clone();
748            let stack_name_for_notif = stack.name.clone();
749            let notification_arns = stack.notification_arns.clone();
750            let resources: Vec<_> = stack.resources.clone();
751
752            // Block delete if any of this stack's exports are still
753            // imported by another live stack. Mirrors real CFN.
754            let owned_exports: Vec<String> = state
755                .exports
756                .iter()
757                .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
758                .map(|(k, _)| k.clone())
759                .collect();
760            for export in &owned_exports {
761                if let Some(consumers) = state.imports.get(export) {
762                    let consumers: Vec<&String> = consumers
763                        .iter()
764                        .filter(|c| **c != stack_name_for_notif)
765                        .collect();
766                    if !consumers.is_empty() {
767                        let names: Vec<&str> = consumers.iter().map(|s| s.as_str()).collect();
768                        return Err(AwsServiceError::aws_error(
769                            StatusCode::BAD_REQUEST,
770                            "ValidationError",
771                            format!(
772                                "Export {export} cannot be deleted as it is in use by {}",
773                                names.join(", ")
774                            ),
775                        ));
776                    }
777                }
778            }
779
780            // Build the provisioner while we still have the stack_id
781            // Drop the write lock temporarily so the provisioner can read state
782            drop(accounts);
783            let provisioner = self.provisioner(&stack_id, &req.account_id, &req.region);
784
785            // Delete resources in reverse order
786            for resource in resources.iter().rev() {
787                let _ = provisioner.delete_resource(resource);
788            }
789
790            // Re-acquire the write lock to update stack status
791            let mut accounts = self.state.write();
792            let state = accounts.get_or_create(&req.account_id);
793            if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
794                stack.status = "DELETE_COMPLETE".to_string();
795                stack.resources.clear();
796                stack.outputs.clear();
797            }
798            // Drop this stack's exports + import-consumer entries.
799            let stale_exports: Vec<String> = state
800                .exports
801                .iter()
802                .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
803                .map(|(k, _)| k.clone())
804                .collect();
805            for k in stale_exports {
806                state.exports.remove(&k);
807            }
808            for entries in state.imports.values_mut() {
809                entries.retain(|s| s != &stack_name_for_notif);
810            }
811            state.imports.retain(|_, v| !v.is_empty());
812            drop(accounts);
813
814            Self::send_stack_notification(
815                &self.deps.delivery,
816                &notification_arns,
817                &stack_name_for_notif,
818                &stack_id,
819                "DELETE_COMPLETE",
820            );
821        }
822
823        Ok(AwsResponse::xml(
824            StatusCode::OK,
825            xml_responses::delete_stack_response(&req.request_id),
826        ))
827    }
828
829    fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
830        let stack_name = Self::get_param(req, "StackName");
831
832        let accounts = self.state.read();
833        let empty = CloudFormationState::new(&req.account_id, &req.region);
834        let state = accounts.get(&req.account_id).unwrap_or(&empty);
835        let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
836            state
837                .stacks
838                .values()
839                .filter(|s| {
840                    (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
841                })
842                .cloned()
843                .collect()
844        } else {
845            state
846                .stacks
847                .values()
848                .filter(|s| s.status != "DELETE_COMPLETE")
849                .cloned()
850                .collect()
851        };
852
853        if let Some(ref name) = stack_name {
854            if stacks.is_empty() {
855                return Err(AwsServiceError::aws_error(
856                    StatusCode::BAD_REQUEST,
857                    "ValidationError",
858                    format!("Stack with id {name} does not exist"),
859                ));
860            }
861        }
862
863        Ok(AwsResponse::xml(
864            StatusCode::OK,
865            xml_responses::describe_stacks_response(&stacks, &req.request_id),
866        ))
867    }
868
869    fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
870        let accounts = self.state.read();
871        let empty = CloudFormationState::new(&req.account_id, &req.region);
872        let state = accounts.get(&req.account_id).unwrap_or(&empty);
873        let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
874
875        Ok(AwsResponse::xml(
876            StatusCode::OK,
877            xml_responses::list_stacks_response(&stacks, &req.request_id),
878        ))
879    }
880
881    fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
882        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
883            AwsServiceError::aws_error(
884                StatusCode::BAD_REQUEST,
885                "ValidationError",
886                "StackName is required",
887            )
888        })?;
889
890        let accounts = self.state.read();
891        let empty = CloudFormationState::new(&req.account_id, &req.region);
892        let state = accounts.get(&req.account_id).unwrap_or(&empty);
893        let stack = state
894            .stacks
895            .values()
896            .find(|s| {
897                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
898            })
899            .ok_or_else(|| {
900                AwsServiceError::aws_error(
901                    StatusCode::BAD_REQUEST,
902                    "ValidationError",
903                    format!("Stack [{stack_name}] does not exist"),
904                )
905            })?;
906
907        Ok(AwsResponse::xml(
908            StatusCode::OK,
909            xml_responses::list_stack_resources_response(&stack.resources, &req.request_id),
910        ))
911    }
912
913    fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
914        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
915            AwsServiceError::aws_error(
916                StatusCode::BAD_REQUEST,
917                "ValidationError",
918                "StackName is required",
919            )
920        })?;
921
922        let accounts = self.state.read();
923        let empty = CloudFormationState::new(&req.account_id, &req.region);
924        let state = accounts.get(&req.account_id).unwrap_or(&empty);
925        let stack = state
926            .stacks
927            .values()
928            .find(|s| {
929                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
930            })
931            .ok_or_else(|| {
932                AwsServiceError::aws_error(
933                    StatusCode::BAD_REQUEST,
934                    "ValidationError",
935                    format!("Stack [{stack_name}] does not exist"),
936                )
937            })?;
938
939        Ok(AwsResponse::xml(
940            StatusCode::OK,
941            xml_responses::describe_stack_resources_response(
942                &stack.resources,
943                &stack.name,
944                &req.request_id,
945            ),
946        ))
947    }
948
949    fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
950        let mut input = UpdateStackInput::from_params(req)?;
951
952        // Get stack_id before write lock for the provisioner
953        let found_stack_id = {
954            let accounts = self.state.read();
955            let empty = CloudFormationState::new(&req.account_id, &req.region);
956            let state = accounts.get(&req.account_id).unwrap_or(&empty);
957            state
958                .stacks
959                .values()
960                .find(|s| {
961                    (s.name == input.stack_name || s.stack_id == input.stack_name)
962                        && s.status != "DELETE_COMPLETE"
963                })
964                .map(|s| s.stack_id.clone())
965                .unwrap_or_default()
966        };
967
968        // Seed pseudo-parameters before parsing — the StackId is now known
969        // (after the read above) so resolve_refs sees the same values that
970        // the original CreateStack invocation used.
971        input
972            .parameters
973            .entry("AWS::Region".to_string())
974            .or_insert_with(|| req.region.clone());
975        input
976            .parameters
977            .entry("AWS::AccountId".to_string())
978            .or_insert_with(|| req.account_id.clone());
979        input
980            .parameters
981            .entry("AWS::StackId".to_string())
982            .or_insert_with(|| found_stack_id.clone());
983        input
984            .parameters
985            .entry("AWS::StackName".to_string())
986            .or_insert_with(|| input.stack_name.clone());
987        input
988            .parameters
989            .entry("AWS::Partition".to_string())
990            .or_insert_with(|| template::partition_for_region(&req.region).to_string());
991        input
992            .parameters
993            .entry("AWS::URLSuffix".to_string())
994            .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
995        // Seed AWS::NotificationARNs from the update payload, falling
996        // back to whatever the existing stack carries when the request
997        // omits the param. Encoded as JSON so pseudo_value can split it
998        // back into the array shape Ref returns.
999        if !input.notification_arns.is_empty() {
1000            input.parameters.insert(
1001                "AWS::NotificationARNs".to_string(),
1002                serde_json::to_string(&input.notification_arns)
1003                    .unwrap_or_else(|_| "[]".to_string()),
1004            );
1005        } else {
1006            // Carry the existing stack's notification ARNs forward so the
1007            // pseudo-param keeps its previous value across updates.
1008            let existing: Vec<String> = {
1009                let accounts = self.state.read();
1010                accounts
1011                    .get(&req.account_id)
1012                    .and_then(|s| {
1013                        s.stacks
1014                            .values()
1015                            .find(|st| st.stack_id == found_stack_id)
1016                            .map(|st| st.notification_arns.clone())
1017                    })
1018                    .unwrap_or_default()
1019            };
1020            input.parameters.insert(
1021                "AWS::NotificationARNs".to_string(),
1022                serde_json::to_string(&existing).unwrap_or_else(|_| "[]".to_string()),
1023            );
1024        }
1025
1026        let parsed =
1027            template::parse_template(&input.template_body, &input.parameters).map_err(|e| {
1028                AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
1029            })?;
1030
1031        let imported_names = Self::validate_import_values(
1032            &self.state,
1033            &req.account_id,
1034            &input.stack_name,
1035            &input.template_body,
1036            &input.parameters,
1037        )?;
1038
1039        let provisioner = self.provisioner(&found_stack_id, &req.account_id, &req.region);
1040
1041        let mut accounts = self.state.write();
1042        let state = accounts.get_or_create(&req.account_id);
1043        let (update_result, stack_id, stack_name_owned, resources_snapshot, notification_arns) = {
1044            let stack = state
1045                .stacks
1046                .values_mut()
1047                .find(|s| {
1048                    (s.name == input.stack_name || s.stack_id == input.stack_name)
1049                        && s.status != "DELETE_COMPLETE"
1050                })
1051                .ok_or_else(|| {
1052                    AwsServiceError::aws_error(
1053                        StatusCode::BAD_REQUEST,
1054                        "ValidationError",
1055                        format!("Stack [{}] does not exist", input.stack_name),
1056                    )
1057                })?;
1058
1059            stack.status = "UPDATE_IN_PROGRESS".to_string();
1060            let update_result = apply_resource_updates(
1061                stack,
1062                &parsed.resources,
1063                &input.template_body,
1064                &input.parameters,
1065                &provisioner,
1066            );
1067
1068            let stack_id = stack.stack_id.clone();
1069            let stack_name_owned = stack.name.clone();
1070            stack.template = input.template_body.clone();
1071            stack.status = if update_result.is_err() {
1072                "UPDATE_ROLLBACK_COMPLETE".to_string()
1073            } else {
1074                "UPDATE_COMPLETE".to_string()
1075            };
1076            stack.parameters = input.parameters.clone();
1077            if !input.tags.is_empty() {
1078                stack.tags = input.tags;
1079            }
1080            stack.updated_at = Some(Utc::now());
1081            stack.description = parsed.description;
1082            if !input.notification_arns.is_empty() {
1083                stack.notification_arns = input.notification_arns.clone();
1084            }
1085            if update_result.is_ok() {
1086                stack.outputs.clear();
1087            }
1088            (
1089                update_result,
1090                stack_id,
1091                stack_name_owned,
1092                stack.resources.clone(),
1093                stack.notification_arns.clone(),
1094            )
1095        };
1096
1097        // Emit lifecycle events (now that the &mut Stack borrow is dropped).
1098        record_stack_status_event(
1099            state,
1100            &stack_id,
1101            &stack_name_owned,
1102            "AWS::CloudFormation::Stack",
1103            "UPDATE_IN_PROGRESS",
1104        );
1105        let update_result = match update_result {
1106            Ok(changes) => {
1107                record_stack_events(state, &stack_id, &stack_name_owned, &changes);
1108                record_stack_status_event(
1109                    state,
1110                    &stack_id,
1111                    &stack_name_owned,
1112                    "AWS::CloudFormation::Stack",
1113                    "UPDATE_COMPLETE",
1114                );
1115                Ok(())
1116            }
1117            Err(e) => {
1118                record_stack_status_event(
1119                    state,
1120                    &stack_id,
1121                    &stack_name_owned,
1122                    "AWS::CloudFormation::Stack",
1123                    "UPDATE_ROLLBACK_COMPLETE",
1124                );
1125                Err(e)
1126            }
1127        };
1128        let stack_name_for_notif = stack_name_owned.clone();
1129
1130        if let Err(error_msg) = update_result {
1131            drop(accounts);
1132            Self::send_stack_notification(
1133                &self.deps.delivery,
1134                &notification_arns,
1135                &stack_name_for_notif,
1136                &stack_id,
1137                "UPDATE_FAILED",
1138            );
1139            return Err(AwsServiceError::aws_error(
1140                StatusCode::BAD_REQUEST,
1141                "ValidationError",
1142                error_msg,
1143            ));
1144        }
1145
1146        drop(accounts);
1147
1148        let outputs = Self::resolve_template_outputs(
1149            &input.template_body,
1150            &input.parameters,
1151            &resources_snapshot,
1152            &self.state,
1153        );
1154        Self::ensure_export_uniqueness(&self.state, &req.account_id, &input.stack_name, &outputs)?;
1155        {
1156            let mut accounts = self.state.write();
1157            let state = accounts.get_or_create(&req.account_id);
1158            if let Some(stack) = state
1159                .stacks
1160                .values_mut()
1161                .find(|s| s.stack_id == stack_id && s.status != "DELETE_COMPLETE")
1162            {
1163                stack.outputs = outputs.clone();
1164            }
1165            Self::sync_exports_imports(
1166                state,
1167                &stack_id,
1168                &input.stack_name,
1169                &outputs,
1170                &imported_names,
1171            );
1172        }
1173
1174        Self::send_stack_notification(
1175            &self.deps.delivery,
1176            &notification_arns,
1177            &stack_name_for_notif,
1178            &stack_id,
1179            "UPDATE_COMPLETE",
1180        );
1181
1182        Ok(AwsResponse::xml(
1183            StatusCode::OK,
1184            xml_responses::update_stack_response(&stack_id, &req.request_id),
1185        ))
1186    }
1187
1188    fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1189        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
1190            AwsServiceError::aws_error(
1191                StatusCode::BAD_REQUEST,
1192                "ValidationError",
1193                "StackName is required",
1194            )
1195        })?;
1196
1197        let accounts = self.state.read();
1198        let empty = CloudFormationState::new(&req.account_id, &req.region);
1199        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1200        let stack = state
1201            .stacks
1202            .values()
1203            .find(|s| {
1204                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1205            })
1206            .ok_or_else(|| {
1207                AwsServiceError::aws_error(
1208                    StatusCode::BAD_REQUEST,
1209                    "ValidationError",
1210                    format!("Stack [{stack_name}] does not exist"),
1211                )
1212            })?;
1213
1214        Ok(AwsResponse::xml(
1215            StatusCode::OK,
1216            xml_responses::get_template_response(&stack.template, &req.request_id),
1217        ))
1218    }
1219}
1220
1221#[async_trait]
1222impl AwsService for CloudFormationService {
1223    fn service_name(&self) -> &str {
1224        "cloudformation"
1225    }
1226
1227    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1228        let action = req.action.as_str();
1229        // Only ops whose handlers actually write to per-account state
1230        // need to trigger snapshot persistence. Pass-through ops that
1231        // return canned IDs but don't touch state are excluded.
1232        let mutates = matches!(
1233            action,
1234            "CreateStack"
1235                | "DeleteStack"
1236                | "UpdateStack"
1237                | "CreateChangeSet"
1238                | "DeleteChangeSet"
1239                | "ExecuteChangeSet"
1240                | "CreateStackSet"
1241                | "DeleteStackSet"
1242                | "CreateStackRefactor"
1243                | "CreateGeneratedTemplate"
1244                | "DeleteGeneratedTemplate"
1245                | "SetStackPolicy"
1246                | "UpdateTerminationProtection"
1247                | "ActivateOrganizationsAccess"
1248                | "DeactivateOrganizationsAccess"
1249        );
1250        let result = match action {
1251            "CreateStack" => self.create_stack(&req),
1252            "DeleteStack" => self.delete_stack(&req),
1253            "DescribeStacks" => self.describe_stacks(&req),
1254            "ListStacks" => self.list_stacks(&req),
1255            "ListStackResources" => self.list_stack_resources(&req),
1256            "DescribeStackResources" => self.describe_stack_resources(&req),
1257            "UpdateStack" => self.update_stack(&req),
1258            "GetTemplate" => self.get_template(&req),
1259            _ => self.handle_extra_action(&req),
1260        };
1261        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
1262            self.save_snapshot().await;
1263        }
1264        result
1265    }
1266
1267    fn supported_actions(&self) -> &[&str] {
1268        &[
1269            "ActivateOrganizationsAccess",
1270            "ActivateType",
1271            "BatchDescribeTypeConfigurations",
1272            "CancelUpdateStack",
1273            "ContinueUpdateRollback",
1274            "CreateChangeSet",
1275            "CreateGeneratedTemplate",
1276            "CreateStack",
1277            "CreateStackInstances",
1278            "CreateStackRefactor",
1279            "CreateStackSet",
1280            "DeactivateOrganizationsAccess",
1281            "DeactivateType",
1282            "DeleteChangeSet",
1283            "DeleteGeneratedTemplate",
1284            "DeleteStack",
1285            "DeleteStackInstances",
1286            "DeleteStackSet",
1287            "DeregisterType",
1288            "DescribeAccountLimits",
1289            "DescribeChangeSet",
1290            "DescribeChangeSetHooks",
1291            "DescribeEvents",
1292            "DescribeGeneratedTemplate",
1293            "DescribeOrganizationsAccess",
1294            "DescribePublisher",
1295            "DescribeResourceScan",
1296            "DescribeStackDriftDetectionStatus",
1297            "DescribeStackEvents",
1298            "DescribeStackInstance",
1299            "DescribeStackRefactor",
1300            "DescribeStackResource",
1301            "DescribeStackResourceDrifts",
1302            "DescribeStackResources",
1303            "DescribeStackSet",
1304            "DescribeStackSetOperation",
1305            "DescribeStacks",
1306            "DescribeType",
1307            "DescribeTypeRegistration",
1308            "DetectStackDrift",
1309            "DetectStackResourceDrift",
1310            "DetectStackSetDrift",
1311            "EstimateTemplateCost",
1312            "ExecuteChangeSet",
1313            "ExecuteStackRefactor",
1314            "GetGeneratedTemplate",
1315            "GetHookResult",
1316            "GetStackPolicy",
1317            "GetTemplate",
1318            "GetTemplateSummary",
1319            "ImportStacksToStackSet",
1320            "ListChangeSets",
1321            "ListExports",
1322            "ListGeneratedTemplates",
1323            "ListHookResults",
1324            "ListImports",
1325            "ListResourceScanRelatedResources",
1326            "ListResourceScanResources",
1327            "ListResourceScans",
1328            "ListStackInstanceResourceDrifts",
1329            "ListStackInstances",
1330            "ListStackRefactorActions",
1331            "ListStackRefactors",
1332            "ListStackResources",
1333            "ListStackSetAutoDeploymentTargets",
1334            "ListStackSetOperationResults",
1335            "ListStackSetOperations",
1336            "ListStackSets",
1337            "ListStacks",
1338            "ListTypeRegistrations",
1339            "ListTypeVersions",
1340            "ListTypes",
1341            "PublishType",
1342            "RecordHandlerProgress",
1343            "RegisterPublisher",
1344            "RegisterType",
1345            "RollbackStack",
1346            "SetStackPolicy",
1347            "SetTypeConfiguration",
1348            "SetTypeDefaultVersion",
1349            "SignalResource",
1350            "StartResourceScan",
1351            "StopStackSetOperation",
1352            "TestType",
1353            "UpdateGeneratedTemplate",
1354            "UpdateStack",
1355            "UpdateStackInstances",
1356            "UpdateStackSet",
1357            "UpdateTerminationProtection",
1358            "ValidateTemplate",
1359        ]
1360    }
1361}
1362
1363/// Parsed + validated inputs for `UpdateStack`.
1364struct UpdateStackInput {
1365    stack_name: String,
1366    template_body: String,
1367    parameters: BTreeMap<String, String>,
1368    tags: BTreeMap<String, String>,
1369    notification_arns: Vec<String>,
1370}
1371
1372impl UpdateStackInput {
1373    fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
1374        let params = CloudFormationService::get_all_params(req);
1375
1376        let stack_name = params
1377            .get("StackName")
1378            .ok_or_else(|| {
1379                AwsServiceError::aws_error(
1380                    StatusCode::BAD_REQUEST,
1381                    "ValidationError",
1382                    "StackName is required",
1383                )
1384            })?
1385            .to_string();
1386
1387        let template_body = params
1388            .get("TemplateBody")
1389            .ok_or_else(|| {
1390                AwsServiceError::aws_error(
1391                    StatusCode::BAD_REQUEST,
1392                    "ValidationError",
1393                    "TemplateBody is required",
1394                )
1395            })?
1396            .to_string();
1397
1398        Ok(Self {
1399            stack_name,
1400            template_body,
1401            parameters: CloudFormationService::extract_parameters(&params),
1402            tags: CloudFormationService::extract_tags(&params),
1403            notification_arns: CloudFormationService::extract_notification_arns(&params),
1404        })
1405    }
1406}
1407
1408/// One row of structured diff returned by `apply_resource_updates`. Used
1409/// by `ExecuteChangeSet` to emit `StackEvent` rows so `DescribeStackEvents`
1410/// reflects the resources actually created / updated / deleted.
1411#[derive(Debug, Clone)]
1412pub(crate) struct ResourceChange {
1413    pub action: ResourceChangeAction,
1414    pub logical_id: String,
1415    pub physical_id: String,
1416    pub resource_type: String,
1417}
1418
1419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1420pub(crate) enum ResourceChangeAction {
1421    Create,
1422    Update,
1423    Delete,
1424}
1425
1426impl ResourceChangeAction {
1427    pub fn status_in_progress(self) -> &'static str {
1428        match self {
1429            Self::Create => "CREATE_IN_PROGRESS",
1430            Self::Update => "UPDATE_IN_PROGRESS",
1431            Self::Delete => "DELETE_IN_PROGRESS",
1432        }
1433    }
1434    pub fn status_complete(self) -> &'static str {
1435        match self {
1436            Self::Create => "CREATE_COMPLETE",
1437            Self::Update => "UPDATE_COMPLETE",
1438            Self::Delete => "DELETE_COMPLETE",
1439        }
1440    }
1441}
1442
1443/// Apply resource updates: delete removed resources, create new ones, and
1444/// in-place update resources whose properties changed. Returns the list of
1445/// changes applied (for event emission) on success or `Err(msg)` if any
1446/// resource operation fails.
1447pub(crate) fn apply_resource_updates(
1448    stack: &mut crate::state::Stack,
1449    new_resource_defs: &[template::ResourceDefinition],
1450    template_body: &str,
1451    parameters: &BTreeMap<String, String>,
1452    provisioner: &crate::resource_provisioner::ResourceProvisioner,
1453) -> Result<Vec<ResourceChange>, String> {
1454    let mut changes: Vec<ResourceChange> = Vec::new();
1455    let old_logical_ids: std::collections::HashSet<String> = stack
1456        .resources
1457        .iter()
1458        .map(|r| r.logical_id.clone())
1459        .collect();
1460    let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
1461        .iter()
1462        .map(|r| r.logical_id.clone())
1463        .collect();
1464
1465    // Delete resources no longer in template
1466    let to_remove: Vec<_> = stack
1467        .resources
1468        .iter()
1469        .filter(|r| !new_logical_ids.contains(&r.logical_id))
1470        .cloned()
1471        .collect();
1472    for resource in &to_remove {
1473        let _ = provisioner.delete_resource(resource);
1474        changes.push(ResourceChange {
1475            action: ResourceChangeAction::Delete,
1476            logical_id: resource.logical_id.clone(),
1477            physical_id: resource.physical_id.clone(),
1478            resource_type: resource.resource_type.clone(),
1479        });
1480    }
1481    stack
1482        .resources
1483        .retain(|r| new_logical_ids.contains(&r.logical_id));
1484
1485    // Build physical ID + attribute maps from existing resources
1486    let mut physical_ids: BTreeMap<String, String> = stack
1487        .resources
1488        .iter()
1489        .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
1490        .collect();
1491    let mut attributes: BTreeMap<String, BTreeMap<String, String>> = stack
1492        .resources
1493        .iter()
1494        .map(|r| (r.logical_id.clone(), r.attributes.clone()))
1495        .collect();
1496
1497    // Create new resources / update resources that already exist
1498    for resource_def in new_resource_defs {
1499        let resolved_def = template::resolve_resource_properties_with_attrs(
1500            resource_def,
1501            template_body,
1502            parameters,
1503            &physical_ids,
1504            &attributes,
1505        )
1506        .map_err(|e| {
1507            format!(
1508                "Failed to resolve resource {}: {e}",
1509                resource_def.logical_id
1510            )
1511        })?;
1512
1513        if !old_logical_ids.contains(&resource_def.logical_id) {
1514            match provisioner.create_resource(&resolved_def) {
1515                Ok(stack_resource) => {
1516                    changes.push(ResourceChange {
1517                        action: ResourceChangeAction::Create,
1518                        logical_id: stack_resource.logical_id.clone(),
1519                        physical_id: stack_resource.physical_id.clone(),
1520                        resource_type: stack_resource.resource_type.clone(),
1521                    });
1522                    physical_ids.insert(
1523                        stack_resource.logical_id.clone(),
1524                        stack_resource.physical_id.clone(),
1525                    );
1526                    attributes.insert(
1527                        stack_resource.logical_id.clone(),
1528                        stack_resource.attributes.clone(),
1529                    );
1530                    stack.resources.push(stack_resource);
1531                }
1532                Err(e) => {
1533                    tracing::warn!(
1534                        "Failed to create resource {} during update: {e}",
1535                        resource_def.logical_id
1536                    );
1537                    return Err(format!(
1538                        "Failed to create resource {}: {e}",
1539                        resource_def.logical_id
1540                    ));
1541                }
1542            }
1543        } else {
1544            // Resource exists in both old and new templates — try to apply
1545            // an in-place update. The provisioner returns `Ok(None)` for
1546            // resource types that don't support updates yet; in that case
1547            // the existing resource stays as-is so the rest of the stack
1548            // continues to validate.
1549            let existing = stack
1550                .resources
1551                .iter()
1552                .find(|r| r.logical_id == resource_def.logical_id)
1553                .cloned();
1554            if let Some(existing) = existing {
1555                match provisioner.update_resource(&existing, &resolved_def) {
1556                    Ok(Some(updated)) => {
1557                        changes.push(ResourceChange {
1558                            action: ResourceChangeAction::Update,
1559                            logical_id: updated.logical_id.clone(),
1560                            physical_id: updated.physical_id.clone(),
1561                            resource_type: updated.resource_type.clone(),
1562                        });
1563                        physical_ids
1564                            .insert(updated.logical_id.clone(), updated.physical_id.clone());
1565                        attributes.insert(updated.logical_id.clone(), updated.attributes.clone());
1566                        if let Some(slot) = stack
1567                            .resources
1568                            .iter_mut()
1569                            .find(|r| r.logical_id == updated.logical_id)
1570                        {
1571                            *slot = updated;
1572                        }
1573                    }
1574                    Ok(None) => {
1575                        // Resource type has no update path — leave the
1576                        // existing physical resource untouched.
1577                    }
1578                    Err(e) => {
1579                        tracing::warn!(
1580                            "Failed to update resource {} during update: {e}",
1581                            resource_def.logical_id
1582                        );
1583                        return Err(format!(
1584                            "Failed to update resource {}: {e}",
1585                            resource_def.logical_id
1586                        ));
1587                    }
1588                }
1589            }
1590        }
1591    }
1592
1593    Ok(changes)
1594}
1595
1596/// Pushes a single `StackEvent` row onto the per-stack event log so
1597/// `DescribeStackEvents` returns a chronological history of resource and
1598/// stack-level state transitions.
1599pub(crate) fn record_event(
1600    state: &mut crate::state::CloudFormationState,
1601    stack_id: &str,
1602    stack_name: &str,
1603    logical_id: &str,
1604    physical_id: &str,
1605    resource_type: &str,
1606    status: &str,
1607) {
1608    use serde_json::json;
1609    let event_id = format!(
1610        "{}-{:x}",
1611        logical_id,
1612        std::time::SystemTime::now()
1613            .duration_since(std::time::UNIX_EPOCH)
1614            .map(|d| d.as_nanos())
1615            .unwrap_or(0)
1616    );
1617    let entry = json!({
1618        "EventId": event_id,
1619        "StackId": stack_id,
1620        "StackName": stack_name,
1621        "LogicalResourceId": logical_id,
1622        "PhysicalResourceId": physical_id,
1623        "ResourceType": resource_type,
1624        "ResourceStatus": status,
1625        "Timestamp": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1626    });
1627    state
1628        .events
1629        .entry(stack_id.to_string())
1630        .or_default()
1631        .push(entry);
1632}
1633
1634/// Emits IN_PROGRESS + COMPLETE event pairs for every resource change
1635/// applied during an update. Mirrors the event sequence real CloudFormation
1636/// publishes during `ExecuteChangeSet` / `UpdateStack`.
1637pub(crate) fn record_stack_events(
1638    state: &mut crate::state::CloudFormationState,
1639    stack_id: &str,
1640    stack_name: &str,
1641    changes: &[ResourceChange],
1642) {
1643    for ch in changes {
1644        record_event(
1645            state,
1646            stack_id,
1647            stack_name,
1648            &ch.logical_id,
1649            &ch.physical_id,
1650            &ch.resource_type,
1651            ch.action.status_in_progress(),
1652        );
1653        record_event(
1654            state,
1655            stack_id,
1656            stack_name,
1657            &ch.logical_id,
1658            &ch.physical_id,
1659            &ch.resource_type,
1660            ch.action.status_complete(),
1661        );
1662    }
1663}
1664
1665/// Emits a stack-level lifecycle event (`UPDATE_IN_PROGRESS`,
1666/// `UPDATE_COMPLETE`, `UPDATE_ROLLBACK_COMPLETE`, etc.) keyed on the
1667/// stack's own `LogicalResourceId == stack_name`, matching real CFN.
1668pub(crate) fn record_stack_status_event(
1669    state: &mut crate::state::CloudFormationState,
1670    stack_id: &str,
1671    stack_name: &str,
1672    resource_type: &str,
1673    status: &str,
1674) {
1675    record_event(
1676        state,
1677        stack_id,
1678        stack_name,
1679        stack_name,
1680        stack_id,
1681        resource_type,
1682        status,
1683    );
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688    use super::*;
1689    use http::HeaderMap;
1690    use parking_lot::RwLock;
1691    use std::collections::HashMap;
1692    use std::sync::Arc;
1693
1694    fn make_service() -> CloudFormationService {
1695        let cf_state = Arc::new(RwLock::new(
1696            fakecloud_core::multi_account::MultiAccountState::new(
1697                "123456789012",
1698                "us-east-1",
1699                "http://localhost:4566",
1700            ),
1701        ));
1702        let deps = CloudFormationDeps {
1703            sqs: Arc::new(RwLock::new(
1704                fakecloud_core::multi_account::MultiAccountState::new(
1705                    "123456789012",
1706                    "us-east-1",
1707                    "http://localhost:4566",
1708                ),
1709            )),
1710            sns: Arc::new(RwLock::new(
1711                fakecloud_core::multi_account::MultiAccountState::new(
1712                    "123456789012",
1713                    "us-east-1",
1714                    "http://localhost:4566",
1715                ),
1716            )),
1717            ssm: Arc::new(RwLock::new(
1718                fakecloud_core::multi_account::MultiAccountState::new(
1719                    "123456789012",
1720                    "us-east-1",
1721                    "http://localhost:4566",
1722                ),
1723            )),
1724            iam: Arc::new(RwLock::new(
1725                fakecloud_core::multi_account::MultiAccountState::new(
1726                    "123456789012",
1727                    "us-east-1",
1728                    "",
1729                ),
1730            )),
1731            s3: Arc::new(RwLock::new(
1732                fakecloud_core::multi_account::MultiAccountState::new(
1733                    "123456789012",
1734                    "us-east-1",
1735                    "",
1736                ),
1737            )),
1738            eventbridge: Arc::new(RwLock::new(
1739                fakecloud_core::multi_account::MultiAccountState::new(
1740                    "123456789012",
1741                    "us-east-1",
1742                    "",
1743                ),
1744            )),
1745            dynamodb: Arc::new(RwLock::new(
1746                fakecloud_core::multi_account::MultiAccountState::new(
1747                    "123456789012",
1748                    "us-east-1",
1749                    "",
1750                ),
1751            )),
1752            logs: Arc::new(RwLock::new(
1753                fakecloud_core::multi_account::MultiAccountState::new(
1754                    "123456789012",
1755                    "us-east-1",
1756                    "",
1757                ),
1758            )),
1759            lambda: Arc::new(RwLock::new(
1760                fakecloud_core::multi_account::MultiAccountState::new(
1761                    "123456789012",
1762                    "us-east-1",
1763                    "",
1764                ),
1765            )),
1766            secretsmanager: Arc::new(RwLock::new(
1767                fakecloud_core::multi_account::MultiAccountState::new(
1768                    "123456789012",
1769                    "us-east-1",
1770                    "",
1771                ),
1772            )),
1773            kinesis: Arc::new(RwLock::new(
1774                fakecloud_core::multi_account::MultiAccountState::new(
1775                    "123456789012",
1776                    "us-east-1",
1777                    "",
1778                ),
1779            )),
1780            kms: Arc::new(RwLock::new(
1781                fakecloud_core::multi_account::MultiAccountState::new(
1782                    "123456789012",
1783                    "us-east-1",
1784                    "",
1785                ),
1786            )),
1787            ecr: Arc::new(RwLock::new(
1788                fakecloud_core::multi_account::MultiAccountState::new(
1789                    "123456789012",
1790                    "us-east-1",
1791                    "",
1792                ),
1793            )),
1794            cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
1795            elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
1796            organizations: Arc::new(RwLock::new(None)),
1797            cognito: Arc::new(RwLock::new(
1798                fakecloud_core::multi_account::MultiAccountState::new(
1799                    "123456789012",
1800                    "us-east-1",
1801                    "",
1802                ),
1803            )),
1804            rds: Arc::new(RwLock::new(
1805                fakecloud_core::multi_account::MultiAccountState::new(
1806                    "123456789012",
1807                    "us-east-1",
1808                    "",
1809                ),
1810            )),
1811            ecs: Arc::new(RwLock::new(
1812                fakecloud_core::multi_account::MultiAccountState::new(
1813                    "123456789012",
1814                    "us-east-1",
1815                    "",
1816                ),
1817            )),
1818            acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
1819            elasticache: Arc::new(RwLock::new(
1820                fakecloud_core::multi_account::MultiAccountState::new(
1821                    "123456789012",
1822                    "us-east-1",
1823                    "",
1824                ),
1825            )),
1826            route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
1827            cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
1828            stepfunctions: Arc::new(RwLock::new(
1829                fakecloud_core::multi_account::MultiAccountState::new(
1830                    "123456789012",
1831                    "us-east-1",
1832                    "",
1833                ),
1834            )),
1835            wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
1836            apigateway: Arc::new(RwLock::new(
1837                fakecloud_core::multi_account::MultiAccountState::new(
1838                    "123456789012",
1839                    "us-east-1",
1840                    "",
1841                ),
1842            )),
1843            apigatewayv2: Arc::new(RwLock::new(
1844                fakecloud_core::multi_account::MultiAccountState::new(
1845                    "123456789012",
1846                    "us-east-1",
1847                    "",
1848                ),
1849            )),
1850            ses: Arc::new(RwLock::new(
1851                fakecloud_core::multi_account::MultiAccountState::new(
1852                    "123456789012",
1853                    "us-east-1",
1854                    "",
1855                ),
1856            )),
1857            application_autoscaling: Arc::new(parking_lot::RwLock::new(
1858                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
1859            )),
1860            athena: Arc::new(parking_lot::RwLock::new(
1861                fakecloud_athena::AthenaAccounts::new(),
1862            )),
1863            firehose: Arc::new(parking_lot::RwLock::new(
1864                fakecloud_firehose::FirehoseAccounts::new(),
1865            )),
1866            glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
1867            delivery: Arc::new(DeliveryBus::new()),
1868        };
1869        CloudFormationService::new(cf_state, deps)
1870    }
1871
1872    fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
1873        AwsRequest {
1874            service: "cloudformation".to_string(),
1875            action: action.to_string(),
1876            region: "us-east-1".to_string(),
1877            account_id: "123456789012".to_string(),
1878            request_id: "test-request-id".to_string(),
1879            headers: HeaderMap::new(),
1880            query_params: params,
1881            body: bytes::Bytes::new(),
1882            body_stream: parking_lot::Mutex::new(None),
1883            path_segments: vec![],
1884            raw_path: "/".to_string(),
1885            raw_query: String::new(),
1886            method: http::Method::POST,
1887            is_query_protocol: true,
1888            access_key_id: None,
1889            principal: None,
1890        }
1891    }
1892
1893    #[test]
1894    fn update_stack_sets_failed_status_on_resource_error() {
1895        let svc = make_service();
1896
1897        // Create a stack with just a queue
1898        let mut create_params = HashMap::new();
1899        create_params.insert("StackName".to_string(), "test-stack".to_string());
1900        create_params.insert(
1901            "TemplateBody".to_string(),
1902            r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
1903        );
1904        let req = make_request("CreateStack", create_params);
1905        let result = svc.create_stack(&req);
1906        assert!(result.is_ok());
1907
1908        // Update stack adding an SNS subscription with a non-existent topic
1909        let mut update_params = HashMap::new();
1910        update_params.insert("StackName".to_string(), "test-stack".to_string());
1911        update_params.insert(
1912            "TemplateBody".to_string(),
1913            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(),
1914        );
1915        let req = make_request("UpdateStack", update_params);
1916        let result = svc.update_stack(&req);
1917
1918        // Should return an error
1919        assert!(result.is_err());
1920
1921        // Stack status should be UPDATE_ROLLBACK_COMPLETE — matches the
1922        // terminal status real CloudFormation lands on after a failed
1923        // update attempt that gets rolled back.
1924        let accounts = svc.state.read();
1925        let state = accounts.get("123456789012").unwrap();
1926        let stack = state.stacks.get("test-stack").unwrap();
1927        assert_eq!(stack.status, "UPDATE_ROLLBACK_COMPLETE");
1928    }
1929
1930    #[test]
1931    fn create_stack_resolves_ref_to_physical_id() {
1932        let svc = make_service();
1933
1934        // Template where subscription Refs the topic
1935        let template = r#"{
1936            "Resources": {
1937                "MyTopic": {
1938                    "Type": "AWS::SNS::Topic",
1939                    "Properties": { "TopicName": "ref-test-topic" }
1940                },
1941                "MySub": {
1942                    "Type": "AWS::SNS::Subscription",
1943                    "Properties": {
1944                        "TopicArn": { "Ref": "MyTopic" },
1945                        "Protocol": "sqs",
1946                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
1947                    }
1948                }
1949            }
1950        }"#;
1951
1952        let mut params = HashMap::new();
1953        params.insert("StackName".to_string(), "ref-stack".to_string());
1954        params.insert("TemplateBody".to_string(), template.to_string());
1955        let req = make_request("CreateStack", params);
1956        let result = svc.create_stack(&req);
1957        assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
1958
1959        // Verify both resources were created
1960        let accounts = svc.state.read();
1961        let state = accounts.get("123456789012").unwrap();
1962        let stack = state.stacks.get("ref-stack").unwrap();
1963        assert_eq!(stack.resources.len(), 2);
1964        assert_eq!(stack.status, "CREATE_COMPLETE");
1965
1966        // The subscription's physical ID should be an ARN (not just "MyTopic")
1967        let sub = stack
1968            .resources
1969            .iter()
1970            .find(|r| r.logical_id == "MySub")
1971            .unwrap();
1972        assert!(
1973            sub.physical_id.contains("ref-test-topic"),
1974            "Subscription physical ID should reference the topic ARN, got: {}",
1975            sub.physical_id
1976        );
1977    }
1978
1979    // ── Service error paths ──
1980
1981    #[test]
1982    fn create_stack_missing_name_errors() {
1983        let svc = make_service();
1984        let mut params = HashMap::new();
1985        params.insert("TemplateBody".to_string(), "{}".to_string());
1986        let req = make_request("CreateStack", params);
1987        assert!(svc.create_stack(&req).is_err());
1988    }
1989
1990    #[test]
1991    fn create_stack_missing_template_errors() {
1992        let svc = make_service();
1993        let mut params = HashMap::new();
1994        params.insert("StackName".to_string(), "s".to_string());
1995        let req = make_request("CreateStack", params);
1996        assert!(svc.create_stack(&req).is_err());
1997    }
1998
1999    #[test]
2000    fn create_stack_duplicate_errors() {
2001        let svc = make_service();
2002        let mut params = HashMap::new();
2003        params.insert("StackName".to_string(), "dup".to_string());
2004        params.insert(
2005            "TemplateBody".to_string(),
2006            r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
2007                .to_string(),
2008        );
2009        let req = make_request("CreateStack", params.clone());
2010        svc.create_stack(&req).unwrap();
2011        let req = make_request("CreateStack", params);
2012        assert!(svc.create_stack(&req).is_err());
2013    }
2014
2015    #[test]
2016    fn create_stack_invalid_template_errors() {
2017        let svc = make_service();
2018        let mut params = HashMap::new();
2019        params.insert("StackName".to_string(), "bad".to_string());
2020        params.insert("TemplateBody".to_string(), "not json".to_string());
2021        let req = make_request("CreateStack", params);
2022        assert!(svc.create_stack(&req).is_err());
2023    }
2024
2025    #[test]
2026    fn delete_stack_unknown_is_noop() {
2027        let svc = make_service();
2028        let mut params = HashMap::new();
2029        params.insert("StackName".to_string(), "ghost".to_string());
2030        let req = make_request("DeleteStack", params);
2031        assert!(svc.delete_stack(&req).is_ok());
2032    }
2033
2034    #[test]
2035    fn describe_stacks_nonexistent_errors() {
2036        let svc = make_service();
2037        let mut params = HashMap::new();
2038        params.insert("StackName".to_string(), "ghost".to_string());
2039        let req = make_request("DescribeStacks", params);
2040        assert!(svc.describe_stacks(&req).is_err());
2041    }
2042
2043    #[test]
2044    fn describe_stacks_empty_returns_all() {
2045        let svc = make_service();
2046        let req = make_request("DescribeStacks", HashMap::new());
2047        let resp = svc.describe_stacks(&req).unwrap();
2048        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2049        assert!(b.contains("DescribeStacksResult"));
2050    }
2051
2052    #[test]
2053    fn list_stacks_empty_returns_ok() {
2054        let svc = make_service();
2055        let req = make_request("ListStacks", HashMap::new());
2056        let resp = svc.list_stacks(&req).unwrap();
2057        let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2058        assert!(b.contains("ListStacksResult"));
2059    }
2060
2061    #[test]
2062    fn list_stack_resources_missing_name_errors() {
2063        let svc = make_service();
2064        let req = make_request("ListStackResources", HashMap::new());
2065        assert!(svc.list_stack_resources(&req).is_err());
2066    }
2067
2068    #[test]
2069    fn list_stack_resources_unknown_stack_errors() {
2070        let svc = make_service();
2071        let mut params = HashMap::new();
2072        params.insert("StackName".to_string(), "ghost".to_string());
2073        let req = make_request("ListStackResources", params);
2074        assert!(svc.list_stack_resources(&req).is_err());
2075    }
2076
2077    #[test]
2078    fn describe_stack_resources_missing_name_errors() {
2079        let svc = make_service();
2080        let req = make_request("DescribeStackResources", HashMap::new());
2081        assert!(svc.describe_stack_resources(&req).is_err());
2082    }
2083
2084    #[test]
2085    fn get_template_missing_name_errors() {
2086        let svc = make_service();
2087        let req = make_request("GetTemplate", HashMap::new());
2088        assert!(svc.get_template(&req).is_err());
2089    }
2090
2091    #[test]
2092    fn get_template_unknown_stack_errors() {
2093        let svc = make_service();
2094        let mut params = HashMap::new();
2095        params.insert("StackName".to_string(), "ghost".to_string());
2096        let req = make_request("GetTemplate", params);
2097        assert!(svc.get_template(&req).is_err());
2098    }
2099
2100    #[test]
2101    fn update_stack_missing_name_errors() {
2102        let svc = make_service();
2103        let mut params = HashMap::new();
2104        params.insert("TemplateBody".to_string(), "{}".to_string());
2105        let req = make_request("UpdateStack", params);
2106        assert!(svc.update_stack(&req).is_err());
2107    }
2108
2109    #[test]
2110    fn update_stack_unknown_stack_errors() {
2111        let svc = make_service();
2112        let mut params = HashMap::new();
2113        params.insert("StackName".to_string(), "ghost".to_string());
2114        params.insert(
2115            "TemplateBody".to_string(),
2116            r#"{"Resources":{}}"#.to_string(),
2117        );
2118        let req = make_request("UpdateStack", params);
2119        assert!(svc.update_stack(&req).is_err());
2120    }
2121
2122    #[test]
2123    fn create_stack_resolves_outputs_and_records_export() {
2124        let svc = make_service();
2125        let template = r#"{
2126            "Resources": {
2127                "Q": {"Type":"AWS::SQS::Queue","Properties":{"QueueName":"out-q"}}
2128            },
2129            "Outputs": {
2130                "QueueUrl": {
2131                    "Value": {"Ref": "Q"},
2132                    "Description": "Url",
2133                    "Export": {"Name": "TheQueueUrl"}
2134                }
2135            }
2136        }"#;
2137        let mut params = HashMap::new();
2138        params.insert("StackName".to_string(), "outs".to_string());
2139        params.insert("TemplateBody".to_string(), template.to_string());
2140        let req = make_request("CreateStack", params);
2141        svc.create_stack(&req).expect("create stack");
2142
2143        let accounts = svc.state.read();
2144        let stack = accounts
2145            .get("123456789012")
2146            .unwrap()
2147            .stacks
2148            .get("outs")
2149            .unwrap();
2150        assert_eq!(stack.outputs.len(), 1);
2151        assert_eq!(stack.outputs[0].key, "QueueUrl");
2152        assert_eq!(stack.outputs[0].export_name.as_deref(), Some("TheQueueUrl"));
2153        assert!(!stack.outputs[0].value.is_empty());
2154    }
2155
2156    #[test]
2157    fn create_stack_rejects_duplicate_export_name() {
2158        let svc = make_service();
2159        let mk = |name: &str| {
2160            let template = format!(
2161                r#"{{
2162                    "Resources": {{"Q":{{"Type":"AWS::SQS::Queue","Properties":{{"QueueName":"q-{name}"}}}}}},
2163                    "Outputs": {{"QueueUrl":{{"Value":{{"Ref":"Q"}},"Export":{{"Name":"DupExport"}}}}}}
2164                }}"#
2165            );
2166            let mut params = HashMap::new();
2167            params.insert("StackName".to_string(), name.to_string());
2168            params.insert("TemplateBody".to_string(), template);
2169            make_request("CreateStack", params)
2170        };
2171        match svc.create_stack(&mk("first")) {
2172            Ok(_) => {}
2173            Err(e) => panic!("first stack: {e:?}"),
2174        }
2175        match svc.create_stack(&mk("second")) {
2176            Ok(_) => panic!("expected duplicate-export error"),
2177            Err(e) => assert!(
2178                format!("{e:?}").contains("already exported"),
2179                "expected duplicate-export error, got {e:?}"
2180            ),
2181        }
2182    }
2183
2184    #[test]
2185    fn import_value_resolves_against_other_stack_export() {
2186        let svc = make_service();
2187
2188        let producer_tpl = r#"{
2189            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod-q"}}},
2190            "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"SharedQueueUrl"}}}
2191        }"#;
2192        let mut p = HashMap::new();
2193        p.insert("StackName".to_string(), "producer".to_string());
2194        p.insert("TemplateBody".to_string(), producer_tpl.to_string());
2195        svc.create_stack(&make_request("CreateStack", p))
2196            .expect("producer");
2197
2198        let consumer_tpl = r#"{
2199            "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cons-q"}}},
2200            "Outputs": {"Imp":{"Value":{"Fn::ImportValue":"SharedQueueUrl"}}}
2201        }"#;
2202        let mut p = HashMap::new();
2203        p.insert("StackName".to_string(), "consumer".to_string());
2204        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2205        svc.create_stack(&make_request("CreateStack", p))
2206            .expect("consumer");
2207
2208        let accounts = svc.state.read();
2209        let prod_url = accounts
2210            .get("123456789012")
2211            .unwrap()
2212            .stacks
2213            .get("producer")
2214            .unwrap()
2215            .outputs[0]
2216            .value
2217            .clone();
2218        let cons = accounts
2219            .get("123456789012")
2220            .unwrap()
2221            .stacks
2222            .get("consumer")
2223            .unwrap();
2224        assert_eq!(cons.outputs[0].value, prod_url);
2225    }
2226
2227    #[test]
2228    fn create_stack_records_export_in_state_registry() {
2229        let svc = make_service();
2230        let template = r#"{
2231            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"reg-q"}}},
2232            "Outputs": {"Url":{"Value":{"Ref":"Q"},"Export":{"Name":"reg-url"}}}
2233        }"#;
2234        let mut params = HashMap::new();
2235        params.insert("StackName".to_string(), "reg".to_string());
2236        params.insert("TemplateBody".to_string(), template.to_string());
2237        svc.create_stack(&make_request("CreateStack", params))
2238            .expect("create");
2239
2240        let accounts = svc.state.read();
2241        let state = accounts.get("123456789012").unwrap();
2242        let export = state
2243            .exports
2244            .get("reg-url")
2245            .expect("export registered in state.exports");
2246        assert_eq!(export.exporting_stack_name, "reg");
2247        assert!(!export.value.is_empty());
2248        assert!(export.exporting_stack_id.contains("reg"));
2249    }
2250
2251    #[test]
2252    fn import_value_with_unknown_export_errors() {
2253        let svc = make_service();
2254        let consumer_tpl = r#"{
2255            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{
2256                "QueueName": {"Fn::ImportValue":"missing-export"}
2257            }}}
2258        }"#;
2259        let mut p = HashMap::new();
2260        p.insert("StackName".to_string(), "bad-consumer".to_string());
2261        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2262        match svc.create_stack(&make_request("CreateStack", p)) {
2263            Ok(_) => panic!("expected ValidationError for unknown export"),
2264            Err(e) => {
2265                let msg = format!("{e:?}");
2266                assert!(msg.contains("No export named missing-export"), "got {msg}");
2267            }
2268        }
2269    }
2270
2271    #[test]
2272    fn delete_stack_blocked_when_export_in_use_and_unblocked_after_consumer_delete() {
2273        let svc = make_service();
2274
2275        let producer_tpl = r#"{
2276            "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod"}}},
2277            "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"my-arn"}}}
2278        }"#;
2279        let mut p = HashMap::new();
2280        p.insert("StackName".to_string(), "producer".to_string());
2281        p.insert("TemplateBody".to_string(), producer_tpl.to_string());
2282        svc.create_stack(&make_request("CreateStack", p))
2283            .expect("producer");
2284
2285        let consumer_tpl = r#"{
2286            "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{
2287                "QueueName": "cons-q",
2288                "Tags": [{"Key":"k","Value":{"Fn::ImportValue":"my-arn"}}]
2289            }}}
2290        }"#;
2291        let mut p = HashMap::new();
2292        p.insert("StackName".to_string(), "consumer".to_string());
2293        p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2294        svc.create_stack(&make_request("CreateStack", p))
2295            .expect("consumer");
2296
2297        // Producer delete must fail while consumer still imports.
2298        let mut p = HashMap::new();
2299        p.insert("StackName".to_string(), "producer".to_string());
2300        match svc.delete_stack(&make_request("DeleteStack", p)) {
2301            Ok(_) => panic!("delete must fail while imports exist"),
2302            Err(e) => {
2303                let msg = format!("{e:?}");
2304                assert!(msg.contains("Export my-arn cannot be deleted"), "got {msg}");
2305            }
2306        }
2307
2308        // Delete consumer first.
2309        let mut p = HashMap::new();
2310        p.insert("StackName".to_string(), "consumer".to_string());
2311        svc.delete_stack(&make_request("DeleteStack", p))
2312            .expect("consumer delete");
2313
2314        // Now producer delete succeeds.
2315        let mut p = HashMap::new();
2316        p.insert("StackName".to_string(), "producer".to_string());
2317        svc.delete_stack(&make_request("DeleteStack", p))
2318            .expect("producer delete after consumer gone");
2319
2320        let accounts = svc.state.read();
2321        let state = accounts.get("123456789012").unwrap();
2322        assert!(state.exports.is_empty(), "exports cleared after delete");
2323        assert!(state.imports.is_empty(), "imports cleared after delete");
2324    }
2325}