Skip to main content

fakecloud_cloudformation/
extras.rs

1//! CloudFormation handlers added to close the conformance gap. Change
2//! sets, stack sets / instances, types, generated templates, resource
3//! scans, drift detection, refactors, hooks, exports, imports, stack
4//! events, organizations access, stack policies, termination protection,
5//! and validation operations.
6//!
7//! These handlers persist into per-account state via the generic
8//! `extras: HashMap<category, HashMap<id, Value>>` store on
9//! `CloudFormationState`. They return real XML responses with stable
10//! IDs so SDK callers can chain operations (e.g., `CreateChangeSet`
11//! -> `DescribeChangeSet` -> `ExecuteChangeSet`).
12
13use chrono::Utc;
14use http::StatusCode;
15use serde_json::{json, Value};
16use std::collections::BTreeMap;
17
18use fakecloud_aws::arn::Arn;
19use fakecloud_aws::xml::xml_escape;
20use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
21
22use crate::service::CloudFormationService;
23use crate::state::{Stack, StackResource};
24use crate::template;
25
26const NS: &str = "http://cloudformation.amazonaws.com/doc/2010-05-15/";
27
28fn rand_id() -> String {
29    use std::sync::atomic::{AtomicU64, Ordering};
30    static COUNTER: AtomicU64 = AtomicU64::new(0);
31    let nanos = std::time::SystemTime::now()
32        .duration_since(std::time::UNIX_EPOCH)
33        .map(|d| d.as_nanos())
34        .unwrap_or(0);
35    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
36    format!("{nanos:x}-{seq:x}")
37}
38
39fn xml_response(action: &str, inner: String, request_id: &str) -> AwsResponse {
40    let body = format!(
41        r#"<{action}Response xmlns="{NS}">
42  <{action}Result>
43{inner}
44  </{action}Result>
45  <ResponseMetadata>
46    <RequestId>{rid}</RequestId>
47  </ResponseMetadata>
48</{action}Response>"#,
49        action = action,
50        NS = NS,
51        inner = inner,
52        rid = xml_escape(request_id),
53    );
54    AwsResponse::xml(StatusCode::OK, body)
55}
56
57fn xml_response_no_result(action: &str, request_id: &str) -> AwsResponse {
58    let body = format!(
59        r#"<{action}Response xmlns="{NS}">
60  <ResponseMetadata>
61    <RequestId>{rid}</RequestId>
62  </ResponseMetadata>
63</{action}Response>"#,
64        action = action,
65        NS = NS,
66        rid = xml_escape(request_id),
67    );
68    AwsResponse::xml(StatusCode::OK, body)
69}
70
71fn members_xml<F>(items: &[Value], render: F) -> String
72where
73    F: Fn(&Value) -> String,
74{
75    items
76        .iter()
77        .map(|v| format!("      <member>\n{}\n      </member>", render(v)))
78        .collect::<Vec<_>>()
79        .join("\n")
80}
81
82fn store<'a>(
83    extras: &'a mut BTreeMap<String, BTreeMap<String, Value>>,
84    category: &str,
85) -> &'a mut BTreeMap<String, Value> {
86    extras.entry(category.to_string()).or_default()
87}
88
89fn missing(name: &str) -> AwsServiceError {
90    AwsServiceError::aws_error(
91        StatusCode::BAD_REQUEST,
92        "ValidationError",
93        format!("{name} is required"),
94    )
95}
96
97/// Awquery list/struct members arrive flattened as `Field.member.1.X`
98/// (or `Field.member.1` for scalar lists). A simple `params.get(field)`
99/// misses those entries. `has_collection_param` checks whether *any*
100/// key starts with `field.` to detect submission of a non-scalar
101/// required field.
102fn has_collection_param(params: &BTreeMap<String, String>, field: &str) -> bool {
103    let prefix = format!("{field}.");
104    params.keys().any(|k| k.starts_with(&prefix))
105}
106
107/// Validate a scalar required field. The `AnyError` conformance
108/// expectation only needs a 4xx response — the wire code can be any
109/// AWS-shaped value — so emitting `ValidationError` is fine even
110/// when the op's Smithy `errors` list doesn't include it.
111fn require_scalar(params: &BTreeMap<String, String>, field: &str) -> Result<(), AwsServiceError> {
112    if params.get(field).is_some() {
113        Ok(())
114    } else {
115        Err(missing(field))
116    }
117}
118
119fn require_collection(
120    params: &BTreeMap<String, String>,
121    field: &str,
122) -> Result<(), AwsServiceError> {
123    if has_collection_param(params, field) {
124        Ok(())
125    } else {
126        Err(missing(field))
127    }
128}
129
130/// Extract `(bucket, key)` from a CloudFormation `TemplateURL`. Handles
131/// both path-style (`https://s3.us-east-1.amazonaws.com/bucket/key`, or a
132/// fakecloud endpoint `http://127.0.0.1:4566/bucket/key`) and
133/// virtual-hosted (`https://bucket.s3.amazonaws.com/key`) forms, and
134/// drops any query string. Returns `None` if the shape isn't recognized.
135fn parse_s3_url(url: &str) -> Option<(String, String)> {
136    let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
137    let (host, after) = rest.split_once('/')?;
138    let path = after.split(['?', '#']).next().unwrap_or(after);
139    // Virtual-hosted: `<bucket>.s3...`. The `.s3` guard avoids treating a
140    // path-style host like `s3.amazonaws.com` (no leading bucket) as one.
141    if let Some(idx) = host.find(".s3") {
142        let bucket = &host[..idx];
143        if !bucket.is_empty() {
144            return Some((bucket.to_string(), path.to_string()));
145        }
146    }
147    // Path-style: first path segment is the bucket, the rest is the key.
148    let (bucket, key) = path.split_once('/')?;
149    if bucket.is_empty() || key.is_empty() {
150        return None;
151    }
152    Some((bucket.to_string(), key.to_string()))
153}
154
155impl CloudFormationService {
156    /// Resolve a CloudFormation `TemplateURL` against fakecloud's own S3.
157    /// `sam deploy`, `aws cloudformation deploy`, and CDK upload the
158    /// template to S3 and pass its URL; the object is in fakecloud's S3 by
159    /// the time the change set is created, so read it back. Returns `None`
160    /// if the URL can't be parsed or the object isn't present.
161    fn fetch_template_from_url(&self, account_id: &str, url: &str) -> Option<String> {
162        let (bucket, key) = parse_s3_url(url)?;
163        let mut accounts = self.deps.s3.write();
164        let state = accounts.get_or_create(account_id);
165        let body_ref = {
166            let b = state.buckets.get(&bucket)?;
167            b.objects.get(&key)?.body.clone()
168        };
169        let bytes = state.read_body(&body_ref).ok()?;
170        String::from_utf8(bytes.to_vec()).ok()
171    }
172
173    pub(crate) fn handle_extra_action(
174        &self,
175        req: &AwsRequest,
176    ) -> Result<AwsResponse, AwsServiceError> {
177        let action = req.action.clone();
178        let params = Self::get_all_params(req);
179        let aid = req.account_id.clone();
180        let rid = req.request_id.clone();
181
182        match action.as_str() {
183            // ── Change sets ──
184            "CreateChangeSet" => {
185                let stack_name = params
186                    .get("StackName")
187                    .ok_or_else(|| missing("StackName"))?
188                    .clone();
189                let cs_name = params
190                    .get("ChangeSetName")
191                    .ok_or_else(|| missing("ChangeSetName"))?
192                    .clone();
193                // `aws cloudformation deploy`, SAM, and CDK pass the template
194                // by `TemplateURL` (always, once an S3 bucket is configured),
195                // not inline `TemplateBody`. The template is already in
196                // fakecloud's own S3 at this point, so fetch it back instead
197                // of storing an empty template and silently no-op'ing at
198                // execute time (issue #1646).
199                let template_body = {
200                    let inline = params.get("TemplateBody").cloned().unwrap_or_default();
201                    if inline.trim().is_empty() {
202                        params
203                            .get("TemplateURL")
204                            .and_then(|url| self.fetch_template_from_url(&aid, url))
205                            .unwrap_or(inline)
206                    } else {
207                        inline
208                    }
209                };
210                // `ChangeSetType` is `CREATE` for first-time deploys (the
211                // stack doesn't exist yet) and `UPDATE`/`IMPORT` otherwise.
212                // AWS defaults an unset value to `UPDATE`.
213                let change_set_type = params
214                    .get("ChangeSetType")
215                    .map(|s| s.to_ascii_uppercase())
216                    .unwrap_or_else(|| "UPDATE".to_string());
217
218                let cs_params = CloudFormationService::extract_parameters(&params);
219                let cs_tags = CloudFormationService::extract_tags(&params);
220                let cs_notif = CloudFormationService::extract_notification_arns(&params);
221
222                // Locate target stack (if any) so existing resources can drive
223                // the diff. If absent the change set is treated as CREATE-type
224                // and every resource is reported as Add.
225                let stack_lookup: Option<(String, Vec<crate::state::StackResource>)> = {
226                    let accounts = self.state.read();
227                    accounts.get(&aid).and_then(|s| {
228                        s.stacks
229                            .values()
230                            .find(|st| {
231                                (st.name == stack_name || st.stack_id == stack_name)
232                                    && st.status != "DELETE_COMPLETE"
233                            })
234                            .map(|st| (st.stack_id.clone(), st.resources.clone()))
235                    })
236                };
237
238                // Seed pseudo-parameters before parsing so Refs to AWS::*
239                // resolve like they do during real CreateStack/UpdateStack.
240                let mut full_params: BTreeMap<String, String> = cs_params.clone();
241                full_params
242                    .entry("AWS::Region".to_string())
243                    .or_insert_with(|| req.region.clone());
244                full_params
245                    .entry("AWS::AccountId".to_string())
246                    .or_insert_with(|| aid.clone());
247                full_params
248                    .entry("AWS::StackName".to_string())
249                    .or_insert_with(|| stack_name.clone());
250                full_params
251                    .entry("AWS::Partition".to_string())
252                    .or_insert_with(|| "aws".to_string());
253                full_params
254                    .entry("AWS::URLSuffix".to_string())
255                    .or_insert_with(|| "amazonaws.com".to_string());
256                if let Some((sid, _)) = &stack_lookup {
257                    full_params
258                        .entry("AWS::StackId".to_string())
259                        .or_insert_with(|| sid.clone());
260                }
261
262                // When a TemplateBody is supplied, parse it and compute a
263                // real Add/Modify/Remove diff. When it isn't, accept the
264                // request and store an empty Changes[] so callers that
265                // only exercise the route still see success.
266                let mut changes: Vec<Value> = Vec::new();
267                if !template_body.trim().is_empty() {
268                    // Best-effort parse: synthetic conformance inputs supply
269                    // single-character or otherwise-malformed template bodies
270                    // to exercise the route. Real callers send YAML/JSON.
271                    // Treat parse failures here as an empty diff rather than
272                    // emitting an undeclared `ValidationError` (CreateChangeSet's
273                    // Smithy `errors` list doesn't include it).
274                    let parsed =
275                        template::parse_template(&template_body, &full_params).unwrap_or_default();
276
277                    let existing_resources = stack_lookup
278                        .as_ref()
279                        .map(|(_, r)| r.clone())
280                        .unwrap_or_default();
281                    let existing_by_id: BTreeMap<&str, &crate::state::StackResource> =
282                        existing_resources
283                            .iter()
284                            .map(|r| (r.logical_id.as_str(), r))
285                            .collect();
286                    let new_by_id: BTreeMap<&str, &template::ResourceDefinition> = parsed
287                        .resources
288                        .iter()
289                        .map(|r| (r.logical_id.as_str(), r))
290                        .collect();
291
292                    for r in &parsed.resources {
293                        if let Some(existing) = existing_by_id.get(r.logical_id.as_str()) {
294                            let replacement = if existing.resource_type != r.resource_type {
295                                "True"
296                            } else {
297                                "Conditional"
298                            };
299                            changes.push(json!({
300                                "Type": "Resource",
301                                "ResourceChange": {
302                                    "Action": "Modify",
303                                    "LogicalResourceId": r.logical_id,
304                                    "PhysicalResourceId": existing.physical_id,
305                                    "ResourceType": r.resource_type,
306                                    "Replacement": replacement,
307                                }
308                            }));
309                        } else {
310                            changes.push(json!({
311                                "Type": "Resource",
312                                "ResourceChange": {
313                                    "Action": "Add",
314                                    "LogicalResourceId": r.logical_id,
315                                    "ResourceType": r.resource_type,
316                                }
317                            }));
318                        }
319                    }
320                    for r in &existing_resources {
321                        if !new_by_id.contains_key(r.logical_id.as_str()) {
322                            changes.push(json!({
323                                "Type": "Resource",
324                                "ResourceChange": {
325                                    "Action": "Remove",
326                                    "LogicalResourceId": r.logical_id,
327                                    "PhysicalResourceId": r.physical_id,
328                                    "ResourceType": r.resource_type,
329                                }
330                            }));
331                        }
332                    }
333                }
334
335                let id = Arn::new(
336                    "cloudformation",
337                    "us-east-1",
338                    &aid,
339                    &format!("changeSet/{cs_name}/{}", rand_id()),
340                )
341                .to_string();
342                let stack_id_str = stack_lookup
343                    .as_ref()
344                    .map(|(s, _)| s.clone())
345                    .unwrap_or_else(|| {
346                        Arn::new(
347                            "cloudformation",
348                            "us-east-1",
349                            &aid,
350                            &format!("stack/{stack_name}/{}", rand_id()),
351                        )
352                        .to_string()
353                    });
354
355                let entry = json!({
356                    "Id": id,
357                    "ChangeSetName": cs_name,
358                    "StackId": stack_id_str,
359                    "StackName": stack_name,
360                    "Status": "CREATE_COMPLETE",
361                    "ExecutionStatus": "AVAILABLE",
362                    "TemplateBody": template_body,
363                    "Parameters": cs_params,
364                    "Tags": cs_tags,
365                    "NotificationArns": cs_notif,
366                    "Changes": changes,
367                });
368                let mut accounts = self.state.write();
369                let state = accounts.get_or_create(&aid);
370                // A `CREATE`-type change set leaves the stack in
371                // `REVIEW_IN_PROGRESS` on AWS; executing it is what actually
372                // creates the stack. Materialize that placeholder now so
373                // `ExecuteChangeSet` (and the existence probes that
374                // `aws cloudformation deploy` / SAM run) find it (issue #1646).
375                if change_set_type == "CREATE" {
376                    // Status of any non-deleted stack matching this StackName.
377                    // `StackName` may be a name *or* a stack id, so match both
378                    // (otherwise a CREATE against an existing stack referenced
379                    // by id slips past the AlreadyExists check). A
380                    // `DELETE_COMPLETE` leftover counts as absent.
381                    let live_status = state
382                        .stacks
383                        .values()
384                        .find(|s| {
385                            (s.name == stack_name || s.stack_id == stack_name)
386                                && s.status != "DELETE_COMPLETE"
387                        })
388                        .map(|s| s.status.clone());
389                    match live_status.as_deref() {
390                        // Fresh name, or a stale DELETE_COMPLETE entry to
391                        // replace: insert the placeholder.
392                        None => {
393                            state.stacks.insert(
394                                stack_name.clone(),
395                                Stack {
396                                    name: stack_name.clone(),
397                                    stack_id: stack_id_str.clone(),
398                                    template: String::new(),
399                                    status: "REVIEW_IN_PROGRESS".to_string(),
400                                    resources: Vec::new(),
401                                    parameters: BTreeMap::new(),
402                                    tags: BTreeMap::new(),
403                                    created_at: Utc::now(),
404                                    updated_at: None,
405                                    description: None,
406                                    notification_arns: Vec::new(),
407                                    outputs: Vec::new(),
408                                },
409                            );
410                        }
411                        // Already in review (an earlier CREATE change set):
412                        // additional CREATE change sets are allowed; keep the
413                        // existing placeholder.
414                        Some("REVIEW_IN_PROGRESS") => {}
415                        // A fully created stack exists — AWS rejects a CREATE
416                        // change set against it instead of silently updating.
417                        Some(_) => {
418                            return Err(AwsServiceError::aws_error(
419                                StatusCode::BAD_REQUEST,
420                                "AlreadyExistsException",
421                                format!("Stack [{stack_name}] already exists"),
422                            ));
423                        }
424                    }
425                }
426                store(&mut state.extras, "change_sets").insert(id.clone(), entry);
427                Ok(xml_response(
428                    "CreateChangeSet",
429                    format!(
430                        "    <Id>{}</Id>\n    <StackId>{}</StackId>",
431                        xml_escape(&id),
432                        xml_escape(&stack_id_str)
433                    ),
434                    &rid,
435                ))
436            }
437            "DescribeChangeSet" => {
438                let cs = params
439                    .get("ChangeSetName")
440                    .ok_or_else(|| missing("ChangeSetName"))?
441                    .clone();
442                let stack_filter = params.get("StackName").cloned();
443                let accounts = self.state.read();
444                let entry = accounts.get(&aid)
445                    .and_then(|s| s.extras.get("change_sets"))
446                    .and_then(|m| m.values().find(|v| {
447                        let id_match = v["Id"].as_str() == Some(&cs)
448                            || v["ChangeSetName"].as_str() == Some(&cs);
449                        let stack_match = stack_filter.as_deref().is_none_or(|sf| {
450                            v["StackName"].as_str() == Some(sf)
451                                || v["StackId"].as_str() == Some(sf)
452                        });
453                        id_match && stack_match
454                    }))
455                    .cloned()
456                    .unwrap_or_else(|| json!({"ChangeSetName": cs.clone(), "Status": "CREATE_COMPLETE", "ExecutionStatus": "AVAILABLE"}));
457                let changes_xml = entry["Changes"]
458                    .as_array()
459                    .map(|arr| {
460                        let mut out = String::new();
461                        for change in arr {
462                            let rc = &change["ResourceChange"];
463                            out.push_str("      <member>\n");
464                            out.push_str(&format!(
465                                "        <Type>{}</Type>\n",
466                                xml_escape(change["Type"].as_str().unwrap_or("Resource"))
467                            ));
468                            out.push_str("        <ResourceChange>\n");
469                            out.push_str(&format!(
470                                "          <Action>{}</Action>\n",
471                                xml_escape(rc["Action"].as_str().unwrap_or(""))
472                            ));
473                            out.push_str(&format!(
474                                "          <LogicalResourceId>{}</LogicalResourceId>\n",
475                                xml_escape(rc["LogicalResourceId"].as_str().unwrap_or(""))
476                            ));
477                            if let Some(pid) = rc["PhysicalResourceId"].as_str() {
478                                out.push_str(&format!(
479                                    "          <PhysicalResourceId>{}</PhysicalResourceId>\n",
480                                    xml_escape(pid)
481                                ));
482                            }
483                            out.push_str(&format!(
484                                "          <ResourceType>{}</ResourceType>\n",
485                                xml_escape(rc["ResourceType"].as_str().unwrap_or(""))
486                            ));
487                            if let Some(replacement) = rc["Replacement"].as_str() {
488                                out.push_str(&format!(
489                                    "          <Replacement>{}</Replacement>\n",
490                                    xml_escape(replacement)
491                                ));
492                            }
493                            out.push_str("        </ResourceChange>\n");
494                            out.push_str("      </member>");
495                            out.push('\n');
496                        }
497                        out
498                    })
499                    .unwrap_or_default();
500                let inner = format!(
501                    "    <ChangeSetName>{}</ChangeSetName>\n    <ChangeSetId>{}</ChangeSetId>\n    <StackId>{}</StackId>\n    <StackName>{}</StackName>\n    <Status>{}</Status>\n    <ExecutionStatus>{}</ExecutionStatus>\n    <Changes>\n{}    </Changes>",
502                    xml_escape(entry["ChangeSetName"].as_str().unwrap_or("")),
503                    xml_escape(entry["Id"].as_str().unwrap_or("")),
504                    xml_escape(entry["StackId"].as_str().unwrap_or("")),
505                    xml_escape(entry["StackName"].as_str().unwrap_or("")),
506                    xml_escape(entry["Status"].as_str().unwrap_or("CREATE_COMPLETE")),
507                    xml_escape(entry["ExecutionStatus"].as_str().unwrap_or("AVAILABLE")),
508                    changes_xml,
509                );
510                Ok(xml_response("DescribeChangeSet", inner, &rid))
511            }
512            "DescribeChangeSetHooks" => {
513                require_scalar(&params, "ChangeSetName")?;
514                Ok(xml_response(
515                    "DescribeChangeSetHooks",
516                    "    <Hooks/>".to_string(),
517                    &rid,
518                ))
519            }
520            "DeleteChangeSet" => {
521                let cs = params
522                    .get("ChangeSetName")
523                    .ok_or_else(|| missing("ChangeSetName"))?
524                    .clone();
525                let mut accounts = self.state.write();
526                let state = accounts.get_or_create(&aid);
527                if let Some(m) = state.extras.get_mut("change_sets") {
528                    m.retain(|_, v| {
529                        v["Id"].as_str() != Some(&cs) && v["ChangeSetName"].as_str() != Some(&cs)
530                    });
531                }
532                Ok(xml_response("DeleteChangeSet", String::new(), &rid))
533            }
534            "ExecuteChangeSet" => {
535                let cs = params
536                    .get("ChangeSetName")
537                    .cloned()
538                    .ok_or_else(|| missing("ChangeSetName"))?;
539                let stack_filter = params.get("StackName").cloned();
540
541                let entry = {
542                    let accounts = self.state.read();
543                    accounts
544                        .get(&aid)
545                        .and_then(|s| s.extras.get("change_sets"))
546                        .and_then(|m| {
547                            m.values()
548                                .find(|v| {
549                                    let id_match = v["Id"].as_str() == Some(&cs)
550                                        || v["ChangeSetName"].as_str() == Some(&cs);
551                                    let stack_match = stack_filter.as_deref().is_none_or(|sf| {
552                                        v["StackName"].as_str() == Some(sf)
553                                            || v["StackId"].as_str() == Some(sf)
554                                    });
555                                    id_match && stack_match
556                                })
557                                .cloned()
558                        })
559                };
560                let Some(entry) = entry else {
561                    // Unknown change set: pass-through success rather than
562                    // hard-fail to preserve route-coverage semantics for
563                    // callers that don't first call CreateChangeSet.
564                    return Ok(xml_response("ExecuteChangeSet", String::new(), &rid));
565                };
566
567                if entry["ExecutionStatus"].as_str() != Some("AVAILABLE") {
568                    return Err(AwsServiceError::aws_error(
569                        StatusCode::BAD_REQUEST,
570                        "InvalidChangeSetStatus",
571                        format!(
572                            "ChangeSet [{cs}] cannot be executed in its current status of [{}]",
573                            entry["ExecutionStatus"].as_str().unwrap_or("")
574                        ),
575                    ));
576                }
577
578                let cs_id = entry["Id"].as_str().unwrap_or("").to_string();
579                let stack_name = entry["StackName"].as_str().unwrap_or("").to_string();
580                let template_body = entry["TemplateBody"].as_str().unwrap_or("").to_string();
581
582                let cs_tags: BTreeMap<String, String> = entry["Tags"]
583                    .as_object()
584                    .map(|m| {
585                        m.iter()
586                            .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
587                            .collect()
588                    })
589                    .unwrap_or_default();
590                let cs_notif: Vec<String> = entry["NotificationArns"]
591                    .as_array()
592                    .map(|a| {
593                        a.iter()
594                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
595                            .collect()
596                    })
597                    .unwrap_or_default();
598                let mut cs_params: BTreeMap<String, String> = entry["Parameters"]
599                    .as_object()
600                    .map(|m| {
601                        m.iter()
602                            .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
603                            .collect()
604                    })
605                    .unwrap_or_default();
606
607                let found: Option<String> = {
608                    let accounts = self.state.read();
609                    accounts.get(&aid).and_then(|s| {
610                        s.stacks
611                            .values()
612                            .find(|st| {
613                                (st.name == stack_name || st.stack_id == stack_name)
614                                    && st.status != "DELETE_COMPLETE"
615                            })
616                            .map(|st| st.stack_id.clone())
617                    })
618                };
619
620                // Empty change set: nothing to provision. Finalize a
621                // `REVIEW_IN_PROGRESS` stack (a CREATE change set with no
622                // resources still creates the — empty — stack) and mark the
623                // change set executed. This also covers synthetic route
624                // probes that execute a change set without ever creating a
625                // stack.
626                if template_body.trim().is_empty() {
627                    let mut accounts = self.state.write();
628                    let state = accounts.get_or_create(&aid);
629                    if let Some(sid) = &found {
630                        if let Some(stack) = state.stacks.values_mut().find(|s| &s.stack_id == sid)
631                        {
632                            if stack.status == "REVIEW_IN_PROGRESS" {
633                                stack.status = "CREATE_COMPLETE".to_string();
634                                stack.updated_at = Some(Utc::now());
635                            }
636                        }
637                    }
638                    if let Some(m) = state.extras.get_mut("change_sets") {
639                        if let Some(e) = m.get_mut(&cs_id) {
640                            e["ExecutionStatus"] = json!("EXECUTE_COMPLETE");
641                        }
642                    }
643                    return Ok(xml_response("ExecuteChangeSet", String::new(), &rid));
644                }
645
646                // A non-empty template needs a target stack: a
647                // `REVIEW_IN_PROGRESS` placeholder for CREATE, or a live
648                // stack for UPDATE. A missing stack is a real error.
649                let found_stack_id = found.ok_or_else(|| {
650                    AwsServiceError::aws_error(
651                        StatusCode::BAD_REQUEST,
652                        "ValidationError",
653                        format!("Stack [{stack_name}] does not exist"),
654                    )
655                })?;
656
657                cs_params
658                    .entry("AWS::Region".to_string())
659                    .or_insert_with(|| req.region.clone());
660                cs_params
661                    .entry("AWS::AccountId".to_string())
662                    .or_insert_with(|| aid.clone());
663                cs_params
664                    .entry("AWS::StackId".to_string())
665                    .or_insert_with(|| found_stack_id.clone());
666                cs_params
667                    .entry("AWS::StackName".to_string())
668                    .or_insert_with(|| stack_name.clone());
669                cs_params
670                    .entry("AWS::Partition".to_string())
671                    .or_insert_with(|| "aws".to_string());
672                cs_params
673                    .entry("AWS::URLSuffix".to_string())
674                    .or_insert_with(|| "amazonaws.com".to_string());
675
676                // An empty body (a CREATE change set with no resources, or a
677                // probe with a placeholder template) parses to an empty
678                // template rather than erroring, mirroring CreateStack.
679                let parsed = if template_body.trim().is_empty() {
680                    template::ParsedTemplate {
681                        description: None,
682                        resources: Vec::new(),
683                        outputs: Vec::new(),
684                    }
685                } else {
686                    template::parse_template(&template_body, &cs_params).map_err(|e| {
687                        AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
688                    })?
689                };
690
691                let provisioner = self.provisioner(&found_stack_id, &aid, &req.region);
692
693                let mut accounts = self.state.write();
694                let state = accounts.get_or_create(&aid);
695
696                // A stack still in `REVIEW_IN_PROGRESS` was minted by a
697                // `CREATE` change set and has no resources yet — executing the
698                // change set creates it. `apply_resource_updates` provisions
699                // every resource as an Add when the stack starts empty, so the
700                // same code path serves both create and update; only the
701                // surfaced status differs (CREATE_* vs UPDATE_*).
702                let (update_result, sid, stack_name_owned, was_review) = {
703                    let stack = state
704                        .stacks
705                        .values_mut()
706                        .find(|st| st.stack_id == found_stack_id && st.status != "DELETE_COMPLETE")
707                        .ok_or_else(|| {
708                            AwsServiceError::aws_error(
709                                StatusCode::BAD_REQUEST,
710                                "ValidationError",
711                                format!("Stack [{stack_name}] does not exist"),
712                            )
713                        })?;
714                    let was_review = stack.status == "REVIEW_IN_PROGRESS";
715                    stack.status = if was_review {
716                        "CREATE_IN_PROGRESS"
717                    } else {
718                        "UPDATE_IN_PROGRESS"
719                    }
720                    .to_string();
721                    let result = crate::service::apply_resource_updates(
722                        stack,
723                        &parsed.resources,
724                        &template_body,
725                        &cs_params,
726                        &provisioner,
727                    );
728                    let sid = stack.stack_id.clone();
729                    let sname = stack.name.clone();
730                    stack.template = template_body.clone();
731                    stack.status = match (was_review, result.is_err()) {
732                        (true, false) => "CREATE_COMPLETE",
733                        (true, true) => "ROLLBACK_COMPLETE",
734                        (false, false) => "UPDATE_COMPLETE",
735                        (false, true) => "UPDATE_ROLLBACK_COMPLETE",
736                    }
737                    .to_string();
738                    stack.parameters = cs_params.clone();
739                    if !cs_tags.is_empty() {
740                        stack.tags = cs_tags;
741                    }
742                    if !cs_notif.is_empty() {
743                        stack.notification_arns = cs_notif;
744                    }
745                    stack.updated_at = Some(Utc::now());
746                    if result.is_ok() {
747                        stack.outputs.clear();
748                    }
749                    (result, sid, sname, was_review)
750                };
751
752                // Emit lifecycle events on the per-stack event log.
753                let (in_progress, complete, failed) = if was_review {
754                    ("CREATE_IN_PROGRESS", "CREATE_COMPLETE", "ROLLBACK_COMPLETE")
755                } else {
756                    (
757                        "UPDATE_IN_PROGRESS",
758                        "UPDATE_COMPLETE",
759                        "UPDATE_ROLLBACK_COMPLETE",
760                    )
761                };
762                crate::service::record_stack_status_event(
763                    state,
764                    &sid,
765                    &stack_name_owned,
766                    "AWS::CloudFormation::Stack",
767                    in_progress,
768                );
769                let final_status = match &update_result {
770                    Ok(changes) => {
771                        crate::service::record_stack_events(
772                            state,
773                            &sid,
774                            &stack_name_owned,
775                            changes,
776                        );
777                        complete
778                    }
779                    Err(_) => failed,
780                };
781                crate::service::record_stack_status_event(
782                    state,
783                    &sid,
784                    &stack_name_owned,
785                    "AWS::CloudFormation::Stack",
786                    final_status,
787                );
788
789                if let Some(m) = state.extras.get_mut("change_sets") {
790                    if let Some(e) = m.get_mut(&cs_id) {
791                        e["ExecutionStatus"] = json!(if update_result.is_err() {
792                            "EXECUTE_FAILED"
793                        } else {
794                            "EXECUTE_COMPLETE"
795                        });
796                    }
797                }
798
799                drop(accounts);
800
801                if let Err(msg) = update_result {
802                    return Err(AwsServiceError::aws_error(
803                        StatusCode::BAD_REQUEST,
804                        "ValidationError",
805                        msg,
806                    ));
807                }
808
809                Ok(xml_response("ExecuteChangeSet", String::new(), &rid))
810            }
811            "ListChangeSets" => {
812                require_scalar(&params, "StackName")?;
813                let accounts = self.state.read();
814                let items: Vec<Value> = accounts
815                    .get(&aid)
816                    .and_then(|s| s.extras.get("change_sets"))
817                    .map(|m| m.values().cloned().collect())
818                    .unwrap_or_default();
819                let inner = format!(
820                    "    <Summaries>\n{}\n    </Summaries>",
821                    members_xml(&items, |v| {
822                        format!(
823                        "        <ChangeSetId>{}</ChangeSetId>\n        <ChangeSetName>{}</ChangeSetName>\n        <Status>{}</Status>",
824                        xml_escape(v["Id"].as_str().unwrap_or("")),
825                        xml_escape(v["ChangeSetName"].as_str().unwrap_or("")),
826                        xml_escape(v["Status"].as_str().unwrap_or("CREATE_COMPLETE")),
827                    )
828                    }),
829                );
830                Ok(xml_response("ListChangeSets", inner, &rid))
831            }
832
833            // ── Stack sets ──
834            "CreateStackSet" => {
835                let name = params
836                    .get("StackSetName")
837                    .ok_or_else(|| missing("StackSetName"))?
838                    .clone();
839                let id = format!("{name}:{}", rand_id());
840                let entry = json!({
841                    "StackSetId": id,
842                    "StackSetName": name,
843                    "Status": "ACTIVE",
844                    "TemplateBody": params.get("TemplateBody").cloned().unwrap_or_default(),
845                });
846                let mut accounts = self.state.write();
847                let state = accounts.get_or_create(&aid);
848                store(&mut state.extras, "stack_sets").insert(name.clone(), entry);
849                Ok(xml_response(
850                    "CreateStackSet",
851                    format!("    <StackSetId>{}</StackSetId>", xml_escape(&id)),
852                    &rid,
853                ))
854            }
855            "DescribeStackSet" => {
856                let name = params
857                    .get("StackSetName")
858                    .ok_or_else(|| missing("StackSetName"))?
859                    .clone();
860                let accounts = self.state.read();
861                let entry = accounts
862                    .get(&aid)
863                    .and_then(|s| s.extras.get("stack_sets"))
864                    .and_then(|m| m.get(&name))
865                    .cloned()
866                    .unwrap_or_else(|| json!({"StackSetName": name.clone(), "Status": "ACTIVE"}));
867                let inner = format!(
868                    "    <StackSet>\n      <StackSetName>{}</StackSetName>\n      <StackSetId>{}</StackSetId>\n      <Status>{}</Status>\n    </StackSet>",
869                    xml_escape(entry["StackSetName"].as_str().unwrap_or(&name)),
870                    xml_escape(entry["StackSetId"].as_str().unwrap_or("")),
871                    xml_escape(entry["Status"].as_str().unwrap_or("ACTIVE")),
872                );
873                Ok(xml_response("DescribeStackSet", inner, &rid))
874            }
875            "ListStackSets" => {
876                let accounts = self.state.read();
877                let items: Vec<Value> = accounts
878                    .get(&aid)
879                    .and_then(|s| s.extras.get("stack_sets"))
880                    .map(|m| m.values().cloned().collect())
881                    .unwrap_or_default();
882                let inner = format!(
883                    "    <Summaries>\n{}\n    </Summaries>",
884                    members_xml(&items, |v| {
885                        format!(
886                        "        <StackSetName>{}</StackSetName>\n        <StackSetId>{}</StackSetId>\n        <Status>{}</Status>",
887                        xml_escape(v["StackSetName"].as_str().unwrap_or("")),
888                        xml_escape(v["StackSetId"].as_str().unwrap_or("")),
889                        xml_escape(v["Status"].as_str().unwrap_or("ACTIVE")),
890                    )
891                    }),
892                );
893                Ok(xml_response("ListStackSets", inner, &rid))
894            }
895            "UpdateStackSet" => {
896                require_scalar(&params, "StackSetName")?;
897                let op_id = rand_id();
898                Ok(xml_response(
899                    "UpdateStackSet",
900                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
901                    &rid,
902                ))
903            }
904            "DeleteStackSet" => {
905                let name = params
906                    .get("StackSetName")
907                    .ok_or_else(|| missing("StackSetName"))?
908                    .clone();
909                let mut accounts = self.state.write();
910                let state = accounts.get_or_create(&aid);
911                if let Some(m) = state.extras.get_mut("stack_sets") {
912                    m.remove(&name);
913                }
914                Ok(xml_response("DeleteStackSet", String::new(), &rid))
915            }
916            "DescribeStackSetOperation" => {
917                require_scalar(&params, "StackSetName")?;
918                require_scalar(&params, "OperationId")?;
919                let op_id = params.get("OperationId").cloned().unwrap_or_else(rand_id);
920                let inner = format!(
921                    "    <StackSetOperation>\n      <OperationId>{}</OperationId>\n      <Status>SUCCEEDED</Status>\n    </StackSetOperation>",
922                    xml_escape(&op_id),
923                );
924                Ok(xml_response("DescribeStackSetOperation", inner, &rid))
925            }
926            "ListStackSetOperations" => {
927                require_scalar(&params, "StackSetName")?;
928                Ok(xml_response(
929                    "ListStackSetOperations",
930                    "    <Summaries/>".to_string(),
931                    &rid,
932                ))
933            }
934            "ListStackSetOperationResults" => {
935                require_scalar(&params, "StackSetName")?;
936                require_scalar(&params, "OperationId")?;
937                Ok(xml_response(
938                    "ListStackSetOperationResults",
939                    "    <Summaries/>".to_string(),
940                    &rid,
941                ))
942            }
943            "ListStackSetAutoDeploymentTargets" => {
944                require_scalar(&params, "StackSetName")?;
945                Ok(xml_response(
946                    "ListStackSetAutoDeploymentTargets",
947                    "    <Summaries/>".to_string(),
948                    &rid,
949                ))
950            }
951            "StopStackSetOperation" => {
952                require_scalar(&params, "StackSetName")?;
953                require_scalar(&params, "OperationId")?;
954                Ok(xml_response("StopStackSetOperation", String::new(), &rid))
955            }
956            "ImportStacksToStackSet" => {
957                require_scalar(&params, "StackSetName")?;
958                let op_id = rand_id();
959                Ok(xml_response(
960                    "ImportStacksToStackSet",
961                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
962                    &rid,
963                ))
964            }
965
966            // ── Stack instances ──
967            // The `Regions` list is `@required` in Smithy, but the Smithy
968            // `errors` list on these ops doesn't include `ValidationError`,
969            // so a missing-collection rejection would surface as an
970            // undeclared error to conformance. Accept an empty list and
971            // return a synthetic OperationId — real callers always supply
972            // regions and still get a valid response.
973            "CreateStackInstances" => {
974                require_scalar(&params, "StackSetName")?;
975                let op_id = rand_id();
976                Ok(xml_response(
977                    "CreateStackInstances",
978                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
979                    &rid,
980                ))
981            }
982            "UpdateStackInstances" => {
983                require_scalar(&params, "StackSetName")?;
984                let op_id = rand_id();
985                Ok(xml_response(
986                    "UpdateStackInstances",
987                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
988                    &rid,
989                ))
990            }
991            "DeleteStackInstances" => {
992                require_scalar(&params, "StackSetName")?;
993                require_scalar(&params, "RetainStacks")?;
994                let op_id = rand_id();
995                Ok(xml_response(
996                    "DeleteStackInstances",
997                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
998                    &rid,
999                ))
1000            }
1001            "DescribeStackInstance" => {
1002                require_scalar(&params, "StackSetName")?;
1003                require_scalar(&params, "StackInstanceAccount")?;
1004                require_scalar(&params, "StackInstanceRegion")?;
1005                let inner =
1006                    "    <StackInstance>\n      <Status>CURRENT</Status>\n    </StackInstance>"
1007                        .to_string();
1008                Ok(xml_response("DescribeStackInstance", inner, &rid))
1009            }
1010            "ListStackInstances" => {
1011                require_scalar(&params, "StackSetName")?;
1012                Ok(xml_response(
1013                    "ListStackInstances",
1014                    "    <Summaries/>".to_string(),
1015                    &rid,
1016                ))
1017            }
1018            "ListStackInstanceResourceDrifts" => {
1019                require_scalar(&params, "StackSetName")?;
1020                require_scalar(&params, "StackInstanceAccount")?;
1021                require_scalar(&params, "StackInstanceRegion")?;
1022                require_scalar(&params, "OperationId")?;
1023                Ok(xml_response(
1024                    "ListStackInstanceResourceDrifts",
1025                    "    <Summaries/>".to_string(),
1026                    &rid,
1027                ))
1028            }
1029
1030            // ── Stack refactors ──
1031            "CreateStackRefactor" => {
1032                require_collection(&params, "StackDefinitions")?;
1033                let id = rand_id();
1034                let entry = json!({"StackRefactorId": id.clone(), "Status": "CREATE_COMPLETE"});
1035                let mut accounts = self.state.write();
1036                let state = accounts.get_or_create(&aid);
1037                store(&mut state.extras, "refactors").insert(id.clone(), entry);
1038                Ok(xml_response(
1039                    "CreateStackRefactor",
1040                    format!("    <StackRefactorId>{}</StackRefactorId>", xml_escape(&id)),
1041                    &rid,
1042                ))
1043            }
1044            "DescribeStackRefactor" => {
1045                let id = params
1046                    .get("StackRefactorId")
1047                    .ok_or_else(|| missing("StackRefactorId"))?
1048                    .clone();
1049                let inner = format!(
1050                    "    <StackRefactorId>{}</StackRefactorId>\n    <Status>CREATE_COMPLETE</Status>",
1051                    xml_escape(&id),
1052                );
1053                Ok(xml_response("DescribeStackRefactor", inner, &rid))
1054            }
1055            "ExecuteStackRefactor" => {
1056                require_scalar(&params, "StackRefactorId")?;
1057                Ok(xml_response("ExecuteStackRefactor", String::new(), &rid))
1058            }
1059            "ListStackRefactors" => Ok(xml_response(
1060                "ListStackRefactors",
1061                "    <StackRefactorSummaries/>".to_string(),
1062                &rid,
1063            )),
1064            "ListStackRefactorActions" => {
1065                require_scalar(&params, "StackRefactorId")?;
1066                Ok(xml_response(
1067                    "ListStackRefactorActions",
1068                    "    <StackRefactorActions/>".to_string(),
1069                    &rid,
1070                ))
1071            }
1072
1073            // ── Types / extensions ──
1074            "ActivateType" => {
1075                let arn = Arn::new(
1076                    "cloudformation",
1077                    "us-east-1",
1078                    &aid,
1079                    &format!("type/resource/{}", rand_id()),
1080                )
1081                .to_string();
1082                Ok(xml_response(
1083                    "ActivateType",
1084                    format!("    <Arn>{}</Arn>", xml_escape(&arn)),
1085                    &rid,
1086                ))
1087            }
1088            "DeactivateType" => Ok(xml_response("DeactivateType", String::new(), &rid)),
1089            "DescribeType" => {
1090                let arn = params.get("Arn").cloned().unwrap_or_else(|| {
1091                    Arn::new("cloudformation", "us-east-1", &aid, "type/resource/Default")
1092                        .to_string()
1093                });
1094                let inner = format!(
1095                    "    <Arn>{}</Arn>\n    <Type>RESOURCE</Type>\n    <TypeName>AWS::Custom::Type</TypeName>",
1096                    xml_escape(&arn),
1097                );
1098                Ok(xml_response("DescribeType", inner, &rid))
1099            }
1100            "DescribeTypeRegistration" => {
1101                let token = params
1102                    .get("RegistrationToken")
1103                    .cloned()
1104                    .ok_or_else(|| missing("RegistrationToken"))?;
1105                let inner = format!(
1106                    "    <ProgressStatus>COMPLETE</ProgressStatus>\n    <Description>{}</Description>",
1107                    xml_escape(&token),
1108                );
1109                Ok(xml_response("DescribeTypeRegistration", inner, &rid))
1110            }
1111            "RegisterType" => {
1112                require_scalar(&params, "TypeName")?;
1113                require_scalar(&params, "SchemaHandlerPackage")?;
1114                let token = rand_id();
1115                Ok(xml_response(
1116                    "RegisterType",
1117                    format!(
1118                        "    <RegistrationToken>{}</RegistrationToken>",
1119                        xml_escape(&token)
1120                    ),
1121                    &rid,
1122                ))
1123            }
1124            "DeregisterType" => Ok(xml_response("DeregisterType", String::new(), &rid)),
1125            "ListTypes" => Ok(xml_response(
1126                "ListTypes",
1127                "    <TypeSummaries/>".to_string(),
1128                &rid,
1129            )),
1130            "ListTypeRegistrations" => Ok(xml_response(
1131                "ListTypeRegistrations",
1132                "    <RegistrationTokenList/>".to_string(),
1133                &rid,
1134            )),
1135            "ListTypeVersions" => Ok(xml_response(
1136                "ListTypeVersions",
1137                "    <TypeVersionSummaries/>".to_string(),
1138                &rid,
1139            )),
1140            "BatchDescribeTypeConfigurations" => {
1141                // `TypeConfigurationIdentifiers` is `@required` but AWS query
1142                // protocol can't distinguish an absent list from an empty
1143                // one on the wire. Accept either and return zero entries.
1144                Ok(xml_response(
1145                    "BatchDescribeTypeConfigurations",
1146                    "    <Errors/>\n    <TypeConfigurations/>".to_string(),
1147                    &rid,
1148                ))
1149            }
1150            "SetTypeConfiguration" => {
1151                require_scalar(&params, "Configuration")?;
1152                let arn = Arn::new(
1153                    "cloudformation",
1154                    "us-east-1",
1155                    &aid,
1156                    &format!("type-config/{}", rand_id()),
1157                )
1158                .to_string();
1159                Ok(xml_response(
1160                    "SetTypeConfiguration",
1161                    format!(
1162                        "    <ConfigurationArn>{}</ConfigurationArn>",
1163                        xml_escape(&arn)
1164                    ),
1165                    &rid,
1166                ))
1167            }
1168            "SetTypeDefaultVersion" => {
1169                Ok(xml_response("SetTypeDefaultVersion", String::new(), &rid))
1170            }
1171            "TestType" => {
1172                let arn = Arn::new(
1173                    "cloudformation",
1174                    "us-east-1",
1175                    &aid,
1176                    &format!("type/resource/{}", rand_id()),
1177                )
1178                .to_string();
1179                Ok(xml_response(
1180                    "TestType",
1181                    format!("    <TypeVersionArn>{}</TypeVersionArn>", xml_escape(&arn)),
1182                    &rid,
1183                ))
1184            }
1185            "PublishType" => {
1186                let arn = Arn::new(
1187                    "cloudformation",
1188                    "us-east-1",
1189                    &aid,
1190                    &format!("type/resource/{}", rand_id()),
1191                )
1192                .to_string();
1193                Ok(xml_response(
1194                    "PublishType",
1195                    format!("    <PublicTypeArn>{}</PublicTypeArn>", xml_escape(&arn)),
1196                    &rid,
1197                ))
1198            }
1199            "RegisterPublisher" => {
1200                let id = rand_id();
1201                Ok(xml_response(
1202                    "RegisterPublisher",
1203                    format!("    <PublisherId>{}</PublisherId>", xml_escape(&id)),
1204                    &rid,
1205                ))
1206            }
1207            "DescribePublisher" => {
1208                let id = params
1209                    .get("PublisherId")
1210                    .cloned()
1211                    .unwrap_or_else(|| "default-publisher".to_string());
1212                let inner = format!(
1213                    "    <PublisherId>{}</PublisherId>\n    <PublisherStatus>VERIFIED</PublisherStatus>\n    <IdentityProvider>AWS_Marketplace</IdentityProvider>",
1214                    xml_escape(&id),
1215                );
1216                Ok(xml_response("DescribePublisher", inner, &rid))
1217            }
1218
1219            // ── Generated templates ──
1220            "CreateGeneratedTemplate" => {
1221                let name = params
1222                    .get("GeneratedTemplateName")
1223                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1224                    .clone();
1225                let id = Arn::new(
1226                    "cloudformation",
1227                    "us-east-1",
1228                    &aid,
1229                    &format!("generatedtemplate/{}", rand_id()),
1230                )
1231                .to_string();
1232                let entry = json!({"GeneratedTemplateId": id.clone(), "Name": name.clone(), "Status": "COMPLETE"});
1233                let mut accounts = self.state.write();
1234                let state = accounts.get_or_create(&aid);
1235                store(&mut state.extras, "generated_templates").insert(name.clone(), entry);
1236                Ok(xml_response(
1237                    "CreateGeneratedTemplate",
1238                    format!(
1239                        "    <GeneratedTemplateId>{}</GeneratedTemplateId>",
1240                        xml_escape(&id)
1241                    ),
1242                    &rid,
1243                ))
1244            }
1245            "UpdateGeneratedTemplate" => {
1246                let name = params
1247                    .get("GeneratedTemplateName")
1248                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1249                    .clone();
1250                let id = Arn::new(
1251                    "cloudformation",
1252                    "us-east-1",
1253                    &aid,
1254                    &format!("generatedtemplate/{name}"),
1255                )
1256                .to_string();
1257                Ok(xml_response(
1258                    "UpdateGeneratedTemplate",
1259                    format!(
1260                        "    <GeneratedTemplateId>{}</GeneratedTemplateId>",
1261                        xml_escape(&id)
1262                    ),
1263                    &rid,
1264                ))
1265            }
1266            "DescribeGeneratedTemplate" => {
1267                let name = params
1268                    .get("GeneratedTemplateName")
1269                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1270                    .clone();
1271                let inner = format!(
1272                    "    <GeneratedTemplateId>arn:aws:cloudformation:us-east-1:{}:generatedtemplate/{}</GeneratedTemplateId>\n    <GeneratedTemplateName>{}</GeneratedTemplateName>\n    <Status>COMPLETE</Status>",
1273                    xml_escape(&aid),
1274                    xml_escape(&name),
1275                    xml_escape(&name),
1276                );
1277                Ok(xml_response("DescribeGeneratedTemplate", inner, &rid))
1278            }
1279            "GetGeneratedTemplate" => {
1280                require_scalar(&params, "GeneratedTemplateName")?;
1281                Ok(xml_response(
1282                    "GetGeneratedTemplate",
1283                    "    <Status>COMPLETE</Status>\n    <TemplateBody>{}</TemplateBody>"
1284                        .to_string(),
1285                    &rid,
1286                ))
1287            }
1288            "DeleteGeneratedTemplate" => {
1289                let name = params
1290                    .get("GeneratedTemplateName")
1291                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1292                    .clone();
1293                let mut accounts = self.state.write();
1294                let state = accounts.get_or_create(&aid);
1295                if let Some(m) = state.extras.get_mut("generated_templates") {
1296                    m.remove(&name);
1297                }
1298                Ok(xml_response("DeleteGeneratedTemplate", String::new(), &rid))
1299            }
1300            "ListGeneratedTemplates" => Ok(xml_response(
1301                "ListGeneratedTemplates",
1302                "    <Summaries/>".to_string(),
1303                &rid,
1304            )),
1305
1306            // ── Resource scans ──
1307            "StartResourceScan" => {
1308                let id = Arn::new(
1309                    "cloudformation",
1310                    "us-east-1",
1311                    &aid,
1312                    &format!("resourceScan/{}", rand_id()),
1313                )
1314                .to_string();
1315                Ok(xml_response(
1316                    "StartResourceScan",
1317                    format!("    <ResourceScanId>{}</ResourceScanId>", xml_escape(&id)),
1318                    &rid,
1319                ))
1320            }
1321            "DescribeResourceScan" => {
1322                let id = params
1323                    .get("ResourceScanId")
1324                    .cloned()
1325                    .ok_or_else(|| missing("ResourceScanId"))?;
1326                let inner = format!(
1327                    "    <ResourceScanId>{}</ResourceScanId>\n    <Status>COMPLETE</Status>",
1328                    xml_escape(&id),
1329                );
1330                Ok(xml_response("DescribeResourceScan", inner, &rid))
1331            }
1332            "ListResourceScans" => Ok(xml_response(
1333                "ListResourceScans",
1334                "    <ResourceScanSummaries/>".to_string(),
1335                &rid,
1336            )),
1337            "ListResourceScanResources" => {
1338                require_scalar(&params, "ResourceScanId")?;
1339                Ok(xml_response(
1340                    "ListResourceScanResources",
1341                    "    <Resources/>".to_string(),
1342                    &rid,
1343                ))
1344            }
1345            "ListResourceScanRelatedResources" => {
1346                require_scalar(&params, "ResourceScanId")?;
1347                Ok(xml_response(
1348                    "ListResourceScanRelatedResources",
1349                    "    <RelatedResources/>".to_string(),
1350                    &rid,
1351                ))
1352            }
1353
1354            // ── Drift detection ──
1355            "DetectStackDrift" => {
1356                let stack_name = params
1357                    .get("StackName")
1358                    .ok_or_else(|| missing("StackName"))?
1359                    .clone();
1360                let id = rand_id();
1361
1362                let resources: Vec<StackResource> = {
1363                    let accounts = self.state.read();
1364                    let stack = accounts.get(&aid).and_then(|s| {
1365                        s.stacks.values().find(|st| {
1366                            (st.name == stack_name || st.stack_id == stack_name)
1367                                && st.status != "DELETE_COMPLETE"
1368                        })
1369                    });
1370                    stack.map(|s| s.resources.clone()).unwrap_or_default()
1371                };
1372
1373                let mut drifted_resources: Vec<Value> = Vec::new();
1374
1375                for resource in &resources {
1376                    let exists = match resource.resource_type.as_str() {
1377                        "AWS::SQS::Queue" => self
1378                            .deps
1379                            .sqs
1380                            .read()
1381                            .get(&aid)
1382                            .map(|s| s.queues.contains_key(&resource.physical_id))
1383                            .unwrap_or(false),
1384                        "AWS::SNS::Topic" => self
1385                            .deps
1386                            .sns
1387                            .read()
1388                            .get(&aid)
1389                            .map(|s| s.topics.contains_key(&resource.physical_id))
1390                            .unwrap_or(false),
1391                        "AWS::S3::Bucket" => self
1392                            .deps
1393                            .s3
1394                            .read()
1395                            .get(&aid)
1396                            .map(|s| s.buckets.contains_key(&resource.physical_id))
1397                            .unwrap_or(false),
1398                        "AWS::Lambda::Function" => self
1399                            .deps
1400                            .lambda
1401                            .read()
1402                            .get(&aid)
1403                            .map(|s| s.functions.contains_key(&resource.physical_id))
1404                            .unwrap_or(false),
1405                        "AWS::IAM::Role" => self
1406                            .deps
1407                            .iam
1408                            .read()
1409                            .get(&aid)
1410                            .map(|s| s.roles.contains_key(&resource.physical_id))
1411                            .unwrap_or(false),
1412                        "AWS::DynamoDB::Table" => self
1413                            .deps
1414                            .dynamodb
1415                            .read()
1416                            .get(&aid)
1417                            .map(|s| s.tables.values().any(|t| t.arn == resource.physical_id))
1418                            .unwrap_or(false),
1419                        "AWS::KMS::Key" => self
1420                            .deps
1421                            .kms
1422                            .read()
1423                            .get(&aid)
1424                            .map(|s| s.keys.contains_key(&resource.physical_id))
1425                            .unwrap_or(false),
1426                        "AWS::SecretsManager::Secret" => self
1427                            .deps
1428                            .secretsmanager
1429                            .read()
1430                            .get(&aid)
1431                            .map(|s| s.secrets.contains_key(&resource.physical_id))
1432                            .unwrap_or(false),
1433                        _ => true, // NOT_CHECKED — assume exists
1434                    };
1435                    if !exists {
1436                        drifted_resources.push(json!({
1437                            "LogicalResourceId": resource.logical_id,
1438                            "PhysicalResourceId": resource.physical_id,
1439                            "ResourceType": resource.resource_type,
1440                            "StackResourceDriftStatus": "DELETED",
1441                            "PropertyDifferences": [],
1442                        }));
1443                    }
1444                }
1445
1446                let stack_drift_status = if drifted_resources.is_empty() {
1447                    "IN_SYNC"
1448                } else {
1449                    "DRIFTED"
1450                };
1451
1452                let record = json!({
1453                    "StackDriftDetectionId": id,
1454                    "StackName": stack_name,
1455                    "StackDriftStatus": stack_drift_status,
1456                    "DetectionStatus": "DETECTION_COMPLETE",
1457                    "DriftedResources": drifted_resources,
1458                });
1459
1460                {
1461                    let mut accounts = self.state.write();
1462                    let state = accounts.get_or_create(&aid);
1463                    store(&mut state.extras, "drift_detection").insert(id.clone(), record);
1464                }
1465
1466                Ok(xml_response(
1467                    "DetectStackDrift",
1468                    format!(
1469                        "    <StackDriftDetectionId>{}</StackDriftDetectionId>",
1470                        xml_escape(&id)
1471                    ),
1472                    &rid,
1473                ))
1474            }
1475            "DetectStackResourceDrift" => {
1476                let stack_name = params
1477                    .get("StackName")
1478                    .ok_or_else(|| missing("StackName"))?
1479                    .clone();
1480                let logical = params
1481                    .get("LogicalResourceId")
1482                    .ok_or_else(|| missing("LogicalResourceId"))?
1483                    .clone();
1484                let accounts = self.state.read();
1485                let resource_drift = accounts
1486                    .get(&aid)
1487                    .and_then(|s| {
1488                        s.stacks.values().find(|st| {
1489                            (st.name == stack_name || st.stack_id == stack_name)
1490                                && st.status != "DELETE_COMPLETE"
1491                        })
1492                    })
1493                    .and_then(|stack| stack.resources.iter().find(|r| r.logical_id == logical))
1494                    .map(|resource| {
1495                        let exists = match resource.resource_type.as_str() {
1496                            "AWS::SQS::Queue" => self
1497                                .deps
1498                                .sqs
1499                                .read()
1500                                .get(&aid)
1501                                .map(|s| s.queues.contains_key(&resource.physical_id))
1502                                .unwrap_or(false),
1503                            "AWS::SNS::Topic" => self
1504                                .deps
1505                                .sns
1506                                .read()
1507                                .get(&aid)
1508                                .map(|s| s.topics.contains_key(&resource.physical_id))
1509                                .unwrap_or(false),
1510                            "AWS::S3::Bucket" => self
1511                                .deps
1512                                .s3
1513                                .read()
1514                                .get(&aid)
1515                                .map(|s| s.buckets.contains_key(&resource.physical_id))
1516                                .unwrap_or(false),
1517                            "AWS::Lambda::Function" => self
1518                                .deps
1519                                .lambda
1520                                .read()
1521                                .get(&aid)
1522                                .map(|s| s.functions.contains_key(&resource.physical_id))
1523                                .unwrap_or(false),
1524                            "AWS::IAM::Role" => self
1525                                .deps
1526                                .iam
1527                                .read()
1528                                .get(&aid)
1529                                .map(|s| s.roles.contains_key(&resource.physical_id))
1530                                .unwrap_or(false),
1531                            "AWS::DynamoDB::Table" => self
1532                                .deps
1533                                .dynamodb
1534                                .read()
1535                                .get(&aid)
1536                                .map(|s| s.tables.values().any(|t| t.arn == resource.physical_id))
1537                                .unwrap_or(false),
1538                            "AWS::KMS::Key" => self
1539                                .deps
1540                                .kms
1541                                .read()
1542                                .get(&aid)
1543                                .map(|s| s.keys.contains_key(&resource.physical_id))
1544                                .unwrap_or(false),
1545                            "AWS::SecretsManager::Secret" => self
1546                                .deps
1547                                .secretsmanager
1548                                .read()
1549                                .get(&aid)
1550                                .map(|s| s.secrets.contains_key(&resource.physical_id))
1551                                .unwrap_or(false),
1552                            _ => true,
1553                        };
1554                        if exists {
1555                            "IN_SYNC"
1556                        } else {
1557                            "DELETED"
1558                        }
1559                    })
1560                    .unwrap_or("NOT_CHECKED");
1561
1562                let inner = format!(
1563                    "    <StackResourceDrift>\n      <LogicalResourceId>{}</LogicalResourceId>\n      <StackResourceDriftStatus>{}</StackResourceDriftStatus>\n    </StackResourceDrift>",
1564                    xml_escape(&logical),
1565                    xml_escape(resource_drift),
1566                );
1567                Ok(xml_response("DetectStackResourceDrift", inner, &rid))
1568            }
1569            "DetectStackSetDrift" => {
1570                require_scalar(&params, "StackSetName")?;
1571                let op_id = rand_id();
1572                Ok(xml_response(
1573                    "DetectStackSetDrift",
1574                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
1575                    &rid,
1576                ))
1577            }
1578            "DescribeStackDriftDetectionStatus" => {
1579                let id = params
1580                    .get("StackDriftDetectionId")
1581                    .ok_or_else(|| missing("StackDriftDetectionId"))?
1582                    .clone();
1583                let accounts = self.state.read();
1584                let record = accounts
1585                    .get(&aid)
1586                    .and_then(|s| s.extras.get("drift_detection"))
1587                    .and_then(|m| m.get(&id))
1588                    .cloned()
1589                    .unwrap_or_else(|| {
1590                        json!({
1591                            "StackDriftDetectionId": id,
1592                            "StackDriftStatus": "IN_SYNC",
1593                            "DetectionStatus": "DETECTION_COMPLETE",
1594                        })
1595                    });
1596                // The Smithy output declares `StackId` and `Timestamp` as
1597                // `@required` alongside `StackDriftDetectionId` and
1598                // `DetectionStatus`. Synthetic detection records won't have
1599                // a real stack ARN behind them, so synthesise a deterministic
1600                // placeholder ARN from the detection id.
1601                let stack_id = record["StackId"]
1602                    .as_str()
1603                    .map(str::to_owned)
1604                    .unwrap_or_else(|| {
1605                        Arn::new(
1606                            "cloudformation",
1607                            "us-east-1",
1608                            &aid,
1609                            &format!("stack/drift-{id}/{}", rand_id()),
1610                        )
1611                        .to_string()
1612                    });
1613                let timestamp = record["Timestamp"]
1614                    .as_str()
1615                    .map(str::to_owned)
1616                    .unwrap_or_else(|| "2024-01-01T00:00:00Z".to_string());
1617
1618                let inner = format!(
1619                    "    <StackId>{}</StackId>\n    <StackDriftDetectionId>{}</StackDriftDetectionId>\n    <DetectionStatus>{}</DetectionStatus>\n    <StackDriftStatus>{}</StackDriftStatus>\n    <Timestamp>{}</Timestamp>",
1620                    xml_escape(&stack_id),
1621                    xml_escape(record["StackDriftDetectionId"].as_str().unwrap_or("")),
1622                    xml_escape(record["DetectionStatus"].as_str().unwrap_or("DETECTION_COMPLETE")),
1623                    xml_escape(record["StackDriftStatus"].as_str().unwrap_or("IN_SYNC")),
1624                    xml_escape(&timestamp),
1625                );
1626                Ok(xml_response(
1627                    "DescribeStackDriftDetectionStatus",
1628                    inner,
1629                    &rid,
1630                ))
1631            }
1632            "DescribeStackResourceDrifts" => {
1633                let stack_name = params
1634                    .get("StackName")
1635                    .cloned()
1636                    .ok_or_else(|| missing("StackName"))?;
1637                let accounts = self.state.read();
1638                let drifted: Vec<Value> = accounts
1639                    .get(&aid)
1640                    .and_then(|s| {
1641                        let found = s
1642                            .stacks
1643                            .values()
1644                            .find(|st| {
1645                                (st.name == stack_name || st.stack_id == stack_name)
1646                                    && st.status != "DELETE_COMPLETE"
1647                            })
1648                            .is_some();
1649                        if !found {
1650                            return None;
1651                        }
1652                        s.extras
1653                            .get("drift_detection")
1654                            .and_then(|m| {
1655                                m.values()
1656                                    .find(|v| v["StackName"].as_str() == Some(stack_name.as_str()))
1657                            })
1658                            .and_then(|v| v["DriftedResources"].as_array().cloned())
1659                    })
1660                    .unwrap_or_default();
1661
1662                let inner = if drifted.is_empty() {
1663                    "    <StackResourceDrifts/>".to_string()
1664                } else {
1665                    format!(
1666                        "    <StackResourceDrifts>\n{}\n    </StackResourceDrifts>",
1667                        members_xml(&drifted, |v| {
1668                            format!(
1669                            "        <StackResourceDrift>\n          <LogicalResourceId>{}</LogicalResourceId>\n          <PhysicalResourceId>{}</PhysicalResourceId>\n          <ResourceType>{}</ResourceType>\n          <StackResourceDriftStatus>{}</StackResourceDriftStatus>\n        </StackResourceDrift>",
1670                            xml_escape(v["LogicalResourceId"].as_str().unwrap_or("")),
1671                            xml_escape(v["PhysicalResourceId"].as_str().unwrap_or("")),
1672                            xml_escape(v["ResourceType"].as_str().unwrap_or("")),
1673                            xml_escape(v["StackResourceDriftStatus"].as_str().unwrap_or("IN_SYNC")),
1674                        )
1675                        }),
1676                    )
1677                };
1678                Ok(xml_response("DescribeStackResourceDrifts", inner, &rid))
1679            }
1680            "DescribeStackResource" => {
1681                let stack_name = params
1682                    .get("StackName")
1683                    .ok_or_else(|| missing("StackName"))?
1684                    .clone();
1685                let logical = params
1686                    .get("LogicalResourceId")
1687                    .ok_or_else(|| missing("LogicalResourceId"))?
1688                    .clone();
1689                let accounts = self.state.read();
1690                let detail = accounts
1691                    .get(&aid)
1692                    .and_then(|s| s.stacks.get(&stack_name))
1693                    .and_then(|s| s.resources.iter().find(|r| r.logical_id == logical))
1694                    .map(|r| {
1695                        (
1696                            r.physical_id.clone(),
1697                            r.resource_type.clone(),
1698                            r.status.clone(),
1699                        )
1700                    })
1701                    .unwrap_or_else(|| {
1702                        (
1703                            "pid".to_string(),
1704                            "AWS::Custom".to_string(),
1705                            "CREATE_COMPLETE".to_string(),
1706                        )
1707                    });
1708                let inner = format!(
1709                    "    <StackResourceDetail>\n      <StackName>{}</StackName>\n      <LogicalResourceId>{}</LogicalResourceId>\n      <PhysicalResourceId>{}</PhysicalResourceId>\n      <ResourceType>{}</ResourceType>\n      <ResourceStatus>{}</ResourceStatus>\n      <LastUpdatedTimestamp>{}</LastUpdatedTimestamp>\n    </StackResourceDetail>",
1710                    xml_escape(&stack_name),
1711                    xml_escape(&logical),
1712                    xml_escape(&detail.0),
1713                    xml_escape(&detail.1),
1714                    xml_escape(&detail.2),
1715                    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
1716                );
1717                Ok(xml_response("DescribeStackResource", inner, &rid))
1718            }
1719
1720            // ── Events ──
1721            "DescribeStackEvents" => {
1722                require_scalar(&params, "StackName")?;
1723                let stack_filter = params.get("StackName").cloned();
1724                let accounts = self.state.read();
1725                let events: Vec<Value> = accounts
1726                    .get(&aid)
1727                    .map(|s| {
1728                        let mut all: Vec<Value> = Vec::new();
1729                        for (sid, evs) in &s.events {
1730                            // Resolve to find matching stack id by name or id.
1731                            let matches = match &stack_filter {
1732                                None => true,
1733                                Some(filter) => {
1734                                    sid == filter
1735                                        || s.stacks.values().any(|st| {
1736                                            st.stack_id == *sid
1737                                                && (st.name == *filter || st.stack_id == *filter)
1738                                        })
1739                                }
1740                            };
1741                            if matches {
1742                                all.extend(evs.iter().cloned());
1743                            }
1744                        }
1745                        // Newest first, matching real CloudFormation.
1746                        all.reverse();
1747                        all
1748                    })
1749                    .unwrap_or_default();
1750                let inner = if events.is_empty() {
1751                    "    <StackEvents/>".to_string()
1752                } else {
1753                    format!(
1754                        "    <StackEvents>\n{}\n    </StackEvents>",
1755                        members_xml(&events, |v| {
1756                            format!(
1757                            "        <EventId>{}</EventId>\n        <StackId>{}</StackId>\n        <StackName>{}</StackName>\n        <LogicalResourceId>{}</LogicalResourceId>\n        <PhysicalResourceId>{}</PhysicalResourceId>\n        <ResourceType>{}</ResourceType>\n        <ResourceStatus>{}</ResourceStatus>\n        <Timestamp>{}</Timestamp>",
1758                            xml_escape(v["EventId"].as_str().unwrap_or("")),
1759                            xml_escape(v["StackId"].as_str().unwrap_or("")),
1760                            xml_escape(v["StackName"].as_str().unwrap_or("")),
1761                            xml_escape(v["LogicalResourceId"].as_str().unwrap_or("")),
1762                            xml_escape(v["PhysicalResourceId"].as_str().unwrap_or("")),
1763                            xml_escape(v["ResourceType"].as_str().unwrap_or("")),
1764                            xml_escape(v["ResourceStatus"].as_str().unwrap_or("")),
1765                            xml_escape(v["Timestamp"].as_str().unwrap_or("")),
1766                        )
1767                        }),
1768                    )
1769                };
1770                Ok(xml_response("DescribeStackEvents", inner, &rid))
1771            }
1772            "DescribeEvents" => Ok(xml_response(
1773                "DescribeEvents",
1774                "    <Events/>".to_string(),
1775                &rid,
1776            )),
1777
1778            // ── Hooks ──
1779            "GetHookResult" => Ok(xml_response(
1780                "GetHookResult",
1781                "    <Status>HOOK_COMPLETE_SUCCEEDED</Status>".to_string(),
1782                &rid,
1783            )),
1784            "ListHookResults" => Ok(xml_response(
1785                "ListHookResults",
1786                "    <HookResults/>".to_string(),
1787                &rid,
1788            )),
1789            "RecordHandlerProgress" => {
1790                require_scalar(&params, "BearerToken")?;
1791                require_scalar(&params, "OperationStatus")?;
1792                Ok(xml_response_no_result("RecordHandlerProgress", &rid))
1793            }
1794
1795            // ── Imports / exports ──
1796            "ListExports" => {
1797                let accounts = self.state.read();
1798                let mut entries = String::new();
1799                if let Some(state) = accounts.get(&aid) {
1800                    for (name, export) in &state.exports {
1801                        entries.push_str(&format!(
1802                            "      <member>\n        <ExportingStackId>{}</ExportingStackId>\n        <Name>{}</Name>\n        <Value>{}</Value>\n      </member>\n",
1803                            xml_escape(&export.exporting_stack_id),
1804                            xml_escape(name),
1805                            xml_escape(&export.value),
1806                        ));
1807                    }
1808                }
1809                let inner = if entries.is_empty() {
1810                    "    <Exports/>".to_string()
1811                } else {
1812                    format!("    <Exports>\n{entries}    </Exports>")
1813                };
1814                Ok(xml_response("ListExports", inner, &rid))
1815            }
1816            "ListImports" => {
1817                let export_name = params
1818                    .get("ExportName")
1819                    .cloned()
1820                    .ok_or_else(|| missing("ExportName"))?;
1821                let accounts = self.state.read();
1822                let mut entries = String::new();
1823                if let Some(state) = accounts.get(&aid) {
1824                    if let Some(consumers) = state.imports.get(&export_name) {
1825                        for stack_name in consumers {
1826                            entries.push_str(&format!(
1827                                "      <member>{}</member>\n",
1828                                xml_escape(stack_name)
1829                            ));
1830                        }
1831                    }
1832                }
1833                let inner = if entries.is_empty() {
1834                    "    <Imports/>".to_string()
1835                } else {
1836                    format!("    <Imports>\n{entries}    </Imports>")
1837                };
1838                Ok(xml_response("ListImports", inner, &rid))
1839            }
1840
1841            // ── Stack policies ──
1842            "GetStackPolicy" => {
1843                let stack = params
1844                    .get("StackName")
1845                    .ok_or_else(|| missing("StackName"))?
1846                    .clone();
1847                let accounts = self.state.read();
1848                let body = accounts.get(&aid)
1849                    .and_then(|s| s.stack_policies.get(&stack))
1850                    .cloned()
1851                    .unwrap_or_else(|| r#"{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}"#.to_string());
1852                let inner = format!(
1853                    "    <StackPolicyBody>{}</StackPolicyBody>",
1854                    xml_escape(&body)
1855                );
1856                Ok(xml_response("GetStackPolicy", inner, &rid))
1857            }
1858            "SetStackPolicy" => {
1859                let stack = params
1860                    .get("StackName")
1861                    .ok_or_else(|| missing("StackName"))?
1862                    .clone();
1863                let body = params.get("StackPolicyBody").cloned().unwrap_or_default();
1864                let mut accounts = self.state.write();
1865                let state = accounts.get_or_create(&aid);
1866                state.stack_policies.insert(stack, body);
1867                Ok(xml_response_no_result("SetStackPolicy", &rid))
1868            }
1869
1870            // ── Termination protection ──
1871            "UpdateTerminationProtection" => {
1872                let stack = params
1873                    .get("StackName")
1874                    .ok_or_else(|| missing("StackName"))?
1875                    .clone();
1876                let enabled_raw = params
1877                    .get("EnableTerminationProtection")
1878                    .ok_or_else(|| missing("EnableTerminationProtection"))?;
1879                let enabled = enabled_raw.eq_ignore_ascii_case("true");
1880                let stack_id = {
1881                    let mut accounts = self.state.write();
1882                    let state = accounts.get_or_create(&aid);
1883                    state.termination_protection.insert(stack.clone(), enabled);
1884                    state
1885                        .stacks
1886                        .get(&stack)
1887                        .map(|s| s.stack_id.clone())
1888                        .unwrap_or_else(|| stack.clone())
1889                };
1890                Ok(xml_response(
1891                    "UpdateTerminationProtection",
1892                    format!("    <StackId>{}</StackId>", xml_escape(&stack_id)),
1893                    &rid,
1894                ))
1895            }
1896
1897            // ── Account / org / validation / utilities ──
1898            "DescribeAccountLimits" => Ok(xml_response(
1899                "DescribeAccountLimits",
1900                r#"    <AccountLimits>
1901      <member>
1902        <Name>StackLimit</Name>
1903        <Value>2000</Value>
1904      </member>
1905    </AccountLimits>"#
1906                    .to_string(),
1907                &rid,
1908            )),
1909            "ActivateOrganizationsAccess" => {
1910                let mut accounts = self.state.write();
1911                let state = accounts.get_or_create(&aid);
1912                state.orgs_access_enabled = true;
1913                Ok(xml_response(
1914                    "ActivateOrganizationsAccess",
1915                    String::new(),
1916                    &rid,
1917                ))
1918            }
1919            "DeactivateOrganizationsAccess" => {
1920                let mut accounts = self.state.write();
1921                let state = accounts.get_or_create(&aid);
1922                state.orgs_access_enabled = false;
1923                Ok(xml_response(
1924                    "DeactivateOrganizationsAccess",
1925                    String::new(),
1926                    &rid,
1927                ))
1928            }
1929            "DescribeOrganizationsAccess" => {
1930                let accounts = self.state.read();
1931                let status = if accounts
1932                    .get(&aid)
1933                    .map(|s| s.orgs_access_enabled)
1934                    .unwrap_or(false)
1935                {
1936                    "ENABLED"
1937                } else {
1938                    "DISABLED"
1939                };
1940                Ok(xml_response(
1941                    "DescribeOrganizationsAccess",
1942                    format!("    <Status>{}</Status>", status),
1943                    &rid,
1944                ))
1945            }
1946            "ValidateTemplate" => Ok(xml_response(
1947                "ValidateTemplate",
1948                "    <Description>Validated</Description>\n    <Capabilities/>\n    <Parameters/>"
1949                    .to_string(),
1950                &rid,
1951            )),
1952            "EstimateTemplateCost" => Ok(xml_response(
1953                "EstimateTemplateCost",
1954                "    <Url>https://calculator.aws/#/estimate</Url>".to_string(),
1955                &rid,
1956            )),
1957            "GetTemplateSummary" => Ok(xml_response(
1958                "GetTemplateSummary",
1959                "    <Parameters/>\n    <ResourceTypes/>\n    <Capabilities/>".to_string(),
1960                &rid,
1961            )),
1962            "CancelUpdateStack" => {
1963                params
1964                    .get("StackName")
1965                    .ok_or_else(|| missing("StackName"))?;
1966                Ok(xml_response_no_result("CancelUpdateStack", &rid))
1967            }
1968            "ContinueUpdateRollback" => {
1969                params
1970                    .get("StackName")
1971                    .ok_or_else(|| missing("StackName"))?;
1972                Ok(xml_response("ContinueUpdateRollback", String::new(), &rid))
1973            }
1974            "RollbackStack" => {
1975                let stack = params
1976                    .get("StackName")
1977                    .ok_or_else(|| missing("StackName"))?
1978                    .clone();
1979                let stack_id = {
1980                    let accounts = self.state.read();
1981                    accounts
1982                        .get(&aid)
1983                        .and_then(|s| s.stacks.get(&stack))
1984                        .map(|s| s.stack_id.clone())
1985                        .unwrap_or_else(|| stack.clone())
1986                };
1987                Ok(xml_response(
1988                    "RollbackStack",
1989                    format!("    <StackId>{}</StackId>", xml_escape(&stack_id)),
1990                    &rid,
1991                ))
1992            }
1993            "SignalResource" => {
1994                require_scalar(&params, "StackName")?;
1995                require_scalar(&params, "LogicalResourceId")?;
1996                require_scalar(&params, "UniqueId")?;
1997                require_scalar(&params, "Status")?;
1998                Ok(xml_response_no_result("SignalResource", &rid))
1999            }
2000
2001            _ => Err(AwsServiceError::action_not_implemented(
2002                "cloudformation",
2003                &action,
2004            )),
2005        }
2006    }
2007}
2008
2009#[cfg(test)]
2010mod tests {
2011    use super::parse_s3_url;
2012    use crate::service::{CloudFormationDeps, CloudFormationService};
2013    use crate::state::{CloudFormationState, SharedCloudFormationState};
2014    use fakecloud_core::delivery::DeliveryBus;
2015    use fakecloud_core::multi_account::MultiAccountState;
2016    use fakecloud_core::service::AwsRequest;
2017    use http::Method;
2018    use parking_lot::RwLock;
2019    use std::collections::HashMap;
2020    use std::sync::Arc;
2021
2022    #[test]
2023    fn parse_s3_url_handles_path_and_virtual_hosted() {
2024        // Path-style against a fakecloud endpoint (what TemplateURL looks
2025        // like locally) — key keeps its embedded slashes.
2026        assert_eq!(
2027            parse_s3_url("http://127.0.0.1:4566/bucket/deploy/template.json"),
2028            Some(("bucket".to_string(), "deploy/template.json".to_string()))
2029        );
2030        // Path-style against real AWS S3 host.
2031        assert_eq!(
2032            parse_s3_url("https://s3.us-east-1.amazonaws.com/my-bucket/key.yaml"),
2033            Some(("my-bucket".to_string(), "key.yaml".to_string()))
2034        );
2035        // Virtual-hosted style.
2036        assert_eq!(
2037            parse_s3_url("https://my-bucket.s3.amazonaws.com/key.yaml"),
2038            Some(("my-bucket".to_string(), "key.yaml".to_string()))
2039        );
2040        // Query string (e.g. ?versionId=...) is dropped.
2041        assert_eq!(
2042            parse_s3_url("https://s3.amazonaws.com/b/k.json?versionId=abc"),
2043            Some(("b".to_string(), "k.json".to_string()))
2044        );
2045        // Not an object URL (no key).
2046        assert_eq!(parse_s3_url("https://s3.amazonaws.com/bucket-only"), None);
2047    }
2048
2049    fn deps() -> CloudFormationDeps {
2050        use fakecloud_dynamodb::DynamoDbState;
2051        use fakecloud_ecr::EcrState;
2052        use fakecloud_eventbridge::EventBridgeState;
2053        use fakecloud_iam::IamState;
2054        use fakecloud_kinesis::KinesisState;
2055        use fakecloud_kms::KmsState;
2056        use fakecloud_lambda::LambdaState;
2057        use fakecloud_logs::LogsState;
2058        use fakecloud_s3::S3State;
2059        use fakecloud_secretsmanager::SecretsManagerState;
2060        use fakecloud_sns::SnsState;
2061        use fakecloud_sqs::SqsState;
2062        use fakecloud_ssm::SsmState;
2063
2064        fn shared<T: fakecloud_core::multi_account::AccountState>(
2065        ) -> Arc<RwLock<MultiAccountState<T>>> {
2066            Arc::new(RwLock::new(MultiAccountState::<T>::new(
2067                "000000000000",
2068                "us-east-1",
2069                "",
2070            )))
2071        }
2072        CloudFormationDeps {
2073            sqs: shared::<SqsState>(),
2074            sns: shared::<SnsState>(),
2075            ssm: shared::<SsmState>(),
2076            iam: shared::<IamState>(),
2077            s3: shared::<S3State>(),
2078            eventbridge: shared::<EventBridgeState>(),
2079            dynamodb: shared::<DynamoDbState>(),
2080            logs: shared::<LogsState>(),
2081            lambda: shared::<LambdaState>(),
2082            secretsmanager: shared::<SecretsManagerState>(),
2083            kinesis: shared::<KinesisState>(),
2084            kms: shared::<KmsState>(),
2085            ecr: shared::<EcrState>(),
2086            cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
2087            elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
2088            organizations: Arc::new(RwLock::new(None)),
2089            cognito: shared::<fakecloud_cognito::CognitoState>(),
2090            rds: shared::<fakecloud_rds::RdsState>(),
2091            ecs: shared::<fakecloud_ecs::EcsState>(),
2092            acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
2093            elasticache: shared::<fakecloud_elasticache::ElastiCacheState>(),
2094            route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
2095            cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
2096            stepfunctions: shared::<fakecloud_stepfunctions::StepFunctionsState>(),
2097            wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
2098            apigateway: shared::<fakecloud_apigateway::ApiGatewayState>(),
2099            apigatewayv2: shared::<fakecloud_apigatewayv2::ApiGatewayV2State>(),
2100            ses: shared::<fakecloud_ses::SesState>(),
2101            application_autoscaling: Arc::new(parking_lot::RwLock::new(
2102                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
2103            )),
2104            athena: Arc::new(parking_lot::RwLock::new(
2105                fakecloud_athena::AthenaAccounts::new(),
2106            )),
2107            firehose: Arc::new(parking_lot::RwLock::new(
2108                fakecloud_firehose::FirehoseAccounts::new(),
2109            )),
2110            glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
2111            delivery: Arc::new(DeliveryBus::new()),
2112            lambda_runtime: None,
2113        }
2114    }
2115
2116    fn svc() -> CloudFormationService {
2117        let state: SharedCloudFormationState =
2118            Arc::new(RwLock::new(MultiAccountState::<CloudFormationState>::new(
2119                "000000000000",
2120                "us-east-1",
2121                "",
2122            )));
2123        CloudFormationService::new(state, deps())
2124    }
2125
2126    fn req(action: &str, params: &[(&str, &str)]) -> AwsRequest {
2127        let mut q = HashMap::new();
2128        q.insert("Action".to_string(), action.to_string());
2129        for (k, v) in params {
2130            q.insert(k.to_string(), v.to_string());
2131        }
2132        AwsRequest {
2133            service: "cloudformation".to_string(),
2134            method: Method::POST,
2135            raw_path: "/".to_string(),
2136            raw_query: String::new(),
2137            path_segments: vec![],
2138            query_params: q,
2139            headers: http::HeaderMap::new(),
2140            body: bytes::Bytes::new(),
2141            body_stream: parking_lot::Mutex::new(None),
2142            account_id: "000000000000".to_string(),
2143            region: "us-east-1".to_string(),
2144            request_id: "rid".to_string(),
2145            action: action.to_string(),
2146            is_query_protocol: true,
2147            access_key_id: None,
2148            principal: None,
2149        }
2150    }
2151
2152    fn ok(action: &str, params: &[(&str, &str)]) {
2153        let r = svc().handle_extra_action(&req(action, params));
2154        match r {
2155            Ok(resp) => assert!(resp.status.is_success(), "{action} status: {}", resp.status),
2156            Err(e) => panic!("{action} failed: {e:?}"),
2157        }
2158    }
2159
2160    #[test]
2161    fn change_sets() {
2162        ok(
2163            "CreateChangeSet",
2164            &[("StackName", "s"), ("ChangeSetName", "cs")],
2165        );
2166        ok("DescribeChangeSet", &[("ChangeSetName", "cs")]);
2167        ok("DescribeChangeSetHooks", &[("ChangeSetName", "cs")]);
2168        ok("ListChangeSets", &[("StackName", "s")]);
2169        ok("ExecuteChangeSet", &[("ChangeSetName", "cs")]);
2170        ok("DeleteChangeSet", &[("ChangeSetName", "cs")]);
2171    }
2172
2173    #[test]
2174    fn stack_sets_instances_refactors() {
2175        ok("CreateStackSet", &[("StackSetName", "ss")]);
2176        ok("DescribeStackSet", &[("StackSetName", "ss")]);
2177        ok("ListStackSets", &[]);
2178        ok("UpdateStackSet", &[("StackSetName", "ss")]);
2179        ok(
2180            "DescribeStackSetOperation",
2181            &[("StackSetName", "ss"), ("OperationId", "op")],
2182        );
2183        ok("ListStackSetOperations", &[("StackSetName", "ss")]);
2184        ok(
2185            "ListStackSetOperationResults",
2186            &[("StackSetName", "ss"), ("OperationId", "op")],
2187        );
2188        ok(
2189            "ListStackSetAutoDeploymentTargets",
2190            &[("StackSetName", "ss")],
2191        );
2192        ok(
2193            "StopStackSetOperation",
2194            &[("StackSetName", "ss"), ("OperationId", "op")],
2195        );
2196        ok("ImportStacksToStackSet", &[("StackSetName", "ss")]);
2197        ok("DeleteStackSet", &[("StackSetName", "ss")]);
2198        ok(
2199            "CreateStackInstances",
2200            &[("StackSetName", "ss"), ("Regions.member.1", "us-east-1")],
2201        );
2202        ok(
2203            "UpdateStackInstances",
2204            &[("StackSetName", "ss"), ("Regions.member.1", "us-east-1")],
2205        );
2206        ok(
2207            "DeleteStackInstances",
2208            &[
2209                ("StackSetName", "ss"),
2210                ("Regions.member.1", "us-east-1"),
2211                ("RetainStacks", "false"),
2212            ],
2213        );
2214        ok(
2215            "DescribeStackInstance",
2216            &[
2217                ("StackSetName", "ss"),
2218                ("StackInstanceAccount", "000000000000"),
2219                ("StackInstanceRegion", "us-east-1"),
2220            ],
2221        );
2222        ok("ListStackInstances", &[("StackSetName", "ss")]);
2223        ok(
2224            "ListStackInstanceResourceDrifts",
2225            &[
2226                ("StackSetName", "ss"),
2227                ("StackInstanceAccount", "000000000000"),
2228                ("StackInstanceRegion", "us-east-1"),
2229                ("OperationId", "op"),
2230            ],
2231        );
2232        ok(
2233            "CreateStackRefactor",
2234            &[("StackDefinitions.member.1.StackName", "s")],
2235        );
2236        ok("DescribeStackRefactor", &[("StackRefactorId", "r")]);
2237        ok("ExecuteStackRefactor", &[("StackRefactorId", "r")]);
2238        ok("ListStackRefactors", &[]);
2239        ok("ListStackRefactorActions", &[("StackRefactorId", "r")]);
2240    }
2241
2242    #[test]
2243    fn types_and_publishers() {
2244        ok("ActivateType", &[]);
2245        ok("DeactivateType", &[]);
2246        ok("DescribeType", &[]);
2247        ok("DescribeTypeRegistration", &[("RegistrationToken", "tok")]);
2248        ok(
2249            "RegisterType",
2250            &[("TypeName", "T"), ("SchemaHandlerPackage", "pkg")],
2251        );
2252        ok("DeregisterType", &[]);
2253        ok("ListTypes", &[]);
2254        ok("ListTypeRegistrations", &[]);
2255        ok("ListTypeVersions", &[]);
2256        ok(
2257            "BatchDescribeTypeConfigurations",
2258            &[("TypeConfigurationIdentifiers.member.1.Type", "RESOURCE")],
2259        );
2260        ok("SetTypeConfiguration", &[("Configuration", "{}")]);
2261        ok("SetTypeDefaultVersion", &[]);
2262        ok("TestType", &[]);
2263        ok("PublishType", &[]);
2264        ok("RegisterPublisher", &[]);
2265        ok("DescribePublisher", &[]);
2266    }
2267
2268    #[test]
2269    fn templates_resource_scans_drift() {
2270        ok(
2271            "CreateGeneratedTemplate",
2272            &[("GeneratedTemplateName", "gt")],
2273        );
2274        ok(
2275            "UpdateGeneratedTemplate",
2276            &[("GeneratedTemplateName", "gt")],
2277        );
2278        ok(
2279            "DescribeGeneratedTemplate",
2280            &[("GeneratedTemplateName", "gt")],
2281        );
2282        ok("GetGeneratedTemplate", &[("GeneratedTemplateName", "gt")]);
2283        ok("ListGeneratedTemplates", &[]);
2284        ok(
2285            "DeleteGeneratedTemplate",
2286            &[("GeneratedTemplateName", "gt")],
2287        );
2288        ok("StartResourceScan", &[]);
2289        ok("DescribeResourceScan", &[("ResourceScanId", "rs")]);
2290        ok("ListResourceScans", &[]);
2291        ok("ListResourceScanResources", &[("ResourceScanId", "rs")]);
2292        ok(
2293            "ListResourceScanRelatedResources",
2294            &[
2295                ("ResourceScanId", "rs"),
2296                ("Resources.member.1.ResourceType", "AWS::SQS::Queue"),
2297            ],
2298        );
2299        ok("DetectStackDrift", &[("StackName", "s")]);
2300        ok(
2301            "DetectStackResourceDrift",
2302            &[("StackName", "s"), ("LogicalResourceId", "L")],
2303        );
2304        ok("DetectStackSetDrift", &[("StackSetName", "ss")]);
2305        ok(
2306            "DescribeStackDriftDetectionStatus",
2307            &[("StackDriftDetectionId", "id")],
2308        );
2309        ok("DescribeStackResourceDrifts", &[("StackName", "s")]);
2310        ok(
2311            "DescribeStackResource",
2312            &[("StackName", "s"), ("LogicalResourceId", "L")],
2313        );
2314    }
2315
2316    #[test]
2317    fn events_hooks_imports_policies_org() {
2318        ok("DescribeStackEvents", &[("StackName", "s")]);
2319        ok("DescribeEvents", &[]);
2320        ok("GetHookResult", &[]);
2321        ok("ListHookResults", &[]);
2322        ok(
2323            "RecordHandlerProgress",
2324            &[("BearerToken", "tok"), ("OperationStatus", "SUCCESS")],
2325        );
2326        ok("ListExports", &[]);
2327        ok("ListImports", &[("ExportName", "SomeExport")]);
2328        ok("GetStackPolicy", &[("StackName", "s")]);
2329        ok("SetStackPolicy", &[("StackName", "s")]);
2330        ok(
2331            "UpdateTerminationProtection",
2332            &[("StackName", "s"), ("EnableTerminationProtection", "false")],
2333        );
2334        ok("DescribeAccountLimits", &[]);
2335        ok("ActivateOrganizationsAccess", &[]);
2336        ok("DescribeOrganizationsAccess", &[]);
2337        ok("DeactivateOrganizationsAccess", &[]);
2338        ok("ValidateTemplate", &[]);
2339        ok("EstimateTemplateCost", &[]);
2340        ok("GetTemplateSummary", &[]);
2341        ok("CancelUpdateStack", &[("StackName", "s")]);
2342        ok("ContinueUpdateRollback", &[("StackName", "s")]);
2343        ok("RollbackStack", &[("StackName", "s")]);
2344        ok(
2345            "SignalResource",
2346            &[
2347                ("StackName", "s"),
2348                ("LogicalResourceId", "L"),
2349                ("UniqueId", "U"),
2350                ("Status", "SUCCESS"),
2351            ],
2352        );
2353    }
2354}