1use async_trait::async_trait;
2use chrono::Utc;
3use http::StatusCode;
4use std::collections::HashMap;
5
6use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
7use fakecloud_dynamodb::state::SharedDynamoDbState;
8use fakecloud_eventbridge::state::SharedEventBridgeState;
9use fakecloud_iam::state::SharedIamState;
10use fakecloud_logs::state::SharedLogsState;
11use fakecloud_s3::state::SharedS3State;
12use fakecloud_sns::state::SharedSnsState;
13use fakecloud_sqs::state::SharedSqsState;
14use fakecloud_ssm::state::SharedSsmState;
15
16use crate::resource_provisioner::ResourceProvisioner;
17use crate::state::{SharedCloudFormationState, Stack};
18use crate::template;
19use crate::xml_responses;
20
21pub struct CloudFormationService {
22 state: SharedCloudFormationState,
23 sqs_state: SharedSqsState,
24 sns_state: SharedSnsState,
25 ssm_state: SharedSsmState,
26 iam_state: SharedIamState,
27 s3_state: SharedS3State,
28 eventbridge_state: SharedEventBridgeState,
29 dynamodb_state: SharedDynamoDbState,
30 logs_state: SharedLogsState,
31}
32
33impl CloudFormationService {
34 #[allow(clippy::too_many_arguments)]
35 pub fn new(
36 state: SharedCloudFormationState,
37 sqs_state: SharedSqsState,
38 sns_state: SharedSnsState,
39 ssm_state: SharedSsmState,
40 iam_state: SharedIamState,
41 s3_state: SharedS3State,
42 eventbridge_state: SharedEventBridgeState,
43 dynamodb_state: SharedDynamoDbState,
44 logs_state: SharedLogsState,
45 ) -> Self {
46 Self {
47 state,
48 sqs_state,
49 sns_state,
50 ssm_state,
51 iam_state,
52 s3_state,
53 eventbridge_state,
54 dynamodb_state,
55 logs_state,
56 }
57 }
58
59 fn provisioner(&self) -> ResourceProvisioner {
60 let cf_state = self.state.read();
61 ResourceProvisioner {
62 sqs_state: self.sqs_state.clone(),
63 sns_state: self.sns_state.clone(),
64 ssm_state: self.ssm_state.clone(),
65 iam_state: self.iam_state.clone(),
66 s3_state: self.s3_state.clone(),
67 eventbridge_state: self.eventbridge_state.clone(),
68 dynamodb_state: self.dynamodb_state.clone(),
69 logs_state: self.logs_state.clone(),
70 account_id: cf_state.account_id.clone(),
71 region: cf_state.region.clone(),
72 }
73 }
74
75 fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
76 if let Some(v) = req.query_params.get(key) {
78 return Some(v.clone());
79 }
80 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
82 body_params.get(key).cloned()
83 }
84
85 fn get_all_params(req: &AwsRequest) -> HashMap<String, String> {
86 let mut params = req.query_params.clone();
87 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
88 for (k, v) in body_params {
89 params.entry(k).or_insert(v);
90 }
91 params
92 }
93
94 fn extract_tags(params: &HashMap<String, String>) -> HashMap<String, String> {
95 let mut tags = HashMap::new();
96 for i in 1.. {
97 let key_param = format!("Tags.member.{i}.Key");
98 let value_param = format!("Tags.member.{i}.Value");
99 match (params.get(&key_param), params.get(&value_param)) {
100 (Some(k), Some(v)) => {
101 tags.insert(k.clone(), v.clone());
102 }
103 _ => break,
104 }
105 }
106 tags
107 }
108
109 fn extract_parameters(params: &HashMap<String, String>) -> HashMap<String, String> {
110 let mut result = HashMap::new();
111 for i in 1.. {
112 let key_param = format!("Parameters.member.{i}.ParameterKey");
113 let value_param = format!("Parameters.member.{i}.ParameterValue");
114 match (params.get(&key_param), params.get(&value_param)) {
115 (Some(k), Some(v)) => {
116 result.insert(k.clone(), v.clone());
117 }
118 _ => break,
119 }
120 }
121 result
122 }
123
124 fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
125 let params = Self::get_all_params(req);
126
127 let stack_name = params.get("StackName").ok_or_else(|| {
128 AwsServiceError::aws_error(
129 StatusCode::BAD_REQUEST,
130 "ValidationError",
131 "StackName is required",
132 )
133 })?;
134
135 let template_body = params.get("TemplateBody").ok_or_else(|| {
136 AwsServiceError::aws_error(
137 StatusCode::BAD_REQUEST,
138 "ValidationError",
139 "TemplateBody is required",
140 )
141 })?;
142
143 {
145 let state = self.state.read();
146 if let Some(existing) = state.stacks.get(stack_name.as_str()) {
147 if existing.status != "DELETE_COMPLETE" {
148 return Err(AwsServiceError::aws_error(
149 StatusCode::BAD_REQUEST,
150 "AlreadyExistsException",
151 format!("Stack [{stack_name}] already exists"),
152 ));
153 }
154 }
155 }
156
157 let tags = Self::extract_tags(¶ms);
158 let parameters = Self::extract_parameters(¶ms);
159
160 let parsed = template::parse_template(template_body, ¶meters).map_err(|e| {
162 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
163 })?;
164
165 let provisioner = self.provisioner();
166 let mut resources = Vec::new();
167 let mut physical_ids: HashMap<String, String> = HashMap::new();
168
169 let mut pending: Vec<&template::ResourceDefinition> = parsed.resources.iter().collect();
173 let max_passes = pending.len() + 1;
174 for _ in 0..max_passes {
175 if pending.is_empty() {
176 break;
177 }
178 let mut still_pending = Vec::new();
179 let mut made_progress = false;
180
181 for resource_def in pending {
182 let resolved_def = template::resolve_resource_properties(
183 resource_def,
184 template_body,
185 ¶meters,
186 &physical_ids,
187 )
188 .map_err(|e| {
189 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
190 })?;
191
192 match provisioner.create_resource(&resolved_def) {
193 Ok(stack_resource) => {
194 physical_ids.insert(
195 stack_resource.logical_id.clone(),
196 stack_resource.physical_id.clone(),
197 );
198 resources.push(stack_resource);
199 made_progress = true;
200 }
201 Err(_) => {
202 still_pending.push(resource_def);
203 }
204 }
205 }
206
207 pending = still_pending;
208 if !made_progress && !pending.is_empty() {
209 let resource_def = pending[0];
211 let resolved_def = template::resolve_resource_properties(
212 resource_def,
213 template_body,
214 ¶meters,
215 &physical_ids,
216 )
217 .unwrap_or_else(|_| resource_def.clone());
218 let err = provisioner.create_resource(&resolved_def).unwrap_err();
219 for r in &resources {
221 let _ = provisioner.delete_resource(r);
222 }
223 return Err(AwsServiceError::aws_error(
224 StatusCode::BAD_REQUEST,
225 "ValidationError",
226 format!(
227 "Failed to create resource {}: {err}",
228 resource_def.logical_id
229 ),
230 ));
231 }
232 }
233
234 let stack_id = {
235 let state = self.state.read();
236 format!(
237 "arn:aws:cloudformation:{}:{}:stack/{}/{}",
238 state.region,
239 state.account_id,
240 stack_name,
241 uuid::Uuid::new_v4()
242 )
243 };
244
245 let stack = Stack {
246 name: stack_name.clone(),
247 stack_id: stack_id.clone(),
248 template: template_body.clone(),
249 status: "CREATE_COMPLETE".to_string(),
250 resources,
251 parameters,
252 tags,
253 created_at: Utc::now(),
254 updated_at: None,
255 description: parsed.description,
256 };
257
258 {
259 let mut state = self.state.write();
260 state.stacks.insert(stack_name.clone(), stack);
261 }
262
263 Ok(AwsResponse::xml(
264 StatusCode::OK,
265 xml_responses::create_stack_response(&stack_id, &req.request_id),
266 ))
267 }
268
269 fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
270 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
271 AwsServiceError::aws_error(
272 StatusCode::BAD_REQUEST,
273 "ValidationError",
274 "StackName is required",
275 )
276 })?;
277
278 let provisioner = self.provisioner();
279
280 let mut state = self.state.write();
281
282 let stack = state.stacks.values_mut().find(|s| {
284 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
285 });
286
287 if let Some(stack) = stack {
288 let resources: Vec<_> = stack.resources.clone();
290 for resource in resources.iter().rev() {
291 let _ = provisioner.delete_resource(resource);
292 }
293 stack.status = "DELETE_COMPLETE".to_string();
294 stack.resources.clear();
295 }
296
297 Ok(AwsResponse::xml(
298 StatusCode::OK,
299 xml_responses::delete_stack_response(&req.request_id),
300 ))
301 }
302
303 fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
304 let stack_name = Self::get_param(req, "StackName");
305
306 let state = self.state.read();
307 let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
308 state
309 .stacks
310 .values()
311 .filter(|s| {
312 (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
313 })
314 .cloned()
315 .collect()
316 } else {
317 state
318 .stacks
319 .values()
320 .filter(|s| s.status != "DELETE_COMPLETE")
321 .cloned()
322 .collect()
323 };
324
325 if let Some(ref name) = stack_name {
326 if stacks.is_empty() {
327 return Err(AwsServiceError::aws_error(
328 StatusCode::BAD_REQUEST,
329 "ValidationError",
330 format!("Stack with id {name} does not exist"),
331 ));
332 }
333 }
334
335 Ok(AwsResponse::xml(
336 StatusCode::OK,
337 xml_responses::describe_stacks_response(&stacks, &req.request_id),
338 ))
339 }
340
341 fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
342 let state = self.state.read();
343 let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
344
345 Ok(AwsResponse::xml(
346 StatusCode::OK,
347 xml_responses::list_stacks_response(&stacks, &req.request_id),
348 ))
349 }
350
351 fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
352 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
353 AwsServiceError::aws_error(
354 StatusCode::BAD_REQUEST,
355 "ValidationError",
356 "StackName is required",
357 )
358 })?;
359
360 let state = self.state.read();
361 let stack = state
362 .stacks
363 .values()
364 .find(|s| {
365 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
366 })
367 .ok_or_else(|| {
368 AwsServiceError::aws_error(
369 StatusCode::BAD_REQUEST,
370 "ValidationError",
371 format!("Stack [{stack_name}] does not exist"),
372 )
373 })?;
374
375 Ok(AwsResponse::xml(
376 StatusCode::OK,
377 xml_responses::list_stack_resources_response(&stack.resources, &req.request_id),
378 ))
379 }
380
381 fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
382 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
383 AwsServiceError::aws_error(
384 StatusCode::BAD_REQUEST,
385 "ValidationError",
386 "StackName is required",
387 )
388 })?;
389
390 let state = self.state.read();
391 let stack = state
392 .stacks
393 .values()
394 .find(|s| {
395 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
396 })
397 .ok_or_else(|| {
398 AwsServiceError::aws_error(
399 StatusCode::BAD_REQUEST,
400 "ValidationError",
401 format!("Stack [{stack_name}] does not exist"),
402 )
403 })?;
404
405 Ok(AwsResponse::xml(
406 StatusCode::OK,
407 xml_responses::describe_stack_resources_response(
408 &stack.resources,
409 &stack.name,
410 &req.request_id,
411 ),
412 ))
413 }
414
415 fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
416 let params = Self::get_all_params(req);
417
418 let stack_name = params.get("StackName").ok_or_else(|| {
419 AwsServiceError::aws_error(
420 StatusCode::BAD_REQUEST,
421 "ValidationError",
422 "StackName is required",
423 )
424 })?;
425
426 let template_body = params.get("TemplateBody").ok_or_else(|| {
427 AwsServiceError::aws_error(
428 StatusCode::BAD_REQUEST,
429 "ValidationError",
430 "TemplateBody is required",
431 )
432 })?;
433
434 let new_parameters = Self::extract_parameters(¶ms);
435 let new_tags = Self::extract_tags(¶ms);
436
437 let parsed = template::parse_template(template_body, &new_parameters).map_err(|e| {
438 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
439 })?;
440
441 let provisioner = self.provisioner();
442
443 let mut state = self.state.write();
444 let stack = state
445 .stacks
446 .values_mut()
447 .find(|s| {
448 (s.name == *stack_name || s.stack_id == *stack_name)
449 && s.status != "DELETE_COMPLETE"
450 })
451 .ok_or_else(|| {
452 AwsServiceError::aws_error(
453 StatusCode::BAD_REQUEST,
454 "ValidationError",
455 format!("Stack [{stack_name}] does not exist"),
456 )
457 })?;
458
459 let old_logical_ids: std::collections::HashSet<String> = stack
461 .resources
462 .iter()
463 .map(|r| r.logical_id.clone())
464 .collect();
465 let new_logical_ids: std::collections::HashSet<String> = parsed
466 .resources
467 .iter()
468 .map(|r| r.logical_id.clone())
469 .collect();
470
471 let to_remove: Vec<_> = stack
473 .resources
474 .iter()
475 .filter(|r| !new_logical_ids.contains(&r.logical_id))
476 .cloned()
477 .collect();
478 for resource in &to_remove {
479 let _ = provisioner.delete_resource(resource);
480 }
481 stack
482 .resources
483 .retain(|r| new_logical_ids.contains(&r.logical_id));
484
485 let mut physical_ids: HashMap<String, String> = stack
487 .resources
488 .iter()
489 .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
490 .collect();
491
492 let mut update_failed = false;
494 let mut update_error_msg = String::new();
495 for resource_def in &parsed.resources {
496 if !old_logical_ids.contains(&resource_def.logical_id) {
497 let resolved_def = match template::resolve_resource_properties(
498 resource_def,
499 template_body,
500 &new_parameters,
501 &physical_ids,
502 ) {
503 Ok(d) => d,
504 Err(e) => {
505 update_failed = true;
506 update_error_msg = format!(
507 "Failed to resolve resource {}: {e}",
508 resource_def.logical_id
509 );
510 continue;
511 }
512 };
513 match provisioner.create_resource(&resolved_def) {
514 Ok(stack_resource) => {
515 physical_ids.insert(
516 stack_resource.logical_id.clone(),
517 stack_resource.physical_id.clone(),
518 );
519 stack.resources.push(stack_resource);
520 }
521 Err(e) => {
522 tracing::warn!(
523 "Failed to create resource {} during update: {e}",
524 resource_def.logical_id
525 );
526 update_failed = true;
527 update_error_msg =
528 format!("Failed to create resource {}: {e}", resource_def.logical_id);
529 }
530 }
531 }
532 }
533
534 let stack_id = stack.stack_id.clone();
535 stack.template = template_body.clone();
536 stack.status = if update_failed {
537 "UPDATE_FAILED".to_string()
538 } else {
539 "UPDATE_COMPLETE".to_string()
540 };
541 stack.parameters = new_parameters;
542 if !new_tags.is_empty() {
543 stack.tags = new_tags;
544 }
545 stack.updated_at = Some(Utc::now());
546 stack.description = parsed.description;
547
548 if update_failed {
549 return Err(AwsServiceError::aws_error(
550 StatusCode::BAD_REQUEST,
551 "ValidationError",
552 update_error_msg,
553 ));
554 }
555
556 Ok(AwsResponse::xml(
557 StatusCode::OK,
558 xml_responses::update_stack_response(&stack_id, &req.request_id),
559 ))
560 }
561
562 fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
563 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
564 AwsServiceError::aws_error(
565 StatusCode::BAD_REQUEST,
566 "ValidationError",
567 "StackName is required",
568 )
569 })?;
570
571 let state = self.state.read();
572 let stack = state
573 .stacks
574 .values()
575 .find(|s| {
576 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
577 })
578 .ok_or_else(|| {
579 AwsServiceError::aws_error(
580 StatusCode::BAD_REQUEST,
581 "ValidationError",
582 format!("Stack [{stack_name}] does not exist"),
583 )
584 })?;
585
586 Ok(AwsResponse::xml(
587 StatusCode::OK,
588 xml_responses::get_template_response(&stack.template, &req.request_id),
589 ))
590 }
591}
592
593#[async_trait]
594impl AwsService for CloudFormationService {
595 fn service_name(&self) -> &str {
596 "cloudformation"
597 }
598
599 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
600 match req.action.as_str() {
601 "CreateStack" => self.create_stack(&req),
602 "DeleteStack" => self.delete_stack(&req),
603 "DescribeStacks" => self.describe_stacks(&req),
604 "ListStacks" => self.list_stacks(&req),
605 "ListStackResources" => self.list_stack_resources(&req),
606 "DescribeStackResources" => self.describe_stack_resources(&req),
607 "UpdateStack" => self.update_stack(&req),
608 "GetTemplate" => self.get_template(&req),
609 _ => Err(AwsServiceError::action_not_implemented(
610 "cloudformation",
611 &req.action,
612 )),
613 }
614 }
615
616 fn supported_actions(&self) -> &[&str] {
617 &[
618 "CreateStack",
619 "DeleteStack",
620 "DescribeStacks",
621 "ListStacks",
622 "ListStackResources",
623 "DescribeStackResources",
624 "UpdateStack",
625 "GetTemplate",
626 ]
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use crate::state::CloudFormationState;
634 use http::HeaderMap;
635 use parking_lot::RwLock;
636 use std::sync::Arc;
637
638 fn make_service() -> CloudFormationService {
639 let cf_state = Arc::new(RwLock::new(CloudFormationState::new(
640 "123456789012",
641 "us-east-1",
642 )));
643 CloudFormationService::new(
644 cf_state,
645 Arc::new(RwLock::new(fakecloud_sqs::state::SqsState::new(
646 "123456789012",
647 "us-east-1",
648 "http://localhost:4566",
649 ))),
650 Arc::new(RwLock::new(fakecloud_sns::state::SnsState::new(
651 "123456789012",
652 "us-east-1",
653 ))),
654 Arc::new(RwLock::new(fakecloud_ssm::state::SsmState::new(
655 "123456789012",
656 "us-east-1",
657 ))),
658 Arc::new(RwLock::new(fakecloud_iam::state::IamState::new(
659 "123456789012",
660 ))),
661 Arc::new(RwLock::new(fakecloud_s3::state::S3State::new(
662 "123456789012",
663 "us-east-1",
664 ))),
665 Arc::new(RwLock::new(
666 fakecloud_eventbridge::state::EventBridgeState::new("123456789012", "us-east-1"),
667 )),
668 Arc::new(RwLock::new(fakecloud_dynamodb::state::DynamoDbState::new(
669 "123456789012",
670 "us-east-1",
671 ))),
672 Arc::new(RwLock::new(fakecloud_logs::state::LogsState::new(
673 "123456789012",
674 "us-east-1",
675 ))),
676 )
677 }
678
679 fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
680 AwsRequest {
681 service: "cloudformation".to_string(),
682 action: action.to_string(),
683 region: "us-east-1".to_string(),
684 account_id: "123456789012".to_string(),
685 request_id: "test-request-id".to_string(),
686 headers: HeaderMap::new(),
687 query_params: params,
688 body: bytes::Bytes::new(),
689 path_segments: vec![],
690 raw_path: "/".to_string(),
691 method: http::Method::POST,
692 is_query_protocol: true,
693 access_key_id: None,
694 }
695 }
696
697 #[test]
698 fn update_stack_sets_failed_status_on_resource_error() {
699 let svc = make_service();
700
701 let mut create_params = HashMap::new();
703 create_params.insert("StackName".to_string(), "test-stack".to_string());
704 create_params.insert(
705 "TemplateBody".to_string(),
706 r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
707 );
708 let req = make_request("CreateStack", create_params);
709 let result = svc.create_stack(&req);
710 assert!(result.is_ok());
711
712 let mut update_params = HashMap::new();
714 update_params.insert("StackName".to_string(), "test-stack".to_string());
715 update_params.insert(
716 "TemplateBody".to_string(),
717 r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}},"BadSub":{"Type":"AWS::SNS::Subscription","Properties":{"TopicArn":"arn:aws:sns:us-east-1:123456789012:nope","Protocol":"sqs","Endpoint":"arn:aws:sqs:us-east-1:123456789012:q1"}}}}"#.to_string(),
718 );
719 let req = make_request("UpdateStack", update_params);
720 let result = svc.update_stack(&req);
721
722 assert!(result.is_err());
724
725 let state = svc.state.read();
727 let stack = state.stacks.get("test-stack").unwrap();
728 assert_eq!(stack.status, "UPDATE_FAILED");
729 }
730
731 #[test]
732 fn create_stack_resolves_ref_to_physical_id() {
733 let svc = make_service();
734
735 let template = r#"{
737 "Resources": {
738 "MyTopic": {
739 "Type": "AWS::SNS::Topic",
740 "Properties": { "TopicName": "ref-test-topic" }
741 },
742 "MySub": {
743 "Type": "AWS::SNS::Subscription",
744 "Properties": {
745 "TopicArn": { "Ref": "MyTopic" },
746 "Protocol": "sqs",
747 "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
748 }
749 }
750 }
751 }"#;
752
753 let mut params = HashMap::new();
754 params.insert("StackName".to_string(), "ref-stack".to_string());
755 params.insert("TemplateBody".to_string(), template.to_string());
756 let req = make_request("CreateStack", params);
757 let result = svc.create_stack(&req);
758 assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
759
760 let state = svc.state.read();
762 let stack = state.stacks.get("ref-stack").unwrap();
763 assert_eq!(stack.resources.len(), 2);
764 assert_eq!(stack.status, "CREATE_COMPLETE");
765
766 let sub = stack
768 .resources
769 .iter()
770 .find(|r| r.logical_id == "MySub")
771 .unwrap();
772 assert!(
773 sub.physical_id.contains("ref-test-topic"),
774 "Subscription physical ID should reference the topic ARN, got: {}",
775 sub.physical_id
776 );
777 }
778}