1use async_trait::async_trait;
2use chrono::Utc;
3use http::StatusCode;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use fakecloud_core::delivery::DeliveryBus;
8use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
9use fakecloud_dynamodb::state::SharedDynamoDbState;
10use fakecloud_eventbridge::state::SharedEventBridgeState;
11use fakecloud_iam::state::SharedIamState;
12use fakecloud_logs::state::SharedLogsState;
13use fakecloud_persistence::SnapshotStore;
14use fakecloud_s3::state::SharedS3State;
15use fakecloud_sns::state::SharedSnsState;
16use fakecloud_sqs::state::SharedSqsState;
17use fakecloud_ssm::state::SharedSsmState;
18use tokio::sync::Mutex as AsyncMutex;
19
20use crate::resource_provisioner::ResourceProvisioner;
21use crate::state::{
22 CloudFormationSnapshot, CloudFormationState, SharedCloudFormationState, Stack, StackResource,
23 CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
24};
25use crate::template;
26use crate::xml_responses;
27
28fn provision_stack_resources(
37 provisioner: &ResourceProvisioner,
38 resource_defs: &[template::ResourceDefinition],
39 template_body: &str,
40 parameters: &HashMap<String, String>,
41) -> Result<Vec<StackResource>, AwsServiceError> {
42 let mut resources = Vec::new();
43 let mut physical_ids: HashMap<String, String> = HashMap::new();
44 let mut pending: Vec<&template::ResourceDefinition> = resource_defs.iter().collect();
45 let max_passes = pending.len() + 1;
46
47 for _ in 0..max_passes {
48 if pending.is_empty() {
49 break;
50 }
51 let mut still_pending = Vec::new();
52 let mut made_progress = false;
53
54 for resource_def in pending {
55 let resolved_def = template::resolve_resource_properties(
56 resource_def,
57 template_body,
58 parameters,
59 &physical_ids,
60 )
61 .map_err(|e| {
62 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
63 })?;
64
65 match provisioner.create_resource(&resolved_def) {
66 Ok(stack_resource) => {
67 physical_ids.insert(
68 stack_resource.logical_id.clone(),
69 stack_resource.physical_id.clone(),
70 );
71 resources.push(stack_resource);
72 made_progress = true;
73 }
74 Err(_) => still_pending.push(resource_def),
75 }
76 }
77
78 pending = still_pending;
79 if !made_progress && !pending.is_empty() {
80 let resource_def = pending[0];
83 let resolved_def = template::resolve_resource_properties(
84 resource_def,
85 template_body,
86 parameters,
87 &physical_ids,
88 )
89 .unwrap_or_else(|_| resource_def.clone());
90 let err = provisioner.create_resource(&resolved_def).unwrap_err();
91 for r in &resources {
92 let _ = provisioner.delete_resource(r);
93 }
94 return Err(AwsServiceError::aws_error(
95 StatusCode::BAD_REQUEST,
96 "ValidationError",
97 format!(
98 "Failed to create resource {}: {err}",
99 resource_def.logical_id
100 ),
101 ));
102 }
103 }
104
105 Ok(resources)
106}
107
108pub struct CloudFormationDeps {
110 pub sqs: SharedSqsState,
111 pub sns: SharedSnsState,
112 pub ssm: SharedSsmState,
113 pub iam: SharedIamState,
114 pub s3: SharedS3State,
115 pub eventbridge: SharedEventBridgeState,
116 pub dynamodb: SharedDynamoDbState,
117 pub logs: SharedLogsState,
118 pub delivery: Arc<DeliveryBus>,
119}
120
121pub struct CloudFormationService {
122 state: SharedCloudFormationState,
123 deps: CloudFormationDeps,
124 snapshot_store: Option<Arc<dyn SnapshotStore>>,
125 snapshot_lock: Arc<AsyncMutex<()>>,
126}
127
128impl CloudFormationService {
129 pub fn new(state: SharedCloudFormationState, deps: CloudFormationDeps) -> Self {
130 Self {
131 state,
132 deps,
133 snapshot_store: None,
134 snapshot_lock: Arc::new(AsyncMutex::new(())),
135 }
136 }
137
138 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
139 self.snapshot_store = Some(store);
140 self
141 }
142
143 async fn save_snapshot(&self) {
144 let Some(store) = self.snapshot_store.clone() else {
145 return;
146 };
147 let _guard = self.snapshot_lock.lock().await;
148 let snapshot = CloudFormationSnapshot {
149 schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
150 state: None,
151 accounts: Some(self.state.read().clone()),
152 };
153 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
154 let bytes = serde_json::to_vec(&snapshot)
155 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
156 store.save(&bytes)
157 })
158 .await;
159 match join {
160 Ok(Ok(())) => {}
161 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
162 Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
163 }
164 }
165
166 fn provisioner(&self, stack_id: &str, account_id: &str, region: &str) -> ResourceProvisioner {
167 ResourceProvisioner {
168 sqs_state: self.deps.sqs.clone(),
169 sns_state: self.deps.sns.clone(),
170 ssm_state: self.deps.ssm.clone(),
171 iam_state: self.deps.iam.clone(),
172 s3_state: self.deps.s3.clone(),
173 eventbridge_state: self.deps.eventbridge.clone(),
174 dynamodb_state: self.deps.dynamodb.clone(),
175 logs_state: self.deps.logs.clone(),
176 delivery: self.deps.delivery.clone(),
177 account_id: account_id.to_string(),
178 region: region.to_string(),
179 stack_id: stack_id.to_string(),
180 }
181 }
182
183 fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
184 if let Some(v) = req.query_params.get(key) {
186 return Some(v.clone());
187 }
188 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
190 body_params.get(key).cloned()
191 }
192
193 fn get_all_params(req: &AwsRequest) -> HashMap<String, String> {
194 let mut params = req.query_params.clone();
195 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
196 for (k, v) in body_params {
197 params.entry(k).or_insert(v);
198 }
199 params
200 }
201
202 fn extract_tags(params: &HashMap<String, String>) -> HashMap<String, String> {
203 let mut tags = HashMap::new();
204 for i in 1.. {
205 let key_param = format!("Tags.member.{i}.Key");
206 let value_param = format!("Tags.member.{i}.Value");
207 match (params.get(&key_param), params.get(&value_param)) {
208 (Some(k), Some(v)) => {
209 tags.insert(k.clone(), v.clone());
210 }
211 _ => break,
212 }
213 }
214 tags
215 }
216
217 fn extract_parameters(params: &HashMap<String, String>) -> HashMap<String, String> {
218 let mut result = HashMap::new();
219 for i in 1.. {
220 let key_param = format!("Parameters.member.{i}.ParameterKey");
221 let value_param = format!("Parameters.member.{i}.ParameterValue");
222 match (params.get(&key_param), params.get(&value_param)) {
223 (Some(k), Some(v)) => {
224 result.insert(k.clone(), v.clone());
225 }
226 _ => break,
227 }
228 }
229 result
230 }
231
232 fn extract_notification_arns(params: &HashMap<String, String>) -> Vec<String> {
233 let mut arns = Vec::new();
234 for i in 1.. {
235 let key = format!("NotificationARNs.member.{i}");
236 match params.get(&key) {
237 Some(arn) => arns.push(arn.clone()),
238 None => break,
239 }
240 }
241 arns
242 }
243
244 fn send_stack_notification(
245 delivery: &DeliveryBus,
246 notification_arns: &[String],
247 stack_name: &str,
248 stack_id: &str,
249 status: &str,
250 ) {
251 if notification_arns.is_empty() {
252 return;
253 }
254 let message = format!(
255 "StackId='{}'\nTimestamp='{}'\nEventId='{}'\nLogicalResourceId='{}'\nResourceStatus='{}'\nResourceType='AWS::CloudFormation::Stack'\nStackName='{}'",
256 stack_id,
257 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
258 uuid::Uuid::new_v4(),
259 stack_name,
260 status,
261 stack_name,
262 );
263 for arn in notification_arns {
264 delivery.publish_to_sns(arn, &message, Some("AWS CloudFormation Notification"));
265 }
266 }
267
268 fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
269 let params = Self::get_all_params(req);
270
271 let stack_name = params.get("StackName").ok_or_else(|| {
272 AwsServiceError::aws_error(
273 StatusCode::BAD_REQUEST,
274 "ValidationError",
275 "StackName is required",
276 )
277 })?;
278
279 let template_body = params.get("TemplateBody").ok_or_else(|| {
280 AwsServiceError::aws_error(
281 StatusCode::BAD_REQUEST,
282 "ValidationError",
283 "TemplateBody is required",
284 )
285 })?;
286
287 {
289 let accounts = self.state.read();
290 let empty = CloudFormationState::new(&req.account_id, &req.region);
291 let state = accounts.get(&req.account_id).unwrap_or(&empty);
292 if let Some(existing) = state.stacks.get(stack_name.as_str()) {
293 if existing.status != "DELETE_COMPLETE" {
294 return Err(AwsServiceError::aws_error(
295 StatusCode::BAD_REQUEST,
296 "AlreadyExistsException",
297 format!("Stack [{stack_name}] already exists"),
298 ));
299 }
300 }
301 }
302
303 let tags = Self::extract_tags(¶ms);
304 let parameters = Self::extract_parameters(¶ms);
305 let notification_arns = Self::extract_notification_arns(¶ms);
306
307 let parsed = template::parse_template(template_body, ¶meters).map_err(|e| {
309 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
310 })?;
311
312 let stack_id = format!(
313 "arn:aws:cloudformation:{}:{}:stack/{}/{}",
314 req.region,
315 req.account_id,
316 stack_name,
317 uuid::Uuid::new_v4()
318 );
319
320 let provisioner = self.provisioner(&stack_id, &req.account_id, &req.region);
321 let resources =
322 provision_stack_resources(&provisioner, &parsed.resources, template_body, ¶meters)?;
323
324 let stack = Stack {
325 name: stack_name.clone(),
326 stack_id: stack_id.clone(),
327 template: template_body.clone(),
328 status: "CREATE_COMPLETE".to_string(),
329 resources,
330 parameters,
331 tags,
332 created_at: Utc::now(),
333 updated_at: None,
334 description: parsed.description,
335 notification_arns: notification_arns.clone(),
336 };
337
338 {
339 let mut accounts = self.state.write();
340 let state = accounts.get_or_create(&req.account_id);
341 state.stacks.insert(stack_name.clone(), stack);
342 }
343
344 Self::send_stack_notification(
345 &self.deps.delivery,
346 ¬ification_arns,
347 stack_name,
348 &stack_id,
349 "CREATE_COMPLETE",
350 );
351
352 Ok(AwsResponse::xml(
353 StatusCode::OK,
354 xml_responses::create_stack_response(&stack_id, &req.request_id),
355 ))
356 }
357
358 fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
359 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
360 AwsServiceError::aws_error(
361 StatusCode::BAD_REQUEST,
362 "ValidationError",
363 "StackName is required",
364 )
365 })?;
366
367 let mut accounts = self.state.write();
368 let state = accounts.get_or_create(&req.account_id);
369
370 let stack = state.stacks.values_mut().find(|s| {
372 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
373 });
374
375 if let Some(stack) = stack {
376 let stack_id = stack.stack_id.clone();
377 let stack_name_for_notif = stack.name.clone();
378 let notification_arns = stack.notification_arns.clone();
379 let resources: Vec<_> = stack.resources.clone();
380
381 drop(accounts);
384 let provisioner = self.provisioner(&stack_id, &req.account_id, &req.region);
385
386 for resource in resources.iter().rev() {
388 let _ = provisioner.delete_resource(resource);
389 }
390
391 let mut accounts = self.state.write();
393 let state = accounts.get_or_create(&req.account_id);
394 if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
395 stack.status = "DELETE_COMPLETE".to_string();
396 stack.resources.clear();
397 }
398 drop(accounts);
399
400 Self::send_stack_notification(
401 &self.deps.delivery,
402 ¬ification_arns,
403 &stack_name_for_notif,
404 &stack_id,
405 "DELETE_COMPLETE",
406 );
407 }
408
409 Ok(AwsResponse::xml(
410 StatusCode::OK,
411 xml_responses::delete_stack_response(&req.request_id),
412 ))
413 }
414
415 fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
416 let stack_name = Self::get_param(req, "StackName");
417
418 let accounts = self.state.read();
419 let empty = CloudFormationState::new(&req.account_id, &req.region);
420 let state = accounts.get(&req.account_id).unwrap_or(&empty);
421 let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
422 state
423 .stacks
424 .values()
425 .filter(|s| {
426 (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
427 })
428 .cloned()
429 .collect()
430 } else {
431 state
432 .stacks
433 .values()
434 .filter(|s| s.status != "DELETE_COMPLETE")
435 .cloned()
436 .collect()
437 };
438
439 if let Some(ref name) = stack_name {
440 if stacks.is_empty() {
441 return Err(AwsServiceError::aws_error(
442 StatusCode::BAD_REQUEST,
443 "ValidationError",
444 format!("Stack with id {name} does not exist"),
445 ));
446 }
447 }
448
449 Ok(AwsResponse::xml(
450 StatusCode::OK,
451 xml_responses::describe_stacks_response(&stacks, &req.request_id),
452 ))
453 }
454
455 fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
456 let accounts = self.state.read();
457 let empty = CloudFormationState::new(&req.account_id, &req.region);
458 let state = accounts.get(&req.account_id).unwrap_or(&empty);
459 let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
460
461 Ok(AwsResponse::xml(
462 StatusCode::OK,
463 xml_responses::list_stacks_response(&stacks, &req.request_id),
464 ))
465 }
466
467 fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
468 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
469 AwsServiceError::aws_error(
470 StatusCode::BAD_REQUEST,
471 "ValidationError",
472 "StackName is required",
473 )
474 })?;
475
476 let accounts = self.state.read();
477 let empty = CloudFormationState::new(&req.account_id, &req.region);
478 let state = accounts.get(&req.account_id).unwrap_or(&empty);
479 let stack = state
480 .stacks
481 .values()
482 .find(|s| {
483 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
484 })
485 .ok_or_else(|| {
486 AwsServiceError::aws_error(
487 StatusCode::BAD_REQUEST,
488 "ValidationError",
489 format!("Stack [{stack_name}] does not exist"),
490 )
491 })?;
492
493 Ok(AwsResponse::xml(
494 StatusCode::OK,
495 xml_responses::list_stack_resources_response(&stack.resources, &req.request_id),
496 ))
497 }
498
499 fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
500 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
501 AwsServiceError::aws_error(
502 StatusCode::BAD_REQUEST,
503 "ValidationError",
504 "StackName is required",
505 )
506 })?;
507
508 let accounts = self.state.read();
509 let empty = CloudFormationState::new(&req.account_id, &req.region);
510 let state = accounts.get(&req.account_id).unwrap_or(&empty);
511 let stack = state
512 .stacks
513 .values()
514 .find(|s| {
515 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
516 })
517 .ok_or_else(|| {
518 AwsServiceError::aws_error(
519 StatusCode::BAD_REQUEST,
520 "ValidationError",
521 format!("Stack [{stack_name}] does not exist"),
522 )
523 })?;
524
525 Ok(AwsResponse::xml(
526 StatusCode::OK,
527 xml_responses::describe_stack_resources_response(
528 &stack.resources,
529 &stack.name,
530 &req.request_id,
531 ),
532 ))
533 }
534
535 fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
536 let input = UpdateStackInput::from_params(req)?;
537 let parsed =
538 template::parse_template(&input.template_body, &input.parameters).map_err(|e| {
539 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
540 })?;
541
542 let found_stack_id = {
544 let accounts = self.state.read();
545 let empty = CloudFormationState::new(&req.account_id, &req.region);
546 let state = accounts.get(&req.account_id).unwrap_or(&empty);
547 state
548 .stacks
549 .values()
550 .find(|s| {
551 (s.name == input.stack_name || s.stack_id == input.stack_name)
552 && s.status != "DELETE_COMPLETE"
553 })
554 .map(|s| s.stack_id.clone())
555 .unwrap_or_default()
556 };
557
558 let provisioner = self.provisioner(&found_stack_id, &req.account_id, &req.region);
559
560 let mut accounts = self.state.write();
561 let state = accounts.get_or_create(&req.account_id);
562 let stack = state
563 .stacks
564 .values_mut()
565 .find(|s| {
566 (s.name == input.stack_name || s.stack_id == input.stack_name)
567 && s.status != "DELETE_COMPLETE"
568 })
569 .ok_or_else(|| {
570 AwsServiceError::aws_error(
571 StatusCode::BAD_REQUEST,
572 "ValidationError",
573 format!("Stack [{}] does not exist", input.stack_name),
574 )
575 })?;
576
577 let update_result = apply_resource_updates(
578 stack,
579 &parsed.resources,
580 &input.template_body,
581 &input.parameters,
582 &provisioner,
583 );
584
585 let stack_id = stack.stack_id.clone();
586 stack.template = input.template_body.clone();
587 stack.status = if update_result.is_err() {
588 "UPDATE_FAILED".to_string()
589 } else {
590 "UPDATE_COMPLETE".to_string()
591 };
592 stack.parameters = input.parameters;
593 if !input.tags.is_empty() {
594 stack.tags = input.tags;
595 }
596 stack.updated_at = Some(Utc::now());
597 stack.description = parsed.description;
598 if !input.notification_arns.is_empty() {
599 stack.notification_arns = input.notification_arns.clone();
600 }
601 let notification_arns = stack.notification_arns.clone();
602 let stack_name_for_notif = stack.name.clone();
603
604 if let Err(error_msg) = update_result {
605 drop(accounts);
606 Self::send_stack_notification(
607 &self.deps.delivery,
608 ¬ification_arns,
609 &stack_name_for_notif,
610 &stack_id,
611 "UPDATE_FAILED",
612 );
613 return Err(AwsServiceError::aws_error(
614 StatusCode::BAD_REQUEST,
615 "ValidationError",
616 error_msg,
617 ));
618 }
619
620 drop(accounts);
621 Self::send_stack_notification(
622 &self.deps.delivery,
623 ¬ification_arns,
624 &stack_name_for_notif,
625 &stack_id,
626 "UPDATE_COMPLETE",
627 );
628
629 Ok(AwsResponse::xml(
630 StatusCode::OK,
631 xml_responses::update_stack_response(&stack_id, &req.request_id),
632 ))
633 }
634
635 fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
636 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
637 AwsServiceError::aws_error(
638 StatusCode::BAD_REQUEST,
639 "ValidationError",
640 "StackName is required",
641 )
642 })?;
643
644 let accounts = self.state.read();
645 let empty = CloudFormationState::new(&req.account_id, &req.region);
646 let state = accounts.get(&req.account_id).unwrap_or(&empty);
647 let stack = state
648 .stacks
649 .values()
650 .find(|s| {
651 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
652 })
653 .ok_or_else(|| {
654 AwsServiceError::aws_error(
655 StatusCode::BAD_REQUEST,
656 "ValidationError",
657 format!("Stack [{stack_name}] does not exist"),
658 )
659 })?;
660
661 Ok(AwsResponse::xml(
662 StatusCode::OK,
663 xml_responses::get_template_response(&stack.template, &req.request_id),
664 ))
665 }
666}
667
668#[async_trait]
669impl AwsService for CloudFormationService {
670 fn service_name(&self) -> &str {
671 "cloudformation"
672 }
673
674 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
675 let mutates = matches!(
676 req.action.as_str(),
677 "CreateStack" | "DeleteStack" | "UpdateStack"
678 );
679 let result = match req.action.as_str() {
680 "CreateStack" => self.create_stack(&req),
681 "DeleteStack" => self.delete_stack(&req),
682 "DescribeStacks" => self.describe_stacks(&req),
683 "ListStacks" => self.list_stacks(&req),
684 "ListStackResources" => self.list_stack_resources(&req),
685 "DescribeStackResources" => self.describe_stack_resources(&req),
686 "UpdateStack" => self.update_stack(&req),
687 "GetTemplate" => self.get_template(&req),
688 _ => Err(AwsServiceError::action_not_implemented(
689 "cloudformation",
690 &req.action,
691 )),
692 };
693 if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
694 self.save_snapshot().await;
695 }
696 result
697 }
698
699 fn supported_actions(&self) -> &[&str] {
700 &[
701 "CreateStack",
702 "DeleteStack",
703 "DescribeStacks",
704 "ListStacks",
705 "ListStackResources",
706 "DescribeStackResources",
707 "UpdateStack",
708 "GetTemplate",
709 ]
710 }
711}
712
713struct UpdateStackInput {
715 stack_name: String,
716 template_body: String,
717 parameters: HashMap<String, String>,
718 tags: HashMap<String, String>,
719 notification_arns: Vec<String>,
720}
721
722impl UpdateStackInput {
723 fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
724 let params = CloudFormationService::get_all_params(req);
725
726 let stack_name = params
727 .get("StackName")
728 .ok_or_else(|| {
729 AwsServiceError::aws_error(
730 StatusCode::BAD_REQUEST,
731 "ValidationError",
732 "StackName is required",
733 )
734 })?
735 .to_string();
736
737 let template_body = params
738 .get("TemplateBody")
739 .ok_or_else(|| {
740 AwsServiceError::aws_error(
741 StatusCode::BAD_REQUEST,
742 "ValidationError",
743 "TemplateBody is required",
744 )
745 })?
746 .to_string();
747
748 Ok(Self {
749 stack_name,
750 template_body,
751 parameters: CloudFormationService::extract_parameters(¶ms),
752 tags: CloudFormationService::extract_tags(¶ms),
753 notification_arns: CloudFormationService::extract_notification_arns(¶ms),
754 })
755 }
756}
757
758fn apply_resource_updates(
761 stack: &mut crate::state::Stack,
762 new_resource_defs: &[template::ResourceDefinition],
763 template_body: &str,
764 parameters: &HashMap<String, String>,
765 provisioner: &crate::resource_provisioner::ResourceProvisioner,
766) -> Result<(), String> {
767 let old_logical_ids: std::collections::HashSet<String> = stack
768 .resources
769 .iter()
770 .map(|r| r.logical_id.clone())
771 .collect();
772 let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
773 .iter()
774 .map(|r| r.logical_id.clone())
775 .collect();
776
777 let to_remove: Vec<_> = stack
779 .resources
780 .iter()
781 .filter(|r| !new_logical_ids.contains(&r.logical_id))
782 .cloned()
783 .collect();
784 for resource in &to_remove {
785 let _ = provisioner.delete_resource(resource);
786 }
787 stack
788 .resources
789 .retain(|r| new_logical_ids.contains(&r.logical_id));
790
791 let mut physical_ids: HashMap<String, String> = stack
793 .resources
794 .iter()
795 .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
796 .collect();
797
798 for resource_def in new_resource_defs {
800 if !old_logical_ids.contains(&resource_def.logical_id) {
801 let resolved_def = template::resolve_resource_properties(
802 resource_def,
803 template_body,
804 parameters,
805 &physical_ids,
806 )
807 .map_err(|e| {
808 format!(
809 "Failed to resolve resource {}: {e}",
810 resource_def.logical_id
811 )
812 })?;
813
814 match provisioner.create_resource(&resolved_def) {
815 Ok(stack_resource) => {
816 physical_ids.insert(
817 stack_resource.logical_id.clone(),
818 stack_resource.physical_id.clone(),
819 );
820 stack.resources.push(stack_resource);
821 }
822 Err(e) => {
823 tracing::warn!(
824 "Failed to create resource {} during update: {e}",
825 resource_def.logical_id
826 );
827 return Err(format!(
828 "Failed to create resource {}: {e}",
829 resource_def.logical_id
830 ));
831 }
832 }
833 }
834 }
835
836 Ok(())
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842 use http::HeaderMap;
843 use parking_lot::RwLock;
844 use std::sync::Arc;
845
846 fn make_service() -> CloudFormationService {
847 let cf_state = Arc::new(RwLock::new(
848 fakecloud_core::multi_account::MultiAccountState::new(
849 "123456789012",
850 "us-east-1",
851 "http://localhost:4566",
852 ),
853 ));
854 let deps = CloudFormationDeps {
855 sqs: Arc::new(RwLock::new(
856 fakecloud_core::multi_account::MultiAccountState::new(
857 "123456789012",
858 "us-east-1",
859 "http://localhost:4566",
860 ),
861 )),
862 sns: Arc::new(RwLock::new(
863 fakecloud_core::multi_account::MultiAccountState::new(
864 "123456789012",
865 "us-east-1",
866 "http://localhost:4566",
867 ),
868 )),
869 ssm: Arc::new(RwLock::new(
870 fakecloud_core::multi_account::MultiAccountState::new(
871 "123456789012",
872 "us-east-1",
873 "http://localhost:4566",
874 ),
875 )),
876 iam: Arc::new(RwLock::new(
877 fakecloud_core::multi_account::MultiAccountState::new(
878 "123456789012",
879 "us-east-1",
880 "",
881 ),
882 )),
883 s3: Arc::new(RwLock::new(
884 fakecloud_core::multi_account::MultiAccountState::new(
885 "123456789012",
886 "us-east-1",
887 "",
888 ),
889 )),
890 eventbridge: Arc::new(RwLock::new(
891 fakecloud_core::multi_account::MultiAccountState::new(
892 "123456789012",
893 "us-east-1",
894 "",
895 ),
896 )),
897 dynamodb: Arc::new(RwLock::new(
898 fakecloud_core::multi_account::MultiAccountState::new(
899 "123456789012",
900 "us-east-1",
901 "",
902 ),
903 )),
904 logs: Arc::new(RwLock::new(
905 fakecloud_core::multi_account::MultiAccountState::new(
906 "123456789012",
907 "us-east-1",
908 "",
909 ),
910 )),
911 delivery: Arc::new(DeliveryBus::new()),
912 };
913 CloudFormationService::new(cf_state, deps)
914 }
915
916 fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
917 AwsRequest {
918 service: "cloudformation".to_string(),
919 action: action.to_string(),
920 region: "us-east-1".to_string(),
921 account_id: "123456789012".to_string(),
922 request_id: "test-request-id".to_string(),
923 headers: HeaderMap::new(),
924 query_params: params,
925 body: bytes::Bytes::new(),
926 path_segments: vec![],
927 raw_path: "/".to_string(),
928 raw_query: String::new(),
929 method: http::Method::POST,
930 is_query_protocol: true,
931 access_key_id: None,
932 principal: None,
933 }
934 }
935
936 #[test]
937 fn update_stack_sets_failed_status_on_resource_error() {
938 let svc = make_service();
939
940 let mut create_params = HashMap::new();
942 create_params.insert("StackName".to_string(), "test-stack".to_string());
943 create_params.insert(
944 "TemplateBody".to_string(),
945 r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
946 );
947 let req = make_request("CreateStack", create_params);
948 let result = svc.create_stack(&req);
949 assert!(result.is_ok());
950
951 let mut update_params = HashMap::new();
953 update_params.insert("StackName".to_string(), "test-stack".to_string());
954 update_params.insert(
955 "TemplateBody".to_string(),
956 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(),
957 );
958 let req = make_request("UpdateStack", update_params);
959 let result = svc.update_stack(&req);
960
961 assert!(result.is_err());
963
964 let accounts = svc.state.read();
966 let state = accounts.get("123456789012").unwrap();
967 let stack = state.stacks.get("test-stack").unwrap();
968 assert_eq!(stack.status, "UPDATE_FAILED");
969 }
970
971 #[test]
972 fn create_stack_resolves_ref_to_physical_id() {
973 let svc = make_service();
974
975 let template = r#"{
977 "Resources": {
978 "MyTopic": {
979 "Type": "AWS::SNS::Topic",
980 "Properties": { "TopicName": "ref-test-topic" }
981 },
982 "MySub": {
983 "Type": "AWS::SNS::Subscription",
984 "Properties": {
985 "TopicArn": { "Ref": "MyTopic" },
986 "Protocol": "sqs",
987 "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
988 }
989 }
990 }
991 }"#;
992
993 let mut params = HashMap::new();
994 params.insert("StackName".to_string(), "ref-stack".to_string());
995 params.insert("TemplateBody".to_string(), template.to_string());
996 let req = make_request("CreateStack", params);
997 let result = svc.create_stack(&req);
998 assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
999
1000 let accounts = svc.state.read();
1002 let state = accounts.get("123456789012").unwrap();
1003 let stack = state.stacks.get("ref-stack").unwrap();
1004 assert_eq!(stack.resources.len(), 2);
1005 assert_eq!(stack.status, "CREATE_COMPLETE");
1006
1007 let sub = stack
1009 .resources
1010 .iter()
1011 .find(|r| r.logical_id == "MySub")
1012 .unwrap();
1013 assert!(
1014 sub.physical_id.contains("ref-test-topic"),
1015 "Subscription physical ID should reference the topic ARN, got: {}",
1016 sub.physical_id
1017 );
1018 }
1019
1020 #[test]
1023 fn create_stack_missing_name_errors() {
1024 let svc = make_service();
1025 let mut params = HashMap::new();
1026 params.insert("TemplateBody".to_string(), "{}".to_string());
1027 let req = make_request("CreateStack", params);
1028 assert!(svc.create_stack(&req).is_err());
1029 }
1030
1031 #[test]
1032 fn create_stack_missing_template_errors() {
1033 let svc = make_service();
1034 let mut params = HashMap::new();
1035 params.insert("StackName".to_string(), "s".to_string());
1036 let req = make_request("CreateStack", params);
1037 assert!(svc.create_stack(&req).is_err());
1038 }
1039
1040 #[test]
1041 fn create_stack_duplicate_errors() {
1042 let svc = make_service();
1043 let mut params = HashMap::new();
1044 params.insert("StackName".to_string(), "dup".to_string());
1045 params.insert(
1046 "TemplateBody".to_string(),
1047 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
1048 .to_string(),
1049 );
1050 let req = make_request("CreateStack", params.clone());
1051 svc.create_stack(&req).unwrap();
1052 let req = make_request("CreateStack", params);
1053 assert!(svc.create_stack(&req).is_err());
1054 }
1055
1056 #[test]
1057 fn create_stack_invalid_template_errors() {
1058 let svc = make_service();
1059 let mut params = HashMap::new();
1060 params.insert("StackName".to_string(), "bad".to_string());
1061 params.insert("TemplateBody".to_string(), "not json".to_string());
1062 let req = make_request("CreateStack", params);
1063 assert!(svc.create_stack(&req).is_err());
1064 }
1065
1066 #[test]
1067 fn delete_stack_unknown_is_noop() {
1068 let svc = make_service();
1069 let mut params = HashMap::new();
1070 params.insert("StackName".to_string(), "ghost".to_string());
1071 let req = make_request("DeleteStack", params);
1072 assert!(svc.delete_stack(&req).is_ok());
1073 }
1074
1075 #[test]
1076 fn describe_stacks_nonexistent_errors() {
1077 let svc = make_service();
1078 let mut params = HashMap::new();
1079 params.insert("StackName".to_string(), "ghost".to_string());
1080 let req = make_request("DescribeStacks", params);
1081 assert!(svc.describe_stacks(&req).is_err());
1082 }
1083
1084 #[test]
1085 fn describe_stacks_empty_returns_all() {
1086 let svc = make_service();
1087 let req = make_request("DescribeStacks", HashMap::new());
1088 let resp = svc.describe_stacks(&req).unwrap();
1089 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1090 assert!(b.contains("DescribeStacksResult"));
1091 }
1092
1093 #[test]
1094 fn list_stacks_empty_returns_ok() {
1095 let svc = make_service();
1096 let req = make_request("ListStacks", HashMap::new());
1097 let resp = svc.list_stacks(&req).unwrap();
1098 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1099 assert!(b.contains("ListStacksResult"));
1100 }
1101
1102 #[test]
1103 fn list_stack_resources_missing_name_errors() {
1104 let svc = make_service();
1105 let req = make_request("ListStackResources", HashMap::new());
1106 assert!(svc.list_stack_resources(&req).is_err());
1107 }
1108
1109 #[test]
1110 fn list_stack_resources_unknown_stack_errors() {
1111 let svc = make_service();
1112 let mut params = HashMap::new();
1113 params.insert("StackName".to_string(), "ghost".to_string());
1114 let req = make_request("ListStackResources", params);
1115 assert!(svc.list_stack_resources(&req).is_err());
1116 }
1117
1118 #[test]
1119 fn describe_stack_resources_missing_name_errors() {
1120 let svc = make_service();
1121 let req = make_request("DescribeStackResources", HashMap::new());
1122 assert!(svc.describe_stack_resources(&req).is_err());
1123 }
1124
1125 #[test]
1126 fn get_template_missing_name_errors() {
1127 let svc = make_service();
1128 let req = make_request("GetTemplate", HashMap::new());
1129 assert!(svc.get_template(&req).is_err());
1130 }
1131
1132 #[test]
1133 fn get_template_unknown_stack_errors() {
1134 let svc = make_service();
1135 let mut params = HashMap::new();
1136 params.insert("StackName".to_string(), "ghost".to_string());
1137 let req = make_request("GetTemplate", params);
1138 assert!(svc.get_template(&req).is_err());
1139 }
1140
1141 #[test]
1142 fn update_stack_missing_name_errors() {
1143 let svc = make_service();
1144 let mut params = HashMap::new();
1145 params.insert("TemplateBody".to_string(), "{}".to_string());
1146 let req = make_request("UpdateStack", params);
1147 assert!(svc.update_stack(&req).is_err());
1148 }
1149
1150 #[test]
1151 fn update_stack_unknown_stack_errors() {
1152 let svc = make_service();
1153 let mut params = HashMap::new();
1154 params.insert("StackName".to_string(), "ghost".to_string());
1155 params.insert(
1156 "TemplateBody".to_string(),
1157 r#"{"Resources":{}}"#.to_string(),
1158 );
1159 let req = make_request("UpdateStack", params);
1160 assert!(svc.update_stack(&req).is_err());
1161 }
1162}