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