1use 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 is_hook_type(type_kind: Option<&str>, type_name: Option<&str>) -> bool {
93 if type_kind
94 .map(|k| k.eq_ignore_ascii_case("HOOK"))
95 .unwrap_or(false)
96 {
97 return true;
98 }
99 type_name
100 .map(|n| {
101 let upper = n.to_ascii_uppercase();
102 upper.ends_with("::HOOK") || upper.contains("::HOOK::")
103 })
104 .unwrap_or(false)
105}
106
107fn register_hook(
111 extras: &mut BTreeMap<String, BTreeMap<String, Value>>,
112 type_name: &str,
113 failure_mode: Option<&str>,
114 configuration: Option<&str>,
115) {
116 let entry = json!({
117 "TypeName": type_name,
118 "TypeVersionId": "00000001",
119 "TypeConfigurationVersionId": "1",
120 "FailureMode": failure_mode.unwrap_or("FAIL"),
124 "Configuration": configuration.unwrap_or(""),
125 });
126 store(extras, "hooks").insert(type_name.to_string(), entry);
127}
128
129struct HookTarget<'a> {
136 account_id: &'a str,
137 target_type: &'a str,
138 target_id: &'a str,
139 logical_resource_id: &'a str,
140 invocation_point: &'a str,
141 op_failed: bool,
142}
143
144fn record_hook_results(
145 extras: &mut BTreeMap<String, BTreeMap<String, Value>>,
146 hooks: &[Value],
147 target: &HookTarget<'_>,
148) {
149 let HookTarget {
150 account_id,
151 target_type,
152 target_id,
153 logical_resource_id,
154 invocation_point,
155 op_failed,
156 } = *target;
157 let now = Utc::now().timestamp_millis();
158 for hook in hooks {
159 let type_name = hook
160 .get("TypeName")
161 .and_then(Value::as_str)
162 .unwrap_or("Unknown::Hook");
163 let failure_mode = hook
164 .get("FailureMode")
165 .and_then(Value::as_str)
166 .unwrap_or("FAIL");
167 let status = if failure_mode.eq_ignore_ascii_case("FAIL") && op_failed {
171 "HOOK_COMPLETE_FAILED"
172 } else {
173 "HOOK_COMPLETE_SUCCEEDED"
174 };
175 let result_id = rand_id();
176 let type_arn = Arn::new(
177 "cloudformation",
178 "us-east-1",
179 account_id,
180 &format!("type/hook/{}", type_name.replace("::", "-")),
181 )
182 .to_string();
183 let record = json!({
184 "HookResultId": result_id,
185 "InvocationPoint": invocation_point,
186 "FailureMode": failure_mode,
187 "TypeName": type_name,
188 "TypeVersionId": hook.get("TypeVersionId").and_then(Value::as_str).unwrap_or("00000001"),
189 "TypeConfigurationVersionId": hook.get("TypeConfigurationVersionId").and_then(Value::as_str).unwrap_or("1"),
190 "TypeArn": type_arn,
191 "Status": status,
192 "HookStatusReason": if status == "HOOK_COMPLETE_FAILED" { "Hook failed" } else { "Hook succeeded" },
193 "InvokedAt": now,
194 "TargetType": target_type,
195 "TargetId": target_id,
196 "LogicalResourceId": logical_resource_id,
197 });
198 store(extras, "hook_results").insert(result_id, record);
199 }
200}
201
202fn missing(name: &str) -> AwsServiceError {
203 AwsServiceError::aws_error(
204 StatusCode::BAD_REQUEST,
205 "ValidationError",
206 format!("{name} is required"),
207 )
208}
209
210fn has_collection_param(params: &BTreeMap<String, String>, field: &str) -> bool {
216 let prefix = format!("{field}.");
217 params.keys().any(|k| k.starts_with(&prefix))
218}
219
220fn require_scalar(params: &BTreeMap<String, String>, field: &str) -> Result<(), AwsServiceError> {
225 if params.get(field).is_some() {
226 Ok(())
227 } else {
228 Err(missing(field))
229 }
230}
231
232fn require_collection(
233 params: &BTreeMap<String, String>,
234 field: &str,
235) -> Result<(), AwsServiceError> {
236 if has_collection_param(params, field) {
237 Ok(())
238 } else {
239 Err(missing(field))
240 }
241}
242
243fn parse_s3_url(url: &str) -> Option<(String, String)> {
249 let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
250 let (host, after) = rest.split_once('/')?;
251 let path = after.split(['?', '#']).next().unwrap_or(after);
252 if let Some(idx) = host.find(".s3") {
255 let bucket = &host[..idx];
256 if !bucket.is_empty() {
257 return Some((bucket.to_string(), path.to_string()));
258 }
259 }
260 let (bucket, key) = path.split_once('/')?;
262 if bucket.is_empty() || key.is_empty() {
263 return None;
264 }
265 Some((bucket.to_string(), key.to_string()))
266}
267
268impl CloudFormationService {
269 fn fetch_template_from_url(&self, account_id: &str, url: &str) -> Option<String> {
275 let (bucket, key) = parse_s3_url(url)?;
276 let mut accounts = self.deps.s3.write();
277 let state = accounts.get_or_create(account_id);
278 let body_ref = {
279 let b = state.buckets.get(&bucket)?;
280 b.objects.get(&key)?.body.clone()
281 };
282 let bytes = state.read_body(&body_ref).ok()?;
283 String::from_utf8(bytes.to_vec()).ok()
284 }
285
286 pub(crate) fn handle_extra_action(
287 &self,
288 req: &AwsRequest,
289 ) -> Result<AwsResponse, AwsServiceError> {
290 let action = req.action.clone();
291 let params = Self::get_all_params(req);
292 let aid = req.account_id.clone();
293 let rid = req.request_id.clone();
294
295 match action.as_str() {
296 "CreateChangeSet" => {
298 let stack_name = params
299 .get("StackName")
300 .ok_or_else(|| missing("StackName"))?
301 .clone();
302 let cs_name = params
303 .get("ChangeSetName")
304 .ok_or_else(|| missing("ChangeSetName"))?
305 .clone();
306 let template_body = {
313 let inline = params.get("TemplateBody").cloned().unwrap_or_default();
314 if inline.trim().is_empty() {
315 params
316 .get("TemplateURL")
317 .and_then(|url| self.fetch_template_from_url(&aid, url))
318 .unwrap_or(inline)
319 } else {
320 inline
321 }
322 };
323 let change_set_type = params
327 .get("ChangeSetType")
328 .map(|s| s.to_ascii_uppercase())
329 .unwrap_or_else(|| "UPDATE".to_string());
330
331 let cs_params = CloudFormationService::extract_parameters(¶ms);
332 let cs_tags = CloudFormationService::extract_tags(¶ms);
333 let cs_notif = CloudFormationService::extract_notification_arns(¶ms);
334
335 let stack_lookup: Option<(String, Vec<crate::state::StackResource>)> = {
339 let accounts = self.state.read();
340 accounts.get(&aid).and_then(|s| {
341 s.stacks
342 .values()
343 .find(|st| {
344 (st.name == stack_name || st.stack_id == stack_name)
345 && st.status != "DELETE_COMPLETE"
346 })
347 .map(|st| (st.stack_id.clone(), st.resources.clone()))
348 })
349 };
350
351 let mut full_params: BTreeMap<String, String> = cs_params.clone();
354 full_params
355 .entry("AWS::Region".to_string())
356 .or_insert_with(|| req.region.clone());
357 full_params
358 .entry("AWS::AccountId".to_string())
359 .or_insert_with(|| aid.clone());
360 full_params
361 .entry("AWS::StackName".to_string())
362 .or_insert_with(|| stack_name.clone());
363 full_params
364 .entry("AWS::Partition".to_string())
365 .or_insert_with(|| "aws".to_string());
366 full_params
367 .entry("AWS::URLSuffix".to_string())
368 .or_insert_with(|| "amazonaws.com".to_string());
369 if let Some((sid, _)) = &stack_lookup {
370 full_params
371 .entry("AWS::StackId".to_string())
372 .or_insert_with(|| sid.clone());
373 }
374
375 let mut changes: Vec<Value> = Vec::new();
380 if !template_body.trim().is_empty() {
381 let parsed =
388 template::parse_template(&template_body, &full_params).unwrap_or_default();
389
390 let existing_resources = stack_lookup
391 .as_ref()
392 .map(|(_, r)| r.clone())
393 .unwrap_or_default();
394 let existing_by_id: BTreeMap<&str, &crate::state::StackResource> =
395 existing_resources
396 .iter()
397 .map(|r| (r.logical_id.as_str(), r))
398 .collect();
399 let new_by_id: BTreeMap<&str, &template::ResourceDefinition> = parsed
400 .resources
401 .iter()
402 .map(|r| (r.logical_id.as_str(), r))
403 .collect();
404
405 for r in &parsed.resources {
406 if let Some(existing) = existing_by_id.get(r.logical_id.as_str()) {
407 let replacement = if existing.resource_type != r.resource_type {
408 "True"
409 } else {
410 "Conditional"
411 };
412 changes.push(json!({
413 "Type": "Resource",
414 "ResourceChange": {
415 "Action": "Modify",
416 "LogicalResourceId": r.logical_id,
417 "PhysicalResourceId": existing.physical_id,
418 "ResourceType": r.resource_type,
419 "Replacement": replacement,
420 }
421 }));
422 } else {
423 changes.push(json!({
424 "Type": "Resource",
425 "ResourceChange": {
426 "Action": "Add",
427 "LogicalResourceId": r.logical_id,
428 "ResourceType": r.resource_type,
429 }
430 }));
431 }
432 }
433 for r in &existing_resources {
434 if !new_by_id.contains_key(r.logical_id.as_str()) {
435 changes.push(json!({
436 "Type": "Resource",
437 "ResourceChange": {
438 "Action": "Remove",
439 "LogicalResourceId": r.logical_id,
440 "PhysicalResourceId": r.physical_id,
441 "ResourceType": r.resource_type,
442 }
443 }));
444 }
445 }
446 }
447
448 let id = Arn::new(
449 "cloudformation",
450 "us-east-1",
451 &aid,
452 &format!("changeSet/{cs_name}/{}", rand_id()),
453 )
454 .to_string();
455 let stack_id_str = stack_lookup
456 .as_ref()
457 .map(|(s, _)| s.clone())
458 .unwrap_or_else(|| {
459 Arn::new(
460 "cloudformation",
461 "us-east-1",
462 &aid,
463 &format!("stack/{stack_name}/{}", rand_id()),
464 )
465 .to_string()
466 });
467
468 let activated_hooks: Vec<Value> = {
473 let accounts = self.state.read();
474 accounts
475 .get(&aid)
476 .and_then(|s| s.extras.get("hooks"))
477 .map(|m| m.values().cloned().collect())
478 .unwrap_or_default()
479 };
480
481 let entry = json!({
482 "Id": id,
483 "ChangeSetName": cs_name,
484 "StackId": stack_id_str,
485 "StackName": stack_name,
486 "Status": "CREATE_COMPLETE",
487 "ExecutionStatus": "AVAILABLE",
488 "TemplateBody": template_body,
489 "Parameters": cs_params,
490 "Tags": cs_tags,
491 "NotificationArns": cs_notif,
492 "Changes": changes,
493 "Hooks": activated_hooks,
494 });
495 let mut accounts = self.state.write();
496 let state = accounts.get_or_create(&aid);
497 if change_set_type == "CREATE" {
503 let live_status = state
509 .stacks
510 .values()
511 .find(|s| {
512 (s.name == stack_name || s.stack_id == stack_name)
513 && s.status != "DELETE_COMPLETE"
514 })
515 .map(|s| s.status.clone());
516 match live_status.as_deref() {
517 None => {
520 state.stacks.insert(
521 stack_name.clone(),
522 Stack {
523 name: stack_name.clone(),
524 stack_id: stack_id_str.clone(),
525 template: String::new(),
526 status: "REVIEW_IN_PROGRESS".to_string(),
527 resources: Vec::new(),
528 parameters: BTreeMap::new(),
529 tags: BTreeMap::new(),
530 created_at: Utc::now(),
531 updated_at: None,
532 description: None,
533 notification_arns: Vec::new(),
534 outputs: Vec::new(),
535 },
536 );
537 crate::service::record_stack_status_event(
546 state,
547 &stack_id_str,
548 &stack_name,
549 "AWS::CloudFormation::Stack",
550 "REVIEW_IN_PROGRESS",
551 );
552 }
553 Some("REVIEW_IN_PROGRESS") => {}
557 Some(_) => {
560 return Err(AwsServiceError::aws_error(
561 StatusCode::BAD_REQUEST,
562 "AlreadyExistsException",
563 format!("Stack [{stack_name}] already exists"),
564 ));
565 }
566 }
567 }
568 store(&mut state.extras, "change_sets").insert(id.clone(), entry);
569 Ok(xml_response(
570 "CreateChangeSet",
571 format!(
572 " <Id>{}</Id>\n <StackId>{}</StackId>",
573 xml_escape(&id),
574 xml_escape(&stack_id_str)
575 ),
576 &rid,
577 ))
578 }
579 "DescribeChangeSet" => {
580 let cs = params
581 .get("ChangeSetName")
582 .ok_or_else(|| missing("ChangeSetName"))?
583 .clone();
584 let stack_filter = params.get("StackName").cloned();
585 let accounts = self.state.read();
586 let entry = accounts.get(&aid)
587 .and_then(|s| s.extras.get("change_sets"))
588 .and_then(|m| m.values().find(|v| {
589 let id_match = v["Id"].as_str() == Some(&cs)
590 || v["ChangeSetName"].as_str() == Some(&cs);
591 let stack_match = stack_filter.as_deref().is_none_or(|sf| {
592 v["StackName"].as_str() == Some(sf)
593 || v["StackId"].as_str() == Some(sf)
594 });
595 id_match && stack_match
596 }))
597 .cloned()
598 .unwrap_or_else(|| json!({"ChangeSetName": cs.clone(), "Status": "CREATE_COMPLETE", "ExecutionStatus": "AVAILABLE"}));
599 let changes_xml = entry["Changes"]
600 .as_array()
601 .map(|arr| {
602 let mut out = String::new();
603 for change in arr {
604 let rc = &change["ResourceChange"];
605 out.push_str(" <member>\n");
606 out.push_str(&format!(
607 " <Type>{}</Type>\n",
608 xml_escape(change["Type"].as_str().unwrap_or("Resource"))
609 ));
610 out.push_str(" <ResourceChange>\n");
611 out.push_str(&format!(
612 " <Action>{}</Action>\n",
613 xml_escape(rc["Action"].as_str().unwrap_or(""))
614 ));
615 out.push_str(&format!(
616 " <LogicalResourceId>{}</LogicalResourceId>\n",
617 xml_escape(rc["LogicalResourceId"].as_str().unwrap_or(""))
618 ));
619 if let Some(pid) = rc["PhysicalResourceId"].as_str() {
620 out.push_str(&format!(
621 " <PhysicalResourceId>{}</PhysicalResourceId>\n",
622 xml_escape(pid)
623 ));
624 }
625 out.push_str(&format!(
626 " <ResourceType>{}</ResourceType>\n",
627 xml_escape(rc["ResourceType"].as_str().unwrap_or(""))
628 ));
629 if let Some(replacement) = rc["Replacement"].as_str() {
630 out.push_str(&format!(
631 " <Replacement>{}</Replacement>\n",
632 xml_escape(replacement)
633 ));
634 }
635 out.push_str(" </ResourceChange>\n");
636 out.push_str(" </member>");
637 out.push('\n');
638 }
639 out
640 })
641 .unwrap_or_default();
642 let inner = format!(
643 " <ChangeSetName>{}</ChangeSetName>\n <ChangeSetId>{}</ChangeSetId>\n <StackId>{}</StackId>\n <StackName>{}</StackName>\n <Status>{}</Status>\n <ExecutionStatus>{}</ExecutionStatus>\n <Changes>\n{} </Changes>",
644 xml_escape(entry["ChangeSetName"].as_str().unwrap_or("")),
645 xml_escape(entry["Id"].as_str().unwrap_or("")),
646 xml_escape(entry["StackId"].as_str().unwrap_or("")),
647 xml_escape(entry["StackName"].as_str().unwrap_or("")),
648 xml_escape(entry["Status"].as_str().unwrap_or("CREATE_COMPLETE")),
649 xml_escape(entry["ExecutionStatus"].as_str().unwrap_or("AVAILABLE")),
650 changes_xml,
651 );
652 Ok(xml_response("DescribeChangeSet", inner, &rid))
653 }
654 "DescribeChangeSetHooks" => {
655 let cs = params
656 .get("ChangeSetName")
657 .ok_or_else(|| missing("ChangeSetName"))?
658 .clone();
659 let stack_filter = params.get("StackName").cloned();
660 let entry = {
664 let accounts = self.state.read();
665 accounts
666 .get(&aid)
667 .and_then(|s| s.extras.get("change_sets"))
668 .and_then(|m| {
669 m.values()
670 .find(|v| {
671 let id_match = v["Id"].as_str() == Some(&cs)
672 || v["ChangeSetName"].as_str() == Some(&cs);
673 let stack_match = stack_filter.as_deref().is_none_or(|sf| {
674 v["StackName"].as_str() == Some(sf)
675 || v["StackId"].as_str() == Some(sf)
676 });
677 id_match && stack_match
678 })
679 .cloned()
680 })
681 };
682 let (cs_id, cs_name, stack_id, stack_name, hooks) = match &entry {
683 Some(e) => (
684 e["Id"].as_str().unwrap_or("").to_string(),
685 e["ChangeSetName"].as_str().unwrap_or("").to_string(),
686 e["StackId"].as_str().unwrap_or("").to_string(),
687 e["StackName"].as_str().unwrap_or("").to_string(),
688 e["Hooks"].as_array().cloned().unwrap_or_default(),
689 ),
690 None => (
691 cs.clone(),
692 cs.clone(),
693 String::new(),
694 String::new(),
695 Vec::new(),
696 ),
697 };
698 let logical_filter = params.get("LogicalResourceId").cloned();
699 let hooks_xml = if hooks.is_empty() {
700 " <Hooks/>".to_string()
701 } else {
702 let members = members_xml(&hooks, |h| {
703 let type_name = h["TypeName"].as_str().unwrap_or("");
704 let resource_action =
705 logical_filter.as_deref().map(|_| "Modify").unwrap_or("Add");
706 let logical = logical_filter.as_deref().unwrap_or("");
707 format!(
708 " <InvocationPoint>PRE_PROVISION</InvocationPoint>\n <FailureMode>{}</FailureMode>\n <TypeName>{}</TypeName>\n <TypeVersionId>{}</TypeVersionId>\n <TypeConfigurationVersionId>{}</TypeConfigurationVersionId>\n <TargetDetails>\n <TargetType>RESOURCE</TargetType>\n <ResourceTargetDetails>\n <LogicalResourceId>{}</LogicalResourceId>\n <ResourceType>AWS::CloudFormation::Stack</ResourceType>\n <ResourceAction>{}</ResourceAction>\n </ResourceTargetDetails>\n </TargetDetails>",
709 xml_escape(h["FailureMode"].as_str().unwrap_or("FAIL")),
710 xml_escape(type_name),
711 xml_escape(h["TypeVersionId"].as_str().unwrap_or("00000001")),
712 xml_escape(h["TypeConfigurationVersionId"].as_str().unwrap_or("1")),
713 xml_escape(logical),
714 resource_action,
715 )
716 });
717 format!(" <Hooks>\n{members}\n </Hooks>")
718 };
719 let inner = format!(
720 " <ChangeSetId>{}</ChangeSetId>\n <ChangeSetName>{}</ChangeSetName>\n <StackId>{}</StackId>\n <StackName>{}</StackName>\n <Status>UNAVAILABLE</Status>\n{}",
721 xml_escape(&cs_id),
722 xml_escape(&cs_name),
723 xml_escape(&stack_id),
724 xml_escape(&stack_name),
725 hooks_xml,
726 );
727 Ok(xml_response("DescribeChangeSetHooks", inner, &rid))
728 }
729 "DeleteChangeSet" => {
730 let cs = params
731 .get("ChangeSetName")
732 .ok_or_else(|| missing("ChangeSetName"))?
733 .clone();
734 let mut accounts = self.state.write();
735 let state = accounts.get_or_create(&aid);
736 if let Some(m) = state.extras.get_mut("change_sets") {
737 m.retain(|_, v| {
738 v["Id"].as_str() != Some(&cs) && v["ChangeSetName"].as_str() != Some(&cs)
739 });
740 }
741 Ok(xml_response("DeleteChangeSet", String::new(), &rid))
742 }
743 "ExecuteChangeSet" => {
744 let cs = params
745 .get("ChangeSetName")
746 .cloned()
747 .ok_or_else(|| missing("ChangeSetName"))?;
748 let stack_filter = params.get("StackName").cloned();
749
750 let entry = {
751 let accounts = self.state.read();
752 accounts
753 .get(&aid)
754 .and_then(|s| s.extras.get("change_sets"))
755 .and_then(|m| {
756 m.values()
757 .find(|v| {
758 let id_match = v["Id"].as_str() == Some(&cs)
759 || v["ChangeSetName"].as_str() == Some(&cs);
760 let stack_match = stack_filter.as_deref().is_none_or(|sf| {
761 v["StackName"].as_str() == Some(sf)
762 || v["StackId"].as_str() == Some(sf)
763 });
764 id_match && stack_match
765 })
766 .cloned()
767 })
768 };
769 let Some(entry) = entry else {
770 return Ok(xml_response("ExecuteChangeSet", String::new(), &rid));
774 };
775
776 if entry["ExecutionStatus"].as_str() != Some("AVAILABLE") {
777 return Err(AwsServiceError::aws_error(
778 StatusCode::BAD_REQUEST,
779 "InvalidChangeSetStatus",
780 format!(
781 "ChangeSet [{cs}] cannot be executed in its current status of [{}]",
782 entry["ExecutionStatus"].as_str().unwrap_or("")
783 ),
784 ));
785 }
786
787 let cs_id = entry["Id"].as_str().unwrap_or("").to_string();
788 let stack_name = entry["StackName"].as_str().unwrap_or("").to_string();
789 let template_body = entry["TemplateBody"].as_str().unwrap_or("").to_string();
790 let cs_hooks: Vec<Value> = entry["Hooks"].as_array().cloned().unwrap_or_default();
791
792 let cs_tags: BTreeMap<String, String> = entry["Tags"]
793 .as_object()
794 .map(|m| {
795 m.iter()
796 .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
797 .collect()
798 })
799 .unwrap_or_default();
800 let cs_notif: Vec<String> = entry["NotificationArns"]
801 .as_array()
802 .map(|a| {
803 a.iter()
804 .filter_map(|v| v.as_str().map(|s| s.to_string()))
805 .collect()
806 })
807 .unwrap_or_default();
808 let mut cs_params: BTreeMap<String, String> = entry["Parameters"]
809 .as_object()
810 .map(|m| {
811 m.iter()
812 .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
813 .collect()
814 })
815 .unwrap_or_default();
816
817 let found: Option<String> = {
818 let accounts = self.state.read();
819 accounts.get(&aid).and_then(|s| {
820 s.stacks
821 .values()
822 .find(|st| {
823 (st.name == stack_name || st.stack_id == stack_name)
824 && st.status != "DELETE_COMPLETE"
825 })
826 .map(|st| st.stack_id.clone())
827 })
828 };
829
830 if template_body.trim().is_empty() {
837 let mut accounts = self.state.write();
838 let state = accounts.get_or_create(&aid);
839 if let Some(sid) = &found {
840 if let Some(stack) = state.stacks.values_mut().find(|s| &s.stack_id == sid)
841 {
842 if stack.status == "REVIEW_IN_PROGRESS" {
843 stack.status = "CREATE_COMPLETE".to_string();
844 stack.updated_at = Some(Utc::now());
845 }
846 }
847 }
848 if let Some(m) = state.extras.get_mut("change_sets") {
849 if let Some(e) = m.get_mut(&cs_id) {
850 e["ExecutionStatus"] = json!("EXECUTE_COMPLETE");
851 }
852 }
853 if !cs_hooks.is_empty() {
854 let target_id = found.clone().unwrap_or_else(|| cs_id.clone());
855 record_hook_results(
856 &mut state.extras,
857 &cs_hooks,
858 &HookTarget {
859 account_id: &aid,
860 target_type: "CLOUD_FORMATION",
861 target_id: &target_id,
862 logical_resource_id: &stack_name,
863 invocation_point: "PRE_PROVISION",
864 op_failed: false,
865 },
866 );
867 }
868 return Ok(xml_response("ExecuteChangeSet", String::new(), &rid));
869 }
870
871 let found_stack_id = found.ok_or_else(|| {
875 AwsServiceError::aws_error(
876 StatusCode::BAD_REQUEST,
877 "ValidationError",
878 format!("Stack [{stack_name}] does not exist"),
879 )
880 })?;
881
882 cs_params
883 .entry("AWS::Region".to_string())
884 .or_insert_with(|| req.region.clone());
885 cs_params
886 .entry("AWS::AccountId".to_string())
887 .or_insert_with(|| aid.clone());
888 cs_params
889 .entry("AWS::StackId".to_string())
890 .or_insert_with(|| found_stack_id.clone());
891 cs_params
892 .entry("AWS::StackName".to_string())
893 .or_insert_with(|| stack_name.clone());
894 cs_params
895 .entry("AWS::Partition".to_string())
896 .or_insert_with(|| "aws".to_string());
897 cs_params
898 .entry("AWS::URLSuffix".to_string())
899 .or_insert_with(|| "amazonaws.com".to_string());
900
901 let parsed = if template_body.trim().is_empty() {
905 template::ParsedTemplate {
906 description: None,
907 resources: Vec::new(),
908 outputs: Vec::new(),
909 }
910 } else {
911 template::parse_template(&template_body, &cs_params).map_err(|e| {
912 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
913 })?
914 };
915
916 let provisioner = self.provisioner(&found_stack_id, &aid, &req.region);
917
918 let mut accounts = self.state.write();
919 let state = accounts.get_or_create(&aid);
920
921 let (update_result, sid, stack_name_owned, was_review, resources_snapshot) = {
928 let stack = state
929 .stacks
930 .values_mut()
931 .find(|st| st.stack_id == found_stack_id && st.status != "DELETE_COMPLETE")
932 .ok_or_else(|| {
933 AwsServiceError::aws_error(
934 StatusCode::BAD_REQUEST,
935 "ValidationError",
936 format!("Stack [{stack_name}] does not exist"),
937 )
938 })?;
939 let was_review = stack.status == "REVIEW_IN_PROGRESS";
940 stack.status = if was_review {
941 "CREATE_IN_PROGRESS"
942 } else {
943 "UPDATE_IN_PROGRESS"
944 }
945 .to_string();
946 let result = crate::service::apply_resource_updates(
947 stack,
948 &parsed.resources,
949 &template_body,
950 &cs_params,
951 &provisioner,
952 );
953 let sid = stack.stack_id.clone();
954 let sname = stack.name.clone();
955 stack.template = template_body.clone();
956 stack.status = match (was_review, result.is_err()) {
957 (true, false) => "CREATE_COMPLETE",
958 (true, true) => "ROLLBACK_COMPLETE",
959 (false, false) => "UPDATE_COMPLETE",
960 (false, true) => "UPDATE_ROLLBACK_COMPLETE",
961 }
962 .to_string();
963 stack.parameters = cs_params.clone();
964 if !cs_tags.is_empty() {
965 stack.tags = cs_tags;
966 }
967 if !cs_notif.is_empty() {
968 stack.notification_arns = cs_notif;
969 }
970 stack.updated_at = Some(Utc::now());
971 stack.outputs.clear();
974 let resources_snapshot = stack.resources.clone();
975 (result, sid, sname, was_review, resources_snapshot)
976 };
977
978 let (in_progress, complete, failed) = if was_review {
980 ("CREATE_IN_PROGRESS", "CREATE_COMPLETE", "ROLLBACK_COMPLETE")
981 } else {
982 (
983 "UPDATE_IN_PROGRESS",
984 "UPDATE_COMPLETE",
985 "UPDATE_ROLLBACK_COMPLETE",
986 )
987 };
988 crate::service::record_stack_status_event(
989 state,
990 &sid,
991 &stack_name_owned,
992 "AWS::CloudFormation::Stack",
993 in_progress,
994 );
995 let final_status = match &update_result {
996 Ok(changes) => {
997 crate::service::record_stack_events(
998 state,
999 &sid,
1000 &stack_name_owned,
1001 changes,
1002 );
1003 complete
1004 }
1005 Err(_) => failed,
1006 };
1007 crate::service::record_stack_status_event(
1008 state,
1009 &sid,
1010 &stack_name_owned,
1011 "AWS::CloudFormation::Stack",
1012 final_status,
1013 );
1014
1015 if let Some(m) = state.extras.get_mut("change_sets") {
1016 if let Some(e) = m.get_mut(&cs_id) {
1017 e["ExecutionStatus"] = json!(if update_result.is_err() {
1018 "EXECUTE_FAILED"
1019 } else {
1020 "EXECUTE_COMPLETE"
1021 });
1022 }
1023 }
1024
1025 if !cs_hooks.is_empty() {
1030 record_hook_results(
1031 &mut state.extras,
1032 &cs_hooks,
1033 &HookTarget {
1034 account_id: &aid,
1035 target_type: "CLOUD_FORMATION",
1036 target_id: &sid,
1037 logical_resource_id: &stack_name_owned,
1038 invocation_point: "PRE_PROVISION",
1039 op_failed: update_result.is_err(),
1040 },
1041 );
1042 }
1043
1044 drop(accounts);
1045
1046 if let Err(msg) = update_result {
1047 return Err(AwsServiceError::aws_error(
1048 StatusCode::BAD_REQUEST,
1049 "ValidationError",
1050 msg,
1051 ));
1052 }
1053
1054 let outputs = CloudFormationService::resolve_template_outputs(
1062 &template_body,
1063 &cs_params,
1064 &resources_snapshot,
1065 &self.state,
1066 );
1067 {
1068 let mut accounts = self.state.write();
1069 let state = accounts.get_or_create(&aid);
1070 if let Some(stack) = state
1071 .stacks
1072 .values_mut()
1073 .find(|s| s.stack_id == sid && s.status != "DELETE_COMPLETE")
1074 {
1075 stack.outputs = outputs.clone();
1076 }
1077 CloudFormationService::sync_exports_imports(
1080 state,
1081 &sid,
1082 &stack_name_owned,
1083 &outputs,
1084 &[],
1085 );
1086 }
1087
1088 Ok(xml_response("ExecuteChangeSet", String::new(), &rid))
1089 }
1090 "ListChangeSets" => {
1091 require_scalar(¶ms, "StackName")?;
1092 let accounts = self.state.read();
1093 let items: Vec<Value> = accounts
1094 .get(&aid)
1095 .and_then(|s| s.extras.get("change_sets"))
1096 .map(|m| m.values().cloned().collect())
1097 .unwrap_or_default();
1098 let inner = format!(
1099 " <Summaries>\n{}\n </Summaries>",
1100 members_xml(&items, |v| {
1101 format!(
1102 " <ChangeSetId>{}</ChangeSetId>\n <ChangeSetName>{}</ChangeSetName>\n <Status>{}</Status>",
1103 xml_escape(v["Id"].as_str().unwrap_or("")),
1104 xml_escape(v["ChangeSetName"].as_str().unwrap_or("")),
1105 xml_escape(v["Status"].as_str().unwrap_or("CREATE_COMPLETE")),
1106 )
1107 }),
1108 );
1109 Ok(xml_response("ListChangeSets", inner, &rid))
1110 }
1111
1112 "CreateStackSet" => {
1114 let name = params
1115 .get("StackSetName")
1116 .ok_or_else(|| missing("StackSetName"))?
1117 .clone();
1118 let id = format!("{name}:{}", rand_id());
1119 let entry = json!({
1120 "StackSetId": id,
1121 "StackSetName": name,
1122 "Status": "ACTIVE",
1123 "TemplateBody": params.get("TemplateBody").cloned().unwrap_or_default(),
1124 });
1125 let mut accounts = self.state.write();
1126 let state = accounts.get_or_create(&aid);
1127 store(&mut state.extras, "stack_sets").insert(name.clone(), entry);
1128 Ok(xml_response(
1129 "CreateStackSet",
1130 format!(" <StackSetId>{}</StackSetId>", xml_escape(&id)),
1131 &rid,
1132 ))
1133 }
1134 "DescribeStackSet" => {
1135 let name = params
1136 .get("StackSetName")
1137 .ok_or_else(|| missing("StackSetName"))?
1138 .clone();
1139 let accounts = self.state.read();
1140 let entry = accounts
1141 .get(&aid)
1142 .and_then(|s| s.extras.get("stack_sets"))
1143 .and_then(|m| m.get(&name))
1144 .cloned()
1145 .unwrap_or_else(|| json!({"StackSetName": name.clone(), "Status": "ACTIVE"}));
1146 let inner = format!(
1147 " <StackSet>\n <StackSetName>{}</StackSetName>\n <StackSetId>{}</StackSetId>\n <Status>{}</Status>\n </StackSet>",
1148 xml_escape(entry["StackSetName"].as_str().unwrap_or(&name)),
1149 xml_escape(entry["StackSetId"].as_str().unwrap_or("")),
1150 xml_escape(entry["Status"].as_str().unwrap_or("ACTIVE")),
1151 );
1152 Ok(xml_response("DescribeStackSet", inner, &rid))
1153 }
1154 "ListStackSets" => {
1155 let accounts = self.state.read();
1156 let items: Vec<Value> = accounts
1157 .get(&aid)
1158 .and_then(|s| s.extras.get("stack_sets"))
1159 .map(|m| m.values().cloned().collect())
1160 .unwrap_or_default();
1161 let inner = format!(
1162 " <Summaries>\n{}\n </Summaries>",
1163 members_xml(&items, |v| {
1164 format!(
1165 " <StackSetName>{}</StackSetName>\n <StackSetId>{}</StackSetId>\n <Status>{}</Status>",
1166 xml_escape(v["StackSetName"].as_str().unwrap_or("")),
1167 xml_escape(v["StackSetId"].as_str().unwrap_or("")),
1168 xml_escape(v["Status"].as_str().unwrap_or("ACTIVE")),
1169 )
1170 }),
1171 );
1172 Ok(xml_response("ListStackSets", inner, &rid))
1173 }
1174 "UpdateStackSet" => {
1175 require_scalar(¶ms, "StackSetName")?;
1176 let op_id = rand_id();
1177 Ok(xml_response(
1178 "UpdateStackSet",
1179 format!(" <OperationId>{}</OperationId>", xml_escape(&op_id)),
1180 &rid,
1181 ))
1182 }
1183 "DeleteStackSet" => {
1184 let name = params
1185 .get("StackSetName")
1186 .ok_or_else(|| missing("StackSetName"))?
1187 .clone();
1188 let mut accounts = self.state.write();
1189 let state = accounts.get_or_create(&aid);
1190 if let Some(m) = state.extras.get_mut("stack_sets") {
1191 m.remove(&name);
1192 }
1193 Ok(xml_response("DeleteStackSet", String::new(), &rid))
1194 }
1195 "DescribeStackSetOperation" => {
1196 require_scalar(¶ms, "StackSetName")?;
1197 require_scalar(¶ms, "OperationId")?;
1198 let op_id = params.get("OperationId").cloned().unwrap_or_else(rand_id);
1199 let inner = format!(
1200 " <StackSetOperation>\n <OperationId>{}</OperationId>\n <Status>SUCCEEDED</Status>\n </StackSetOperation>",
1201 xml_escape(&op_id),
1202 );
1203 Ok(xml_response("DescribeStackSetOperation", inner, &rid))
1204 }
1205 "ListStackSetOperations" => {
1206 require_scalar(¶ms, "StackSetName")?;
1207 Ok(xml_response(
1208 "ListStackSetOperations",
1209 " <Summaries/>".to_string(),
1210 &rid,
1211 ))
1212 }
1213 "ListStackSetOperationResults" => {
1214 require_scalar(¶ms, "StackSetName")?;
1215 require_scalar(¶ms, "OperationId")?;
1216 Ok(xml_response(
1217 "ListStackSetOperationResults",
1218 " <Summaries/>".to_string(),
1219 &rid,
1220 ))
1221 }
1222 "ListStackSetAutoDeploymentTargets" => {
1223 require_scalar(¶ms, "StackSetName")?;
1224 Ok(xml_response(
1225 "ListStackSetAutoDeploymentTargets",
1226 " <Summaries/>".to_string(),
1227 &rid,
1228 ))
1229 }
1230 "StopStackSetOperation" => {
1231 require_scalar(¶ms, "StackSetName")?;
1232 require_scalar(¶ms, "OperationId")?;
1233 Ok(xml_response("StopStackSetOperation", String::new(), &rid))
1234 }
1235 "ImportStacksToStackSet" => {
1236 require_scalar(¶ms, "StackSetName")?;
1237 let op_id = rand_id();
1238 Ok(xml_response(
1239 "ImportStacksToStackSet",
1240 format!(" <OperationId>{}</OperationId>", xml_escape(&op_id)),
1241 &rid,
1242 ))
1243 }
1244
1245 "CreateStackInstances" => {
1253 require_scalar(¶ms, "StackSetName")?;
1254 let op_id = rand_id();
1255 Ok(xml_response(
1256 "CreateStackInstances",
1257 format!(" <OperationId>{}</OperationId>", xml_escape(&op_id)),
1258 &rid,
1259 ))
1260 }
1261 "UpdateStackInstances" => {
1262 require_scalar(¶ms, "StackSetName")?;
1263 let op_id = rand_id();
1264 Ok(xml_response(
1265 "UpdateStackInstances",
1266 format!(" <OperationId>{}</OperationId>", xml_escape(&op_id)),
1267 &rid,
1268 ))
1269 }
1270 "DeleteStackInstances" => {
1271 require_scalar(¶ms, "StackSetName")?;
1272 require_scalar(¶ms, "RetainStacks")?;
1273 let op_id = rand_id();
1274 Ok(xml_response(
1275 "DeleteStackInstances",
1276 format!(" <OperationId>{}</OperationId>", xml_escape(&op_id)),
1277 &rid,
1278 ))
1279 }
1280 "DescribeStackInstance" => {
1281 require_scalar(¶ms, "StackSetName")?;
1282 require_scalar(¶ms, "StackInstanceAccount")?;
1283 require_scalar(¶ms, "StackInstanceRegion")?;
1284 let inner =
1285 " <StackInstance>\n <Status>CURRENT</Status>\n </StackInstance>"
1286 .to_string();
1287 Ok(xml_response("DescribeStackInstance", inner, &rid))
1288 }
1289 "ListStackInstances" => {
1290 require_scalar(¶ms, "StackSetName")?;
1291 Ok(xml_response(
1292 "ListStackInstances",
1293 " <Summaries/>".to_string(),
1294 &rid,
1295 ))
1296 }
1297 "ListStackInstanceResourceDrifts" => {
1298 require_scalar(¶ms, "StackSetName")?;
1299 require_scalar(¶ms, "StackInstanceAccount")?;
1300 require_scalar(¶ms, "StackInstanceRegion")?;
1301 require_scalar(¶ms, "OperationId")?;
1302 Ok(xml_response(
1303 "ListStackInstanceResourceDrifts",
1304 " <Summaries/>".to_string(),
1305 &rid,
1306 ))
1307 }
1308
1309 "CreateStackRefactor" => {
1311 require_collection(¶ms, "StackDefinitions")?;
1312 let id = rand_id();
1313 let entry = json!({"StackRefactorId": id.clone(), "Status": "CREATE_COMPLETE"});
1314 let mut accounts = self.state.write();
1315 let state = accounts.get_or_create(&aid);
1316 store(&mut state.extras, "refactors").insert(id.clone(), entry);
1317 Ok(xml_response(
1318 "CreateStackRefactor",
1319 format!(" <StackRefactorId>{}</StackRefactorId>", xml_escape(&id)),
1320 &rid,
1321 ))
1322 }
1323 "DescribeStackRefactor" => {
1324 let id = params
1325 .get("StackRefactorId")
1326 .ok_or_else(|| missing("StackRefactorId"))?
1327 .clone();
1328 let inner = format!(
1329 " <StackRefactorId>{}</StackRefactorId>\n <Status>CREATE_COMPLETE</Status>",
1330 xml_escape(&id),
1331 );
1332 Ok(xml_response("DescribeStackRefactor", inner, &rid))
1333 }
1334 "ExecuteStackRefactor" => {
1335 require_scalar(¶ms, "StackRefactorId")?;
1336 Ok(xml_response("ExecuteStackRefactor", String::new(), &rid))
1337 }
1338 "ListStackRefactors" => Ok(xml_response(
1339 "ListStackRefactors",
1340 " <StackRefactorSummaries/>".to_string(),
1341 &rid,
1342 )),
1343 "ListStackRefactorActions" => {
1344 require_scalar(¶ms, "StackRefactorId")?;
1345 Ok(xml_response(
1346 "ListStackRefactorActions",
1347 " <StackRefactorActions/>".to_string(),
1348 &rid,
1349 ))
1350 }
1351
1352 "ActivateType" => {
1354 let arn = Arn::new(
1355 "cloudformation",
1356 "us-east-1",
1357 &aid,
1358 &format!("type/resource/{}", rand_id()),
1359 )
1360 .to_string();
1361 let type_name = params
1366 .get("TypeNameAlias")
1367 .or_else(|| params.get("TypeName"));
1368 if is_hook_type(
1369 params.get("Type").map(String::as_str),
1370 type_name.map(String::as_str),
1371 ) {
1372 if let Some(name) = type_name {
1373 let mut accounts = self.state.write();
1374 let state = accounts.get_or_create(&aid);
1375 register_hook(&mut state.extras, name, None, None);
1376 }
1377 }
1378 Ok(xml_response(
1379 "ActivateType",
1380 format!(" <Arn>{}</Arn>", xml_escape(&arn)),
1381 &rid,
1382 ))
1383 }
1384 "DeactivateType" => Ok(xml_response("DeactivateType", String::new(), &rid)),
1385 "DescribeType" => {
1386 let arn = params.get("Arn").cloned().unwrap_or_else(|| {
1387 Arn::new("cloudformation", "us-east-1", &aid, "type/resource/Default")
1388 .to_string()
1389 });
1390 let inner = format!(
1391 " <Arn>{}</Arn>\n <Type>RESOURCE</Type>\n <TypeName>AWS::Custom::Type</TypeName>",
1392 xml_escape(&arn),
1393 );
1394 Ok(xml_response("DescribeType", inner, &rid))
1395 }
1396 "DescribeTypeRegistration" => {
1397 let token = params
1398 .get("RegistrationToken")
1399 .cloned()
1400 .ok_or_else(|| missing("RegistrationToken"))?;
1401 let inner = format!(
1402 " <ProgressStatus>COMPLETE</ProgressStatus>\n <Description>{}</Description>",
1403 xml_escape(&token),
1404 );
1405 Ok(xml_response("DescribeTypeRegistration", inner, &rid))
1406 }
1407 "RegisterType" => {
1408 require_scalar(¶ms, "TypeName")?;
1409 require_scalar(¶ms, "SchemaHandlerPackage")?;
1410 if is_hook_type(
1411 params.get("Type").map(String::as_str),
1412 params.get("TypeName").map(String::as_str),
1413 ) {
1414 if let Some(name) = params.get("TypeName") {
1415 let mut accounts = self.state.write();
1416 let state = accounts.get_or_create(&aid);
1417 register_hook(&mut state.extras, name, None, None);
1418 }
1419 }
1420 let token = rand_id();
1421 Ok(xml_response(
1422 "RegisterType",
1423 format!(
1424 " <RegistrationToken>{}</RegistrationToken>",
1425 xml_escape(&token)
1426 ),
1427 &rid,
1428 ))
1429 }
1430 "DeregisterType" => Ok(xml_response("DeregisterType", String::new(), &rid)),
1431 "ListTypes" => Ok(xml_response(
1432 "ListTypes",
1433 " <TypeSummaries/>".to_string(),
1434 &rid,
1435 )),
1436 "ListTypeRegistrations" => Ok(xml_response(
1437 "ListTypeRegistrations",
1438 " <RegistrationTokenList/>".to_string(),
1439 &rid,
1440 )),
1441 "ListTypeVersions" => Ok(xml_response(
1442 "ListTypeVersions",
1443 " <TypeVersionSummaries/>".to_string(),
1444 &rid,
1445 )),
1446 "BatchDescribeTypeConfigurations" => {
1447 Ok(xml_response(
1451 "BatchDescribeTypeConfigurations",
1452 " <Errors/>\n <TypeConfigurations/>".to_string(),
1453 &rid,
1454 ))
1455 }
1456 "SetTypeConfiguration" => {
1457 require_scalar(¶ms, "Configuration")?;
1458 let configuration = params.get("Configuration");
1464 if is_hook_type(
1465 params.get("Type").map(String::as_str),
1466 params.get("TypeName").map(String::as_str),
1467 ) {
1468 if let Some(name) = params.get("TypeName") {
1469 let failure_mode = configuration
1470 .and_then(|c| serde_json::from_str::<Value>(c).ok())
1471 .as_ref()
1472 .and_then(|c| {
1473 c.get("CloudFormationConfiguration")
1474 .and_then(|h| h.get("HookConfiguration"))
1475 .and_then(|h| h.get("FailureMode"))
1476 .and_then(Value::as_str)
1477 })
1478 .map(str::to_string);
1479 let mut accounts = self.state.write();
1480 let state = accounts.get_or_create(&aid);
1481 register_hook(
1482 &mut state.extras,
1483 name,
1484 failure_mode.as_deref(),
1485 configuration.map(String::as_str),
1486 );
1487 }
1488 }
1489 let arn = Arn::new(
1490 "cloudformation",
1491 "us-east-1",
1492 &aid,
1493 &format!("type-config/{}", rand_id()),
1494 )
1495 .to_string();
1496 Ok(xml_response(
1497 "SetTypeConfiguration",
1498 format!(
1499 " <ConfigurationArn>{}</ConfigurationArn>",
1500 xml_escape(&arn)
1501 ),
1502 &rid,
1503 ))
1504 }
1505 "SetTypeDefaultVersion" => {
1506 Ok(xml_response("SetTypeDefaultVersion", String::new(), &rid))
1507 }
1508 "TestType" => {
1509 let arn = Arn::new(
1510 "cloudformation",
1511 "us-east-1",
1512 &aid,
1513 &format!("type/resource/{}", rand_id()),
1514 )
1515 .to_string();
1516 Ok(xml_response(
1517 "TestType",
1518 format!(" <TypeVersionArn>{}</TypeVersionArn>", xml_escape(&arn)),
1519 &rid,
1520 ))
1521 }
1522 "PublishType" => {
1523 let arn = Arn::new(
1524 "cloudformation",
1525 "us-east-1",
1526 &aid,
1527 &format!("type/resource/{}", rand_id()),
1528 )
1529 .to_string();
1530 Ok(xml_response(
1531 "PublishType",
1532 format!(" <PublicTypeArn>{}</PublicTypeArn>", xml_escape(&arn)),
1533 &rid,
1534 ))
1535 }
1536 "RegisterPublisher" => {
1537 let id = rand_id();
1538 Ok(xml_response(
1539 "RegisterPublisher",
1540 format!(" <PublisherId>{}</PublisherId>", xml_escape(&id)),
1541 &rid,
1542 ))
1543 }
1544 "DescribePublisher" => {
1545 let id = params
1546 .get("PublisherId")
1547 .cloned()
1548 .unwrap_or_else(|| "default-publisher".to_string());
1549 let inner = format!(
1550 " <PublisherId>{}</PublisherId>\n <PublisherStatus>VERIFIED</PublisherStatus>\n <IdentityProvider>AWS_Marketplace</IdentityProvider>",
1551 xml_escape(&id),
1552 );
1553 Ok(xml_response("DescribePublisher", inner, &rid))
1554 }
1555
1556 "CreateGeneratedTemplate" => {
1558 let name = params
1559 .get("GeneratedTemplateName")
1560 .ok_or_else(|| missing("GeneratedTemplateName"))?
1561 .clone();
1562 let id = Arn::new(
1563 "cloudformation",
1564 "us-east-1",
1565 &aid,
1566 &format!("generatedtemplate/{}", rand_id()),
1567 )
1568 .to_string();
1569 let entry = json!({"GeneratedTemplateId": id.clone(), "Name": name.clone(), "Status": "COMPLETE"});
1570 let mut accounts = self.state.write();
1571 let state = accounts.get_or_create(&aid);
1572 store(&mut state.extras, "generated_templates").insert(name.clone(), entry);
1573 Ok(xml_response(
1574 "CreateGeneratedTemplate",
1575 format!(
1576 " <GeneratedTemplateId>{}</GeneratedTemplateId>",
1577 xml_escape(&id)
1578 ),
1579 &rid,
1580 ))
1581 }
1582 "UpdateGeneratedTemplate" => {
1583 let name = params
1584 .get("GeneratedTemplateName")
1585 .ok_or_else(|| missing("GeneratedTemplateName"))?
1586 .clone();
1587 let id = Arn::new(
1588 "cloudformation",
1589 "us-east-1",
1590 &aid,
1591 &format!("generatedtemplate/{name}"),
1592 )
1593 .to_string();
1594 Ok(xml_response(
1595 "UpdateGeneratedTemplate",
1596 format!(
1597 " <GeneratedTemplateId>{}</GeneratedTemplateId>",
1598 xml_escape(&id)
1599 ),
1600 &rid,
1601 ))
1602 }
1603 "DescribeGeneratedTemplate" => {
1604 let name = params
1605 .get("GeneratedTemplateName")
1606 .ok_or_else(|| missing("GeneratedTemplateName"))?
1607 .clone();
1608 let inner = format!(
1609 " <GeneratedTemplateId>arn:aws:cloudformation:us-east-1:{}:generatedtemplate/{}</GeneratedTemplateId>\n <GeneratedTemplateName>{}</GeneratedTemplateName>\n <Status>COMPLETE</Status>",
1610 xml_escape(&aid),
1611 xml_escape(&name),
1612 xml_escape(&name),
1613 );
1614 Ok(xml_response("DescribeGeneratedTemplate", inner, &rid))
1615 }
1616 "GetGeneratedTemplate" => {
1617 require_scalar(¶ms, "GeneratedTemplateName")?;
1618 Ok(xml_response(
1619 "GetGeneratedTemplate",
1620 " <Status>COMPLETE</Status>\n <TemplateBody>{}</TemplateBody>"
1621 .to_string(),
1622 &rid,
1623 ))
1624 }
1625 "DeleteGeneratedTemplate" => {
1626 let name = params
1627 .get("GeneratedTemplateName")
1628 .ok_or_else(|| missing("GeneratedTemplateName"))?
1629 .clone();
1630 let mut accounts = self.state.write();
1631 let state = accounts.get_or_create(&aid);
1632 if let Some(m) = state.extras.get_mut("generated_templates") {
1633 m.remove(&name);
1634 }
1635 Ok(xml_response("DeleteGeneratedTemplate", String::new(), &rid))
1636 }
1637 "ListGeneratedTemplates" => Ok(xml_response(
1638 "ListGeneratedTemplates",
1639 " <Summaries/>".to_string(),
1640 &rid,
1641 )),
1642
1643 "StartResourceScan" => {
1645 let id = Arn::new(
1646 "cloudformation",
1647 "us-east-1",
1648 &aid,
1649 &format!("resourceScan/{}", rand_id()),
1650 )
1651 .to_string();
1652 Ok(xml_response(
1653 "StartResourceScan",
1654 format!(" <ResourceScanId>{}</ResourceScanId>", xml_escape(&id)),
1655 &rid,
1656 ))
1657 }
1658 "DescribeResourceScan" => {
1659 let id = params
1660 .get("ResourceScanId")
1661 .cloned()
1662 .ok_or_else(|| missing("ResourceScanId"))?;
1663 let inner = format!(
1664 " <ResourceScanId>{}</ResourceScanId>\n <Status>COMPLETE</Status>",
1665 xml_escape(&id),
1666 );
1667 Ok(xml_response("DescribeResourceScan", inner, &rid))
1668 }
1669 "ListResourceScans" => Ok(xml_response(
1670 "ListResourceScans",
1671 " <ResourceScanSummaries/>".to_string(),
1672 &rid,
1673 )),
1674 "ListResourceScanResources" => {
1675 require_scalar(¶ms, "ResourceScanId")?;
1676 Ok(xml_response(
1677 "ListResourceScanResources",
1678 " <Resources/>".to_string(),
1679 &rid,
1680 ))
1681 }
1682 "ListResourceScanRelatedResources" => {
1683 require_scalar(¶ms, "ResourceScanId")?;
1684 Ok(xml_response(
1685 "ListResourceScanRelatedResources",
1686 " <RelatedResources/>".to_string(),
1687 &rid,
1688 ))
1689 }
1690
1691 "DetectStackDrift" => {
1693 let stack_name = params
1694 .get("StackName")
1695 .ok_or_else(|| missing("StackName"))?
1696 .clone();
1697 let id = rand_id();
1698
1699 let resources: Vec<StackResource> = {
1700 let accounts = self.state.read();
1701 let stack = accounts.get(&aid).and_then(|s| {
1702 s.stacks.values().find(|st| {
1703 (st.name == stack_name || st.stack_id == stack_name)
1704 && st.status != "DELETE_COMPLETE"
1705 })
1706 });
1707 stack.map(|s| s.resources.clone()).unwrap_or_default()
1708 };
1709
1710 let mut drifted_resources: Vec<Value> = Vec::new();
1711
1712 for resource in &resources {
1713 let exists = match resource.resource_type.as_str() {
1714 "AWS::SQS::Queue" => self
1715 .deps
1716 .sqs
1717 .read()
1718 .get(&aid)
1719 .map(|s| s.queues.contains_key(&resource.physical_id))
1720 .unwrap_or(false),
1721 "AWS::SNS::Topic" => self
1722 .deps
1723 .sns
1724 .read()
1725 .get(&aid)
1726 .map(|s| s.topics.contains_key(&resource.physical_id))
1727 .unwrap_or(false),
1728 "AWS::S3::Bucket" => self
1729 .deps
1730 .s3
1731 .read()
1732 .get(&aid)
1733 .map(|s| s.buckets.contains_key(&resource.physical_id))
1734 .unwrap_or(false),
1735 "AWS::Lambda::Function" => self
1736 .deps
1737 .lambda
1738 .read()
1739 .get(&aid)
1740 .map(|s| s.functions.contains_key(&resource.physical_id))
1741 .unwrap_or(false),
1742 "AWS::IAM::Role" => self
1743 .deps
1744 .iam
1745 .read()
1746 .get(&aid)
1747 .map(|s| s.roles.contains_key(&resource.physical_id))
1748 .unwrap_or(false),
1749 "AWS::DynamoDB::Table" => self
1750 .deps
1751 .dynamodb
1752 .read()
1753 .get(&aid)
1754 .map(|s| s.tables.values().any(|t| t.arn == resource.physical_id))
1755 .unwrap_or(false),
1756 "AWS::KMS::Key" => self
1757 .deps
1758 .kms
1759 .read()
1760 .get(&aid)
1761 .map(|s| s.keys.contains_key(&resource.physical_id))
1762 .unwrap_or(false),
1763 "AWS::SecretsManager::Secret" => self
1764 .deps
1765 .secretsmanager
1766 .read()
1767 .get(&aid)
1768 .map(|s| s.secrets.contains_key(&resource.physical_id))
1769 .unwrap_or(false),
1770 _ => true, };
1772 if !exists {
1773 drifted_resources.push(json!({
1774 "LogicalResourceId": resource.logical_id,
1775 "PhysicalResourceId": resource.physical_id,
1776 "ResourceType": resource.resource_type,
1777 "StackResourceDriftStatus": "DELETED",
1778 "PropertyDifferences": [],
1779 }));
1780 }
1781 }
1782
1783 let stack_drift_status = if drifted_resources.is_empty() {
1784 "IN_SYNC"
1785 } else {
1786 "DRIFTED"
1787 };
1788
1789 let record = json!({
1790 "StackDriftDetectionId": id,
1791 "StackName": stack_name,
1792 "StackDriftStatus": stack_drift_status,
1793 "DetectionStatus": "DETECTION_COMPLETE",
1794 "DriftedResources": drifted_resources,
1795 });
1796
1797 {
1798 let mut accounts = self.state.write();
1799 let state = accounts.get_or_create(&aid);
1800 store(&mut state.extras, "drift_detection").insert(id.clone(), record);
1801 }
1802
1803 Ok(xml_response(
1804 "DetectStackDrift",
1805 format!(
1806 " <StackDriftDetectionId>{}</StackDriftDetectionId>",
1807 xml_escape(&id)
1808 ),
1809 &rid,
1810 ))
1811 }
1812 "DetectStackResourceDrift" => {
1813 let stack_name = params
1814 .get("StackName")
1815 .ok_or_else(|| missing("StackName"))?
1816 .clone();
1817 let logical = params
1818 .get("LogicalResourceId")
1819 .ok_or_else(|| missing("LogicalResourceId"))?
1820 .clone();
1821 let accounts = self.state.read();
1822 let resource_drift = accounts
1823 .get(&aid)
1824 .and_then(|s| {
1825 s.stacks.values().find(|st| {
1826 (st.name == stack_name || st.stack_id == stack_name)
1827 && st.status != "DELETE_COMPLETE"
1828 })
1829 })
1830 .and_then(|stack| stack.resources.iter().find(|r| r.logical_id == logical))
1831 .map(|resource| {
1832 let exists = match resource.resource_type.as_str() {
1833 "AWS::SQS::Queue" => self
1834 .deps
1835 .sqs
1836 .read()
1837 .get(&aid)
1838 .map(|s| s.queues.contains_key(&resource.physical_id))
1839 .unwrap_or(false),
1840 "AWS::SNS::Topic" => self
1841 .deps
1842 .sns
1843 .read()
1844 .get(&aid)
1845 .map(|s| s.topics.contains_key(&resource.physical_id))
1846 .unwrap_or(false),
1847 "AWS::S3::Bucket" => self
1848 .deps
1849 .s3
1850 .read()
1851 .get(&aid)
1852 .map(|s| s.buckets.contains_key(&resource.physical_id))
1853 .unwrap_or(false),
1854 "AWS::Lambda::Function" => self
1855 .deps
1856 .lambda
1857 .read()
1858 .get(&aid)
1859 .map(|s| s.functions.contains_key(&resource.physical_id))
1860 .unwrap_or(false),
1861 "AWS::IAM::Role" => self
1862 .deps
1863 .iam
1864 .read()
1865 .get(&aid)
1866 .map(|s| s.roles.contains_key(&resource.physical_id))
1867 .unwrap_or(false),
1868 "AWS::DynamoDB::Table" => self
1869 .deps
1870 .dynamodb
1871 .read()
1872 .get(&aid)
1873 .map(|s| s.tables.values().any(|t| t.arn == resource.physical_id))
1874 .unwrap_or(false),
1875 "AWS::KMS::Key" => self
1876 .deps
1877 .kms
1878 .read()
1879 .get(&aid)
1880 .map(|s| s.keys.contains_key(&resource.physical_id))
1881 .unwrap_or(false),
1882 "AWS::SecretsManager::Secret" => self
1883 .deps
1884 .secretsmanager
1885 .read()
1886 .get(&aid)
1887 .map(|s| s.secrets.contains_key(&resource.physical_id))
1888 .unwrap_or(false),
1889 _ => true,
1890 };
1891 if exists {
1892 "IN_SYNC"
1893 } else {
1894 "DELETED"
1895 }
1896 })
1897 .unwrap_or("NOT_CHECKED");
1898
1899 let inner = format!(
1900 " <StackResourceDrift>\n <LogicalResourceId>{}</LogicalResourceId>\n <StackResourceDriftStatus>{}</StackResourceDriftStatus>\n </StackResourceDrift>",
1901 xml_escape(&logical),
1902 xml_escape(resource_drift),
1903 );
1904 Ok(xml_response("DetectStackResourceDrift", inner, &rid))
1905 }
1906 "DetectStackSetDrift" => {
1907 require_scalar(¶ms, "StackSetName")?;
1908 let op_id = rand_id();
1909 Ok(xml_response(
1910 "DetectStackSetDrift",
1911 format!(" <OperationId>{}</OperationId>", xml_escape(&op_id)),
1912 &rid,
1913 ))
1914 }
1915 "DescribeStackDriftDetectionStatus" => {
1916 let id = params
1917 .get("StackDriftDetectionId")
1918 .ok_or_else(|| missing("StackDriftDetectionId"))?
1919 .clone();
1920 let accounts = self.state.read();
1921 let record = accounts
1922 .get(&aid)
1923 .and_then(|s| s.extras.get("drift_detection"))
1924 .and_then(|m| m.get(&id))
1925 .cloned()
1926 .unwrap_or_else(|| {
1927 json!({
1928 "StackDriftDetectionId": id,
1929 "StackDriftStatus": "IN_SYNC",
1930 "DetectionStatus": "DETECTION_COMPLETE",
1931 })
1932 });
1933 let stack_id = record["StackId"]
1939 .as_str()
1940 .map(str::to_owned)
1941 .unwrap_or_else(|| {
1942 Arn::new(
1943 "cloudformation",
1944 "us-east-1",
1945 &aid,
1946 &format!("stack/drift-{id}/{}", rand_id()),
1947 )
1948 .to_string()
1949 });
1950 let timestamp = record["Timestamp"]
1951 .as_str()
1952 .map(str::to_owned)
1953 .unwrap_or_else(|| "2024-01-01T00:00:00Z".to_string());
1954
1955 let inner = format!(
1956 " <StackId>{}</StackId>\n <StackDriftDetectionId>{}</StackDriftDetectionId>\n <DetectionStatus>{}</DetectionStatus>\n <StackDriftStatus>{}</StackDriftStatus>\n <Timestamp>{}</Timestamp>",
1957 xml_escape(&stack_id),
1958 xml_escape(record["StackDriftDetectionId"].as_str().unwrap_or("")),
1959 xml_escape(record["DetectionStatus"].as_str().unwrap_or("DETECTION_COMPLETE")),
1960 xml_escape(record["StackDriftStatus"].as_str().unwrap_or("IN_SYNC")),
1961 xml_escape(×tamp),
1962 );
1963 Ok(xml_response(
1964 "DescribeStackDriftDetectionStatus",
1965 inner,
1966 &rid,
1967 ))
1968 }
1969 "DescribeStackResourceDrifts" => {
1970 let stack_name = params
1971 .get("StackName")
1972 .cloned()
1973 .ok_or_else(|| missing("StackName"))?;
1974 let accounts = self.state.read();
1975 let drifted: Vec<Value> = accounts
1976 .get(&aid)
1977 .and_then(|s| {
1978 let found = s
1979 .stacks
1980 .values()
1981 .find(|st| {
1982 (st.name == stack_name || st.stack_id == stack_name)
1983 && st.status != "DELETE_COMPLETE"
1984 })
1985 .is_some();
1986 if !found {
1987 return None;
1988 }
1989 s.extras
1990 .get("drift_detection")
1991 .and_then(|m| {
1992 m.values()
1993 .find(|v| v["StackName"].as_str() == Some(stack_name.as_str()))
1994 })
1995 .and_then(|v| v["DriftedResources"].as_array().cloned())
1996 })
1997 .unwrap_or_default();
1998
1999 let inner = if drifted.is_empty() {
2000 " <StackResourceDrifts/>".to_string()
2001 } else {
2002 format!(
2003 " <StackResourceDrifts>\n{}\n </StackResourceDrifts>",
2004 members_xml(&drifted, |v| {
2005 format!(
2006 " <StackResourceDrift>\n <LogicalResourceId>{}</LogicalResourceId>\n <PhysicalResourceId>{}</PhysicalResourceId>\n <ResourceType>{}</ResourceType>\n <StackResourceDriftStatus>{}</StackResourceDriftStatus>\n </StackResourceDrift>",
2007 xml_escape(v["LogicalResourceId"].as_str().unwrap_or("")),
2008 xml_escape(v["PhysicalResourceId"].as_str().unwrap_or("")),
2009 xml_escape(v["ResourceType"].as_str().unwrap_or("")),
2010 xml_escape(v["StackResourceDriftStatus"].as_str().unwrap_or("IN_SYNC")),
2011 )
2012 }),
2013 )
2014 };
2015 Ok(xml_response("DescribeStackResourceDrifts", inner, &rid))
2016 }
2017 "DescribeStackResource" => {
2018 let stack_name = params
2019 .get("StackName")
2020 .ok_or_else(|| missing("StackName"))?
2021 .clone();
2022 let logical = params
2023 .get("LogicalResourceId")
2024 .ok_or_else(|| missing("LogicalResourceId"))?
2025 .clone();
2026 let accounts = self.state.read();
2027 let detail = accounts
2028 .get(&aid)
2029 .and_then(|s| s.stacks.get(&stack_name))
2030 .and_then(|s| s.resources.iter().find(|r| r.logical_id == logical))
2031 .map(|r| {
2032 (
2033 r.physical_id.clone(),
2034 r.resource_type.clone(),
2035 r.status.clone(),
2036 )
2037 })
2038 .unwrap_or_else(|| {
2039 (
2040 "pid".to_string(),
2041 "AWS::Custom".to_string(),
2042 "CREATE_COMPLETE".to_string(),
2043 )
2044 });
2045 let inner = format!(
2046 " <StackResourceDetail>\n <StackName>{}</StackName>\n <LogicalResourceId>{}</LogicalResourceId>\n <PhysicalResourceId>{}</PhysicalResourceId>\n <ResourceType>{}</ResourceType>\n <ResourceStatus>{}</ResourceStatus>\n <LastUpdatedTimestamp>{}</LastUpdatedTimestamp>\n </StackResourceDetail>",
2047 xml_escape(&stack_name),
2048 xml_escape(&logical),
2049 xml_escape(&detail.0),
2050 xml_escape(&detail.1),
2051 xml_escape(&detail.2),
2052 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
2053 );
2054 Ok(xml_response("DescribeStackResource", inner, &rid))
2055 }
2056
2057 "DescribeStackEvents" => {
2059 require_scalar(¶ms, "StackName")?;
2060 let stack_filter = params.get("StackName").cloned();
2061 let accounts = self.state.read();
2062 let events: Vec<Value> = accounts
2063 .get(&aid)
2064 .map(|s| {
2065 let mut all: Vec<Value> = Vec::new();
2066 for (sid, evs) in &s.events {
2067 let matches = match &stack_filter {
2069 None => true,
2070 Some(filter) => {
2071 sid == filter
2072 || s.stacks.values().any(|st| {
2073 st.stack_id == *sid
2074 && (st.name == *filter || st.stack_id == *filter)
2075 })
2076 }
2077 };
2078 if matches {
2079 all.extend(evs.iter().cloned());
2080 }
2081 }
2082 all.reverse();
2084 all
2085 })
2086 .unwrap_or_default();
2087 let inner = if events.is_empty() {
2088 " <StackEvents/>".to_string()
2089 } else {
2090 format!(
2091 " <StackEvents>\n{}\n </StackEvents>",
2092 members_xml(&events, |v| {
2093 format!(
2094 " <EventId>{}</EventId>\n <StackId>{}</StackId>\n <StackName>{}</StackName>\n <LogicalResourceId>{}</LogicalResourceId>\n <PhysicalResourceId>{}</PhysicalResourceId>\n <ResourceType>{}</ResourceType>\n <ResourceStatus>{}</ResourceStatus>\n <Timestamp>{}</Timestamp>",
2095 xml_escape(v["EventId"].as_str().unwrap_or("")),
2096 xml_escape(v["StackId"].as_str().unwrap_or("")),
2097 xml_escape(v["StackName"].as_str().unwrap_or("")),
2098 xml_escape(v["LogicalResourceId"].as_str().unwrap_or("")),
2099 xml_escape(v["PhysicalResourceId"].as_str().unwrap_or("")),
2100 xml_escape(v["ResourceType"].as_str().unwrap_or("")),
2101 xml_escape(v["ResourceStatus"].as_str().unwrap_or("")),
2102 xml_escape(v["Timestamp"].as_str().unwrap_or("")),
2103 )
2104 }),
2105 )
2106 };
2107 Ok(xml_response("DescribeStackEvents", inner, &rid))
2108 }
2109 "DescribeEvents" => Ok(xml_response(
2110 "DescribeEvents",
2111 " <Events/>".to_string(),
2112 &rid,
2113 )),
2114
2115 "GetHookResult" => {
2117 let result_id = params
2125 .get("HookResultId")
2126 .or_else(|| params.get("HookId"))
2127 .cloned()
2128 .unwrap_or_default();
2129 let record = {
2130 let accounts = self.state.read();
2131 accounts
2132 .get(&aid)
2133 .and_then(|s| s.extras.get("hook_results"))
2134 .and_then(|m| m.get(&result_id))
2135 .cloned()
2136 };
2137 let r = record.unwrap_or_else(|| {
2138 serde_json::json!({
2139 "HookResultId": result_id,
2140 "InvocationPoint": params
2141 .get("InvocationPoint")
2142 .cloned()
2143 .unwrap_or_default(),
2144 "Status": "",
2145 "HookStatusReason": "",
2146 })
2147 });
2148 let inner = format!(
2149 " <HookResultId>{}</HookResultId>\n <InvocationPoint>{}</InvocationPoint>\n <FailureMode>{}</FailureMode>\n <TypeName>{}</TypeName>\n <TypeVersionId>{}</TypeVersionId>\n <TypeConfigurationVersionId>{}</TypeConfigurationVersionId>\n <TypeArn>{}</TypeArn>\n <Status>{}</Status>\n <HookStatusReason>{}</HookStatusReason>",
2150 xml_escape(r["HookResultId"].as_str().unwrap_or("")),
2151 xml_escape(r["InvocationPoint"].as_str().unwrap_or("PRE_PROVISION")),
2152 xml_escape(r["FailureMode"].as_str().unwrap_or("FAIL")),
2153 xml_escape(r["TypeName"].as_str().unwrap_or("")),
2154 xml_escape(r["TypeVersionId"].as_str().unwrap_or("00000001")),
2155 xml_escape(r["TypeConfigurationVersionId"].as_str().unwrap_or("1")),
2156 xml_escape(r["TypeArn"].as_str().unwrap_or("")),
2157 xml_escape(r["Status"].as_str().unwrap_or("HOOK_COMPLETE_SUCCEEDED")),
2158 xml_escape(r["HookStatusReason"].as_str().unwrap_or("")),
2159 );
2160 Ok(xml_response("GetHookResult", inner, &rid))
2161 }
2162 "ListHookResults" => {
2163 let target_type = params.get("TargetType").cloned();
2167 let target_id = params.get("TargetId").cloned();
2168 let type_arn = params.get("TypeArn").cloned();
2169 let status_filter = params.get("Status").cloned();
2170 let records: Vec<Value> = {
2171 let accounts = self.state.read();
2172 accounts
2173 .get(&aid)
2174 .and_then(|s| s.extras.get("hook_results"))
2175 .map(|m| {
2176 m.values()
2177 .filter(|r| {
2178 target_type
2179 .as_deref()
2180 .is_none_or(|t| r["TargetType"].as_str() == Some(t))
2181 && target_id
2182 .as_deref()
2183 .is_none_or(|t| r["TargetId"].as_str() == Some(t))
2184 && type_arn
2185 .as_deref()
2186 .is_none_or(|t| r["TypeArn"].as_str() == Some(t))
2187 && status_filter
2188 .as_deref()
2189 .is_none_or(|s| r["Status"].as_str() == Some(s))
2190 })
2191 .cloned()
2192 .collect()
2193 })
2194 .unwrap_or_default()
2195 };
2196 let results_xml = if records.is_empty() {
2197 " <HookResults/>".to_string()
2198 } else {
2199 let members = members_xml(&records, |r| {
2200 format!(
2201 " <HookResultId>{}</HookResultId>\n <InvocationPoint>{}</InvocationPoint>\n <FailureMode>{}</FailureMode>\n <TypeName>{}</TypeName>\n <TypeVersionId>{}</TypeVersionId>\n <TypeConfigurationVersionId>{}</TypeConfigurationVersionId>\n <TypeArn>{}</TypeArn>\n <Status>{}</Status>\n <HookStatusReason>{}</HookStatusReason>\n <TargetType>{}</TargetType>\n <TargetId>{}</TargetId>",
2202 xml_escape(r["HookResultId"].as_str().unwrap_or("")),
2203 xml_escape(r["InvocationPoint"].as_str().unwrap_or("PRE_PROVISION")),
2204 xml_escape(r["FailureMode"].as_str().unwrap_or("FAIL")),
2205 xml_escape(r["TypeName"].as_str().unwrap_or("")),
2206 xml_escape(r["TypeVersionId"].as_str().unwrap_or("00000001")),
2207 xml_escape(r["TypeConfigurationVersionId"].as_str().unwrap_or("1")),
2208 xml_escape(r["TypeArn"].as_str().unwrap_or("")),
2209 xml_escape(r["Status"].as_str().unwrap_or("HOOK_COMPLETE_SUCCEEDED")),
2210 xml_escape(r["HookStatusReason"].as_str().unwrap_or("")),
2211 xml_escape(r["TargetType"].as_str().unwrap_or("")),
2212 xml_escape(r["TargetId"].as_str().unwrap_or("")),
2213 )
2214 });
2215 format!(" <HookResults>\n{members}\n </HookResults>")
2216 };
2217 let mut inner = String::new();
2218 if let Some(t) = &target_type {
2219 inner.push_str(&format!(" <TargetType>{}</TargetType>\n", xml_escape(t)));
2220 }
2221 if let Some(t) = &target_id {
2222 inner.push_str(&format!(" <TargetId>{}</TargetId>\n", xml_escape(t)));
2223 }
2224 inner.push_str(&results_xml);
2225 Ok(xml_response("ListHookResults", inner, &rid))
2226 }
2227 "RecordHandlerProgress" => {
2228 require_scalar(¶ms, "BearerToken")?;
2229 require_scalar(¶ms, "OperationStatus")?;
2230 Ok(xml_response_no_result("RecordHandlerProgress", &rid))
2231 }
2232
2233 "ListExports" => {
2235 let accounts = self.state.read();
2236 let mut entries = String::new();
2237 if let Some(state) = accounts.get(&aid) {
2238 for (name, export) in &state.exports {
2239 entries.push_str(&format!(
2240 " <member>\n <ExportingStackId>{}</ExportingStackId>\n <Name>{}</Name>\n <Value>{}</Value>\n </member>\n",
2241 xml_escape(&export.exporting_stack_id),
2242 xml_escape(name),
2243 xml_escape(&export.value),
2244 ));
2245 }
2246 }
2247 let inner = if entries.is_empty() {
2248 " <Exports/>".to_string()
2249 } else {
2250 format!(" <Exports>\n{entries} </Exports>")
2251 };
2252 Ok(xml_response("ListExports", inner, &rid))
2253 }
2254 "ListImports" => {
2255 let export_name = params
2256 .get("ExportName")
2257 .cloned()
2258 .ok_or_else(|| missing("ExportName"))?;
2259 let accounts = self.state.read();
2260 let mut entries = String::new();
2261 if let Some(state) = accounts.get(&aid) {
2262 if let Some(consumers) = state.imports.get(&export_name) {
2263 for stack_name in consumers {
2264 entries.push_str(&format!(
2265 " <member>{}</member>\n",
2266 xml_escape(stack_name)
2267 ));
2268 }
2269 }
2270 }
2271 let inner = if entries.is_empty() {
2272 " <Imports/>".to_string()
2273 } else {
2274 format!(" <Imports>\n{entries} </Imports>")
2275 };
2276 Ok(xml_response("ListImports", inner, &rid))
2277 }
2278
2279 "GetStackPolicy" => {
2281 let stack = params
2282 .get("StackName")
2283 .ok_or_else(|| missing("StackName"))?
2284 .clone();
2285 let accounts = self.state.read();
2286 let body = accounts.get(&aid)
2287 .and_then(|s| s.stack_policies.get(&stack))
2288 .cloned()
2289 .unwrap_or_else(|| r#"{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}"#.to_string());
2290 let inner = format!(
2291 " <StackPolicyBody>{}</StackPolicyBody>",
2292 xml_escape(&body)
2293 );
2294 Ok(xml_response("GetStackPolicy", inner, &rid))
2295 }
2296 "SetStackPolicy" => {
2297 let stack = params
2298 .get("StackName")
2299 .ok_or_else(|| missing("StackName"))?
2300 .clone();
2301 let body = params.get("StackPolicyBody").cloned().unwrap_or_default();
2302 let mut accounts = self.state.write();
2303 let state = accounts.get_or_create(&aid);
2304 state.stack_policies.insert(stack, body);
2305 Ok(xml_response_no_result("SetStackPolicy", &rid))
2306 }
2307
2308 "UpdateTerminationProtection" => {
2310 let stack = params
2311 .get("StackName")
2312 .ok_or_else(|| missing("StackName"))?
2313 .clone();
2314 let enabled_raw = params
2315 .get("EnableTerminationProtection")
2316 .ok_or_else(|| missing("EnableTerminationProtection"))?;
2317 let enabled = enabled_raw.eq_ignore_ascii_case("true");
2318 let stack_id = {
2319 let mut accounts = self.state.write();
2320 let state = accounts.get_or_create(&aid);
2321 state.termination_protection.insert(stack.clone(), enabled);
2322 state
2323 .stacks
2324 .get(&stack)
2325 .map(|s| s.stack_id.clone())
2326 .unwrap_or_else(|| stack.clone())
2327 };
2328 Ok(xml_response(
2329 "UpdateTerminationProtection",
2330 format!(" <StackId>{}</StackId>", xml_escape(&stack_id)),
2331 &rid,
2332 ))
2333 }
2334
2335 "DescribeAccountLimits" => Ok(xml_response(
2337 "DescribeAccountLimits",
2338 r#" <AccountLimits>
2339 <member>
2340 <Name>StackLimit</Name>
2341 <Value>2000</Value>
2342 </member>
2343 </AccountLimits>"#
2344 .to_string(),
2345 &rid,
2346 )),
2347 "ActivateOrganizationsAccess" => {
2348 let mut accounts = self.state.write();
2349 let state = accounts.get_or_create(&aid);
2350 state.orgs_access_enabled = true;
2351 Ok(xml_response(
2352 "ActivateOrganizationsAccess",
2353 String::new(),
2354 &rid,
2355 ))
2356 }
2357 "DeactivateOrganizationsAccess" => {
2358 let mut accounts = self.state.write();
2359 let state = accounts.get_or_create(&aid);
2360 state.orgs_access_enabled = false;
2361 Ok(xml_response(
2362 "DeactivateOrganizationsAccess",
2363 String::new(),
2364 &rid,
2365 ))
2366 }
2367 "DescribeOrganizationsAccess" => {
2368 let accounts = self.state.read();
2369 let status = if accounts
2370 .get(&aid)
2371 .map(|s| s.orgs_access_enabled)
2372 .unwrap_or(false)
2373 {
2374 "ENABLED"
2375 } else {
2376 "DISABLED"
2377 };
2378 Ok(xml_response(
2379 "DescribeOrganizationsAccess",
2380 format!(" <Status>{}</Status>", status),
2381 &rid,
2382 ))
2383 }
2384 "ValidateTemplate" => Ok(xml_response(
2385 "ValidateTemplate",
2386 " <Description>Validated</Description>\n <Capabilities/>\n <Parameters/>"
2387 .to_string(),
2388 &rid,
2389 )),
2390 "EstimateTemplateCost" => Ok(xml_response(
2391 "EstimateTemplateCost",
2392 " <Url>https://calculator.aws/#/estimate</Url>".to_string(),
2393 &rid,
2394 )),
2395 "GetTemplateSummary" => Ok(xml_response(
2396 "GetTemplateSummary",
2397 " <Parameters/>\n <ResourceTypes/>\n <Capabilities/>".to_string(),
2398 &rid,
2399 )),
2400 "CancelUpdateStack" => {
2401 params
2402 .get("StackName")
2403 .ok_or_else(|| missing("StackName"))?;
2404 Ok(xml_response_no_result("CancelUpdateStack", &rid))
2405 }
2406 "ContinueUpdateRollback" => {
2407 params
2408 .get("StackName")
2409 .ok_or_else(|| missing("StackName"))?;
2410 Ok(xml_response("ContinueUpdateRollback", String::new(), &rid))
2411 }
2412 "RollbackStack" => {
2413 let stack = params
2414 .get("StackName")
2415 .ok_or_else(|| missing("StackName"))?
2416 .clone();
2417 let stack_id = {
2418 let accounts = self.state.read();
2419 accounts
2420 .get(&aid)
2421 .and_then(|s| s.stacks.get(&stack))
2422 .map(|s| s.stack_id.clone())
2423 .unwrap_or_else(|| stack.clone())
2424 };
2425 Ok(xml_response(
2426 "RollbackStack",
2427 format!(" <StackId>{}</StackId>", xml_escape(&stack_id)),
2428 &rid,
2429 ))
2430 }
2431 "SignalResource" => {
2432 require_scalar(¶ms, "StackName")?;
2433 require_scalar(¶ms, "LogicalResourceId")?;
2434 require_scalar(¶ms, "UniqueId")?;
2435 require_scalar(¶ms, "Status")?;
2436 Ok(xml_response_no_result("SignalResource", &rid))
2437 }
2438
2439 _ => Err(AwsServiceError::action_not_implemented(
2440 "cloudformation",
2441 &action,
2442 )),
2443 }
2444 }
2445}
2446
2447#[cfg(test)]
2448mod tests {
2449 use super::parse_s3_url;
2450 use crate::service::{CloudFormationDeps, CloudFormationService};
2451 use crate::state::{CloudFormationState, SharedCloudFormationState};
2452 use fakecloud_core::delivery::DeliveryBus;
2453 use fakecloud_core::multi_account::MultiAccountState;
2454 use fakecloud_core::service::AwsRequest;
2455 use http::Method;
2456 use parking_lot::RwLock;
2457 use std::collections::HashMap;
2458 use std::sync::Arc;
2459
2460 #[test]
2461 fn parse_s3_url_handles_path_and_virtual_hosted() {
2462 assert_eq!(
2465 parse_s3_url("http://127.0.0.1:4566/bucket/deploy/template.json"),
2466 Some(("bucket".to_string(), "deploy/template.json".to_string()))
2467 );
2468 assert_eq!(
2470 parse_s3_url("https://s3.us-east-1.amazonaws.com/my-bucket/key.yaml"),
2471 Some(("my-bucket".to_string(), "key.yaml".to_string()))
2472 );
2473 assert_eq!(
2475 parse_s3_url("https://my-bucket.s3.amazonaws.com/key.yaml"),
2476 Some(("my-bucket".to_string(), "key.yaml".to_string()))
2477 );
2478 assert_eq!(
2480 parse_s3_url("https://s3.amazonaws.com/b/k.json?versionId=abc"),
2481 Some(("b".to_string(), "k.json".to_string()))
2482 );
2483 assert_eq!(parse_s3_url("https://s3.amazonaws.com/bucket-only"), None);
2485 }
2486
2487 fn deps() -> CloudFormationDeps {
2488 use fakecloud_dynamodb::DynamoDbState;
2489 use fakecloud_ecr::EcrState;
2490 use fakecloud_eventbridge::EventBridgeState;
2491 use fakecloud_iam::IamState;
2492 use fakecloud_kinesis::KinesisState;
2493 use fakecloud_kms::KmsState;
2494 use fakecloud_lambda::LambdaState;
2495 use fakecloud_logs::LogsState;
2496 use fakecloud_s3::S3State;
2497 use fakecloud_secretsmanager::SecretsManagerState;
2498 use fakecloud_sns::SnsState;
2499 use fakecloud_sqs::SqsState;
2500 use fakecloud_ssm::SsmState;
2501
2502 fn shared<T: fakecloud_core::multi_account::AccountState>(
2503 ) -> Arc<RwLock<MultiAccountState<T>>> {
2504 Arc::new(RwLock::new(MultiAccountState::<T>::new(
2505 "000000000000",
2506 "us-east-1",
2507 "",
2508 )))
2509 }
2510 CloudFormationDeps {
2511 sqs: shared::<SqsState>(),
2512 sns: shared::<SnsState>(),
2513 ssm: shared::<SsmState>(),
2514 iam: shared::<IamState>(),
2515 s3: shared::<S3State>(),
2516 eventbridge: shared::<EventBridgeState>(),
2517 dynamodb: shared::<DynamoDbState>(),
2518 logs: shared::<LogsState>(),
2519 lambda: shared::<LambdaState>(),
2520 secretsmanager: shared::<SecretsManagerState>(),
2521 kinesis: shared::<KinesisState>(),
2522 kms: shared::<KmsState>(),
2523 ecr: shared::<EcrState>(),
2524 cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
2525 elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
2526 organizations: Arc::new(RwLock::new(None)),
2527 cognito: shared::<fakecloud_cognito::CognitoState>(),
2528 rds: shared::<fakecloud_rds::RdsState>(),
2529 ecs: shared::<fakecloud_ecs::EcsState>(),
2530 acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
2531 elasticache: shared::<fakecloud_elasticache::ElastiCacheState>(),
2532 route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
2533 cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
2534 stepfunctions: shared::<fakecloud_stepfunctions::StepFunctionsState>(),
2535 wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
2536 apigateway: shared::<fakecloud_apigateway::ApiGatewayState>(),
2537 apigatewayv2: shared::<fakecloud_apigatewayv2::ApiGatewayV2State>(),
2538 ses: shared::<fakecloud_ses::SesState>(),
2539 application_autoscaling: Arc::new(parking_lot::RwLock::new(
2540 fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
2541 )),
2542 athena: Arc::new(parking_lot::RwLock::new(
2543 fakecloud_athena::AthenaAccounts::new(),
2544 )),
2545 firehose: Arc::new(parking_lot::RwLock::new(
2546 fakecloud_firehose::FirehoseAccounts::new(),
2547 )),
2548 glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
2549 delivery: Arc::new(DeliveryBus::new()),
2550 lambda_runtime: None,
2551 }
2552 }
2553
2554 fn svc() -> CloudFormationService {
2555 let state: SharedCloudFormationState =
2556 Arc::new(RwLock::new(MultiAccountState::<CloudFormationState>::new(
2557 "000000000000",
2558 "us-east-1",
2559 "",
2560 )));
2561 CloudFormationService::new(state, deps())
2562 }
2563
2564 fn req(action: &str, params: &[(&str, &str)]) -> AwsRequest {
2565 let mut q = HashMap::new();
2566 q.insert("Action".to_string(), action.to_string());
2567 for (k, v) in params {
2568 q.insert(k.to_string(), v.to_string());
2569 }
2570 AwsRequest {
2571 service: "cloudformation".to_string(),
2572 method: Method::POST,
2573 raw_path: "/".to_string(),
2574 raw_query: String::new(),
2575 path_segments: vec![],
2576 query_params: q,
2577 headers: http::HeaderMap::new(),
2578 body: bytes::Bytes::new(),
2579 body_stream: parking_lot::Mutex::new(None),
2580 account_id: "000000000000".to_string(),
2581 region: "us-east-1".to_string(),
2582 request_id: "rid".to_string(),
2583 action: action.to_string(),
2584 is_query_protocol: true,
2585 access_key_id: None,
2586 principal: None,
2587 }
2588 }
2589
2590 fn ok(action: &str, params: &[(&str, &str)]) {
2591 let r = svc().handle_extra_action(&req(action, params));
2592 match r {
2593 Ok(resp) => assert!(resp.status.is_success(), "{action} status: {}", resp.status),
2594 Err(e) => panic!("{action} failed: {e:?}"),
2595 }
2596 }
2597
2598 #[test]
2599 fn change_sets() {
2600 ok(
2601 "CreateChangeSet",
2602 &[("StackName", "s"), ("ChangeSetName", "cs")],
2603 );
2604 ok("DescribeChangeSet", &[("ChangeSetName", "cs")]);
2605 ok("DescribeChangeSetHooks", &[("ChangeSetName", "cs")]);
2606 ok("ListChangeSets", &[("StackName", "s")]);
2607 ok("ExecuteChangeSet", &[("ChangeSetName", "cs")]);
2608 ok("DeleteChangeSet", &[("ChangeSetName", "cs")]);
2609 }
2610
2611 fn body_str(resp: &fakecloud_core::service::AwsResponse) -> String {
2612 String::from_utf8(resp.body.expect_bytes().to_vec()).unwrap()
2613 }
2614
2615 #[test]
2616 fn hook_round_trip() {
2617 let s = svc();
2622
2623 s.handle_extra_action(&req(
2625 "SetTypeConfiguration",
2626 &[
2627 ("Type", "HOOK"),
2628 ("TypeName", "MyOrg::MyHook::Hook"),
2629 (
2630 "Configuration",
2631 r#"{"CloudFormationConfiguration":{"HookConfiguration":{"FailureMode":"FAIL"}}}"#,
2632 ),
2633 ],
2634 ))
2635 .expect("SetTypeConfiguration");
2636
2637 s.handle_extra_action(&req(
2640 "CreateChangeSet",
2641 &[
2642 ("StackName", "hooked-stack"),
2643 ("ChangeSetName", "cs1"),
2644 ("ChangeSetType", "CREATE"),
2645 ],
2646 ))
2647 .expect("CreateChangeSet");
2648
2649 let resp = s
2651 .handle_extra_action(&req("DescribeChangeSetHooks", &[("ChangeSetName", "cs1")]))
2652 .expect("DescribeChangeSetHooks");
2653 let xml = body_str(&resp);
2654 assert!(xml.contains("MyOrg::MyHook::Hook"), "hooks XML: {xml}");
2655 assert!(xml.contains("<FailureMode>FAIL</FailureMode>"));
2656
2657 s.handle_extra_action(&req("ExecuteChangeSet", &[("ChangeSetName", "cs1")]))
2659 .expect("ExecuteChangeSet");
2660
2661 let resp = s
2663 .handle_extra_action(&req(
2664 "ListHookResults",
2665 &[("TargetType", "CLOUD_FORMATION")],
2666 ))
2667 .expect("ListHookResults");
2668 let xml = body_str(&resp);
2669 assert!(xml.contains("<HookResults>"), "list XML: {xml}");
2670 assert!(xml.contains("MyOrg::MyHook::Hook"));
2671 assert!(xml.contains("HOOK_COMPLETE_SUCCEEDED"));
2673
2674 let id = xml
2676 .split("<HookResultId>")
2677 .nth(1)
2678 .and_then(|s| s.split("</HookResultId>").next())
2679 .expect("a HookResultId in the list")
2680 .to_string();
2681 let resp = s
2682 .handle_extra_action(&req("GetHookResult", &[("HookResultId", &id)]))
2683 .expect("GetHookResult");
2684 let xml = body_str(&resp);
2685 assert!(xml.contains("HOOK_COMPLETE_SUCCEEDED"), "get XML: {xml}");
2686 assert!(xml.contains("MyOrg::MyHook::Hook"));
2687
2688 let resp = s
2693 .handle_extra_action(&req("GetHookResult", &[("HookResultId", "nope")]))
2694 .expect("GetHookResult for an unknown id still returns 2xx");
2695 let xml = body_str(&resp);
2696 assert!(
2697 xml.contains("<HookResultId>nope</HookResultId>"),
2698 "unknown-id XML: {xml}"
2699 );
2700 assert!(
2701 !xml.contains("MyOrg::MyHook::Hook"),
2702 "unknown id must not echo the recorded hook: {xml}"
2703 );
2704 }
2705
2706 const CS_TEMPLATE: &str = r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cs-q"}}},"Outputs":{"QUrl":{"Value":{"Ref":"Q"}}}}"#;
2709
2710 #[test]
2714 fn create_change_set_records_review_in_progress_event() {
2715 let svc = svc();
2716 svc.handle_extra_action(&req(
2717 "CreateChangeSet",
2718 &[
2719 ("StackName", "cs-events"),
2720 ("ChangeSetName", "cs1"),
2721 ("ChangeSetType", "CREATE"),
2722 ("TemplateBody", CS_TEMPLATE),
2723 ],
2724 ))
2725 .expect("create change set");
2726
2727 {
2729 let accounts = svc.state.read();
2730 let acct = accounts.get("000000000000").unwrap();
2731 let total: usize = acct.events.values().map(|v| v.len()).sum();
2732 assert_eq!(total, 1, "expected one event after CreateChangeSet");
2733 let ev = acct.events.values().next().unwrap().last().unwrap();
2734 assert_eq!(ev["ResourceStatus"].as_str(), Some("REVIEW_IN_PROGRESS"));
2735 assert_eq!(
2736 ev["ResourceType"].as_str(),
2737 Some("AWS::CloudFormation::Stack")
2738 );
2739 }
2740
2741 let resp = svc
2743 .handle_extra_action(&req("DescribeStackEvents", &[("StackName", "cs-events")]))
2744 .expect("describe stack events");
2745 let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2746 assert!(!body.contains("<StackEvents/>"), "events list was empty");
2747 assert!(body.contains("REVIEW_IN_PROGRESS"), "body: {body}");
2748 }
2749
2750 #[test]
2755 fn changeset_stack_events_have_monotonic_subsecond_timestamps() {
2756 let svc = svc();
2757 svc.handle_extra_action(&req(
2758 "CreateChangeSet",
2759 &[
2760 ("StackName", "cs-fast"),
2761 ("ChangeSetName", "cs1"),
2762 ("ChangeSetType", "CREATE"),
2763 ("TemplateBody", CS_TEMPLATE),
2764 ],
2765 ))
2766 .expect("create change set");
2767 svc.handle_extra_action(&req(
2768 "ExecuteChangeSet",
2769 &[("StackName", "cs-fast"), ("ChangeSetName", "cs1")],
2770 ))
2771 .expect("execute change set");
2772
2773 let accounts = svc.state.read();
2774 let acct = accounts.get("000000000000").unwrap();
2775 let stack_id = acct.stacks.get("cs-fast").unwrap().stack_id.clone();
2776 let events = acct.events.get(&stack_id).expect("stack has events");
2777
2778 assert!(events.len() >= 3, "expected several lifecycle events");
2781 let ts: Vec<&str> = events
2782 .iter()
2783 .map(|e| e["Timestamp"].as_str().unwrap())
2784 .collect();
2785 assert_eq!(
2786 events.first().unwrap()["ResourceStatus"].as_str(),
2787 Some("REVIEW_IN_PROGRESS")
2788 );
2789 assert_eq!(
2790 events.last().unwrap()["ResourceStatus"].as_str(),
2791 Some("CREATE_COMPLETE")
2792 );
2793 for t in &ts {
2796 assert!(t.contains('.'), "timestamp lacks sub-second precision: {t}");
2797 }
2798 for w in ts.windows(2) {
2799 assert!(w[1] > w[0], "timestamps not strictly increasing: {w:?}");
2800 }
2801 }
2802
2803 #[test]
2807 fn execute_change_set_persists_tags_and_outputs() {
2808 let svc = svc();
2809 svc.handle_extra_action(&req(
2810 "CreateChangeSet",
2811 &[
2812 ("StackName", "cs-stack"),
2813 ("ChangeSetName", "cs1"),
2814 ("ChangeSetType", "CREATE"),
2815 ("TemplateBody", CS_TEMPLATE),
2816 ("Tags.member.1.Key", "ManagedStackSource"),
2817 ("Tags.member.1.Value", "AwsSamCli"),
2818 ],
2819 ))
2820 .expect("create change set");
2821 svc.handle_extra_action(&req(
2822 "ExecuteChangeSet",
2823 &[("StackName", "cs-stack"), ("ChangeSetName", "cs1")],
2824 ))
2825 .expect("execute change set");
2826
2827 let accounts = svc.state.read();
2828 let stack = accounts
2829 .get("000000000000")
2830 .unwrap()
2831 .stacks
2832 .get("cs-stack")
2833 .unwrap();
2834 assert_eq!(stack.status, "CREATE_COMPLETE");
2835 assert_eq!(
2836 stack.tags.get("ManagedStackSource").map(String::as_str),
2837 Some("AwsSamCli"),
2838 "changeset Tags dropped on execute"
2839 );
2840 assert_eq!(stack.outputs.len(), 1, "changeset Outputs not resolved");
2841 assert_eq!(stack.outputs[0].key, "QUrl");
2842 assert!(!stack.outputs[0].value.is_empty());
2843 }
2844
2845 #[test]
2850 fn changeset_provisions_lambda_before_referencing_state_machine() {
2851 let d = deps();
2852 let sfn = d.stepfunctions.clone();
2853 let state: SharedCloudFormationState =
2854 Arc::new(RwLock::new(MultiAccountState::<CloudFormationState>::new(
2855 "000000000000",
2856 "us-east-1",
2857 "",
2858 )));
2859 let svc = CloudFormationService::new(state, d);
2860
2861 let template = r#"{
2864 "Resources": {
2865 "Machine": {
2866 "Type": "AWS::StepFunctions::StateMachine",
2867 "Properties": {
2868 "RoleArn": "arn:aws:iam::000000000000:role/sfn",
2869 "DefinitionString": "{\"StartAt\":\"T\",\"States\":{\"T\":{\"Type\":\"Task\",\"Resource\":\"${fn}\",\"End\":true}}}",
2870 "DefinitionSubstitutions": {"fn": {"Ref": "Worker"}}
2871 }
2872 },
2873 "Worker": {
2874 "Type": "AWS::Lambda::Function",
2875 "Properties": {
2876 "FunctionName": "workflow_dispatcher_v2-1",
2877 "Runtime": "python3.12",
2878 "Handler": "index.handler",
2879 "Role": "arn:aws:iam::000000000000:role/lambda",
2880 "Code": {"ZipFile": "def handler(e, c): return e"}
2881 }
2882 }
2883 }
2884 }"#;
2885
2886 svc.handle_extra_action(&req(
2887 "CreateChangeSet",
2888 &[
2889 ("StackName", "wf"),
2890 ("ChangeSetName", "cs1"),
2891 ("ChangeSetType", "CREATE"),
2892 ("TemplateBody", template),
2893 ],
2894 ))
2895 .expect("create change set");
2896 svc.handle_extra_action(&req(
2897 "ExecuteChangeSet",
2898 &[("StackName", "wf"), ("ChangeSetName", "cs1")],
2899 ))
2900 .expect("execute change set");
2901
2902 let accounts = sfn.read();
2903 let st = accounts.get("000000000000").expect("sfn account exists");
2904 let machine = st
2905 .state_machines
2906 .values()
2907 .next()
2908 .expect("state machine was provisioned");
2909 assert!(
2910 machine.definition.contains("workflow_dispatcher_v2-1"),
2911 "ASL should carry the resolved function name, got: {}",
2912 machine.definition
2913 );
2914 assert!(
2915 !machine.definition.contains("${fn}"),
2916 "substitution token left unreplaced: {}",
2917 machine.definition
2918 );
2919 assert!(
2920 !machine.definition.contains("Worker"),
2921 "logical id leaked into baked ASL: {}",
2922 machine.definition
2923 );
2924 }
2925
2926 #[test]
2927 fn stack_sets_instances_refactors() {
2928 ok("CreateStackSet", &[("StackSetName", "ss")]);
2929 ok("DescribeStackSet", &[("StackSetName", "ss")]);
2930 ok("ListStackSets", &[]);
2931 ok("UpdateStackSet", &[("StackSetName", "ss")]);
2932 ok(
2933 "DescribeStackSetOperation",
2934 &[("StackSetName", "ss"), ("OperationId", "op")],
2935 );
2936 ok("ListStackSetOperations", &[("StackSetName", "ss")]);
2937 ok(
2938 "ListStackSetOperationResults",
2939 &[("StackSetName", "ss"), ("OperationId", "op")],
2940 );
2941 ok(
2942 "ListStackSetAutoDeploymentTargets",
2943 &[("StackSetName", "ss")],
2944 );
2945 ok(
2946 "StopStackSetOperation",
2947 &[("StackSetName", "ss"), ("OperationId", "op")],
2948 );
2949 ok("ImportStacksToStackSet", &[("StackSetName", "ss")]);
2950 ok("DeleteStackSet", &[("StackSetName", "ss")]);
2951 ok(
2952 "CreateStackInstances",
2953 &[("StackSetName", "ss"), ("Regions.member.1", "us-east-1")],
2954 );
2955 ok(
2956 "UpdateStackInstances",
2957 &[("StackSetName", "ss"), ("Regions.member.1", "us-east-1")],
2958 );
2959 ok(
2960 "DeleteStackInstances",
2961 &[
2962 ("StackSetName", "ss"),
2963 ("Regions.member.1", "us-east-1"),
2964 ("RetainStacks", "false"),
2965 ],
2966 );
2967 ok(
2968 "DescribeStackInstance",
2969 &[
2970 ("StackSetName", "ss"),
2971 ("StackInstanceAccount", "000000000000"),
2972 ("StackInstanceRegion", "us-east-1"),
2973 ],
2974 );
2975 ok("ListStackInstances", &[("StackSetName", "ss")]);
2976 ok(
2977 "ListStackInstanceResourceDrifts",
2978 &[
2979 ("StackSetName", "ss"),
2980 ("StackInstanceAccount", "000000000000"),
2981 ("StackInstanceRegion", "us-east-1"),
2982 ("OperationId", "op"),
2983 ],
2984 );
2985 ok(
2986 "CreateStackRefactor",
2987 &[("StackDefinitions.member.1.StackName", "s")],
2988 );
2989 ok("DescribeStackRefactor", &[("StackRefactorId", "r")]);
2990 ok("ExecuteStackRefactor", &[("StackRefactorId", "r")]);
2991 ok("ListStackRefactors", &[]);
2992 ok("ListStackRefactorActions", &[("StackRefactorId", "r")]);
2993 }
2994
2995 #[test]
2996 fn types_and_publishers() {
2997 ok("ActivateType", &[]);
2998 ok("DeactivateType", &[]);
2999 ok("DescribeType", &[]);
3000 ok("DescribeTypeRegistration", &[("RegistrationToken", "tok")]);
3001 ok(
3002 "RegisterType",
3003 &[("TypeName", "T"), ("SchemaHandlerPackage", "pkg")],
3004 );
3005 ok("DeregisterType", &[]);
3006 ok("ListTypes", &[]);
3007 ok("ListTypeRegistrations", &[]);
3008 ok("ListTypeVersions", &[]);
3009 ok(
3010 "BatchDescribeTypeConfigurations",
3011 &[("TypeConfigurationIdentifiers.member.1.Type", "RESOURCE")],
3012 );
3013 ok("SetTypeConfiguration", &[("Configuration", "{}")]);
3014 ok("SetTypeDefaultVersion", &[]);
3015 ok("TestType", &[]);
3016 ok("PublishType", &[]);
3017 ok("RegisterPublisher", &[]);
3018 ok("DescribePublisher", &[]);
3019 }
3020
3021 #[test]
3022 fn templates_resource_scans_drift() {
3023 ok(
3024 "CreateGeneratedTemplate",
3025 &[("GeneratedTemplateName", "gt")],
3026 );
3027 ok(
3028 "UpdateGeneratedTemplate",
3029 &[("GeneratedTemplateName", "gt")],
3030 );
3031 ok(
3032 "DescribeGeneratedTemplate",
3033 &[("GeneratedTemplateName", "gt")],
3034 );
3035 ok("GetGeneratedTemplate", &[("GeneratedTemplateName", "gt")]);
3036 ok("ListGeneratedTemplates", &[]);
3037 ok(
3038 "DeleteGeneratedTemplate",
3039 &[("GeneratedTemplateName", "gt")],
3040 );
3041 ok("StartResourceScan", &[]);
3042 ok("DescribeResourceScan", &[("ResourceScanId", "rs")]);
3043 ok("ListResourceScans", &[]);
3044 ok("ListResourceScanResources", &[("ResourceScanId", "rs")]);
3045 ok(
3046 "ListResourceScanRelatedResources",
3047 &[
3048 ("ResourceScanId", "rs"),
3049 ("Resources.member.1.ResourceType", "AWS::SQS::Queue"),
3050 ],
3051 );
3052 ok("DetectStackDrift", &[("StackName", "s")]);
3053 ok(
3054 "DetectStackResourceDrift",
3055 &[("StackName", "s"), ("LogicalResourceId", "L")],
3056 );
3057 ok("DetectStackSetDrift", &[("StackSetName", "ss")]);
3058 ok(
3059 "DescribeStackDriftDetectionStatus",
3060 &[("StackDriftDetectionId", "id")],
3061 );
3062 ok("DescribeStackResourceDrifts", &[("StackName", "s")]);
3063 ok(
3064 "DescribeStackResource",
3065 &[("StackName", "s"), ("LogicalResourceId", "L")],
3066 );
3067 }
3068
3069 #[test]
3070 fn events_hooks_imports_policies_org() {
3071 ok("DescribeStackEvents", &[("StackName", "s")]);
3072 ok("DescribeEvents", &[]);
3073 ok("ListHookResults", &[]);
3078 ok(
3079 "RecordHandlerProgress",
3080 &[("BearerToken", "tok"), ("OperationStatus", "SUCCESS")],
3081 );
3082 ok("ListExports", &[]);
3083 ok("ListImports", &[("ExportName", "SomeExport")]);
3084 ok("GetStackPolicy", &[("StackName", "s")]);
3085 ok("SetStackPolicy", &[("StackName", "s")]);
3086 ok(
3087 "UpdateTerminationProtection",
3088 &[("StackName", "s"), ("EnableTerminationProtection", "false")],
3089 );
3090 ok("DescribeAccountLimits", &[]);
3091 ok("ActivateOrganizationsAccess", &[]);
3092 ok("DescribeOrganizationsAccess", &[]);
3093 ok("DeactivateOrganizationsAccess", &[]);
3094 ok("ValidateTemplate", &[]);
3095 ok("EstimateTemplateCost", &[]);
3096 ok("GetTemplateSummary", &[]);
3097 ok("CancelUpdateStack", &[("StackName", "s")]);
3098 ok("ContinueUpdateRollback", &[("StackName", "s")]);
3099 ok("RollbackStack", &[("StackName", "s")]);
3100 ok(
3101 "SignalResource",
3102 &[
3103 ("StackName", "s"),
3104 ("LogicalResourceId", "L"),
3105 ("UniqueId", "U"),
3106 ("Status", "SUCCESS"),
3107 ],
3108 );
3109 }
3110}