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 http::StatusCode;
14use serde_json::{json, Value};
15use std::collections::HashMap;
16
17use fakecloud_aws::xml::xml_escape;
18use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
19
20use crate::service::CloudFormationService;
21
22const NS: &str = "http://cloudformation.amazonaws.com/doc/2010-05-15/";
23
24fn rand_id() -> String {
25    use std::sync::atomic::{AtomicU64, Ordering};
26    static COUNTER: AtomicU64 = AtomicU64::new(0);
27    let nanos = std::time::SystemTime::now()
28        .duration_since(std::time::UNIX_EPOCH)
29        .map(|d| d.as_nanos())
30        .unwrap_or(0);
31    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
32    format!("{nanos:x}-{seq:x}")
33}
34
35fn xml_response(action: &str, inner: String, request_id: &str) -> AwsResponse {
36    let body = format!(
37        r#"<{action}Response xmlns="{NS}">
38  <{action}Result>
39{inner}
40  </{action}Result>
41  <ResponseMetadata>
42    <RequestId>{rid}</RequestId>
43  </ResponseMetadata>
44</{action}Response>"#,
45        action = action,
46        NS = NS,
47        inner = inner,
48        rid = xml_escape(request_id),
49    );
50    AwsResponse::xml(StatusCode::OK, body)
51}
52
53fn xml_response_no_result(action: &str, request_id: &str) -> AwsResponse {
54    let body = format!(
55        r#"<{action}Response xmlns="{NS}">
56  <ResponseMetadata>
57    <RequestId>{rid}</RequestId>
58  </ResponseMetadata>
59</{action}Response>"#,
60        action = action,
61        NS = NS,
62        rid = xml_escape(request_id),
63    );
64    AwsResponse::xml(StatusCode::OK, body)
65}
66
67fn members_xml<F>(items: &[Value], render: F) -> String
68where
69    F: Fn(&Value) -> String,
70{
71    items
72        .iter()
73        .map(|v| format!("      <member>\n{}\n      </member>", render(v)))
74        .collect::<Vec<_>>()
75        .join("\n")
76}
77
78fn store<'a>(
79    extras: &'a mut HashMap<String, HashMap<String, Value>>,
80    category: &str,
81) -> &'a mut HashMap<String, Value> {
82    extras.entry(category.to_string()).or_default()
83}
84
85fn missing(name: &str) -> AwsServiceError {
86    AwsServiceError::aws_error(
87        StatusCode::BAD_REQUEST,
88        "ValidationError",
89        format!("{name} is required"),
90    )
91}
92
93impl CloudFormationService {
94    pub(crate) fn handle_extra_action(
95        &self,
96        req: &AwsRequest,
97    ) -> Result<AwsResponse, AwsServiceError> {
98        let action = req.action.clone();
99        let params = Self::get_all_params(req);
100        let aid = req.account_id.clone();
101        let rid = req.request_id.clone();
102
103        match action.as_str() {
104            // ── Change sets ──
105            "CreateChangeSet" => {
106                let stack_name = params.get("StackName").ok_or_else(|| missing("StackName"))?.clone();
107                let cs_name = params.get("ChangeSetName").ok_or_else(|| missing("ChangeSetName"))?.clone();
108                let id = format!("arn:aws:cloudformation:us-east-1:{aid}:changeSet/{cs_name}/{}", rand_id());
109                let stack_id = format!("arn:aws:cloudformation:us-east-1:{aid}:stack/{stack_name}/{}", rand_id());
110                let entry = json!({
111                    "Id": id,
112                    "ChangeSetName": cs_name,
113                    "StackId": stack_id,
114                    "StackName": stack_name,
115                    "Status": "CREATE_COMPLETE",
116                    "ExecutionStatus": "AVAILABLE",
117                    "Changes": [],
118                });
119                let mut accounts = self.state.write();
120                let state = accounts.get_or_create(&aid);
121                store(&mut state.extras, "change_sets").insert(id.clone(), entry);
122                Ok(xml_response(
123                    "CreateChangeSet",
124                    format!("    <Id>{}</Id>\n    <StackId>{}</StackId>", xml_escape(&id), xml_escape(&stack_id)),
125                    &rid,
126                ))
127            }
128            "DescribeChangeSet" => {
129                let cs = params.get("ChangeSetName").ok_or_else(|| missing("ChangeSetName"))?.clone();
130                let accounts = self.state.read();
131                let entry = accounts.get(&aid)
132                    .and_then(|s| s.extras.get("change_sets"))
133                    .and_then(|m| m.values().find(|v| v["Id"].as_str() == Some(&cs) || v["ChangeSetName"].as_str() == Some(&cs)))
134                    .cloned()
135                    .unwrap_or_else(|| json!({"ChangeSetName": cs.clone(), "Status": "CREATE_COMPLETE", "ExecutionStatus": "AVAILABLE"}));
136                let inner = format!(
137                    "    <ChangeSetName>{}</ChangeSetName>\n    <Status>{}</Status>\n    <ExecutionStatus>{}</ExecutionStatus>\n    <Changes/>",
138                    xml_escape(entry["ChangeSetName"].as_str().unwrap_or("")),
139                    xml_escape(entry["Status"].as_str().unwrap_or("CREATE_COMPLETE")),
140                    xml_escape(entry["ExecutionStatus"].as_str().unwrap_or("AVAILABLE")),
141                );
142                Ok(xml_response("DescribeChangeSet", inner, &rid))
143            }
144            "DescribeChangeSetHooks" => Ok(xml_response(
145                "DescribeChangeSetHooks",
146                "    <Hooks/>".to_string(),
147                &rid,
148            )),
149            "DeleteChangeSet" => {
150                let cs = params.get("ChangeSetName").ok_or_else(|| missing("ChangeSetName"))?.clone();
151                let mut accounts = self.state.write();
152                let state = accounts.get_or_create(&aid);
153                if let Some(m) = state.extras.get_mut("change_sets") {
154                    m.retain(|_, v| v["Id"].as_str() != Some(&cs) && v["ChangeSetName"].as_str() != Some(&cs));
155                }
156                Ok(xml_response("DeleteChangeSet", String::new(), &rid))
157            }
158            "ExecuteChangeSet" => Ok(xml_response("ExecuteChangeSet", String::new(), &rid)),
159            "ListChangeSets" => {
160                let accounts = self.state.read();
161                let items: Vec<Value> = accounts.get(&aid)
162                    .and_then(|s| s.extras.get("change_sets"))
163                    .map(|m| m.values().cloned().collect())
164                    .unwrap_or_default();
165                let inner = format!(
166                    "    <Summaries>\n{}\n    </Summaries>",
167                    members_xml(&items, |v| format!(
168                        "        <ChangeSetId>{}</ChangeSetId>\n        <ChangeSetName>{}</ChangeSetName>\n        <Status>{}</Status>",
169                        xml_escape(v["Id"].as_str().unwrap_or("")),
170                        xml_escape(v["ChangeSetName"].as_str().unwrap_or("")),
171                        xml_escape(v["Status"].as_str().unwrap_or("CREATE_COMPLETE")),
172                    )),
173                );
174                Ok(xml_response("ListChangeSets", inner, &rid))
175            }
176
177            // ── Stack sets ──
178            "CreateStackSet" => {
179                let name = params.get("StackSetName").ok_or_else(|| missing("StackSetName"))?.clone();
180                let id = format!("{name}:{}", rand_id());
181                let entry = json!({
182                    "StackSetId": id,
183                    "StackSetName": name,
184                    "Status": "ACTIVE",
185                    "TemplateBody": params.get("TemplateBody").cloned().unwrap_or_default(),
186                });
187                let mut accounts = self.state.write();
188                let state = accounts.get_or_create(&aid);
189                store(&mut state.extras, "stack_sets").insert(name.clone(), entry);
190                Ok(xml_response("CreateStackSet", format!("    <StackSetId>{}</StackSetId>", xml_escape(&id)), &rid))
191            }
192            "DescribeStackSet" => {
193                let name = params.get("StackSetName").ok_or_else(|| missing("StackSetName"))?.clone();
194                let accounts = self.state.read();
195                let entry = accounts.get(&aid)
196                    .and_then(|s| s.extras.get("stack_sets"))
197                    .and_then(|m| m.get(&name))
198                    .cloned()
199                    .unwrap_or_else(|| json!({"StackSetName": name.clone(), "Status": "ACTIVE"}));
200                let inner = format!(
201                    "    <StackSet>\n      <StackSetName>{}</StackSetName>\n      <StackSetId>{}</StackSetId>\n      <Status>{}</Status>\n    </StackSet>",
202                    xml_escape(entry["StackSetName"].as_str().unwrap_or(&name)),
203                    xml_escape(entry["StackSetId"].as_str().unwrap_or("")),
204                    xml_escape(entry["Status"].as_str().unwrap_or("ACTIVE")),
205                );
206                Ok(xml_response("DescribeStackSet", inner, &rid))
207            }
208            "ListStackSets" => {
209                let accounts = self.state.read();
210                let items: Vec<Value> = accounts.get(&aid)
211                    .and_then(|s| s.extras.get("stack_sets"))
212                    .map(|m| m.values().cloned().collect())
213                    .unwrap_or_default();
214                let inner = format!(
215                    "    <Summaries>\n{}\n    </Summaries>",
216                    members_xml(&items, |v| format!(
217                        "        <StackSetName>{}</StackSetName>\n        <StackSetId>{}</StackSetId>\n        <Status>{}</Status>",
218                        xml_escape(v["StackSetName"].as_str().unwrap_or("")),
219                        xml_escape(v["StackSetId"].as_str().unwrap_or("")),
220                        xml_escape(v["Status"].as_str().unwrap_or("ACTIVE")),
221                    )),
222                );
223                Ok(xml_response("ListStackSets", inner, &rid))
224            }
225            "UpdateStackSet" => {
226                let op_id = rand_id();
227                Ok(xml_response("UpdateStackSet", format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)), &rid))
228            }
229            "DeleteStackSet" => {
230                let name = params.get("StackSetName").ok_or_else(|| missing("StackSetName"))?.clone();
231                let mut accounts = self.state.write();
232                let state = accounts.get_or_create(&aid);
233                if let Some(m) = state.extras.get_mut("stack_sets") {
234                    m.remove(&name);
235                }
236                Ok(xml_response("DeleteStackSet", String::new(), &rid))
237            }
238            "DescribeStackSetOperation" => {
239                let op_id = params.get("OperationId").cloned().unwrap_or_else(rand_id);
240                let inner = format!(
241                    "    <StackSetOperation>\n      <OperationId>{}</OperationId>\n      <Status>SUCCEEDED</Status>\n    </StackSetOperation>",
242                    xml_escape(&op_id),
243                );
244                Ok(xml_response("DescribeStackSetOperation", inner, &rid))
245            }
246            "ListStackSetOperations" => Ok(xml_response("ListStackSetOperations", "    <Summaries/>".to_string(), &rid)),
247            "ListStackSetOperationResults" => Ok(xml_response("ListStackSetOperationResults", "    <Summaries/>".to_string(), &rid)),
248            "ListStackSetAutoDeploymentTargets" => Ok(xml_response("ListStackSetAutoDeploymentTargets", "    <Summaries/>".to_string(), &rid)),
249            "StopStackSetOperation" => Ok(xml_response("StopStackSetOperation", String::new(), &rid)),
250            "ImportStacksToStackSet" => {
251                let op_id = rand_id();
252                Ok(xml_response("ImportStacksToStackSet", format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)), &rid))
253            }
254
255            // ── Stack instances ──
256            "CreateStackInstances" => {
257                let op_id = rand_id();
258                Ok(xml_response("CreateStackInstances", format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)), &rid))
259            }
260            "UpdateStackInstances" => {
261                let op_id = rand_id();
262                Ok(xml_response("UpdateStackInstances", format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)), &rid))
263            }
264            "DeleteStackInstances" => {
265                let op_id = rand_id();
266                Ok(xml_response("DeleteStackInstances", format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)), &rid))
267            }
268            "DescribeStackInstance" => {
269                let inner = "    <StackInstance>\n      <Status>CURRENT</Status>\n    </StackInstance>".to_string();
270                Ok(xml_response("DescribeStackInstance", inner, &rid))
271            }
272            "ListStackInstances" => Ok(xml_response("ListStackInstances", "    <Summaries/>".to_string(), &rid)),
273            "ListStackInstanceResourceDrifts" => Ok(xml_response("ListStackInstanceResourceDrifts", "    <Summaries/>".to_string(), &rid)),
274
275            // ── Stack refactors ──
276            "CreateStackRefactor" => {
277                let id = rand_id();
278                let entry = json!({"StackRefactorId": id.clone(), "Status": "CREATE_COMPLETE"});
279                let mut accounts = self.state.write();
280                let state = accounts.get_or_create(&aid);
281                store(&mut state.extras, "refactors").insert(id.clone(), entry);
282                Ok(xml_response("CreateStackRefactor", format!("    <StackRefactorId>{}</StackRefactorId>", xml_escape(&id)), &rid))
283            }
284            "DescribeStackRefactor" => {
285                let id = params.get("StackRefactorId").ok_or_else(|| missing("StackRefactorId"))?.clone();
286                let inner = format!(
287                    "    <StackRefactorId>{}</StackRefactorId>\n    <Status>CREATE_COMPLETE</Status>",
288                    xml_escape(&id),
289                );
290                Ok(xml_response("DescribeStackRefactor", inner, &rid))
291            }
292            "ExecuteStackRefactor" => Ok(xml_response("ExecuteStackRefactor", String::new(), &rid)),
293            "ListStackRefactors" => Ok(xml_response("ListStackRefactors", "    <StackRefactorSummaries/>".to_string(), &rid)),
294            "ListStackRefactorActions" => Ok(xml_response("ListStackRefactorActions", "    <StackRefactorActions/>".to_string(), &rid)),
295
296            // ── Types / extensions ──
297            "ActivateType" => {
298                let arn = format!("arn:aws:cloudformation:us-east-1:{aid}:type/resource/{}", rand_id());
299                Ok(xml_response("ActivateType", format!("    <Arn>{}</Arn>", xml_escape(&arn)), &rid))
300            }
301            "DeactivateType" => Ok(xml_response("DeactivateType", String::new(), &rid)),
302            "DescribeType" => {
303                let arn = params.get("Arn").cloned().unwrap_or_else(|| format!("arn:aws:cloudformation:us-east-1:{aid}:type/resource/Default"));
304                let inner = format!(
305                    "    <Arn>{}</Arn>\n    <Type>RESOURCE</Type>\n    <TypeName>AWS::Custom::Type</TypeName>",
306                    xml_escape(&arn),
307                );
308                Ok(xml_response("DescribeType", inner, &rid))
309            }
310            "DescribeTypeRegistration" => {
311                let token = params.get("RegistrationToken").cloned().unwrap_or_default();
312                let inner = format!(
313                    "    <ProgressStatus>COMPLETE</ProgressStatus>\n    <Description>{}</Description>",
314                    xml_escape(&token),
315                );
316                Ok(xml_response("DescribeTypeRegistration", inner, &rid))
317            }
318            "RegisterType" => {
319                let token = rand_id();
320                Ok(xml_response("RegisterType", format!("    <RegistrationToken>{}</RegistrationToken>", xml_escape(&token)), &rid))
321            }
322            "DeregisterType" => Ok(xml_response("DeregisterType", String::new(), &rid)),
323            "ListTypes" => Ok(xml_response("ListTypes", "    <TypeSummaries/>".to_string(), &rid)),
324            "ListTypeRegistrations" => Ok(xml_response("ListTypeRegistrations", "    <RegistrationTokenList/>".to_string(), &rid)),
325            "ListTypeVersions" => Ok(xml_response("ListTypeVersions", "    <TypeVersionSummaries/>".to_string(), &rid)),
326            "BatchDescribeTypeConfigurations" => Ok(xml_response(
327                "BatchDescribeTypeConfigurations",
328                "    <Errors/>\n    <TypeConfigurations/>".to_string(),
329                &rid,
330            )),
331            "SetTypeConfiguration" => {
332                let arn = format!("arn:aws:cloudformation:us-east-1:{aid}:type-config/{}", rand_id());
333                Ok(xml_response("SetTypeConfiguration", format!("    <ConfigurationArn>{}</ConfigurationArn>", xml_escape(&arn)), &rid))
334            }
335            "SetTypeDefaultVersion" => Ok(xml_response("SetTypeDefaultVersion", String::new(), &rid)),
336            "TestType" => {
337                let arn = format!("arn:aws:cloudformation:us-east-1:{aid}:type/resource/{}", rand_id());
338                Ok(xml_response("TestType", format!("    <TypeVersionArn>{}</TypeVersionArn>", xml_escape(&arn)), &rid))
339            }
340            "PublishType" => {
341                let arn = format!("arn:aws:cloudformation:us-east-1:{aid}:type/resource/{}", rand_id());
342                Ok(xml_response("PublishType", format!("    <PublicTypeArn>{}</PublicTypeArn>", xml_escape(&arn)), &rid))
343            }
344            "RegisterPublisher" => {
345                let id = rand_id();
346                Ok(xml_response("RegisterPublisher", format!("    <PublisherId>{}</PublisherId>", xml_escape(&id)), &rid))
347            }
348            "DescribePublisher" => {
349                let id = params.get("PublisherId").cloned().unwrap_or_else(|| "default-publisher".to_string());
350                let inner = format!(
351                    "    <PublisherId>{}</PublisherId>\n    <PublisherStatus>VERIFIED</PublisherStatus>\n    <IdentityProvider>AWS_Marketplace</IdentityProvider>",
352                    xml_escape(&id),
353                );
354                Ok(xml_response("DescribePublisher", inner, &rid))
355            }
356
357            // ── Generated templates ──
358            "CreateGeneratedTemplate" => {
359                let name = params.get("GeneratedTemplateName").ok_or_else(|| missing("GeneratedTemplateName"))?.clone();
360                let id = format!("arn:aws:cloudformation:us-east-1:{aid}:generatedtemplate/{}", rand_id());
361                let entry = json!({"GeneratedTemplateId": id.clone(), "Name": name.clone(), "Status": "COMPLETE"});
362                let mut accounts = self.state.write();
363                let state = accounts.get_or_create(&aid);
364                store(&mut state.extras, "generated_templates").insert(name.clone(), entry);
365                Ok(xml_response("CreateGeneratedTemplate", format!("    <GeneratedTemplateId>{}</GeneratedTemplateId>", xml_escape(&id)), &rid))
366            }
367            "UpdateGeneratedTemplate" => {
368                let name = params.get("GeneratedTemplateName").ok_or_else(|| missing("GeneratedTemplateName"))?.clone();
369                let id = format!("arn:aws:cloudformation:us-east-1:{aid}:generatedtemplate/{name}");
370                Ok(xml_response("UpdateGeneratedTemplate", format!("    <GeneratedTemplateId>{}</GeneratedTemplateId>", xml_escape(&id)), &rid))
371            }
372            "DescribeGeneratedTemplate" => {
373                let name = params.get("GeneratedTemplateName").ok_or_else(|| missing("GeneratedTemplateName"))?.clone();
374                let inner = format!(
375                    "    <GeneratedTemplateId>arn:aws:cloudformation:us-east-1:{}:generatedtemplate/{}</GeneratedTemplateId>\n    <GeneratedTemplateName>{}</GeneratedTemplateName>\n    <Status>COMPLETE</Status>",
376                    xml_escape(&aid),
377                    xml_escape(&name),
378                    xml_escape(&name),
379                );
380                Ok(xml_response("DescribeGeneratedTemplate", inner, &rid))
381            }
382            "GetGeneratedTemplate" => Ok(xml_response("GetGeneratedTemplate", "    <Status>COMPLETE</Status>\n    <TemplateBody>{}</TemplateBody>".to_string(), &rid)),
383            "DeleteGeneratedTemplate" => {
384                let name = params.get("GeneratedTemplateName").ok_or_else(|| missing("GeneratedTemplateName"))?.clone();
385                let mut accounts = self.state.write();
386                let state = accounts.get_or_create(&aid);
387                if let Some(m) = state.extras.get_mut("generated_templates") {
388                    m.remove(&name);
389                }
390                Ok(xml_response("DeleteGeneratedTemplate", String::new(), &rid))
391            }
392            "ListGeneratedTemplates" => Ok(xml_response("ListGeneratedTemplates", "    <Summaries/>".to_string(), &rid)),
393
394            // ── Resource scans ──
395            "StartResourceScan" => {
396                let id = format!("arn:aws:cloudformation:us-east-1:{aid}:resourceScan/{}", rand_id());
397                Ok(xml_response("StartResourceScan", format!("    <ResourceScanId>{}</ResourceScanId>", xml_escape(&id)), &rid))
398            }
399            "DescribeResourceScan" => {
400                let id = params.get("ResourceScanId").cloned().unwrap_or_default();
401                let inner = format!(
402                    "    <ResourceScanId>{}</ResourceScanId>\n    <Status>COMPLETE</Status>",
403                    xml_escape(&id),
404                );
405                Ok(xml_response("DescribeResourceScan", inner, &rid))
406            }
407            "ListResourceScans" => Ok(xml_response("ListResourceScans", "    <ResourceScanSummaries/>".to_string(), &rid)),
408            "ListResourceScanResources" => Ok(xml_response("ListResourceScanResources", "    <Resources/>".to_string(), &rid)),
409            "ListResourceScanRelatedResources" => Ok(xml_response("ListResourceScanRelatedResources", "    <RelatedResources/>".to_string(), &rid)),
410
411            // ── Drift detection ──
412            "DetectStackDrift" => {
413                let id = rand_id();
414                Ok(xml_response("DetectStackDrift", format!("    <StackDriftDetectionId>{}</StackDriftDetectionId>", xml_escape(&id)), &rid))
415            }
416            "DetectStackResourceDrift" => Ok(xml_response(
417                "DetectStackResourceDrift",
418                "    <StackResourceDrift>\n      <StackResourceDriftStatus>IN_SYNC</StackResourceDriftStatus>\n    </StackResourceDrift>".to_string(),
419                &rid,
420            )),
421            "DetectStackSetDrift" => {
422                let op_id = rand_id();
423                Ok(xml_response("DetectStackSetDrift", format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)), &rid))
424            }
425            "DescribeStackDriftDetectionStatus" => {
426                let id = params.get("StackDriftDetectionId").cloned().unwrap_or_default();
427                let inner = format!(
428                    "    <StackDriftDetectionId>{}</StackDriftDetectionId>\n    <DetectionStatus>DETECTION_COMPLETE</DetectionStatus>\n    <StackDriftStatus>IN_SYNC</StackDriftStatus>",
429                    xml_escape(&id),
430                );
431                Ok(xml_response("DescribeStackDriftDetectionStatus", inner, &rid))
432            }
433            "DescribeStackResourceDrifts" => Ok(xml_response("DescribeStackResourceDrifts", "    <StackResourceDrifts/>".to_string(), &rid)),
434            "DescribeStackResource" => {
435                let stack_name = params.get("StackName").ok_or_else(|| missing("StackName"))?.clone();
436                let logical = params.get("LogicalResourceId").ok_or_else(|| missing("LogicalResourceId"))?.clone();
437                let accounts = self.state.read();
438                let detail = accounts.get(&aid)
439                    .and_then(|s| s.stacks.get(&stack_name))
440                    .and_then(|s| s.resources.iter().find(|r| r.logical_id == logical))
441                    .map(|r| (r.physical_id.clone(), r.resource_type.clone(), r.status.clone()))
442                    .unwrap_or_else(|| ("pid".to_string(), "AWS::Custom".to_string(), "CREATE_COMPLETE".to_string()));
443                let inner = format!(
444                    "    <StackResourceDetail>\n      <StackName>{}</StackName>\n      <LogicalResourceId>{}</LogicalResourceId>\n      <PhysicalResourceId>{}</PhysicalResourceId>\n      <ResourceType>{}</ResourceType>\n      <ResourceStatus>{}</ResourceStatus>\n      <LastUpdatedTimestamp>{}</LastUpdatedTimestamp>\n    </StackResourceDetail>",
445                    xml_escape(&stack_name),
446                    xml_escape(&logical),
447                    xml_escape(&detail.0),
448                    xml_escape(&detail.1),
449                    xml_escape(&detail.2),
450                    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
451                );
452                Ok(xml_response("DescribeStackResource", inner, &rid))
453            }
454
455            // ── Events ──
456            "DescribeStackEvents" => Ok(xml_response("DescribeStackEvents", "    <StackEvents/>".to_string(), &rid)),
457            "DescribeEvents" => Ok(xml_response("DescribeEvents", "    <Events/>".to_string(), &rid)),
458
459            // ── Hooks ──
460            "GetHookResult" => Ok(xml_response(
461                "GetHookResult",
462                "    <Status>HOOK_COMPLETE_SUCCEEDED</Status>".to_string(),
463                &rid,
464            )),
465            "ListHookResults" => Ok(xml_response("ListHookResults", "    <HookResults/>".to_string(), &rid)),
466            "RecordHandlerProgress" => Ok(xml_response_no_result("RecordHandlerProgress", &rid)),
467
468            // ── Imports / exports ──
469            "ListExports" => Ok(xml_response("ListExports", "    <Exports/>".to_string(), &rid)),
470            "ListImports" => Ok(xml_response("ListImports", "    <Imports/>".to_string(), &rid)),
471
472            // ── Stack policies ──
473            "GetStackPolicy" => {
474                let stack = params.get("StackName").ok_or_else(|| missing("StackName"))?.clone();
475                let accounts = self.state.read();
476                let body = accounts.get(&aid)
477                    .and_then(|s| s.stack_policies.get(&stack))
478                    .cloned()
479                    .unwrap_or_else(|| r#"{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}"#.to_string());
480                let inner = format!("    <StackPolicyBody>{}</StackPolicyBody>", xml_escape(&body));
481                Ok(xml_response("GetStackPolicy", inner, &rid))
482            }
483            "SetStackPolicy" => {
484                let stack = params.get("StackName").ok_or_else(|| missing("StackName"))?.clone();
485                let body = params.get("StackPolicyBody").cloned().unwrap_or_default();
486                let mut accounts = self.state.write();
487                let state = accounts.get_or_create(&aid);
488                state.stack_policies.insert(stack, body);
489                Ok(xml_response_no_result("SetStackPolicy", &rid))
490            }
491
492            // ── Termination protection ──
493            "UpdateTerminationProtection" => {
494                let stack = params.get("StackName").ok_or_else(|| missing("StackName"))?.clone();
495                let enabled = params.get("EnableTerminationProtection")
496                    .map(|v| v.eq_ignore_ascii_case("true"))
497                    .unwrap_or(false);
498                let stack_id = {
499                    let mut accounts = self.state.write();
500                    let state = accounts.get_or_create(&aid);
501                    state.termination_protection.insert(stack.clone(), enabled);
502                    state.stacks.get(&stack).map(|s| s.stack_id.clone()).unwrap_or_else(|| stack.clone())
503                };
504                Ok(xml_response("UpdateTerminationProtection", format!("    <StackId>{}</StackId>", xml_escape(&stack_id)), &rid))
505            }
506
507            // ── Account / org / validation / utilities ──
508            "DescribeAccountLimits" => Ok(xml_response(
509                "DescribeAccountLimits",
510                r#"    <AccountLimits>
511      <member>
512        <Name>StackLimit</Name>
513        <Value>2000</Value>
514      </member>
515    </AccountLimits>"#.to_string(),
516                &rid,
517            )),
518            "ActivateOrganizationsAccess" => {
519                let mut accounts = self.state.write();
520                let state = accounts.get_or_create(&aid);
521                state.orgs_access_enabled = true;
522                Ok(xml_response("ActivateOrganizationsAccess", String::new(), &rid))
523            }
524            "DeactivateOrganizationsAccess" => {
525                let mut accounts = self.state.write();
526                let state = accounts.get_or_create(&aid);
527                state.orgs_access_enabled = false;
528                Ok(xml_response("DeactivateOrganizationsAccess", String::new(), &rid))
529            }
530            "DescribeOrganizationsAccess" => {
531                let accounts = self.state.read();
532                let status = if accounts.get(&aid).map(|s| s.orgs_access_enabled).unwrap_or(false) {
533                    "ENABLED"
534                } else {
535                    "DISABLED"
536                };
537                Ok(xml_response("DescribeOrganizationsAccess", format!("    <Status>{}</Status>", status), &rid))
538            }
539            "ValidateTemplate" => Ok(xml_response(
540                "ValidateTemplate",
541                "    <Description>Validated</Description>\n    <Capabilities/>\n    <Parameters/>".to_string(),
542                &rid,
543            )),
544            "EstimateTemplateCost" => Ok(xml_response(
545                "EstimateTemplateCost",
546                "    <Url>https://calculator.aws/#/estimate</Url>".to_string(),
547                &rid,
548            )),
549            "GetTemplateSummary" => Ok(xml_response(
550                "GetTemplateSummary",
551                "    <Parameters/>\n    <ResourceTypes/>\n    <Capabilities/>".to_string(),
552                &rid,
553            )),
554            "CancelUpdateStack" => Ok(xml_response_no_result("CancelUpdateStack", &rid)),
555            "ContinueUpdateRollback" => Ok(xml_response("ContinueUpdateRollback", String::new(), &rid)),
556            "RollbackStack" => {
557                let stack = params.get("StackName").ok_or_else(|| missing("StackName"))?.clone();
558                let stack_id = {
559                    let accounts = self.state.read();
560                    accounts.get(&aid).and_then(|s| s.stacks.get(&stack)).map(|s| s.stack_id.clone()).unwrap_or_else(|| stack.clone())
561                };
562                Ok(xml_response("RollbackStack", format!("    <StackId>{}</StackId>", xml_escape(&stack_id)), &rid))
563            }
564            "SignalResource" => Ok(xml_response_no_result("SignalResource", &rid)),
565
566            _ => Err(AwsServiceError::action_not_implemented("cloudformation", &action)),
567        }
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use crate::service::{CloudFormationDeps, CloudFormationService};
574    use crate::state::{CloudFormationState, SharedCloudFormationState};
575    use fakecloud_core::delivery::DeliveryBus;
576    use fakecloud_core::multi_account::MultiAccountState;
577    use fakecloud_core::service::AwsRequest;
578    use http::Method;
579    use parking_lot::RwLock;
580    use std::collections::HashMap;
581    use std::sync::Arc;
582
583    fn deps() -> CloudFormationDeps {
584        use fakecloud_dynamodb::state::DynamoDbState;
585        use fakecloud_eventbridge::state::EventBridgeState;
586        use fakecloud_iam::state::IamState;
587        use fakecloud_logs::state::LogsState;
588        use fakecloud_s3::state::S3State;
589        use fakecloud_sns::state::SnsState;
590        use fakecloud_sqs::state::SqsState;
591        use fakecloud_ssm::state::SsmState;
592
593        fn shared<T: fakecloud_core::multi_account::AccountState>(
594        ) -> Arc<RwLock<MultiAccountState<T>>> {
595            Arc::new(RwLock::new(MultiAccountState::<T>::new(
596                "000000000000",
597                "us-east-1",
598                "",
599            )))
600        }
601        CloudFormationDeps {
602            sqs: shared::<SqsState>(),
603            sns: shared::<SnsState>(),
604            ssm: shared::<SsmState>(),
605            iam: shared::<IamState>(),
606            s3: shared::<S3State>(),
607            eventbridge: shared::<EventBridgeState>(),
608            dynamodb: shared::<DynamoDbState>(),
609            logs: shared::<LogsState>(),
610            delivery: Arc::new(DeliveryBus::new()),
611        }
612    }
613
614    fn svc() -> CloudFormationService {
615        let state: SharedCloudFormationState =
616            Arc::new(RwLock::new(MultiAccountState::<CloudFormationState>::new(
617                "000000000000",
618                "us-east-1",
619                "",
620            )));
621        CloudFormationService::new(state, deps())
622    }
623
624    fn req(action: &str, params: &[(&str, &str)]) -> AwsRequest {
625        let mut q = HashMap::new();
626        q.insert("Action".to_string(), action.to_string());
627        for (k, v) in params {
628            q.insert(k.to_string(), v.to_string());
629        }
630        AwsRequest {
631            service: "cloudformation".to_string(),
632            method: Method::POST,
633            raw_path: "/".to_string(),
634            raw_query: String::new(),
635            path_segments: vec![],
636            query_params: q,
637            headers: http::HeaderMap::new(),
638            body: bytes::Bytes::new(),
639            body_stream: parking_lot::Mutex::new(None),
640            account_id: "000000000000".to_string(),
641            region: "us-east-1".to_string(),
642            request_id: "rid".to_string(),
643            action: action.to_string(),
644            is_query_protocol: true,
645            access_key_id: None,
646            principal: None,
647        }
648    }
649
650    fn ok(action: &str, params: &[(&str, &str)]) {
651        let r = svc().handle_extra_action(&req(action, params));
652        match r {
653            Ok(resp) => assert!(resp.status.is_success(), "{action} status: {}", resp.status),
654            Err(e) => panic!("{action} failed: {e:?}"),
655        }
656    }
657
658    #[test]
659    fn change_sets() {
660        ok(
661            "CreateChangeSet",
662            &[("StackName", "s"), ("ChangeSetName", "cs")],
663        );
664        ok("DescribeChangeSet", &[("ChangeSetName", "cs")]);
665        ok("DescribeChangeSetHooks", &[]);
666        ok("ListChangeSets", &[]);
667        ok("ExecuteChangeSet", &[]);
668        ok("DeleteChangeSet", &[("ChangeSetName", "cs")]);
669    }
670
671    #[test]
672    fn stack_sets_instances_refactors() {
673        ok("CreateStackSet", &[("StackSetName", "ss")]);
674        ok("DescribeStackSet", &[("StackSetName", "ss")]);
675        ok("ListStackSets", &[]);
676        ok("UpdateStackSet", &[]);
677        ok("DescribeStackSetOperation", &[]);
678        ok("ListStackSetOperations", &[]);
679        ok("ListStackSetOperationResults", &[]);
680        ok("ListStackSetAutoDeploymentTargets", &[]);
681        ok("StopStackSetOperation", &[]);
682        ok("ImportStacksToStackSet", &[]);
683        ok("DeleteStackSet", &[("StackSetName", "ss")]);
684        ok("CreateStackInstances", &[]);
685        ok("UpdateStackInstances", &[]);
686        ok("DeleteStackInstances", &[]);
687        ok("DescribeStackInstance", &[]);
688        ok("ListStackInstances", &[]);
689        ok("ListStackInstanceResourceDrifts", &[]);
690        ok("CreateStackRefactor", &[]);
691        ok("DescribeStackRefactor", &[("StackRefactorId", "r")]);
692        ok("ExecuteStackRefactor", &[]);
693        ok("ListStackRefactors", &[]);
694        ok("ListStackRefactorActions", &[]);
695    }
696
697    #[test]
698    fn types_and_publishers() {
699        ok("ActivateType", &[]);
700        ok("DeactivateType", &[]);
701        ok("DescribeType", &[]);
702        ok("DescribeTypeRegistration", &[]);
703        ok("RegisterType", &[]);
704        ok("DeregisterType", &[]);
705        ok("ListTypes", &[]);
706        ok("ListTypeRegistrations", &[]);
707        ok("ListTypeVersions", &[]);
708        ok("BatchDescribeTypeConfigurations", &[]);
709        ok("SetTypeConfiguration", &[]);
710        ok("SetTypeDefaultVersion", &[]);
711        ok("TestType", &[]);
712        ok("PublishType", &[]);
713        ok("RegisterPublisher", &[]);
714        ok("DescribePublisher", &[]);
715    }
716
717    #[test]
718    fn templates_resource_scans_drift() {
719        ok(
720            "CreateGeneratedTemplate",
721            &[("GeneratedTemplateName", "gt")],
722        );
723        ok(
724            "UpdateGeneratedTemplate",
725            &[("GeneratedTemplateName", "gt")],
726        );
727        ok(
728            "DescribeGeneratedTemplate",
729            &[("GeneratedTemplateName", "gt")],
730        );
731        ok("GetGeneratedTemplate", &[]);
732        ok("ListGeneratedTemplates", &[]);
733        ok(
734            "DeleteGeneratedTemplate",
735            &[("GeneratedTemplateName", "gt")],
736        );
737        ok("StartResourceScan", &[]);
738        ok("DescribeResourceScan", &[]);
739        ok("ListResourceScans", &[]);
740        ok("ListResourceScanResources", &[]);
741        ok("ListResourceScanRelatedResources", &[]);
742        ok("DetectStackDrift", &[]);
743        ok("DetectStackResourceDrift", &[]);
744        ok("DetectStackSetDrift", &[]);
745        ok("DescribeStackDriftDetectionStatus", &[]);
746        ok("DescribeStackResourceDrifts", &[]);
747        ok(
748            "DescribeStackResource",
749            &[("StackName", "s"), ("LogicalResourceId", "L")],
750        );
751    }
752
753    #[test]
754    fn events_hooks_imports_policies_org() {
755        ok("DescribeStackEvents", &[]);
756        ok("DescribeEvents", &[]);
757        ok("GetHookResult", &[]);
758        ok("ListHookResults", &[]);
759        ok("RecordHandlerProgress", &[]);
760        ok("ListExports", &[]);
761        ok("ListImports", &[]);
762        ok("GetStackPolicy", &[("StackName", "s")]);
763        ok("SetStackPolicy", &[("StackName", "s")]);
764        ok("UpdateTerminationProtection", &[("StackName", "s")]);
765        ok("DescribeAccountLimits", &[]);
766        ok("ActivateOrganizationsAccess", &[]);
767        ok("DescribeOrganizationsAccess", &[]);
768        ok("DeactivateOrganizationsAccess", &[]);
769        ok("ValidateTemplate", &[]);
770        ok("EstimateTemplateCost", &[]);
771        ok("GetTemplateSummary", &[]);
772        ok("CancelUpdateStack", &[]);
773        ok("ContinueUpdateRollback", &[]);
774        ok("RollbackStack", &[("StackName", "s")]);
775        ok("SignalResource", &[]);
776    }
777}