1use async_trait::async_trait;
2use chrono::Utc;
3use http::StatusCode;
4use std::collections::{BTreeMap, BTreeSet};
5use std::sync::Arc;
6
7use fakecloud_core::delivery::DeliveryBus;
8use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
9use fakecloud_dynamodb::SharedDynamoDbState;
10use fakecloud_eventbridge::SharedEventBridgeState;
11use fakecloud_iam::SharedIamState;
12use fakecloud_logs::SharedLogsState;
13use fakecloud_persistence::{S3Store, SnapshotHook, SnapshotStore};
14use fakecloud_s3::SharedS3State;
15use fakecloud_sns::SharedSnsState;
16use fakecloud_sqs::SharedSqsState;
17use fakecloud_ssm::SharedSsmState;
18use tokio::sync::Mutex as AsyncMutex;
19
20use crate::resource_provisioner::ResourceProvisioner;
21use crate::state;
22use crate::state::{
23 CloudFormationSnapshot, CloudFormationState, SharedCloudFormationState, Stack, StackResource,
24 CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
25};
26use crate::template;
27use crate::xml_responses;
28
29fn well_known_attributes_for(resource_type: &str) -> &'static [&'static str] {
34 match resource_type {
35 "AWS::S3::Bucket" => &[
36 "Arn",
37 "DomainName",
38 "RegionalDomainName",
39 "DualStackDomainName",
40 "WebsiteURL",
41 ],
42 "AWS::Lambda::Function" => &["Arn", "FunctionUrl", "Version"],
43 "AWS::IAM::Role" => &["Arn", "RoleId"],
44 "AWS::SQS::Queue" => &["Arn", "QueueName", "QueueUrl"],
45 "AWS::SNS::Topic" => &["TopicArn", "TopicName"],
46 "AWS::DynamoDB::Table" => &["Arn", "StreamArn"],
47 "AWS::KMS::Key" => &["Arn", "KeyId"],
48 "AWS::SecretsManager::Secret" => &["Arn", "Id"],
49 "AWS::CloudFront::Distribution" => &["DomainName", "Id"],
50 _ => &[],
51 }
52}
53
54fn service_key_for_type(resource_type: &str) -> Option<&'static str> {
65 let mut parts = resource_type.split("::");
66 let vendor = parts.next()?;
67 let service = parts.next()?;
68 parts.next()?;
71 if vendor != "AWS" {
72 return None;
73 }
74 Some(match service {
75 "Lambda" => "lambda",
76 "SecretsManager" => "secretsmanager",
77 "SQS" => "sqs",
78 "SNS" => "sns",
79 "DynamoDB" => "dynamodb",
80 "StepFunctions" => "stepfunctions",
81 "Events" => "eventbridge",
82 "SSM" => "ssm",
83 "Logs" => "logs",
84 "KMS" => "kms",
85 "Kinesis" => "kinesis",
86 "SES" => "ses",
87 "Cognito" => "cognito",
88 "RDS" => "rds",
89 "ElastiCache" => "elasticache",
90 "ECR" => "ecr",
91 "ECS" => "ecs",
92 "CloudWatch" => "cloudwatch",
93 "ApiGateway" => "apigateway",
94 "ApiGatewayV2" => "apigatewayv2",
95 "Bedrock" => "bedrock",
96 "Scheduler" => "scheduler",
97 "IAM" => "iam",
98 _ => return None,
99 })
100}
101
102async fn persist_touched_services<I>(
111 hooks: &BTreeMap<&'static str, SnapshotHook>,
112 resource_types: I,
113) where
114 I: IntoIterator<Item = String>,
115{
116 if hooks.is_empty() {
117 return;
118 }
119 let mut keys: BTreeSet<&'static str> = BTreeSet::new();
120 for ty in resource_types {
121 if let Some(key) = service_key_for_type(&ty) {
122 keys.insert(key);
123 }
124 }
125 for key in keys {
126 if let Some(hook) = hooks.get(key) {
127 hook().await;
128 }
129 }
130}
131
132pub(crate) fn provision_stack_resources(
141 provisioner: &ResourceProvisioner,
142 resource_defs: &[template::ResourceDefinition],
143 template_body: &str,
144 parameters: &BTreeMap<String, String>,
145) -> Result<Vec<StackResource>, AwsServiceError> {
146 let mut resources = Vec::new();
147 let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
148 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
149 let order = template::dependency_order(template_body, parameters, resource_defs);
154 let mut pending: Vec<&template::ResourceDefinition> =
155 order.iter().map(|&i| &resource_defs[i]).collect();
156 let max_passes = pending.len() + 1;
157
158 for _ in 0..max_passes {
159 if pending.is_empty() {
160 break;
161 }
162 let mut still_pending = Vec::new();
163 let mut made_progress = false;
164
165 for resource_def in pending {
166 let resolved_def = template::resolve_resource_properties_with_attrs(
167 resource_def,
168 template_body,
169 parameters,
170 &physical_ids,
171 &attributes,
172 )
173 .map_err(|e| {
174 AwsServiceError::aws_error(
178 StatusCode::BAD_REQUEST,
179 "InsufficientCapabilitiesException",
180 e,
181 )
182 })?;
183
184 match provisioner.create_resource(&resolved_def) {
185 Ok(stack_resource) => {
186 physical_ids.insert(
187 stack_resource.logical_id.clone(),
188 stack_resource.physical_id.clone(),
189 );
190 let mut attr_map = stack_resource.attributes.clone();
195 for attr in well_known_attributes_for(&stack_resource.resource_type) {
196 if attr_map.contains_key(*attr) {
197 continue;
198 }
199 if let Some(v) = provisioner.get_att(&stack_resource, attr) {
200 attr_map.insert((*attr).to_string(), v);
201 }
202 }
203 attributes.insert(stack_resource.logical_id.clone(), attr_map);
204 resources.push(stack_resource);
205 made_progress = true;
206 }
207 Err(_) => still_pending.push(resource_def),
208 }
209 }
210
211 pending = still_pending;
212 if !made_progress && !pending.is_empty() {
213 let resource_def = pending[0];
216 let resolved_def = template::resolve_resource_properties_with_attrs(
217 resource_def,
218 template_body,
219 parameters,
220 &physical_ids,
221 &attributes,
222 )
223 .unwrap_or_else(|_| resource_def.clone());
224 let err = provisioner.create_resource(&resolved_def).unwrap_err();
225 for r in &resources {
226 let _ = provisioner.delete_resource(r);
227 }
228 return Err(AwsServiceError::aws_error(
229 StatusCode::BAD_REQUEST,
230 "ValidationError",
231 format!(
232 "Failed to create resource {}: {err}",
233 resource_def.logical_id
234 ),
235 ));
236 }
237 }
238
239 Ok(resources)
240}
241
242pub struct CloudFormationDeps {
244 pub sqs: SharedSqsState,
245 pub sns: SharedSnsState,
246 pub ssm: SharedSsmState,
247 pub iam: SharedIamState,
248 pub s3: SharedS3State,
249 pub eventbridge: SharedEventBridgeState,
250 pub dynamodb: SharedDynamoDbState,
251 pub logs: SharedLogsState,
252 pub lambda: fakecloud_lambda::SharedLambdaState,
253 pub secretsmanager: fakecloud_secretsmanager::SharedSecretsManagerState,
254 pub kinesis: fakecloud_kinesis::SharedKinesisState,
255 pub kms: fakecloud_kms::SharedKmsState,
256 pub ecr: fakecloud_ecr::SharedEcrState,
257 pub cloudwatch: fakecloud_cloudwatch::SharedCloudWatchState,
258 pub elbv2: fakecloud_elbv2::SharedElbv2State,
259 pub organizations: fakecloud_organizations::SharedOrganizationsState,
260 pub cognito: fakecloud_cognito::SharedCognitoState,
261 pub rds: fakecloud_rds::SharedRdsState,
262 pub ecs: fakecloud_ecs::SharedEcsState,
263 pub acm: fakecloud_acm::SharedAcmState,
264 pub elasticache: fakecloud_elasticache::SharedElastiCacheState,
265 pub route53: fakecloud_route53::SharedRoute53State,
266 pub cloudfront: fakecloud_cloudfront::SharedCloudFrontState,
267 pub stepfunctions: fakecloud_stepfunctions::SharedStepFunctionsState,
268 pub wafv2: fakecloud_wafv2::SharedWafv2State,
269 pub apigateway: fakecloud_apigateway::SharedApiGatewayState,
270 pub apigatewayv2: fakecloud_apigatewayv2::SharedApiGatewayV2State,
271 pub ses: fakecloud_ses::SharedSesState,
272 pub application_autoscaling:
273 fakecloud_application_autoscaling::SharedApplicationAutoScalingState,
274 pub athena: fakecloud_athena::SharedAthenaState,
275 pub firehose: fakecloud_firehose::SharedFirehoseState,
276 pub glue: fakecloud_glue::SharedGlueState,
277 pub delivery: Arc<DeliveryBus>,
278 pub lambda_runtime: Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
285}
286
287pub struct CloudFormationService {
288 pub(crate) state: SharedCloudFormationState,
289 pub(crate) deps: CloudFormationDeps,
290 snapshot_store: Option<Arc<dyn SnapshotStore>>,
291 snapshot_lock: Arc<AsyncMutex<()>>,
292 s3_store: Arc<dyn S3Store>,
298 snapshot_hooks: BTreeMap<&'static str, SnapshotHook>,
305}
306
307struct CreateStackContext {
311 state: SharedCloudFormationState,
312 delivery: Arc<DeliveryBus>,
313 snapshot_store: Option<Arc<dyn SnapshotStore>>,
314 snapshot_lock: Arc<AsyncMutex<()>>,
315 snapshot_hooks: BTreeMap<&'static str, SnapshotHook>,
316 provisioner: ResourceProvisioner,
317 account_id: String,
318 stack_name: String,
319 stack_id: String,
320 template_body: String,
321 parameters: BTreeMap<String, String>,
322 notification_arns: Vec<String>,
323 imported_names: Vec<String>,
324 resource_defs: Vec<template::ResourceDefinition>,
325}
326
327impl CloudFormationService {
328 pub fn new(state: SharedCloudFormationState, deps: CloudFormationDeps) -> Self {
329 Self {
330 state,
331 deps,
332 snapshot_store: None,
333 snapshot_lock: Arc::new(AsyncMutex::new(())),
334 s3_store: Arc::new(fakecloud_persistence::s3::MemoryS3Store::new()),
335 snapshot_hooks: BTreeMap::new(),
336 }
337 }
338
339 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
340 self.snapshot_store = Some(store);
341 self
342 }
343
344 pub fn with_s3_store(mut self, store: Arc<dyn S3Store>) -> Self {
347 self.s3_store = store;
348 self
349 }
350
351 pub fn with_snapshot_hooks(mut self, hooks: BTreeMap<&'static str, SnapshotHook>) -> Self {
354 self.snapshot_hooks = hooks;
355 self
356 }
357
358 async fn save_snapshot(&self) {
359 let Some(store) = self.snapshot_store.clone() else {
360 return;
361 };
362 let _guard = self.snapshot_lock.lock().await;
363 let snapshot = CloudFormationSnapshot {
364 schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
365 state: None,
366 accounts: Some(self.state.read().clone()),
367 };
368 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
369 let bytes = serde_json::to_vec(&snapshot)
370 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
371 store.save(&bytes)
372 })
373 .await;
374 match join {
375 Ok(Ok(())) => {}
376 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
377 Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
378 }
379 }
380
381 pub(crate) fn provisioner(
382 &self,
383 stack_id: &str,
384 account_id: &str,
385 region: &str,
386 ) -> ResourceProvisioner {
387 ResourceProvisioner {
388 sqs_state: self.deps.sqs.clone(),
389 sns_state: self.deps.sns.clone(),
390 ssm_state: self.deps.ssm.clone(),
391 iam_state: self.deps.iam.clone(),
392 s3_state: self.deps.s3.clone(),
393 eventbridge_state: self.deps.eventbridge.clone(),
394 dynamodb_state: self.deps.dynamodb.clone(),
395 logs_state: self.deps.logs.clone(),
396 lambda_state: self.deps.lambda.clone(),
397 secretsmanager_state: self.deps.secretsmanager.clone(),
398 kinesis_state: self.deps.kinesis.clone(),
399 kms_state: self.deps.kms.clone(),
400 ecr_state: self.deps.ecr.clone(),
401 cloudwatch_state: self.deps.cloudwatch.clone(),
402 elbv2_state: self.deps.elbv2.clone(),
403 organizations_state: self.deps.organizations.clone(),
404 cognito_state: self.deps.cognito.clone(),
405 rds_state: self.deps.rds.clone(),
406 ecs_state: self.deps.ecs.clone(),
407 acm_state: self.deps.acm.clone(),
408 elasticache_state: self.deps.elasticache.clone(),
409 route53_state: self.deps.route53.clone(),
410 cloudfront_state: self.deps.cloudfront.clone(),
411 stepfunctions_state: self.deps.stepfunctions.clone(),
412 wafv2_state: self.deps.wafv2.clone(),
413 apigateway_state: self.deps.apigateway.clone(),
414 apigatewayv2_state: self.deps.apigatewayv2.clone(),
415 ses_state: self.deps.ses.clone(),
416 app_autoscaling_state: self.deps.application_autoscaling.clone(),
417 athena_state: self.deps.athena.clone(),
418 firehose_state: self.deps.firehose.clone(),
419 glue_state: self.deps.glue.clone(),
420 cloudformation_state: self.state.clone(),
421 delivery: self.deps.delivery.clone(),
422 lambda_runtime: self.deps.lambda_runtime.clone(),
423 s3_store: self.s3_store.clone(),
424 account_id: account_id.to_string(),
425 region: region.to_string(),
426 stack_id: stack_id.to_string(),
427 }
428 }
429
430 fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
431 if let Some(v) = req.query_params.get(key) {
433 return Some(v.clone());
434 }
435 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
437 body_params.get(key).cloned()
438 }
439
440 pub(crate) fn get_all_params(req: &AwsRequest) -> BTreeMap<String, String> {
441 let mut params: BTreeMap<String, String> = req.query_params.clone().into_iter().collect();
442 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
443 for (k, v) in body_params {
444 params.entry(k).or_insert(v);
445 }
446 params
447 }
448
449 pub(crate) fn extract_tags(params: &BTreeMap<String, String>) -> BTreeMap<String, String> {
450 let mut tags = BTreeMap::new();
451 for i in 1.. {
452 let key_param = format!("Tags.member.{i}.Key");
453 let value_param = format!("Tags.member.{i}.Value");
454 match (params.get(&key_param), params.get(&value_param)) {
455 (Some(k), Some(v)) => {
456 tags.insert(k.clone(), v.clone());
457 }
458 _ => break,
459 }
460 }
461 tags
462 }
463
464 pub(crate) fn extract_parameters(
465 params: &BTreeMap<String, String>,
466 ) -> BTreeMap<String, String> {
467 let mut result = BTreeMap::new();
468 for i in 1.. {
469 let key_param = format!("Parameters.member.{i}.ParameterKey");
470 let value_param = format!("Parameters.member.{i}.ParameterValue");
471 match (params.get(&key_param), params.get(&value_param)) {
472 (Some(k), Some(v)) => {
473 result.insert(k.clone(), v.clone());
474 }
475 _ => break,
476 }
477 }
478 result
479 }
480
481 pub(crate) fn extract_notification_arns(params: &BTreeMap<String, String>) -> Vec<String> {
482 let mut arns = Vec::new();
483 for i in 1.. {
484 let key = format!("NotificationARNs.member.{i}");
485 match params.get(&key) {
486 Some(arn) => arns.push(arn.clone()),
487 None => break,
488 }
489 }
490 arns
491 }
492
493 fn send_stack_notification(
494 delivery: &DeliveryBus,
495 notification_arns: &[String],
496 stack_name: &str,
497 stack_id: &str,
498 status: &str,
499 ) {
500 if notification_arns.is_empty() {
501 return;
502 }
503 let message = format!(
504 "StackId='{}'\nTimestamp='{}'\nEventId='{}'\nLogicalResourceId='{}'\nResourceStatus='{}'\nResourceType='AWS::CloudFormation::Stack'\nStackName='{}'",
505 stack_id,
506 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
507 uuid::Uuid::new_v4(),
508 stack_name,
509 status,
510 stack_name,
511 );
512 for arn in notification_arns {
513 delivery.publish_to_sns(arn, &message, Some("AWS CloudFormation Notification"));
514 }
515 }
516
517 fn collect_account_imports(
522 state: &SharedCloudFormationState,
523 account_id: &str,
524 skip_stack: Option<&str>,
525 ) -> BTreeMap<String, String> {
526 let mut imports = BTreeMap::new();
527 let accounts = state.read();
528 let Some(state) = accounts.get(account_id) else {
529 return imports;
530 };
531 for (name, export) in &state.exports {
532 if matches!(skip_stack, Some(skip) if skip == export.exporting_stack_name) {
533 continue;
534 }
535 imports.insert(name.clone(), export.value.clone());
536 }
537 imports
538 }
539
540 fn validate_import_values(
545 state: &SharedCloudFormationState,
546 account_id: &str,
547 stack_name: &str,
548 template_body: &str,
549 parameters: &BTreeMap<String, String>,
550 ) -> Result<Vec<String>, AwsServiceError> {
551 let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
552 match serde_json::from_str(template_body) {
553 Ok(v) => v,
554 Err(_) => return Ok(Vec::new()),
555 }
556 } else {
557 match serde_yaml::from_str(template_body) {
558 Ok(v) => v,
559 Err(_) => return Ok(Vec::new()),
560 }
561 };
562 let names = template::collect_import_value_names(&value, parameters);
563 let known = Self::collect_account_imports(state, account_id, Some(stack_name));
564 for n in &names {
565 if !known.contains_key(n) {
566 return Err(AwsServiceError::aws_error(
571 StatusCode::BAD_REQUEST,
572 "InsufficientCapabilitiesException",
573 format!("No export named {n} found."),
574 ));
575 }
576 }
577 Ok(names)
578 }
579
580 pub(crate) fn sync_exports_imports(
584 state: &mut CloudFormationState,
585 stack_id: &str,
586 stack_name: &str,
587 outputs: &[state::StackOutput],
588 imported_names: &[String],
589 ) {
590 let stale_exports: Vec<String> = state
592 .exports
593 .iter()
594 .filter(|(_, e)| e.exporting_stack_name == stack_name)
595 .map(|(k, _)| k.clone())
596 .collect();
597 for k in stale_exports {
598 state.exports.remove(&k);
599 }
600 for entries in state.imports.values_mut() {
602 entries.retain(|s| s != stack_name);
603 }
604 state.imports.retain(|_, v| !v.is_empty());
605
606 for o in outputs {
608 if let Some(export) = &o.export_name {
609 state.exports.insert(
610 export.clone(),
611 state::StackExport {
612 value: o.value.clone(),
613 exporting_stack_id: stack_id.to_string(),
614 exporting_stack_name: stack_name.to_string(),
615 },
616 );
617 }
618 }
619 for name in imported_names {
621 let entry = state.imports.entry(name.clone()).or_default();
622 if !entry.iter().any(|s| s == stack_name) {
623 entry.push(stack_name.to_string());
624 }
625 }
626 }
627
628 pub(crate) fn resolve_template_outputs(
633 template_body: &str,
634 parameters: &BTreeMap<String, String>,
635 resources: &[StackResource],
636 state: &SharedCloudFormationState,
637 ) -> Vec<state::StackOutput> {
638 let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
639 match serde_json::from_str(template_body) {
640 Ok(v) => v,
641 Err(_) => return Vec::new(),
642 }
643 } else {
644 match serde_yaml::from_str(template_body) {
645 Ok(v) => v,
646 Err(_) => return Vec::new(),
647 }
648 };
649
650 let resources_obj = match value.get("Resources").and_then(|v| v.as_object()) {
651 Some(o) => o.clone(),
652 None => return Vec::new(),
653 };
654
655 let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
656 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
657 for r in resources {
658 physical_ids.insert(r.logical_id.clone(), r.physical_id.clone());
659 attributes.insert(r.logical_id.clone(), r.attributes.clone());
660 }
661
662 let imports = {
663 let accounts = state.read();
664 let mut out = BTreeMap::new();
665 for (_account, st) in accounts.iter() {
668 for (name, export) in &st.exports {
669 out.insert(name.clone(), export.value.clone());
670 }
671 }
672 out
673 };
674
675 let parsed = match template::parse_outputs(
676 &value,
677 parameters,
678 &resources_obj,
679 &physical_ids,
680 &attributes,
681 &imports,
682 ) {
683 Ok(o) => o,
684 Err(_) => return Vec::new(),
685 };
686
687 parsed
688 .into_iter()
689 .map(|o| state::StackOutput {
690 key: o.logical_id,
691 value: o.value,
692 description: o.description,
693 export_name: o.export_name,
694 })
695 .collect()
696 }
697
698 fn ensure_export_uniqueness(
701 state: &SharedCloudFormationState,
702 account_id: &str,
703 stack_name: &str,
704 outputs: &[state::StackOutput],
705 ) -> Result<(), AwsServiceError> {
706 let existing = Self::collect_account_imports(state, account_id, Some(stack_name));
707 for o in outputs {
708 if let Some(export) = &o.export_name {
709 if existing.contains_key(export) {
710 return Err(AwsServiceError::aws_error(
714 StatusCode::BAD_REQUEST,
715 "AlreadyExistsException",
716 format!("Export with name {export} is already exported by another stack"),
717 ));
718 }
719 }
720 }
721 Ok(())
722 }
723
724 async fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
725 let params = Self::get_all_params(req);
726
727 let stack_name = params.get("StackName").ok_or_else(|| {
730 AwsServiceError::aws_error(
731 StatusCode::BAD_REQUEST,
732 "ValidationError",
733 "StackName is required",
734 )
735 })?;
736
737 let empty = String::new();
741 let template_body = params.get("TemplateBody").unwrap_or(&empty);
742
743 {
745 let accounts = self.state.read();
746 let empty = CloudFormationState::new(&req.account_id, &req.region);
747 let state = accounts.get(&req.account_id).unwrap_or(&empty);
748 if let Some(existing) = state.stacks.get(stack_name.as_str()) {
749 if existing.status != "DELETE_COMPLETE" {
750 return Err(AwsServiceError::aws_error(
751 StatusCode::BAD_REQUEST,
752 "AlreadyExistsException",
753 format!("Stack [{stack_name}] already exists"),
754 ));
755 }
756 }
757 }
758
759 let tags = Self::extract_tags(¶ms);
760 let mut parameters = Self::extract_parameters(¶ms);
761 let notification_arns = Self::extract_notification_arns(¶ms);
762
763 let stack_id = format!(
766 "arn:aws:cloudformation:{}:{}:stack/{}/{}",
767 req.region,
768 req.account_id,
769 stack_name,
770 uuid::Uuid::new_v4()
771 );
772 parameters
773 .entry("AWS::Region".to_string())
774 .or_insert_with(|| req.region.clone());
775 parameters
776 .entry("AWS::AccountId".to_string())
777 .or_insert_with(|| req.account_id.clone());
778 parameters
779 .entry("AWS::StackId".to_string())
780 .or_insert_with(|| stack_id.clone());
781 parameters
782 .entry("AWS::StackName".to_string())
783 .or_insert_with(|| stack_name.clone());
784 parameters
785 .entry("AWS::Partition".to_string())
786 .or_insert_with(|| template::partition_for_region(&req.region).to_string());
787 parameters
788 .entry("AWS::URLSuffix".to_string())
789 .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
790 parameters.insert(
794 "AWS::NotificationARNs".to_string(),
795 serde_json::to_string(¬ification_arns).unwrap_or_else(|_| "[]".to_string()),
796 );
797
798 let parsed = template::parse_template(template_body, ¶meters).unwrap_or_else(|_| {
803 template::ParsedTemplate {
804 description: None,
805 resources: Vec::new(),
806 outputs: Vec::new(),
807 }
808 });
809
810 let imported_names = Self::validate_import_values(
814 &self.state,
815 &req.account_id,
816 stack_name,
817 template_body,
818 ¶meters,
819 )?;
820
821 {
828 let mut accounts = self.state.write();
829 let state = accounts.get_or_create(&req.account_id);
830 state.stacks.insert(
831 stack_name.clone(),
832 Stack {
833 name: stack_name.clone(),
834 stack_id: stack_id.clone(),
835 template: template_body.clone(),
836 status: "CREATE_IN_PROGRESS".to_string(),
837 resources: Vec::new(),
838 parameters: parameters.clone(),
839 tags: tags.clone(),
840 created_at: Utc::now(),
841 updated_at: None,
842 description: parsed.description.clone(),
843 notification_arns: notification_arns.clone(),
844 outputs: Vec::new(),
845 },
846 );
847 record_stack_status_event(
848 state,
849 &stack_id,
850 stack_name,
851 "AWS::CloudFormation::Stack",
852 "CREATE_IN_PROGRESS",
853 );
854 }
855
856 let ctx = CreateStackContext {
857 state: self.state.clone(),
858 delivery: self.deps.delivery.clone(),
859 snapshot_store: self.snapshot_store.clone(),
860 snapshot_lock: self.snapshot_lock.clone(),
861 snapshot_hooks: self.snapshot_hooks.clone(),
862 provisioner: self.provisioner(&stack_id, &req.account_id, &req.region),
863 account_id: req.account_id.clone(),
864 stack_name: stack_name.clone(),
865 stack_id: stack_id.clone(),
866 template_body: template_body.clone(),
867 parameters,
868 notification_arns,
869 imported_names,
870 resource_defs: parsed.resources,
871 };
872
873 let has_custom_resource = ctx.resource_defs.iter().any(|r| {
889 r.resource_type.starts_with("Custom::")
890 || r.resource_type == "AWS::CloudFormation::CustomResource"
891 });
892 let multi_thread = matches!(
893 tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()),
894 Ok(tokio::runtime::RuntimeFlavor::MultiThread)
895 );
896 if has_custom_resource && multi_thread {
897 Self::send_stack_notification(
902 &self.deps.delivery,
903 &ctx.notification_arns,
904 stack_name,
905 &stack_id,
906 "CREATE_IN_PROGRESS",
907 );
908 tokio::spawn(async move {
909 Self::finish_create_stack(ctx).await;
910 });
911 } else {
912 Self::finish_create_stack(ctx).await;
913 }
914
915 Ok(AwsResponse::xml(
916 StatusCode::OK,
917 xml_responses::create_stack_response(&stack_id, &req.request_id),
918 ))
919 }
920
921 async fn finish_create_stack(ctx: CreateStackContext) {
927 let CreateStackContext {
928 state,
929 delivery,
930 snapshot_store,
931 snapshot_lock,
932 snapshot_hooks,
933 provisioner,
934 account_id,
935 stack_name,
936 stack_id,
937 template_body,
938 parameters,
939 notification_arns,
940 imported_names,
941 resource_defs,
942 } = ctx;
943
944 let provision_result = {
948 let template_body = template_body.clone();
949 let parameters = parameters.clone();
950 tokio::task::spawn_blocking(move || {
951 provision_stack_resources(&provisioner, &resource_defs, &template_body, ¶meters)
952 })
953 .await
954 };
955
956 let provisioned = match provision_result {
959 Ok(Ok(resources)) => Ok(resources),
960 Ok(Err(err)) => Err(err.message()),
961 Err(join_err) => Err(format!("provisioning task failed: {join_err}")),
962 };
963
964 let resources = match provisioned {
965 Ok(resources) => resources,
966 Err(reason) => {
967 Self::mark_create_failed(
968 &state,
969 &delivery,
970 &account_id,
971 &stack_name,
972 &stack_id,
973 ¬ification_arns,
974 &reason,
975 );
976 save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
977 return;
978 }
979 };
980
981 let outputs =
982 Self::resolve_template_outputs(&template_body, ¶meters, &resources, &state);
983
984 if let Err(err) = Self::ensure_export_uniqueness(&state, &account_id, &stack_name, &outputs)
987 {
988 Self::mark_create_failed(
989 &state,
990 &delivery,
991 &account_id,
992 &stack_name,
993 &stack_id,
994 ¬ification_arns,
995 &err.message(),
996 );
997 save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
998 return;
999 }
1000
1001 {
1002 let mut accounts = state.write();
1003 let st = accounts.get_or_create(&account_id);
1004 if let Some(stack) = st.stacks.get_mut(&stack_name) {
1005 stack.status = "CREATE_COMPLETE".to_string();
1006 stack.resources = resources.clone();
1007 stack.outputs = outputs.clone();
1008 }
1009 Self::sync_exports_imports(st, &stack_id, &stack_name, &outputs, &imported_names);
1010
1011 let changes: Vec<ResourceChange> = resources
1012 .iter()
1013 .map(|r| ResourceChange {
1014 action: ResourceChangeAction::Create,
1015 logical_id: r.logical_id.clone(),
1016 physical_id: r.physical_id.clone(),
1017 resource_type: r.resource_type.clone(),
1018 })
1019 .collect();
1020 record_stack_events(st, &stack_id, &stack_name, &changes);
1021 record_stack_status_event(
1022 st,
1023 &stack_id,
1024 &stack_name,
1025 "AWS::CloudFormation::Stack",
1026 "CREATE_COMPLETE",
1027 );
1028 }
1029
1030 Self::send_stack_notification(
1031 &delivery,
1032 ¬ification_arns,
1033 &stack_name,
1034 &stack_id,
1035 "CREATE_COMPLETE",
1036 );
1037
1038 save_snapshot_static(state, snapshot_store, snapshot_lock).await;
1039 persist_touched_services(
1044 &snapshot_hooks,
1045 resources.iter().map(|r| r.resource_type.clone()),
1046 )
1047 .await;
1048 }
1049
1050 fn mark_create_failed(
1054 state: &SharedCloudFormationState,
1055 delivery: &DeliveryBus,
1056 account_id: &str,
1057 stack_name: &str,
1058 stack_id: &str,
1059 notification_arns: &[String],
1060 reason: &str,
1061 ) {
1062 tracing::warn!(%stack_name, %reason, "CreateStack provisioning failed");
1063 {
1064 let mut accounts = state.write();
1065 let st = accounts.get_or_create(account_id);
1066 if let Some(stack) = st.stacks.get_mut(stack_name) {
1067 stack.status = "CREATE_FAILED".to_string();
1068 }
1069 record_stack_status_event(
1070 st,
1071 stack_id,
1072 stack_name,
1073 "AWS::CloudFormation::Stack",
1074 "CREATE_FAILED",
1075 );
1076 }
1077 Self::send_stack_notification(
1078 delivery,
1079 notification_arns,
1080 stack_name,
1081 stack_id,
1082 "CREATE_FAILED",
1083 );
1084 }
1085
1086 async fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1087 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
1088 AwsServiceError::aws_error(
1089 StatusCode::BAD_REQUEST,
1090 "ValidationError",
1091 "StackName is required",
1092 )
1093 })?;
1094
1095 let mut deleted_types: Vec<String> = Vec::new();
1101 {
1102 let mut accounts = self.state.write();
1103 let state = accounts.get_or_create(&req.account_id);
1104
1105 let stack = state.stacks.values_mut().find(|s| {
1107 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1108 });
1109
1110 if let Some(stack) = stack {
1111 let stack_id = stack.stack_id.clone();
1112 let stack_name_for_notif = stack.name.clone();
1113 let notification_arns = stack.notification_arns.clone();
1114 let resources: Vec<_> = stack.resources.clone();
1115
1116 let owned_exports: Vec<String> = state
1119 .exports
1120 .iter()
1121 .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
1122 .map(|(k, _)| k.clone())
1123 .collect();
1124 for export in &owned_exports {
1125 if let Some(consumers) = state.imports.get(export) {
1126 let consumers: Vec<&String> = consumers
1127 .iter()
1128 .filter(|c| **c != stack_name_for_notif)
1129 .collect();
1130 if !consumers.is_empty() {
1131 let names: Vec<&str> = consumers.iter().map(|s| s.as_str()).collect();
1132 return Err(AwsServiceError::aws_error(
1139 StatusCode::BAD_REQUEST,
1140 "TokenAlreadyExistsException",
1141 format!(
1142 "Export {export} cannot be deleted as it is in use by {}",
1143 names.join(", ")
1144 ),
1145 ));
1146 }
1147 }
1148 }
1149
1150 drop(accounts);
1153 let provisioner = self.provisioner(&stack_id, &req.account_id, &req.region);
1154
1155 for resource in resources.iter().rev() {
1157 let _ = provisioner.delete_resource(resource);
1158 }
1159
1160 let mut accounts = self.state.write();
1162 let state = accounts.get_or_create(&req.account_id);
1163 if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
1164 stack.status = "DELETE_COMPLETE".to_string();
1165 stack.resources.clear();
1166 stack.outputs.clear();
1167 }
1168 let stale_exports: Vec<String> = state
1170 .exports
1171 .iter()
1172 .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
1173 .map(|(k, _)| k.clone())
1174 .collect();
1175 for k in stale_exports {
1176 state.exports.remove(&k);
1177 }
1178 for entries in state.imports.values_mut() {
1179 entries.retain(|s| s != &stack_name_for_notif);
1180 }
1181 state.imports.retain(|_, v| !v.is_empty());
1182 drop(accounts);
1183
1184 Self::send_stack_notification(
1185 &self.deps.delivery,
1186 ¬ification_arns,
1187 &stack_name_for_notif,
1188 &stack_id,
1189 "DELETE_COMPLETE",
1190 );
1191
1192 deleted_types = resources.iter().map(|r| r.resource_type.clone()).collect();
1193 }
1194 }
1195
1196 persist_touched_services(&self.snapshot_hooks, deleted_types).await;
1200
1201 Ok(AwsResponse::xml(
1202 StatusCode::OK,
1203 xml_responses::delete_stack_response(&req.request_id),
1204 ))
1205 }
1206
1207 fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1208 let stack_name = Self::get_param(req, "StackName");
1209
1210 let accounts = self.state.read();
1211 let empty = CloudFormationState::new(&req.account_id, &req.region);
1212 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1213 let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
1214 state
1215 .stacks
1216 .values()
1217 .filter(|s| {
1218 (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
1219 })
1220 .cloned()
1221 .collect()
1222 } else {
1223 state
1224 .stacks
1225 .values()
1226 .filter(|s| s.status != "DELETE_COMPLETE")
1227 .cloned()
1228 .collect()
1229 };
1230
1231 if let Some(ref name) = stack_name {
1242 if stacks.is_empty() {
1243 return Err(AwsServiceError::aws_error(
1244 StatusCode::BAD_REQUEST,
1245 "ValidationError",
1246 format!("Stack with id {name} does not exist"),
1247 ));
1248 }
1249 }
1250
1251 Ok(AwsResponse::xml(
1252 StatusCode::OK,
1253 xml_responses::describe_stacks_response(&stacks, &req.request_id),
1254 ))
1255 }
1256
1257 fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1258 let accounts = self.state.read();
1259 let empty = CloudFormationState::new(&req.account_id, &req.region);
1260 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1261 let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
1262
1263 Ok(AwsResponse::xml(
1264 StatusCode::OK,
1265 xml_responses::list_stacks_response(&stacks, &req.request_id),
1266 ))
1267 }
1268
1269 fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1270 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
1276 AwsServiceError::aws_error(
1277 StatusCode::BAD_REQUEST,
1278 "ValidationError",
1279 "StackName is required",
1280 )
1281 })?;
1282
1283 let accounts = self.state.read();
1284 let empty = CloudFormationState::new(&req.account_id, &req.region);
1285 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1286 let resources = state
1287 .stacks
1288 .values()
1289 .find(|s| {
1290 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1291 })
1292 .map(|s| s.resources.clone())
1293 .unwrap_or_default();
1294
1295 Ok(AwsResponse::xml(
1296 StatusCode::OK,
1297 xml_responses::list_stack_resources_response(&resources, &req.request_id),
1298 ))
1299 }
1300
1301 fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1302 let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
1305
1306 let accounts = self.state.read();
1307 let empty = CloudFormationState::new(&req.account_id, &req.region);
1308 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1309 let (resources, resolved_name) = state
1310 .stacks
1311 .values()
1312 .find(|s| {
1313 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1314 })
1315 .map(|s| (s.resources.clone(), s.name.clone()))
1316 .unwrap_or_else(|| (Vec::new(), stack_name.clone()));
1317
1318 Ok(AwsResponse::xml(
1319 StatusCode::OK,
1320 xml_responses::describe_stack_resources_response(
1321 &resources,
1322 &resolved_name,
1323 &req.request_id,
1324 ),
1325 ))
1326 }
1327
1328 async fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1329 let mut input = UpdateStackInput::from_params(req)?;
1330
1331 let found_stack_id = {
1333 let accounts = self.state.read();
1334 let empty = CloudFormationState::new(&req.account_id, &req.region);
1335 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1336 state
1337 .stacks
1338 .values()
1339 .find(|s| {
1340 (s.name == input.stack_name || s.stack_id == input.stack_name)
1341 && s.status != "DELETE_COMPLETE"
1342 })
1343 .map(|s| s.stack_id.clone())
1344 .unwrap_or_default()
1345 };
1346
1347 input
1351 .parameters
1352 .entry("AWS::Region".to_string())
1353 .or_insert_with(|| req.region.clone());
1354 input
1355 .parameters
1356 .entry("AWS::AccountId".to_string())
1357 .or_insert_with(|| req.account_id.clone());
1358 input
1359 .parameters
1360 .entry("AWS::StackId".to_string())
1361 .or_insert_with(|| found_stack_id.clone());
1362 input
1363 .parameters
1364 .entry("AWS::StackName".to_string())
1365 .or_insert_with(|| input.stack_name.clone());
1366 input
1367 .parameters
1368 .entry("AWS::Partition".to_string())
1369 .or_insert_with(|| template::partition_for_region(&req.region).to_string());
1370 input
1371 .parameters
1372 .entry("AWS::URLSuffix".to_string())
1373 .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
1374 if !input.notification_arns.is_empty() {
1379 input.parameters.insert(
1380 "AWS::NotificationARNs".to_string(),
1381 serde_json::to_string(&input.notification_arns)
1382 .unwrap_or_else(|_| "[]".to_string()),
1383 );
1384 } else {
1385 let existing: Vec<String> = {
1388 let accounts = self.state.read();
1389 accounts
1390 .get(&req.account_id)
1391 .and_then(|s| {
1392 s.stacks
1393 .values()
1394 .find(|st| st.stack_id == found_stack_id)
1395 .map(|st| st.notification_arns.clone())
1396 })
1397 .unwrap_or_default()
1398 };
1399 input.parameters.insert(
1400 "AWS::NotificationARNs".to_string(),
1401 serde_json::to_string(&existing).unwrap_or_else(|_| "[]".to_string()),
1402 );
1403 }
1404
1405 let parsed = template::parse_template(&input.template_body, &input.parameters)
1410 .unwrap_or_else(|_| template::ParsedTemplate {
1411 description: None,
1412 resources: Vec::new(),
1413 outputs: Vec::new(),
1414 });
1415
1416 let imported_names = Self::validate_import_values(
1417 &self.state,
1418 &req.account_id,
1419 &input.stack_name,
1420 &input.template_body,
1421 &input.parameters,
1422 )?;
1423
1424 let provisioner = self.provisioner(&found_stack_id, &req.account_id, &req.region);
1425
1426 let (touched_types, stack_id, stack_name_for_notif, notification_arns, resources_snapshot) = {
1431 let mut accounts = self.state.write();
1432 let state = accounts.get_or_create(&req.account_id);
1433 let stack_exists = state.stacks.values().any(|s| {
1442 (s.name == input.stack_name || s.stack_id == input.stack_name)
1443 && s.status != "DELETE_COMPLETE"
1444 });
1445 if !stack_exists {
1446 let stack_id = if found_stack_id.is_empty() {
1447 format!(
1448 "arn:aws:cloudformation:{}:{}:stack/{}/{}",
1449 req.region,
1450 req.account_id,
1451 input.stack_name,
1452 uuid::Uuid::new_v4()
1453 )
1454 } else {
1455 found_stack_id.clone()
1456 };
1457 return Ok(AwsResponse::xml(
1458 StatusCode::OK,
1459 xml_responses::update_stack_response(&stack_id, &req.request_id),
1460 ));
1461 }
1462 let (update_result, stack_id, stack_name_owned, resources_snapshot, notification_arns) = {
1463 let stack = state
1464 .stacks
1465 .values_mut()
1466 .find(|s| {
1467 (s.name == input.stack_name || s.stack_id == input.stack_name)
1468 && s.status != "DELETE_COMPLETE"
1469 })
1470 .expect("stack existence checked above");
1471
1472 stack.status = "UPDATE_IN_PROGRESS".to_string();
1473 let update_result = apply_resource_updates(
1474 stack,
1475 &parsed.resources,
1476 &input.template_body,
1477 &input.parameters,
1478 &provisioner,
1479 );
1480
1481 let stack_id = stack.stack_id.clone();
1482 let stack_name_owned = stack.name.clone();
1483 stack.template = input.template_body.clone();
1484 stack.status = if update_result.is_err() {
1485 "UPDATE_ROLLBACK_COMPLETE".to_string()
1486 } else {
1487 "UPDATE_COMPLETE".to_string()
1488 };
1489 stack.parameters = input.parameters.clone();
1490 if !input.tags.is_empty() {
1491 stack.tags = input.tags;
1492 }
1493 stack.updated_at = Some(Utc::now());
1494 stack.description = parsed.description;
1495 if !input.notification_arns.is_empty() {
1496 stack.notification_arns = input.notification_arns.clone();
1497 }
1498 if update_result.is_ok() {
1499 stack.outputs.clear();
1500 }
1501 (
1502 update_result,
1503 stack_id,
1504 stack_name_owned,
1505 stack.resources.clone(),
1506 stack.notification_arns.clone(),
1507 )
1508 };
1509
1510 record_stack_status_event(
1512 state,
1513 &stack_id,
1514 &stack_name_owned,
1515 "AWS::CloudFormation::Stack",
1516 "UPDATE_IN_PROGRESS",
1517 );
1518 let update_result = match update_result {
1519 Ok(changes) => {
1520 let touched_types: Vec<String> =
1524 changes.iter().map(|c| c.resource_type.clone()).collect();
1525 record_stack_events(state, &stack_id, &stack_name_owned, &changes);
1526 record_stack_status_event(
1527 state,
1528 &stack_id,
1529 &stack_name_owned,
1530 "AWS::CloudFormation::Stack",
1531 "UPDATE_COMPLETE",
1532 );
1533 Ok(touched_types)
1534 }
1535 Err(e) => {
1536 record_stack_status_event(
1537 state,
1538 &stack_id,
1539 &stack_name_owned,
1540 "AWS::CloudFormation::Stack",
1541 "UPDATE_ROLLBACK_COMPLETE",
1542 );
1543 Err(e)
1544 }
1545 };
1546 let stack_name_for_notif = stack_name_owned.clone();
1547
1548 let touched_types = match update_result {
1549 Ok(types) => types,
1550 Err(error_msg) => {
1551 drop(accounts);
1552 Self::send_stack_notification(
1553 &self.deps.delivery,
1554 ¬ification_arns,
1555 &stack_name_for_notif,
1556 &stack_id,
1557 "UPDATE_FAILED",
1558 );
1559 return Err(AwsServiceError::aws_error(
1560 StatusCode::BAD_REQUEST,
1561 "InsufficientCapabilitiesException",
1562 error_msg,
1563 ));
1564 }
1565 };
1566
1567 drop(accounts);
1568 (
1569 touched_types,
1570 stack_id,
1571 stack_name_for_notif,
1572 notification_arns,
1573 resources_snapshot,
1574 )
1575 };
1576
1577 let outputs = Self::resolve_template_outputs(
1578 &input.template_body,
1579 &input.parameters,
1580 &resources_snapshot,
1581 &self.state,
1582 );
1583 Self::ensure_export_uniqueness(&self.state, &req.account_id, &input.stack_name, &outputs)?;
1584 {
1585 let mut accounts = self.state.write();
1586 let state = accounts.get_or_create(&req.account_id);
1587 if let Some(stack) = state
1588 .stacks
1589 .values_mut()
1590 .find(|s| s.stack_id == stack_id && s.status != "DELETE_COMPLETE")
1591 {
1592 stack.outputs = outputs.clone();
1593 }
1594 Self::sync_exports_imports(
1595 state,
1596 &stack_id,
1597 &input.stack_name,
1598 &outputs,
1599 &imported_names,
1600 );
1601 }
1602
1603 Self::send_stack_notification(
1604 &self.deps.delivery,
1605 ¬ification_arns,
1606 &stack_name_for_notif,
1607 &stack_id,
1608 "UPDATE_COMPLETE",
1609 );
1610
1611 persist_touched_services(&self.snapshot_hooks, touched_types).await;
1614
1615 Ok(AwsResponse::xml(
1616 StatusCode::OK,
1617 xml_responses::update_stack_response(&stack_id, &req.request_id),
1618 ))
1619 }
1620
1621 fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1622 let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
1624
1625 let accounts = self.state.read();
1626 let empty = CloudFormationState::new(&req.account_id, &req.region);
1627 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1628 let body = state
1633 .stacks
1634 .values()
1635 .find(|s| {
1636 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1637 })
1638 .map(|s| s.template.clone())
1639 .unwrap_or_default();
1640
1641 Ok(AwsResponse::xml(
1642 StatusCode::OK,
1643 xml_responses::get_template_response(&body, &req.request_id),
1644 ))
1645 }
1646}
1647
1648#[async_trait]
1649impl AwsService for CloudFormationService {
1650 fn service_name(&self) -> &str {
1651 "cloudformation"
1652 }
1653
1654 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1655 let action = req.action.as_str();
1656
1657 crate::input_constraints::validate_input(action, &Self::get_all_params(&req))?;
1664
1665 let mutates = matches!(
1669 action,
1670 "CreateStack"
1671 | "DeleteStack"
1672 | "UpdateStack"
1673 | "CreateChangeSet"
1674 | "DeleteChangeSet"
1675 | "ExecuteChangeSet"
1676 | "CreateStackSet"
1677 | "DeleteStackSet"
1678 | "CreateStackRefactor"
1679 | "CreateGeneratedTemplate"
1680 | "DeleteGeneratedTemplate"
1681 | "SetStackPolicy"
1682 | "UpdateTerminationProtection"
1683 | "ActivateOrganizationsAccess"
1684 | "DeactivateOrganizationsAccess"
1685 );
1686 let result = match action {
1687 "CreateStack" => self.create_stack(&req).await,
1688 "DeleteStack" => self.delete_stack(&req).await,
1689 "DescribeStacks" => self.describe_stacks(&req),
1690 "ListStacks" => self.list_stacks(&req),
1691 "ListStackResources" => self.list_stack_resources(&req),
1692 "DescribeStackResources" => self.describe_stack_resources(&req),
1693 "UpdateStack" => self.update_stack(&req).await,
1694 "GetTemplate" => self.get_template(&req),
1695 _ => self.handle_extra_action(&req),
1696 };
1697 if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
1698 self.save_snapshot().await;
1699 }
1700 result
1701 }
1702
1703 fn supported_actions(&self) -> &[&str] {
1704 &[
1705 "ActivateOrganizationsAccess",
1706 "ActivateType",
1707 "BatchDescribeTypeConfigurations",
1708 "CancelUpdateStack",
1709 "ContinueUpdateRollback",
1710 "CreateChangeSet",
1711 "CreateGeneratedTemplate",
1712 "CreateStack",
1713 "CreateStackInstances",
1714 "CreateStackRefactor",
1715 "CreateStackSet",
1716 "DeactivateOrganizationsAccess",
1717 "DeactivateType",
1718 "DeleteChangeSet",
1719 "DeleteGeneratedTemplate",
1720 "DeleteStack",
1721 "DeleteStackInstances",
1722 "DeleteStackSet",
1723 "DeregisterType",
1724 "DescribeAccountLimits",
1725 "DescribeChangeSet",
1726 "DescribeChangeSetHooks",
1727 "DescribeEvents",
1728 "DescribeGeneratedTemplate",
1729 "DescribeOrganizationsAccess",
1730 "DescribePublisher",
1731 "DescribeResourceScan",
1732 "DescribeStackDriftDetectionStatus",
1733 "DescribeStackEvents",
1734 "DescribeStackInstance",
1735 "DescribeStackRefactor",
1736 "DescribeStackResource",
1737 "DescribeStackResourceDrifts",
1738 "DescribeStackResources",
1739 "DescribeStackSet",
1740 "DescribeStackSetOperation",
1741 "DescribeStacks",
1742 "DescribeType",
1743 "DescribeTypeRegistration",
1744 "DetectStackDrift",
1745 "DetectStackResourceDrift",
1746 "DetectStackSetDrift",
1747 "EstimateTemplateCost",
1748 "ExecuteChangeSet",
1749 "ExecuteStackRefactor",
1750 "GetGeneratedTemplate",
1751 "GetHookResult",
1752 "GetStackPolicy",
1753 "GetTemplate",
1754 "GetTemplateSummary",
1755 "ImportStacksToStackSet",
1756 "ListChangeSets",
1757 "ListExports",
1758 "ListGeneratedTemplates",
1759 "ListHookResults",
1760 "ListImports",
1761 "ListResourceScanRelatedResources",
1762 "ListResourceScanResources",
1763 "ListResourceScans",
1764 "ListStackInstanceResourceDrifts",
1765 "ListStackInstances",
1766 "ListStackRefactorActions",
1767 "ListStackRefactors",
1768 "ListStackResources",
1769 "ListStackSetAutoDeploymentTargets",
1770 "ListStackSetOperationResults",
1771 "ListStackSetOperations",
1772 "ListStackSets",
1773 "ListStacks",
1774 "ListTypeRegistrations",
1775 "ListTypeVersions",
1776 "ListTypes",
1777 "PublishType",
1778 "RecordHandlerProgress",
1779 "RegisterPublisher",
1780 "RegisterType",
1781 "RollbackStack",
1782 "SetStackPolicy",
1783 "SetTypeConfiguration",
1784 "SetTypeDefaultVersion",
1785 "SignalResource",
1786 "StartResourceScan",
1787 "StopStackSetOperation",
1788 "TestType",
1789 "UpdateGeneratedTemplate",
1790 "UpdateStack",
1791 "UpdateStackInstances",
1792 "UpdateStackSet",
1793 "UpdateTerminationProtection",
1794 "ValidateTemplate",
1795 ]
1796 }
1797}
1798
1799struct UpdateStackInput {
1801 stack_name: String,
1802 template_body: String,
1803 parameters: BTreeMap<String, String>,
1804 tags: BTreeMap<String, String>,
1805 notification_arns: Vec<String>,
1806}
1807
1808impl UpdateStackInput {
1809 fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
1810 let params = CloudFormationService::get_all_params(req);
1811
1812 let stack_name = params
1813 .get("StackName")
1814 .ok_or_else(|| {
1815 AwsServiceError::aws_error(
1816 StatusCode::BAD_REQUEST,
1817 "ValidationError",
1818 "StackName is required",
1819 )
1820 })?
1821 .to_string();
1822
1823 let template_body = params.get("TemplateBody").cloned().unwrap_or_default();
1828
1829 Ok(Self {
1830 stack_name,
1831 template_body,
1832 parameters: CloudFormationService::extract_parameters(¶ms),
1833 tags: CloudFormationService::extract_tags(¶ms),
1834 notification_arns: CloudFormationService::extract_notification_arns(¶ms),
1835 })
1836 }
1837}
1838
1839#[derive(Debug, Clone)]
1843pub(crate) struct ResourceChange {
1844 pub action: ResourceChangeAction,
1845 pub logical_id: String,
1846 pub physical_id: String,
1847 pub resource_type: String,
1848}
1849
1850#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1851pub(crate) enum ResourceChangeAction {
1852 Create,
1853 Update,
1854 Delete,
1855}
1856
1857impl ResourceChangeAction {
1858 pub fn status_in_progress(self) -> &'static str {
1859 match self {
1860 Self::Create => "CREATE_IN_PROGRESS",
1861 Self::Update => "UPDATE_IN_PROGRESS",
1862 Self::Delete => "DELETE_IN_PROGRESS",
1863 }
1864 }
1865 pub fn status_complete(self) -> &'static str {
1866 match self {
1867 Self::Create => "CREATE_COMPLETE",
1868 Self::Update => "UPDATE_COMPLETE",
1869 Self::Delete => "DELETE_COMPLETE",
1870 }
1871 }
1872}
1873
1874pub(crate) fn apply_resource_updates(
1879 stack: &mut crate::state::Stack,
1880 new_resource_defs: &[template::ResourceDefinition],
1881 template_body: &str,
1882 parameters: &BTreeMap<String, String>,
1883 provisioner: &crate::resource_provisioner::ResourceProvisioner,
1884) -> Result<Vec<ResourceChange>, String> {
1885 let mut changes: Vec<ResourceChange> = Vec::new();
1886 let old_logical_ids: std::collections::HashSet<String> = stack
1887 .resources
1888 .iter()
1889 .map(|r| r.logical_id.clone())
1890 .collect();
1891 let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
1892 .iter()
1893 .map(|r| r.logical_id.clone())
1894 .collect();
1895
1896 let to_remove: Vec<_> = stack
1898 .resources
1899 .iter()
1900 .filter(|r| !new_logical_ids.contains(&r.logical_id))
1901 .cloned()
1902 .collect();
1903 for resource in &to_remove {
1904 let _ = provisioner.delete_resource(resource);
1905 changes.push(ResourceChange {
1906 action: ResourceChangeAction::Delete,
1907 logical_id: resource.logical_id.clone(),
1908 physical_id: resource.physical_id.clone(),
1909 resource_type: resource.resource_type.clone(),
1910 });
1911 }
1912 stack
1913 .resources
1914 .retain(|r| new_logical_ids.contains(&r.logical_id));
1915
1916 let mut physical_ids: BTreeMap<String, String> = stack
1918 .resources
1919 .iter()
1920 .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
1921 .collect();
1922 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = stack
1923 .resources
1924 .iter()
1925 .map(|r| (r.logical_id.clone(), r.attributes.clone()))
1926 .collect();
1927
1928 let order = template::dependency_order(template_body, parameters, new_resource_defs);
1934 for &idx in &order {
1935 let resource_def = &new_resource_defs[idx];
1936 let resolved_def = template::resolve_resource_properties_with_attrs(
1937 resource_def,
1938 template_body,
1939 parameters,
1940 &physical_ids,
1941 &attributes,
1942 )
1943 .map_err(|e| {
1944 format!(
1945 "Failed to resolve resource {}: {e}",
1946 resource_def.logical_id
1947 )
1948 })?;
1949
1950 if !old_logical_ids.contains(&resource_def.logical_id) {
1951 match provisioner.create_resource(&resolved_def) {
1952 Ok(stack_resource) => {
1953 changes.push(ResourceChange {
1954 action: ResourceChangeAction::Create,
1955 logical_id: stack_resource.logical_id.clone(),
1956 physical_id: stack_resource.physical_id.clone(),
1957 resource_type: stack_resource.resource_type.clone(),
1958 });
1959 physical_ids.insert(
1960 stack_resource.logical_id.clone(),
1961 stack_resource.physical_id.clone(),
1962 );
1963 attributes.insert(
1964 stack_resource.logical_id.clone(),
1965 stack_resource.attributes.clone(),
1966 );
1967 stack.resources.push(stack_resource);
1968 }
1969 Err(e) => {
1970 tracing::warn!(
1971 "Failed to create resource {} during update: {e}",
1972 resource_def.logical_id
1973 );
1974 return Err(format!(
1975 "Failed to create resource {}: {e}",
1976 resource_def.logical_id
1977 ));
1978 }
1979 }
1980 } else {
1981 let existing = stack
1987 .resources
1988 .iter()
1989 .find(|r| r.logical_id == resource_def.logical_id)
1990 .cloned();
1991 if let Some(existing) = existing {
1992 match provisioner.update_resource(&existing, &resolved_def) {
1993 Ok(Some(updated)) => {
1994 changes.push(ResourceChange {
1995 action: ResourceChangeAction::Update,
1996 logical_id: updated.logical_id.clone(),
1997 physical_id: updated.physical_id.clone(),
1998 resource_type: updated.resource_type.clone(),
1999 });
2000 physical_ids
2001 .insert(updated.logical_id.clone(), updated.physical_id.clone());
2002 attributes.insert(updated.logical_id.clone(), updated.attributes.clone());
2003 if let Some(slot) = stack
2004 .resources
2005 .iter_mut()
2006 .find(|r| r.logical_id == updated.logical_id)
2007 {
2008 *slot = updated;
2009 }
2010 }
2011 Ok(None) => {
2012 }
2015 Err(e) => {
2016 tracing::warn!(
2017 "Failed to update resource {} during update: {e}",
2018 resource_def.logical_id
2019 );
2020 return Err(format!(
2021 "Failed to update resource {}: {e}",
2022 resource_def.logical_id
2023 ));
2024 }
2025 }
2026 }
2027 }
2028 }
2029
2030 Ok(changes)
2031}
2032
2033pub(crate) fn record_event(
2037 state: &mut crate::state::CloudFormationState,
2038 stack_id: &str,
2039 stack_name: &str,
2040 logical_id: &str,
2041 physical_id: &str,
2042 resource_type: &str,
2043 status: &str,
2044) {
2045 use serde_json::json;
2046 let event_id = format!(
2047 "{}-{:x}",
2048 logical_id,
2049 std::time::SystemTime::now()
2050 .duration_since(std::time::UNIX_EPOCH)
2051 .map(|d| d.as_nanos())
2052 .unwrap_or(0)
2053 );
2054 let log = state.events.entry(stack_id.to_string()).or_default();
2055
2056 let now = chrono::DateTime::from_timestamp_millis(Utc::now().timestamp_millis())
2069 .unwrap_or_else(Utc::now);
2070 let timestamp = match log.last().and_then(|e| e["Timestamp"].as_str()) {
2071 Some(prev) => match chrono::DateTime::parse_from_rfc3339(prev) {
2072 Ok(prev) => {
2073 let prev = prev.with_timezone(&Utc);
2074 if now > prev {
2075 now
2076 } else {
2077 prev + chrono::Duration::milliseconds(1)
2078 }
2079 }
2080 Err(_) => now,
2081 },
2082 None => now,
2083 };
2084
2085 log.push(json!({
2086 "EventId": event_id,
2087 "StackId": stack_id,
2088 "StackName": stack_name,
2089 "LogicalResourceId": logical_id,
2090 "PhysicalResourceId": physical_id,
2091 "ResourceType": resource_type,
2092 "ResourceStatus": status,
2093 "Timestamp": timestamp.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2094 }));
2095}
2096
2097async fn save_snapshot_static(
2105 state: SharedCloudFormationState,
2106 store: Option<Arc<dyn SnapshotStore>>,
2107 lock: Arc<AsyncMutex<()>>,
2108) {
2109 let Some(store) = store else {
2110 return;
2111 };
2112 let _guard = lock.lock().await;
2113 let snapshot = CloudFormationSnapshot {
2114 schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
2115 state: None,
2116 accounts: Some(state.read().clone()),
2117 };
2118 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2119 let bytes = serde_json::to_vec(&snapshot)
2120 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
2121 store.save(&bytes)
2122 })
2123 .await;
2124 match join {
2125 Ok(Ok(())) => {}
2126 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
2127 Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
2128 }
2129}
2130
2131pub(crate) fn record_stack_events(
2132 state: &mut crate::state::CloudFormationState,
2133 stack_id: &str,
2134 stack_name: &str,
2135 changes: &[ResourceChange],
2136) {
2137 for ch in changes {
2138 record_event(
2139 state,
2140 stack_id,
2141 stack_name,
2142 &ch.logical_id,
2143 &ch.physical_id,
2144 &ch.resource_type,
2145 ch.action.status_in_progress(),
2146 );
2147 record_event(
2148 state,
2149 stack_id,
2150 stack_name,
2151 &ch.logical_id,
2152 &ch.physical_id,
2153 &ch.resource_type,
2154 ch.action.status_complete(),
2155 );
2156 }
2157}
2158
2159pub(crate) fn record_stack_status_event(
2163 state: &mut crate::state::CloudFormationState,
2164 stack_id: &str,
2165 stack_name: &str,
2166 resource_type: &str,
2167 status: &str,
2168) {
2169 record_event(
2170 state,
2171 stack_id,
2172 stack_name,
2173 stack_name,
2174 stack_id,
2175 resource_type,
2176 status,
2177 );
2178}
2179
2180#[cfg(test)]
2181mod tests {
2182 use super::*;
2183 use http::HeaderMap;
2184 use parking_lot::RwLock;
2185 use std::collections::HashMap;
2186 use std::sync::Arc;
2187
2188 fn make_service() -> CloudFormationService {
2189 let cf_state = Arc::new(RwLock::new(
2190 fakecloud_core::multi_account::MultiAccountState::new(
2191 "123456789012",
2192 "us-east-1",
2193 "http://localhost:4566",
2194 ),
2195 ));
2196 let deps = CloudFormationDeps {
2197 sqs: Arc::new(RwLock::new(
2198 fakecloud_core::multi_account::MultiAccountState::new(
2199 "123456789012",
2200 "us-east-1",
2201 "http://localhost:4566",
2202 ),
2203 )),
2204 sns: Arc::new(RwLock::new(
2205 fakecloud_core::multi_account::MultiAccountState::new(
2206 "123456789012",
2207 "us-east-1",
2208 "http://localhost:4566",
2209 ),
2210 )),
2211 ssm: Arc::new(RwLock::new(
2212 fakecloud_core::multi_account::MultiAccountState::new(
2213 "123456789012",
2214 "us-east-1",
2215 "http://localhost:4566",
2216 ),
2217 )),
2218 iam: Arc::new(RwLock::new(
2219 fakecloud_core::multi_account::MultiAccountState::new(
2220 "123456789012",
2221 "us-east-1",
2222 "",
2223 ),
2224 )),
2225 s3: Arc::new(RwLock::new(
2226 fakecloud_core::multi_account::MultiAccountState::new(
2227 "123456789012",
2228 "us-east-1",
2229 "",
2230 ),
2231 )),
2232 eventbridge: Arc::new(RwLock::new(
2233 fakecloud_core::multi_account::MultiAccountState::new(
2234 "123456789012",
2235 "us-east-1",
2236 "",
2237 ),
2238 )),
2239 dynamodb: Arc::new(RwLock::new(
2240 fakecloud_core::multi_account::MultiAccountState::new(
2241 "123456789012",
2242 "us-east-1",
2243 "",
2244 ),
2245 )),
2246 logs: Arc::new(RwLock::new(
2247 fakecloud_core::multi_account::MultiAccountState::new(
2248 "123456789012",
2249 "us-east-1",
2250 "",
2251 ),
2252 )),
2253 lambda: Arc::new(RwLock::new(
2254 fakecloud_core::multi_account::MultiAccountState::new(
2255 "123456789012",
2256 "us-east-1",
2257 "",
2258 ),
2259 )),
2260 secretsmanager: Arc::new(RwLock::new(
2261 fakecloud_core::multi_account::MultiAccountState::new(
2262 "123456789012",
2263 "us-east-1",
2264 "",
2265 ),
2266 )),
2267 kinesis: Arc::new(RwLock::new(
2268 fakecloud_core::multi_account::MultiAccountState::new(
2269 "123456789012",
2270 "us-east-1",
2271 "",
2272 ),
2273 )),
2274 kms: Arc::new(RwLock::new(
2275 fakecloud_core::multi_account::MultiAccountState::new(
2276 "123456789012",
2277 "us-east-1",
2278 "",
2279 ),
2280 )),
2281 ecr: Arc::new(RwLock::new(
2282 fakecloud_core::multi_account::MultiAccountState::new(
2283 "123456789012",
2284 "us-east-1",
2285 "",
2286 ),
2287 )),
2288 cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
2289 elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
2290 organizations: Arc::new(RwLock::new(None)),
2291 cognito: Arc::new(RwLock::new(
2292 fakecloud_core::multi_account::MultiAccountState::new(
2293 "123456789012",
2294 "us-east-1",
2295 "",
2296 ),
2297 )),
2298 rds: Arc::new(RwLock::new(
2299 fakecloud_core::multi_account::MultiAccountState::new(
2300 "123456789012",
2301 "us-east-1",
2302 "",
2303 ),
2304 )),
2305 ecs: Arc::new(RwLock::new(
2306 fakecloud_core::multi_account::MultiAccountState::new(
2307 "123456789012",
2308 "us-east-1",
2309 "",
2310 ),
2311 )),
2312 acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
2313 elasticache: Arc::new(RwLock::new(
2314 fakecloud_core::multi_account::MultiAccountState::new(
2315 "123456789012",
2316 "us-east-1",
2317 "",
2318 ),
2319 )),
2320 route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
2321 cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
2322 stepfunctions: Arc::new(RwLock::new(
2323 fakecloud_core::multi_account::MultiAccountState::new(
2324 "123456789012",
2325 "us-east-1",
2326 "",
2327 ),
2328 )),
2329 wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
2330 apigateway: Arc::new(RwLock::new(
2331 fakecloud_core::multi_account::MultiAccountState::new(
2332 "123456789012",
2333 "us-east-1",
2334 "",
2335 ),
2336 )),
2337 apigatewayv2: Arc::new(RwLock::new(
2338 fakecloud_core::multi_account::MultiAccountState::new(
2339 "123456789012",
2340 "us-east-1",
2341 "",
2342 ),
2343 )),
2344 ses: Arc::new(RwLock::new(
2345 fakecloud_core::multi_account::MultiAccountState::new(
2346 "123456789012",
2347 "us-east-1",
2348 "",
2349 ),
2350 )),
2351 application_autoscaling: Arc::new(parking_lot::RwLock::new(
2352 fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
2353 )),
2354 athena: Arc::new(parking_lot::RwLock::new(
2355 fakecloud_athena::AthenaAccounts::new(),
2356 )),
2357 firehose: Arc::new(parking_lot::RwLock::new(
2358 fakecloud_firehose::FirehoseAccounts::new(),
2359 )),
2360 glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
2361 delivery: Arc::new(DeliveryBus::new()),
2362 lambda_runtime: None,
2363 };
2364 CloudFormationService::new(cf_state, deps)
2365 }
2366
2367 fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
2368 AwsRequest {
2369 service: "cloudformation".to_string(),
2370 action: action.to_string(),
2371 region: "us-east-1".to_string(),
2372 account_id: "123456789012".to_string(),
2373 request_id: "test-request-id".to_string(),
2374 headers: HeaderMap::new(),
2375 query_params: params,
2376 body: bytes::Bytes::new(),
2377 body_stream: parking_lot::Mutex::new(None),
2378 path_segments: vec![],
2379 raw_path: "/".to_string(),
2380 raw_query: String::new(),
2381 method: http::Method::POST,
2382 is_query_protocol: true,
2383 access_key_id: None,
2384 principal: None,
2385 }
2386 }
2387
2388 #[tokio::test]
2389 async fn update_stack_sets_failed_status_on_resource_error() {
2390 let svc = make_service();
2391
2392 let mut create_params = HashMap::new();
2394 create_params.insert("StackName".to_string(), "test-stack".to_string());
2395 create_params.insert(
2396 "TemplateBody".to_string(),
2397 r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
2398 );
2399 let req = make_request("CreateStack", create_params);
2400 let result = svc.create_stack(&req).await;
2401 assert!(result.is_ok());
2402
2403 let mut update_params = HashMap::new();
2405 update_params.insert("StackName".to_string(), "test-stack".to_string());
2406 update_params.insert(
2407 "TemplateBody".to_string(),
2408 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(),
2409 );
2410 let req = make_request("UpdateStack", update_params);
2411 let result = svc.update_stack(&req).await;
2412
2413 assert!(result.is_err());
2415
2416 let accounts = svc.state.read();
2420 let state = accounts.get("123456789012").unwrap();
2421 let stack = state.stacks.get("test-stack").unwrap();
2422 assert_eq!(stack.status, "UPDATE_ROLLBACK_COMPLETE");
2423 }
2424
2425 #[tokio::test]
2426 async fn create_stack_resolves_ref_to_physical_id() {
2427 let svc = make_service();
2428
2429 let template = r#"{
2431 "Resources": {
2432 "MyTopic": {
2433 "Type": "AWS::SNS::Topic",
2434 "Properties": { "TopicName": "ref-test-topic" }
2435 },
2436 "MySub": {
2437 "Type": "AWS::SNS::Subscription",
2438 "Properties": {
2439 "TopicArn": { "Ref": "MyTopic" },
2440 "Protocol": "sqs",
2441 "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
2442 }
2443 }
2444 }
2445 }"#;
2446
2447 let mut params = HashMap::new();
2448 params.insert("StackName".to_string(), "ref-stack".to_string());
2449 params.insert("TemplateBody".to_string(), template.to_string());
2450 let req = make_request("CreateStack", params);
2451 let result = svc.create_stack(&req).await;
2452 assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
2453
2454 let accounts = svc.state.read();
2456 let state = accounts.get("123456789012").unwrap();
2457 let stack = state.stacks.get("ref-stack").unwrap();
2458 assert_eq!(stack.resources.len(), 2);
2459 assert_eq!(stack.status, "CREATE_COMPLETE");
2460
2461 let sub = stack
2463 .resources
2464 .iter()
2465 .find(|r| r.logical_id == "MySub")
2466 .unwrap();
2467 assert!(
2468 sub.physical_id.contains("ref-test-topic"),
2469 "Subscription physical ID should reference the topic ARN, got: {}",
2470 sub.physical_id
2471 );
2472 }
2473
2474 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2481 async fn create_stack_custom_resource_provisions_asynchronously() {
2482 let svc = make_service();
2483 let template = r#"{
2484 "Resources": {
2485 "MyCustom": {
2486 "Type": "Custom::Thing",
2487 "Properties": {
2488 "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:handler"
2489 }
2490 }
2491 }
2492 }"#;
2493 let mut params = HashMap::new();
2494 params.insert("StackName".to_string(), "async-stack".to_string());
2495 params.insert("TemplateBody".to_string(), template.to_string());
2496 let req = make_request("CreateStack", params);
2497
2498 let resp = svc
2505 .create_stack(&req)
2506 .await
2507 .expect("create returns StackId");
2508 assert!(resp.status.is_success());
2509 {
2510 let accounts = svc.state.read();
2511 let stack = accounts
2512 .get("123456789012")
2513 .unwrap()
2514 .stacks
2515 .get("async-stack")
2516 .expect("stack seeded synchronously");
2517 assert!(
2518 stack.status == "CREATE_IN_PROGRESS" || stack.status == "CREATE_COMPLETE",
2519 "unexpected status right after create: {}",
2520 stack.status
2521 );
2522 }
2523
2524 let mut status = String::new();
2527 for _ in 0..200 {
2528 {
2529 let accounts = svc.state.read();
2530 if let Some(stack) = accounts
2531 .get("123456789012")
2532 .and_then(|s| s.stacks.get("async-stack"))
2533 {
2534 status = stack.status.clone();
2535 if status != "CREATE_IN_PROGRESS" {
2536 break;
2537 }
2538 }
2539 }
2540 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2541 }
2542 assert_eq!(
2543 status, "CREATE_COMPLETE",
2544 "stack should reach CREATE_COMPLETE"
2545 );
2546
2547 let accounts = svc.state.read();
2548 let stack = accounts
2549 .get("123456789012")
2550 .unwrap()
2551 .stacks
2552 .get("async-stack")
2553 .unwrap();
2554 assert_eq!(stack.resources.len(), 1);
2555 assert_eq!(stack.resources[0].resource_type, "Custom::Thing");
2556 }
2557
2558 #[tokio::test]
2561 async fn create_stack_missing_name_errors() {
2562 let svc = make_service();
2563 let mut params = HashMap::new();
2564 params.insert("TemplateBody".to_string(), "{}".to_string());
2565 let req = make_request("CreateStack", params);
2566 assert!(svc.create_stack(&req).await.is_err());
2567 }
2568
2569 #[tokio::test]
2570 async fn create_stack_missing_template_creates_empty_stack() {
2571 let svc = make_service();
2576 let mut params = HashMap::new();
2577 params.insert("StackName".to_string(), "s".to_string());
2578 let req = make_request("CreateStack", params);
2579 svc.create_stack(&req)
2580 .await
2581 .expect("empty-body create succeeds");
2582 }
2583
2584 #[tokio::test]
2585 async fn create_stack_duplicate_errors() {
2586 let svc = make_service();
2587 let mut params = HashMap::new();
2588 params.insert("StackName".to_string(), "dup".to_string());
2589 params.insert(
2590 "TemplateBody".to_string(),
2591 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
2592 .to_string(),
2593 );
2594 let req = make_request("CreateStack", params.clone());
2595 svc.create_stack(&req).await.unwrap();
2596 let req = make_request("CreateStack", params);
2597 assert!(svc.create_stack(&req).await.is_err());
2598 }
2599
2600 #[tokio::test]
2601 async fn create_stack_invalid_template_creates_empty_stack() {
2602 let svc = make_service();
2606 let mut params = HashMap::new();
2607 params.insert("StackName".to_string(), "bad".to_string());
2608 params.insert("TemplateBody".to_string(), "not json".to_string());
2609 let req = make_request("CreateStack", params);
2610 svc.create_stack(&req)
2611 .await
2612 .expect("bad-body create succeeds");
2613 }
2614
2615 #[tokio::test]
2616 async fn delete_stack_unknown_is_noop() {
2617 let svc = make_service();
2618 let mut params = HashMap::new();
2619 params.insert("StackName".to_string(), "ghost".to_string());
2620 let req = make_request("DeleteStack", params);
2621 assert!(svc.delete_stack(&req).await.is_ok());
2622 }
2623
2624 #[test]
2625 fn describe_stacks_nonexistent_errors() {
2626 let svc = make_service();
2631 let mut params = HashMap::new();
2632 params.insert("StackName".to_string(), "ghost".to_string());
2633 let req = make_request("DescribeStacks", params);
2634 match svc.describe_stacks(&req) {
2635 Ok(_) => panic!("ghost stack must return an error, not an empty list"),
2636 Err(e) => {
2637 assert_eq!(e.status(), StatusCode::BAD_REQUEST);
2638 assert_eq!(e.code(), "ValidationError");
2639 assert!(
2640 e.message().contains("does not exist"),
2641 "got: {}",
2642 e.message()
2643 );
2644 }
2645 }
2646 }
2647
2648 #[test]
2649 fn describe_stacks_empty_returns_all() {
2650 let svc = make_service();
2651 let req = make_request("DescribeStacks", HashMap::new());
2652 let resp = svc.describe_stacks(&req).unwrap();
2653 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2654 assert!(b.contains("DescribeStacksResult"));
2655 }
2656
2657 #[test]
2658 fn list_stacks_empty_returns_ok() {
2659 let svc = make_service();
2660 let req = make_request("ListStacks", HashMap::new());
2661 let resp = svc.list_stacks(&req).unwrap();
2662 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2663 assert!(b.contains("ListStacksResult"));
2664 }
2665
2666 #[test]
2667 fn list_stack_resources_missing_name_returns_validation_error() {
2668 let svc = make_service();
2674 let req = make_request("ListStackResources", HashMap::new());
2675 let err = match svc.list_stack_resources(&req) {
2676 Err(e) => e,
2677 Ok(_) => panic!("omitted StackName must be rejected"),
2678 };
2679 assert_eq!(err.code(), "ValidationError");
2680 }
2681
2682 #[test]
2683 fn list_stack_resources_unknown_stack_returns_empty() {
2684 let svc = make_service();
2685 let mut params = HashMap::new();
2686 params.insert("StackName".to_string(), "ghost".to_string());
2687 let req = make_request("ListStackResources", params);
2688 svc.list_stack_resources(&req).expect("unknown is empty");
2689 }
2690
2691 #[test]
2692 fn describe_stack_resources_missing_name_returns_empty() {
2693 let svc = make_service();
2694 let req = make_request("DescribeStackResources", HashMap::new());
2695 svc.describe_stack_resources(&req)
2696 .expect("missing name is ok");
2697 }
2698
2699 #[test]
2700 fn get_template_missing_name_returns_empty_body() {
2701 let svc = make_service();
2702 let req = make_request("GetTemplate", HashMap::new());
2703 svc.get_template(&req).expect("missing name is ok");
2704 }
2705
2706 #[test]
2707 fn get_template_unknown_stack_returns_empty_body() {
2708 let svc = make_service();
2709 let mut params = HashMap::new();
2710 params.insert("StackName".to_string(), "ghost".to_string());
2711 let req = make_request("GetTemplate", params);
2712 svc.get_template(&req).expect("unknown is empty");
2713 }
2714
2715 #[tokio::test]
2716 async fn update_stack_missing_name_errors() {
2717 let svc = make_service();
2718 let mut params = HashMap::new();
2719 params.insert("TemplateBody".to_string(), "{}".to_string());
2720 let req = make_request("UpdateStack", params);
2721 assert!(svc.update_stack(&req).await.is_err());
2722 }
2723
2724 #[tokio::test]
2725 async fn update_stack_unknown_stack_returns_synthetic_id() {
2726 let svc = make_service();
2733 let mut params = HashMap::new();
2734 params.insert("StackName".to_string(), "ghost".to_string());
2735 params.insert(
2736 "TemplateBody".to_string(),
2737 r#"{"Resources":{}}"#.to_string(),
2738 );
2739 let req = make_request("UpdateStack", params);
2740 let resp = svc
2741 .update_stack(&req)
2742 .await
2743 .expect("ghost update is synthetic");
2744 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2745 assert!(b.contains("UpdateStackResult"));
2746 }
2747
2748 #[tokio::test]
2749 async fn create_stack_resolves_outputs_and_records_export() {
2750 let svc = make_service();
2751 let template = r#"{
2752 "Resources": {
2753 "Q": {"Type":"AWS::SQS::Queue","Properties":{"QueueName":"out-q"}}
2754 },
2755 "Outputs": {
2756 "QueueUrl": {
2757 "Value": {"Ref": "Q"},
2758 "Description": "Url",
2759 "Export": {"Name": "TheQueueUrl"}
2760 }
2761 }
2762 }"#;
2763 let mut params = HashMap::new();
2764 params.insert("StackName".to_string(), "outs".to_string());
2765 params.insert("TemplateBody".to_string(), template.to_string());
2766 let req = make_request("CreateStack", params);
2767 svc.create_stack(&req).await.expect("create stack");
2768
2769 let accounts = svc.state.read();
2770 let stack = accounts
2771 .get("123456789012")
2772 .unwrap()
2773 .stacks
2774 .get("outs")
2775 .unwrap();
2776 assert_eq!(stack.outputs.len(), 1);
2777 assert_eq!(stack.outputs[0].key, "QueueUrl");
2778 assert_eq!(stack.outputs[0].export_name.as_deref(), Some("TheQueueUrl"));
2779 assert!(!stack.outputs[0].value.is_empty());
2780 }
2781
2782 #[tokio::test]
2783 async fn create_stack_rejects_duplicate_export_name() {
2784 let svc = make_service();
2785 let mk = |name: &str| {
2786 let template = format!(
2787 r#"{{
2788 "Resources": {{"Q":{{"Type":"AWS::SQS::Queue","Properties":{{"QueueName":"q-{name}"}}}}}},
2789 "Outputs": {{"QueueUrl":{{"Value":{{"Ref":"Q"}},"Export":{{"Name":"DupExport"}}}}}}
2790 }}"#
2791 );
2792 let mut params = HashMap::new();
2793 params.insert("StackName".to_string(), name.to_string());
2794 params.insert("TemplateBody".to_string(), template);
2795 make_request("CreateStack", params)
2796 };
2797 match svc.create_stack(&mk("first")).await {
2798 Ok(_) => {}
2799 Err(e) => panic!("first stack: {e:?}"),
2800 }
2801 svc.create_stack(&mk("second"))
2807 .await
2808 .expect("CreateStack returns StackId even when provisioning fails");
2809 let accounts = svc.state.read();
2810 let stack = accounts
2811 .get("123456789012")
2812 .unwrap()
2813 .stacks
2814 .get("second")
2815 .expect("second stack recorded");
2816 assert_eq!(stack.status, "CREATE_FAILED");
2817 let exports = &accounts.get("123456789012").unwrap().exports;
2819 assert_eq!(
2820 exports
2821 .get("DupExport")
2822 .map(|e| e.exporting_stack_name.as_str()),
2823 Some("first")
2824 );
2825 }
2826
2827 #[tokio::test]
2828 async fn import_value_resolves_against_other_stack_export() {
2829 let svc = make_service();
2830
2831 let producer_tpl = r#"{
2832 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod-q"}}},
2833 "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"SharedQueueUrl"}}}
2834 }"#;
2835 let mut p = HashMap::new();
2836 p.insert("StackName".to_string(), "producer".to_string());
2837 p.insert("TemplateBody".to_string(), producer_tpl.to_string());
2838 svc.create_stack(&make_request("CreateStack", p))
2839 .await
2840 .expect("producer");
2841
2842 let consumer_tpl = r#"{
2843 "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cons-q"}}},
2844 "Outputs": {"Imp":{"Value":{"Fn::ImportValue":"SharedQueueUrl"}}}
2845 }"#;
2846 let mut p = HashMap::new();
2847 p.insert("StackName".to_string(), "consumer".to_string());
2848 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2849 svc.create_stack(&make_request("CreateStack", p))
2850 .await
2851 .expect("consumer");
2852
2853 let accounts = svc.state.read();
2854 let prod_url = accounts
2855 .get("123456789012")
2856 .unwrap()
2857 .stacks
2858 .get("producer")
2859 .unwrap()
2860 .outputs[0]
2861 .value
2862 .clone();
2863 let cons = accounts
2864 .get("123456789012")
2865 .unwrap()
2866 .stacks
2867 .get("consumer")
2868 .unwrap();
2869 assert_eq!(cons.outputs[0].value, prod_url);
2870 }
2871
2872 #[tokio::test]
2873 async fn create_stack_records_export_in_state_registry() {
2874 let svc = make_service();
2875 let template = r#"{
2876 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"reg-q"}}},
2877 "Outputs": {"Url":{"Value":{"Ref":"Q"},"Export":{"Name":"reg-url"}}}
2878 }"#;
2879 let mut params = HashMap::new();
2880 params.insert("StackName".to_string(), "reg".to_string());
2881 params.insert("TemplateBody".to_string(), template.to_string());
2882 svc.create_stack(&make_request("CreateStack", params))
2883 .await
2884 .expect("create");
2885
2886 let accounts = svc.state.read();
2887 let state = accounts.get("123456789012").unwrap();
2888 let export = state
2889 .exports
2890 .get("reg-url")
2891 .expect("export registered in state.exports");
2892 assert_eq!(export.exporting_stack_name, "reg");
2893 assert!(!export.value.is_empty());
2894 assert!(export.exporting_stack_id.contains("reg"));
2895 }
2896
2897 #[tokio::test]
2898 async fn import_value_with_unknown_export_errors() {
2899 let svc = make_service();
2900 let consumer_tpl = r#"{
2901 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{
2902 "QueueName": {"Fn::ImportValue":"missing-export"}
2903 }}}
2904 }"#;
2905 let mut p = HashMap::new();
2906 p.insert("StackName".to_string(), "bad-consumer".to_string());
2907 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2908 match svc.create_stack(&make_request("CreateStack", p)).await {
2909 Ok(_) => panic!("expected ValidationError for unknown export"),
2910 Err(e) => {
2911 let msg = format!("{e:?}");
2912 assert!(msg.contains("No export named missing-export"), "got {msg}");
2913 }
2914 }
2915 }
2916
2917 #[tokio::test]
2918 async fn delete_stack_blocked_when_export_in_use_and_unblocked_after_consumer_delete() {
2919 let svc = make_service();
2920
2921 let producer_tpl = r#"{
2922 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod"}}},
2923 "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"my-arn"}}}
2924 }"#;
2925 let mut p = HashMap::new();
2926 p.insert("StackName".to_string(), "producer".to_string());
2927 p.insert("TemplateBody".to_string(), producer_tpl.to_string());
2928 svc.create_stack(&make_request("CreateStack", p))
2929 .await
2930 .expect("producer");
2931
2932 let consumer_tpl = r#"{
2933 "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{
2934 "QueueName": "cons-q",
2935 "Tags": [{"Key":"k","Value":{"Fn::ImportValue":"my-arn"}}]
2936 }}}
2937 }"#;
2938 let mut p = HashMap::new();
2939 p.insert("StackName".to_string(), "consumer".to_string());
2940 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
2941 svc.create_stack(&make_request("CreateStack", p))
2942 .await
2943 .expect("consumer");
2944
2945 let mut p = HashMap::new();
2947 p.insert("StackName".to_string(), "producer".to_string());
2948 match svc.delete_stack(&make_request("DeleteStack", p)).await {
2949 Ok(_) => panic!("delete must fail while imports exist"),
2950 Err(e) => {
2951 let msg = format!("{e:?}");
2952 assert!(msg.contains("Export my-arn cannot be deleted"), "got {msg}");
2953 }
2954 }
2955
2956 let mut p = HashMap::new();
2958 p.insert("StackName".to_string(), "consumer".to_string());
2959 svc.delete_stack(&make_request("DeleteStack", p))
2960 .await
2961 .expect("consumer delete");
2962
2963 let mut p = HashMap::new();
2965 p.insert("StackName".to_string(), "producer".to_string());
2966 svc.delete_stack(&make_request("DeleteStack", p))
2967 .await
2968 .expect("producer delete after consumer gone");
2969
2970 let accounts = svc.state.read();
2971 let state = accounts.get("123456789012").unwrap();
2972 assert!(state.exports.is_empty(), "exports cleared after delete");
2973 assert!(state.imports.is_empty(), "imports cleared after delete");
2974 }
2975
2976 use std::sync::atomic::{AtomicUsize, Ordering};
2979
2980 fn counting_hook(counter: Arc<AtomicUsize>) -> fakecloud_persistence::SnapshotHook {
2983 Arc::new(move || {
2984 let counter = counter.clone();
2985 Box::pin(async move {
2986 counter.fetch_add(1, Ordering::SeqCst);
2987 })
2988 })
2989 }
2990
2991 fn disk_s3_store(tmp: &tempfile::TempDir) -> Arc<fakecloud_persistence::s3::DiskS3Store> {
2992 let cache = Arc::new(fakecloud_persistence::cache::BodyCache::new(1024 * 1024));
2993 Arc::new(fakecloud_persistence::s3::DiskS3Store::new(
2994 tmp.path().to_path_buf(),
2995 cache,
2996 ))
2997 }
2998
2999 const PERSIST_TEMPLATE: &str = r#"{"Resources":{
3003 "Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cfn-q"}},
3004 "T":{"Type":"AWS::SNS::Topic","Properties":{"TopicName":"cfn-t"}},
3005 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"cfn-bucket"}}
3006 }}"#;
3007
3008 fn create_req(stack: &str) -> AwsRequest {
3009 let mut p = HashMap::new();
3010 p.insert("StackName".to_string(), stack.to_string());
3011 p.insert("TemplateBody".to_string(), PERSIST_TEMPLATE.to_string());
3012 make_request("CreateStack", p)
3013 }
3014
3015 #[tokio::test]
3016 async fn cfn_create_persists_touched_services_and_writes_bucket_to_store() {
3017 let tmp = tempfile::tempdir().unwrap();
3018 let store = disk_s3_store(&tmp);
3019 let counter = Arc::new(AtomicUsize::new(0));
3020 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3021 BTreeMap::new();
3022 hooks.insert("sqs", counting_hook(counter.clone()));
3023 hooks.insert("sns", counting_hook(counter.clone()));
3024 hooks.insert("lambda", counting_hook(counter.clone()));
3026 let svc = make_service()
3027 .with_s3_store(store.clone())
3028 .with_snapshot_hooks(hooks);
3029
3030 svc.create_stack(&create_req("probe")).await.unwrap();
3031
3032 assert_eq!(counter.load(Ordering::SeqCst), 2);
3034 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3036 assert!(
3037 loaded.buckets.contains_key("cfn-bucket"),
3038 "CFN bucket should be persisted to the S3 store"
3039 );
3040 }
3041
3042 #[tokio::test]
3043 async fn cfn_delete_persists_touched_services_and_removes_bucket_from_store() {
3044 let tmp = tempfile::tempdir().unwrap();
3045 let store = disk_s3_store(&tmp);
3046 let counter = Arc::new(AtomicUsize::new(0));
3047 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3048 BTreeMap::new();
3049 hooks.insert("sqs", counting_hook(counter.clone()));
3050 hooks.insert("sns", counting_hook(counter.clone()));
3051 let svc = make_service()
3052 .with_s3_store(store.clone())
3053 .with_snapshot_hooks(hooks);
3054
3055 svc.create_stack(&create_req("probe")).await.unwrap();
3056 assert_eq!(counter.load(Ordering::SeqCst), 2, "create fired sqs + sns");
3057
3058 let mut p = HashMap::new();
3059 p.insert("StackName".to_string(), "probe".to_string());
3060 svc.delete_stack(&make_request("DeleteStack", p))
3061 .await
3062 .unwrap();
3063
3064 assert_eq!(counter.load(Ordering::SeqCst), 4, "delete fired sqs + sns");
3066 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3069 assert!(
3070 !loaded.buckets.contains_key("cfn-bucket"),
3071 "CFN-deleted bucket should be removed from the S3 store"
3072 );
3073 }
3074
3075 #[tokio::test]
3076 async fn cfn_persist_skips_services_without_a_registered_hook() {
3077 let tmp = tempfile::tempdir().unwrap();
3080 let store = disk_s3_store(&tmp);
3081 let counter = Arc::new(AtomicUsize::new(0));
3082 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3083 BTreeMap::new();
3084 hooks.insert("sqs", counting_hook(counter.clone()));
3085 let svc = make_service()
3086 .with_s3_store(store.clone())
3087 .with_snapshot_hooks(hooks);
3088
3089 svc.create_stack(&create_req("probe")).await.unwrap();
3090 assert_eq!(counter.load(Ordering::SeqCst), 1, "only sqs has a hook");
3091 }
3092
3093 #[tokio::test]
3094 async fn cfn_update_persists_touched_services() {
3095 let tmp = tempfile::tempdir().unwrap();
3098 let store = disk_s3_store(&tmp);
3099 let counter = Arc::new(AtomicUsize::new(0));
3100 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3101 BTreeMap::new();
3102 hooks.insert("sqs", counting_hook(counter.clone()));
3103 hooks.insert("sns", counting_hook(counter.clone()));
3104 let svc = make_service()
3105 .with_s3_store(store.clone())
3106 .with_snapshot_hooks(hooks);
3107
3108 let mut create = HashMap::new();
3109 create.insert("StackName".to_string(), "upd".to_string());
3110 create.insert(
3111 "TemplateBody".to_string(),
3112 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"u-q"}}}}"#
3113 .to_string(),
3114 );
3115 svc.create_stack(&make_request("CreateStack", create))
3116 .await
3117 .unwrap();
3118 let after_create = counter.load(Ordering::SeqCst);
3119
3120 let mut update = HashMap::new();
3121 update.insert("StackName".to_string(), "upd".to_string());
3122 update.insert("TemplateBody".to_string(), PERSIST_TEMPLATE.to_string());
3123 svc.update_stack(&make_request("UpdateStack", update))
3124 .await
3125 .unwrap();
3126
3127 assert!(
3129 counter.load(Ordering::SeqCst) > after_create,
3130 "update should persist the services it touched"
3131 );
3132 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3133 assert!(loaded.buckets.contains_key("cfn-bucket"));
3134 }
3135
3136 #[test]
3137 fn service_key_for_type_maps_services_and_aliases() {
3138 assert_eq!(
3140 service_key_for_type("AWS::Lambda::Function"),
3141 Some("lambda")
3142 );
3143 assert_eq!(
3144 service_key_for_type("AWS::SecretsManager::Secret"),
3145 Some("secretsmanager")
3146 );
3147 assert_eq!(service_key_for_type("AWS::SQS::Queue"), Some("sqs"));
3148 assert_eq!(service_key_for_type("AWS::IAM::Role"), Some("iam"));
3149 assert_eq!(
3150 service_key_for_type("AWS::StepFunctions::StateMachine"),
3151 Some("stepfunctions")
3152 );
3153 assert_eq!(
3155 service_key_for_type("AWS::Events::Rule"),
3156 Some("eventbridge")
3157 );
3158 assert_eq!(service_key_for_type("AWS::Logs::LogGroup"), Some("logs"));
3159 assert_eq!(service_key_for_type("AWS::S3::Bucket"), None);
3161 assert_eq!(
3163 service_key_for_type("AWS::CertificateManager::Certificate"),
3164 None
3165 );
3166 assert_eq!(service_key_for_type("AWS::Lambda"), None);
3168 assert_eq!(service_key_for_type("Custom::Thing::Resource"), None);
3169 assert_eq!(service_key_for_type("AWS"), None);
3170 assert_eq!(service_key_for_type(""), None);
3171 }
3172
3173 #[tokio::test]
3174 async fn persist_touched_services_noop_with_empty_hooks() {
3175 let hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> = BTreeMap::new();
3177 persist_touched_services(&hooks, vec!["AWS::SQS::Queue".to_string()]).await;
3178 }
3179
3180 #[tokio::test]
3181 async fn cfn_bucket_policy_write_through_create_update_delete() {
3182 let tmp = tempfile::tempdir().unwrap();
3183 let store = disk_s3_store(&tmp);
3184 let svc = make_service().with_s3_store(store.clone());
3185
3186 let mut create = HashMap::new();
3188 create.insert("StackName".to_string(), "pol".to_string());
3189 create.insert(
3190 "TemplateBody".to_string(),
3191 r#"{"Resources":{
3192 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"pol-bucket"}},
3193 "BP":{"Type":"AWS::S3::BucketPolicy","Properties":{"Bucket":"pol-bucket","PolicyDocument":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Principal":"*"}]}}}
3194 }}"#
3195 .to_string(),
3196 );
3197 svc.create_stack(&make_request("CreateStack", create))
3198 .await
3199 .unwrap();
3200 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3201 let policy = loaded.buckets["pol-bucket"]
3202 .subresources
3203 .get("policy.toml")
3204 .cloned()
3205 .expect("bucket policy persisted on create");
3206 assert!(policy.contains("s3:GetObject"));
3207
3208 let mut update = HashMap::new();
3210 update.insert("StackName".to_string(), "pol".to_string());
3211 update.insert(
3212 "TemplateBody".to_string(),
3213 r#"{"Resources":{
3214 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"pol-bucket"}},
3215 "BP":{"Type":"AWS::S3::BucketPolicy","Properties":{"Bucket":"pol-bucket","PolicyDocument":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"*","Principal":"*"}]}}}
3216 }}"#
3217 .to_string(),
3218 );
3219 svc.update_stack(&make_request("UpdateStack", update))
3220 .await
3221 .unwrap();
3222 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3223 let policy = loaded.buckets["pol-bucket"]
3224 .subresources
3225 .get("policy.toml")
3226 .cloned()
3227 .expect("bucket policy still persisted after update");
3228 assert!(
3229 policy.contains("s3:PutObject"),
3230 "updated policy should be written through"
3231 );
3232
3233 let mut del = HashMap::new();
3235 del.insert("StackName".to_string(), "pol".to_string());
3236 svc.delete_stack(&make_request("DeleteStack", del))
3237 .await
3238 .unwrap();
3239 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3240 assert!(
3241 !loaded.buckets.contains_key("pol-bucket"),
3242 "CFN-deleted bucket and policy should be gone from the store"
3243 );
3244 }
3245}