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 "AWS::EC2::VPC" => &["VpcId", "CidrBlock"],
51 "AWS::EC2::Subnet" => &["SubnetId", "AvailabilityZone", "VpcId", "CidrBlock"],
52 "AWS::EC2::SecurityGroup" => &["GroupId", "VpcId"],
53 "AWS::EC2::InternetGateway" => &["InternetGatewayId"],
54 "AWS::EC2::RouteTable" => &["RouteTableId"],
55 _ => &[],
56 }
57}
58
59fn service_key_for_type(resource_type: &str) -> Option<&'static str> {
70 let mut parts = resource_type.split("::");
71 let vendor = parts.next()?;
72 let service = parts.next()?;
73 parts.next()?;
76 if vendor != "AWS" {
77 return None;
78 }
79 Some(match service {
80 "Lambda" => "lambda",
81 "SecretsManager" => "secretsmanager",
82 "SQS" => "sqs",
83 "SNS" => "sns",
84 "DynamoDB" => "dynamodb",
85 "StepFunctions" => "stepfunctions",
86 "Events" => "eventbridge",
87 "SSM" => "ssm",
88 "Logs" => "logs",
89 "KMS" => "kms",
90 "Kinesis" => "kinesis",
91 "SES" => "ses",
92 "Cognito" => "cognito",
93 "RDS" => "rds",
94 "ElastiCache" => "elasticache",
95 "ECR" => "ecr",
96 "ECS" => "ecs",
97 "CloudWatch" => "cloudwatch",
98 "ApiGateway" => "apigateway",
99 "ApiGatewayV2" => "apigatewayv2",
100 "Bedrock" => "bedrock",
101 "Scheduler" => "scheduler",
102 "IAM" => "iam",
103 "CertificateManager" => "acm",
110 "ElasticLoadBalancingV2" => "elbv2",
111 "CloudFront" => "cloudfront",
112 "Route53" => "route53",
113 "KinesisFirehose" => "firehose",
114 "Glue" => "glue",
115 "WAFv2" => "wafv2",
116 "Athena" => "athena",
117 "Organizations" => "organizations",
118 "EC2" => "ec2",
125 "AutoScaling" => "autoscaling",
126 "Batch" => "batch",
127 "ApplicationAutoScaling" => "application-autoscaling",
128 _ => return None,
129 })
130}
131
132async fn persist_touched_services<I>(
141 hooks: &BTreeMap<&'static str, SnapshotHook>,
142 resource_types: I,
143) where
144 I: IntoIterator<Item = String>,
145{
146 if hooks.is_empty() {
147 return;
148 }
149 let mut keys: BTreeSet<&'static str> = BTreeSet::new();
150 for ty in resource_types {
151 if let Some(key) = service_key_for_type(&ty) {
152 keys.insert(key);
153 }
154 }
155 for key in keys {
156 if let Some(hook) = hooks.get(key) {
157 hook().await;
158 }
159 }
160}
161
162pub(crate) fn provision_stack_resources(
171 provisioner: &ResourceProvisioner,
172 resource_defs: &[template::ResourceDefinition],
173 template_body: &str,
174 parameters: &BTreeMap<String, String>,
175 imports: &BTreeMap<String, String>,
176) -> Result<Vec<StackResource>, AwsServiceError> {
177 let mut resources = Vec::new();
178 let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
179 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
180 let order = template::dependency_order(template_body, parameters, resource_defs);
185 let mut pending: Vec<&template::ResourceDefinition> =
186 order.iter().map(|&i| &resource_defs[i]).collect();
187 let max_passes = pending.len() + 1;
188
189 for _ in 0..max_passes {
190 if pending.is_empty() {
191 break;
192 }
193 let mut still_pending = Vec::new();
194 let mut made_progress = false;
195
196 for resource_def in pending {
197 let resolved_def = template::resolve_resource_properties_with_attrs(
198 resource_def,
199 template_body,
200 parameters,
201 &physical_ids,
202 &attributes,
203 imports,
204 )
205 .map_err(|e| {
206 AwsServiceError::aws_error(
210 StatusCode::BAD_REQUEST,
211 "InsufficientCapabilitiesException",
212 e,
213 )
214 })?;
215
216 match provisioner.create_resource(&resolved_def) {
217 Ok(mut stack_resource) => {
218 physical_ids.insert(
219 stack_resource.logical_id.clone(),
220 stack_resource.physical_id.clone(),
221 );
222 let mut attr_map = stack_resource.attributes.clone();
227 for attr in well_known_attributes_for(&stack_resource.resource_type) {
228 if attr_map.contains_key(*attr) {
229 continue;
230 }
231 if let Some(v) = provisioner.get_att(&stack_resource, attr) {
232 attr_map.insert((*attr).to_string(), v);
233 }
234 }
235 attributes.insert(stack_resource.logical_id.clone(), attr_map.clone());
236 stack_resource.attributes = attr_map;
241 resources.push(stack_resource);
242 made_progress = true;
243 }
244 Err(_) => still_pending.push(resource_def),
245 }
246 }
247
248 pending = still_pending;
249 if !made_progress && !pending.is_empty() {
250 let resource_def = pending[0];
253 let resolved_def = template::resolve_resource_properties_with_attrs(
254 resource_def,
255 template_body,
256 parameters,
257 &physical_ids,
258 &attributes,
259 imports,
260 )
261 .unwrap_or_else(|_| resource_def.clone());
262 let err = provisioner.create_resource(&resolved_def).unwrap_err();
263 for r in &resources {
264 let _ = provisioner.delete_resource(r);
265 }
266 return Err(AwsServiceError::aws_error(
267 StatusCode::BAD_REQUEST,
268 "ValidationError",
269 format!(
270 "Failed to create resource {}: {err}",
271 resource_def.logical_id
272 ),
273 ));
274 }
275 }
276
277 Ok(resources)
278}
279
280pub struct CloudFormationDeps {
282 pub sqs: SharedSqsState,
283 pub sns: SharedSnsState,
284 pub ssm: SharedSsmState,
285 pub iam: SharedIamState,
286 pub s3: SharedS3State,
287 pub eventbridge: SharedEventBridgeState,
288 pub dynamodb: SharedDynamoDbState,
289 pub logs: SharedLogsState,
290 pub lambda: fakecloud_lambda::SharedLambdaState,
291 pub secretsmanager: fakecloud_secretsmanager::SharedSecretsManagerState,
292 pub kinesis: fakecloud_kinesis::SharedKinesisState,
293 pub kms: fakecloud_kms::SharedKmsState,
294 pub ecr: fakecloud_ecr::SharedEcrState,
295 pub cloudwatch: fakecloud_cloudwatch::SharedCloudWatchState,
296 pub elbv2: fakecloud_elbv2::SharedElbv2State,
297 pub organizations: fakecloud_organizations::SharedOrganizationsState,
298 pub cognito: fakecloud_cognito::SharedCognitoState,
299 pub rds: fakecloud_rds::SharedRdsState,
300 pub ec2: fakecloud_ec2::SharedEc2State,
301 pub autoscaling: fakecloud_autoscaling::SharedAutoScalingState,
302 pub batch: fakecloud_batch::SharedBatchState,
303 pub ecs: fakecloud_ecs::SharedEcsState,
304 pub acm: fakecloud_acm::SharedAcmState,
305 pub elasticache: fakecloud_elasticache::SharedElastiCacheState,
306 pub route53: fakecloud_route53::SharedRoute53State,
307 pub cloudfront: fakecloud_cloudfront::SharedCloudFrontState,
308 pub stepfunctions: fakecloud_stepfunctions::SharedStepFunctionsState,
309 pub wafv2: fakecloud_wafv2::SharedWafv2State,
310 pub apigateway: fakecloud_apigateway::SharedApiGatewayState,
311 pub apigatewayv2: fakecloud_apigatewayv2::SharedApiGatewayV2State,
312 pub ses: fakecloud_ses::SharedSesState,
313 pub application_autoscaling:
314 fakecloud_application_autoscaling::SharedApplicationAutoScalingState,
315 pub athena: fakecloud_athena::SharedAthenaState,
316 pub firehose: fakecloud_firehose::SharedFirehoseState,
317 pub glue: fakecloud_glue::SharedGlueState,
318 pub delivery: Arc<DeliveryBus>,
319 pub lambda_runtime: Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
326}
327
328pub struct CloudFormationService {
329 pub(crate) state: SharedCloudFormationState,
330 pub(crate) deps: CloudFormationDeps,
331 snapshot_store: Option<Arc<dyn SnapshotStore>>,
332 snapshot_lock: Arc<AsyncMutex<()>>,
333 s3_store: Arc<dyn S3Store>,
339 snapshot_hooks: BTreeMap<&'static str, SnapshotHook>,
346}
347
348struct CreateStackContext {
352 state: SharedCloudFormationState,
353 delivery: Arc<DeliveryBus>,
354 snapshot_store: Option<Arc<dyn SnapshotStore>>,
355 snapshot_lock: Arc<AsyncMutex<()>>,
356 snapshot_hooks: BTreeMap<&'static str, SnapshotHook>,
357 provisioner: ResourceProvisioner,
358 account_id: String,
359 stack_name: String,
360 stack_id: String,
361 template_body: String,
362 parameters: BTreeMap<String, String>,
363 notification_arns: Vec<String>,
364 imported_names: Vec<String>,
365 resource_defs: Vec<template::ResourceDefinition>,
366}
367
368impl CloudFormationService {
369 pub fn new(state: SharedCloudFormationState, deps: CloudFormationDeps) -> Self {
370 Self {
371 state,
372 deps,
373 snapshot_store: None,
374 snapshot_lock: Arc::new(AsyncMutex::new(())),
375 s3_store: Arc::new(fakecloud_persistence::s3::MemoryS3Store::new()),
376 snapshot_hooks: BTreeMap::new(),
377 }
378 }
379
380 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
381 self.snapshot_store = Some(store);
382 self
383 }
384
385 pub fn with_s3_store(mut self, store: Arc<dyn S3Store>) -> Self {
388 self.s3_store = store;
389 self
390 }
391
392 pub fn with_snapshot_hooks(mut self, hooks: BTreeMap<&'static str, SnapshotHook>) -> Self {
395 self.snapshot_hooks = hooks;
396 self
397 }
398
399 async fn save_snapshot(&self) {
400 let Some(store) = self.snapshot_store.clone() else {
401 return;
402 };
403 let _guard = self.snapshot_lock.lock().await;
404 let snapshot = CloudFormationSnapshot {
405 schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
406 state: None,
407 accounts: Some(self.state.read().clone()),
408 };
409 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
410 let bytes = serde_json::to_vec(&snapshot)
411 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
412 store.save(&bytes)
413 })
414 .await;
415 match join {
416 Ok(Ok(())) => {}
417 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
418 Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
419 }
420 }
421
422 pub(crate) fn provisioner(
423 &self,
424 stack_id: &str,
425 account_id: &str,
426 region: &str,
427 ) -> ResourceProvisioner {
428 ResourceProvisioner {
429 sqs_state: self.deps.sqs.clone(),
430 sns_state: self.deps.sns.clone(),
431 ssm_state: self.deps.ssm.clone(),
432 iam_state: self.deps.iam.clone(),
433 s3_state: self.deps.s3.clone(),
434 eventbridge_state: self.deps.eventbridge.clone(),
435 dynamodb_state: self.deps.dynamodb.clone(),
436 logs_state: self.deps.logs.clone(),
437 lambda_state: self.deps.lambda.clone(),
438 secretsmanager_state: self.deps.secretsmanager.clone(),
439 kinesis_state: self.deps.kinesis.clone(),
440 kms_state: self.deps.kms.clone(),
441 ecr_state: self.deps.ecr.clone(),
442 cloudwatch_state: self.deps.cloudwatch.clone(),
443 elbv2_state: self.deps.elbv2.clone(),
444 organizations_state: self.deps.organizations.clone(),
445 cognito_state: self.deps.cognito.clone(),
446 rds_state: self.deps.rds.clone(),
447 ec2_state: self.deps.ec2.clone(),
448 autoscaling_state: self.deps.autoscaling.clone(),
449 batch_state: self.deps.batch.clone(),
450 ecs_state: self.deps.ecs.clone(),
451 acm_state: self.deps.acm.clone(),
452 elasticache_state: self.deps.elasticache.clone(),
453 route53_state: self.deps.route53.clone(),
454 cloudfront_state: self.deps.cloudfront.clone(),
455 stepfunctions_state: self.deps.stepfunctions.clone(),
456 wafv2_state: self.deps.wafv2.clone(),
457 apigateway_state: self.deps.apigateway.clone(),
458 apigatewayv2_state: self.deps.apigatewayv2.clone(),
459 ses_state: self.deps.ses.clone(),
460 app_autoscaling_state: self.deps.application_autoscaling.clone(),
461 athena_state: self.deps.athena.clone(),
462 firehose_state: self.deps.firehose.clone(),
463 glue_state: self.deps.glue.clone(),
464 cloudformation_state: self.state.clone(),
465 delivery: self.deps.delivery.clone(),
466 lambda_runtime: self.deps.lambda_runtime.clone(),
467 s3_store: self.s3_store.clone(),
468 account_id: account_id.to_string(),
469 region: region.to_string(),
470 stack_id: stack_id.to_string(),
471 }
472 }
473
474 fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
475 if let Some(v) = req.query_params.get(key) {
477 return Some(v.clone());
478 }
479 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
481 body_params.get(key).cloned()
482 }
483
484 pub(crate) fn get_all_params(req: &AwsRequest) -> BTreeMap<String, String> {
485 let mut params: BTreeMap<String, String> = req.query_params.clone().into_iter().collect();
486 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
487 for (k, v) in body_params {
488 params.entry(k).or_insert(v);
489 }
490 params
491 }
492
493 pub(crate) fn extract_tags(params: &BTreeMap<String, String>) -> BTreeMap<String, String> {
494 let mut tags = BTreeMap::new();
495 for i in 1.. {
496 let key_param = format!("Tags.member.{i}.Key");
497 let value_param = format!("Tags.member.{i}.Value");
498 match (params.get(&key_param), params.get(&value_param)) {
499 (Some(k), Some(v)) => {
500 tags.insert(k.clone(), v.clone());
501 }
502 _ => break,
503 }
504 }
505 tags
506 }
507
508 pub(crate) fn extract_parameters(
509 params: &BTreeMap<String, String>,
510 ) -> BTreeMap<String, String> {
511 let mut result = BTreeMap::new();
512 for i in 1.. {
513 let key_param = format!("Parameters.member.{i}.ParameterKey");
514 let value_param = format!("Parameters.member.{i}.ParameterValue");
515 match (params.get(&key_param), params.get(&value_param)) {
516 (Some(k), Some(v)) => {
517 result.insert(k.clone(), v.clone());
518 }
519 _ => break,
520 }
521 }
522 result
523 }
524
525 pub(crate) fn merge_parameter_defaults(
531 parameters: &mut BTreeMap<String, String>,
532 template_body: &str,
533 ) {
534 let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
535 match serde_json::from_str(template_body) {
536 Ok(v) => v,
537 Err(_) => return,
538 }
539 } else {
540 match serde_yaml::from_str(template_body) {
541 Ok(v) => v,
542 Err(_) => return,
543 }
544 };
545 let Some(decls) = value.get("Parameters").and_then(|v| v.as_object()) else {
546 return;
547 };
548 for (name, spec) in decls {
549 if parameters.contains_key(name) {
550 continue;
551 }
552 if let Some(default) = spec.get("Default") {
553 let s = default
554 .as_str()
555 .map(|s| s.to_string())
556 .unwrap_or_else(|| default.to_string());
557 parameters.insert(name.clone(), s);
558 }
559 }
560 }
561
562 pub(crate) fn extract_notification_arns(params: &BTreeMap<String, String>) -> Vec<String> {
563 let mut arns = Vec::new();
564 for i in 1.. {
565 let key = format!("NotificationARNs.member.{i}");
566 match params.get(&key) {
567 Some(arn) => arns.push(arn.clone()),
568 None => break,
569 }
570 }
571 arns
572 }
573
574 fn send_stack_notification(
575 delivery: &DeliveryBus,
576 notification_arns: &[String],
577 stack_name: &str,
578 stack_id: &str,
579 status: &str,
580 ) {
581 if notification_arns.is_empty() {
582 return;
583 }
584 let message = format!(
585 "StackId='{}'\nTimestamp='{}'\nEventId='{}'\nLogicalResourceId='{}'\nResourceStatus='{}'\nResourceType='AWS::CloudFormation::Stack'\nStackName='{}'",
586 stack_id,
587 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
588 uuid::Uuid::new_v4(),
589 stack_name,
590 status,
591 stack_name,
592 );
593 for arn in notification_arns {
594 delivery.publish_to_sns(arn, &message, Some("AWS CloudFormation Notification"));
595 }
596 }
597
598 pub(crate) fn collect_account_imports(
603 state: &SharedCloudFormationState,
604 account_id: &str,
605 skip_stack: Option<&str>,
606 ) -> BTreeMap<String, String> {
607 let mut imports = BTreeMap::new();
608 let accounts = state.read();
609 let Some(state) = accounts.get(account_id) else {
610 return imports;
611 };
612 for (name, export) in &state.exports {
613 if matches!(skip_stack, Some(skip) if skip == export.exporting_stack_name) {
614 continue;
615 }
616 imports.insert(name.clone(), export.value.clone());
617 }
618 imports
619 }
620
621 fn validate_import_values(
626 state: &SharedCloudFormationState,
627 account_id: &str,
628 stack_name: &str,
629 template_body: &str,
630 parameters: &BTreeMap<String, String>,
631 ) -> Result<Vec<String>, AwsServiceError> {
632 let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
633 match serde_json::from_str(template_body) {
634 Ok(v) => v,
635 Err(_) => return Ok(Vec::new()),
636 }
637 } else {
638 match serde_yaml::from_str(template_body) {
639 Ok(v) => v,
640 Err(_) => return Ok(Vec::new()),
641 }
642 };
643 let names = template::collect_import_value_names(&value, parameters);
644 let known = Self::collect_account_imports(state, account_id, Some(stack_name));
645 for n in &names {
646 if !known.contains_key(n) {
647 return Err(AwsServiceError::aws_error(
652 StatusCode::BAD_REQUEST,
653 "InsufficientCapabilitiesException",
654 format!("No export named {n} found."),
655 ));
656 }
657 }
658 Ok(names)
659 }
660
661 pub(crate) fn sync_exports_imports(
665 state: &mut CloudFormationState,
666 stack_id: &str,
667 stack_name: &str,
668 outputs: &[state::StackOutput],
669 imported_names: &[String],
670 ) {
671 let stale_exports: Vec<String> = state
673 .exports
674 .iter()
675 .filter(|(_, e)| e.exporting_stack_name == stack_name)
676 .map(|(k, _)| k.clone())
677 .collect();
678 for k in stale_exports {
679 state.exports.remove(&k);
680 }
681 for entries in state.imports.values_mut() {
683 entries.retain(|s| s != stack_name);
684 }
685 state.imports.retain(|_, v| !v.is_empty());
686
687 for o in outputs {
689 if let Some(export) = &o.export_name {
690 state.exports.insert(
691 export.clone(),
692 state::StackExport {
693 value: o.value.clone(),
694 exporting_stack_id: stack_id.to_string(),
695 exporting_stack_name: stack_name.to_string(),
696 },
697 );
698 }
699 }
700 for name in imported_names {
702 let entry = state.imports.entry(name.clone()).or_default();
703 if !entry.iter().any(|s| s == stack_name) {
704 entry.push(stack_name.to_string());
705 }
706 }
707 }
708
709 pub(crate) fn resolve_template_outputs(
714 template_body: &str,
715 parameters: &BTreeMap<String, String>,
716 resources: &[StackResource],
717 state: &SharedCloudFormationState,
718 ) -> Vec<state::StackOutput> {
719 let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
720 match serde_json::from_str(template_body) {
721 Ok(v) => v,
722 Err(_) => return Vec::new(),
723 }
724 } else {
725 match serde_yaml::from_str(template_body) {
726 Ok(v) => v,
727 Err(_) => return Vec::new(),
728 }
729 };
730
731 let resources_obj = match value.get("Resources").and_then(|v| v.as_object()) {
732 Some(o) => o.clone(),
733 None => return Vec::new(),
734 };
735
736 let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
737 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
738 for r in resources {
739 physical_ids.insert(r.logical_id.clone(), r.physical_id.clone());
740 attributes.insert(r.logical_id.clone(), r.attributes.clone());
741 }
742
743 let imports = {
744 let accounts = state.read();
745 let mut out = BTreeMap::new();
746 for (_account, st) in accounts.iter() {
749 for (name, export) in &st.exports {
750 out.insert(name.clone(), export.value.clone());
751 }
752 }
753 out
754 };
755
756 let parsed = match template::parse_outputs(
757 &value,
758 parameters,
759 &resources_obj,
760 &physical_ids,
761 &attributes,
762 &imports,
763 ) {
764 Ok(o) => o,
765 Err(_) => return Vec::new(),
766 };
767
768 parsed
769 .into_iter()
770 .map(|o| state::StackOutput {
771 key: o.logical_id,
772 value: o.value,
773 description: o.description,
774 export_name: o.export_name,
775 })
776 .collect()
777 }
778
779 fn ensure_export_uniqueness(
782 state: &SharedCloudFormationState,
783 account_id: &str,
784 stack_name: &str,
785 outputs: &[state::StackOutput],
786 ) -> Result<(), AwsServiceError> {
787 let existing = Self::collect_account_imports(state, account_id, Some(stack_name));
788 for o in outputs {
789 if let Some(export) = &o.export_name {
790 if existing.contains_key(export) {
791 return Err(AwsServiceError::aws_error(
795 StatusCode::BAD_REQUEST,
796 "AlreadyExistsException",
797 format!("Export with name {export} is already exported by another stack"),
798 ));
799 }
800 }
801 }
802 Ok(())
803 }
804
805 async fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
806 let params = Self::get_all_params(req);
807
808 let stack_name = params.get("StackName").ok_or_else(|| {
811 AwsServiceError::aws_error(
812 StatusCode::BAD_REQUEST,
813 "ValidationError",
814 "StackName is required",
815 )
816 })?;
817
818 let empty = String::new();
822 let template_body = params.get("TemplateBody").unwrap_or(&empty);
823
824 {
826 let accounts = self.state.read();
827 let empty = CloudFormationState::new(&req.account_id, &req.region);
828 let state = accounts.get(&req.account_id).unwrap_or(&empty);
829 if let Some(existing) = state.stacks.get(stack_name.as_str()) {
830 if existing.status != "DELETE_COMPLETE" {
831 return Err(AwsServiceError::aws_error(
832 StatusCode::BAD_REQUEST,
833 "AlreadyExistsException",
834 format!("Stack [{stack_name}] already exists"),
835 ));
836 }
837 }
838 }
839
840 let tags = Self::extract_tags(¶ms);
841 let mut parameters = Self::extract_parameters(¶ms);
842 Self::merge_parameter_defaults(&mut parameters, template_body);
843 let notification_arns = Self::extract_notification_arns(¶ms);
844
845 let stack_id = format!(
848 "arn:aws:cloudformation:{}:{}:stack/{}/{}",
849 req.region,
850 req.account_id,
851 stack_name,
852 uuid::Uuid::new_v4()
853 );
854 parameters
855 .entry("AWS::Region".to_string())
856 .or_insert_with(|| req.region.clone());
857 parameters
858 .entry("AWS::AccountId".to_string())
859 .or_insert_with(|| req.account_id.clone());
860 parameters
861 .entry("AWS::StackId".to_string())
862 .or_insert_with(|| stack_id.clone());
863 parameters
864 .entry("AWS::StackName".to_string())
865 .or_insert_with(|| stack_name.clone());
866 parameters
867 .entry("AWS::Partition".to_string())
868 .or_insert_with(|| template::partition_for_region(&req.region).to_string());
869 parameters
870 .entry("AWS::URLSuffix".to_string())
871 .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
872 parameters.insert(
876 "AWS::NotificationARNs".to_string(),
877 serde_json::to_string(¬ification_arns).unwrap_or_else(|_| "[]".to_string()),
878 );
879
880 let parsed = template::parse_template(template_body, ¶meters).unwrap_or_else(|_| {
885 template::ParsedTemplate {
886 description: None,
887 resources: Vec::new(),
888 outputs: Vec::new(),
889 }
890 });
891
892 let imported_names = Self::validate_import_values(
896 &self.state,
897 &req.account_id,
898 stack_name,
899 template_body,
900 ¶meters,
901 )?;
902
903 {
910 let mut accounts = self.state.write();
911 let state = accounts.get_or_create(&req.account_id);
912 state.stacks.insert(
913 stack_name.clone(),
914 Stack {
915 name: stack_name.clone(),
916 stack_id: stack_id.clone(),
917 template: template_body.clone(),
918 status: "CREATE_IN_PROGRESS".to_string(),
919 resources: Vec::new(),
920 parameters: parameters.clone(),
921 tags: tags.clone(),
922 created_at: Utc::now(),
923 updated_at: None,
924 description: parsed.description.clone(),
925 notification_arns: notification_arns.clone(),
926 outputs: Vec::new(),
927 },
928 );
929 record_stack_status_event(
930 state,
931 &stack_id,
932 stack_name,
933 "AWS::CloudFormation::Stack",
934 "CREATE_IN_PROGRESS",
935 );
936 }
937
938 let ctx = CreateStackContext {
939 state: self.state.clone(),
940 delivery: self.deps.delivery.clone(),
941 snapshot_store: self.snapshot_store.clone(),
942 snapshot_lock: self.snapshot_lock.clone(),
943 snapshot_hooks: self.snapshot_hooks.clone(),
944 provisioner: self.provisioner(&stack_id, &req.account_id, &req.region),
945 account_id: req.account_id.clone(),
946 stack_name: stack_name.clone(),
947 stack_id: stack_id.clone(),
948 template_body: template_body.clone(),
949 parameters,
950 notification_arns,
951 imported_names,
952 resource_defs: parsed.resources,
953 };
954
955 let has_custom_resource = ctx.resource_defs.iter().any(|r| {
971 r.resource_type.starts_with("Custom::")
972 || r.resource_type == "AWS::CloudFormation::CustomResource"
973 });
974 let multi_thread = matches!(
975 tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()),
976 Ok(tokio::runtime::RuntimeFlavor::MultiThread)
977 );
978 if has_custom_resource && multi_thread {
979 Self::send_stack_notification(
984 &self.deps.delivery,
985 &ctx.notification_arns,
986 stack_name,
987 &stack_id,
988 "CREATE_IN_PROGRESS",
989 );
990 tokio::spawn(async move {
991 Self::finish_create_stack(ctx).await;
992 });
993 } else {
994 Self::finish_create_stack(ctx).await;
995 }
996
997 Ok(AwsResponse::xml(
998 StatusCode::OK,
999 xml_responses::create_stack_response(&stack_id, &req.request_id),
1000 ))
1001 }
1002
1003 async fn finish_create_stack(ctx: CreateStackContext) {
1009 let CreateStackContext {
1010 state,
1011 delivery,
1012 snapshot_store,
1013 snapshot_lock,
1014 snapshot_hooks,
1015 provisioner,
1016 account_id,
1017 stack_name,
1018 stack_id,
1019 template_body,
1020 parameters,
1021 notification_arns,
1022 imported_names,
1023 resource_defs,
1024 } = ctx;
1025
1026 let provision_result = {
1030 let template_body = template_body.clone();
1031 let parameters = parameters.clone();
1032 let imports = Self::collect_account_imports(&state, &account_id, Some(&stack_name));
1036 tokio::task::spawn_blocking(move || {
1037 provision_stack_resources(
1038 &provisioner,
1039 &resource_defs,
1040 &template_body,
1041 ¶meters,
1042 &imports,
1043 )
1044 })
1045 .await
1046 };
1047
1048 let provisioned = match provision_result {
1051 Ok(Ok(resources)) => Ok(resources),
1052 Ok(Err(err)) => Err(err.message()),
1053 Err(join_err) => Err(format!("provisioning task failed: {join_err}")),
1054 };
1055
1056 let resources = match provisioned {
1057 Ok(resources) => resources,
1058 Err(reason) => {
1059 Self::mark_create_failed(
1060 &state,
1061 &delivery,
1062 &account_id,
1063 &stack_name,
1064 &stack_id,
1065 ¬ification_arns,
1066 &reason,
1067 );
1068 save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
1069 return;
1070 }
1071 };
1072
1073 let outputs =
1074 Self::resolve_template_outputs(&template_body, ¶meters, &resources, &state);
1075
1076 if let Err(err) = Self::ensure_export_uniqueness(&state, &account_id, &stack_name, &outputs)
1079 {
1080 Self::mark_create_failed(
1081 &state,
1082 &delivery,
1083 &account_id,
1084 &stack_name,
1085 &stack_id,
1086 ¬ification_arns,
1087 &err.message(),
1088 );
1089 save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
1090 return;
1091 }
1092
1093 {
1094 let mut accounts = state.write();
1095 let st = accounts.get_or_create(&account_id);
1096 if let Some(stack) = st.stacks.get_mut(&stack_name) {
1097 stack.status = "CREATE_COMPLETE".to_string();
1098 stack.resources = resources.clone();
1099 stack.outputs = outputs.clone();
1100 }
1101 Self::sync_exports_imports(st, &stack_id, &stack_name, &outputs, &imported_names);
1102
1103 let changes: Vec<ResourceChange> = resources
1104 .iter()
1105 .map(|r| ResourceChange {
1106 action: ResourceChangeAction::Create,
1107 logical_id: r.logical_id.clone(),
1108 physical_id: r.physical_id.clone(),
1109 resource_type: r.resource_type.clone(),
1110 })
1111 .collect();
1112 record_stack_events(st, &stack_id, &stack_name, &changes);
1113 record_stack_status_event(
1114 st,
1115 &stack_id,
1116 &stack_name,
1117 "AWS::CloudFormation::Stack",
1118 "CREATE_COMPLETE",
1119 );
1120 }
1121
1122 Self::send_stack_notification(
1123 &delivery,
1124 ¬ification_arns,
1125 &stack_name,
1126 &stack_id,
1127 "CREATE_COMPLETE",
1128 );
1129
1130 save_snapshot_static(state, snapshot_store, snapshot_lock).await;
1131 persist_touched_services(
1136 &snapshot_hooks,
1137 resources.iter().map(|r| r.resource_type.clone()),
1138 )
1139 .await;
1140 }
1141
1142 fn mark_create_failed(
1146 state: &SharedCloudFormationState,
1147 delivery: &DeliveryBus,
1148 account_id: &str,
1149 stack_name: &str,
1150 stack_id: &str,
1151 notification_arns: &[String],
1152 reason: &str,
1153 ) {
1154 tracing::warn!(%stack_name, %reason, "CreateStack provisioning failed");
1155 {
1156 let mut accounts = state.write();
1157 let st = accounts.get_or_create(account_id);
1158 if let Some(stack) = st.stacks.get_mut(stack_name) {
1159 stack.status = "CREATE_FAILED".to_string();
1160 }
1161 record_stack_status_event(
1162 st,
1163 stack_id,
1164 stack_name,
1165 "AWS::CloudFormation::Stack",
1166 "CREATE_FAILED",
1167 );
1168 }
1169 Self::send_stack_notification(
1170 delivery,
1171 notification_arns,
1172 stack_name,
1173 stack_id,
1174 "CREATE_FAILED",
1175 );
1176 }
1177
1178 async fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1179 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
1180 AwsServiceError::aws_error(
1181 StatusCode::BAD_REQUEST,
1182 "ValidationError",
1183 "StackName is required",
1184 )
1185 })?;
1186
1187 let mut deleted_types: Vec<String> = Vec::new();
1193 {
1194 let mut accounts = self.state.write();
1195 let state = accounts.get_or_create(&req.account_id);
1196
1197 let stack = state.stacks.values_mut().find(|s| {
1199 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1200 });
1201
1202 if let Some(stack) = stack {
1203 let stack_id = stack.stack_id.clone();
1204 let stack_name_for_notif = stack.name.clone();
1205 let notification_arns = stack.notification_arns.clone();
1206 let resources: Vec<_> = stack.resources.clone();
1207
1208 let owned_exports: Vec<String> = state
1211 .exports
1212 .iter()
1213 .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
1214 .map(|(k, _)| k.clone())
1215 .collect();
1216 for export in &owned_exports {
1217 if let Some(consumers) = state.imports.get(export) {
1218 let consumers: Vec<&String> = consumers
1219 .iter()
1220 .filter(|c| **c != stack_name_for_notif)
1221 .collect();
1222 if !consumers.is_empty() {
1223 let names: Vec<&str> = consumers.iter().map(|s| s.as_str()).collect();
1224 return Err(AwsServiceError::aws_error(
1231 StatusCode::BAD_REQUEST,
1232 "TokenAlreadyExistsException",
1233 format!(
1234 "Export {export} cannot be deleted as it is in use by {}",
1235 names.join(", ")
1236 ),
1237 ));
1238 }
1239 }
1240 }
1241
1242 drop(accounts);
1245 let provisioner = self.provisioner(&stack_id, &req.account_id, &req.region);
1246
1247 for resource in resources.iter().rev() {
1249 let _ = provisioner.delete_resource(resource);
1250 }
1251
1252 let mut accounts = self.state.write();
1254 let state = accounts.get_or_create(&req.account_id);
1255 if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
1256 stack.status = "DELETE_COMPLETE".to_string();
1257 stack.resources.clear();
1258 stack.outputs.clear();
1259 }
1260 let stale_exports: Vec<String> = state
1262 .exports
1263 .iter()
1264 .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
1265 .map(|(k, _)| k.clone())
1266 .collect();
1267 for k in stale_exports {
1268 state.exports.remove(&k);
1269 }
1270 for entries in state.imports.values_mut() {
1271 entries.retain(|s| s != &stack_name_for_notif);
1272 }
1273 state.imports.retain(|_, v| !v.is_empty());
1274 drop(accounts);
1275
1276 Self::send_stack_notification(
1277 &self.deps.delivery,
1278 ¬ification_arns,
1279 &stack_name_for_notif,
1280 &stack_id,
1281 "DELETE_COMPLETE",
1282 );
1283
1284 deleted_types = resources.iter().map(|r| r.resource_type.clone()).collect();
1285 }
1286 }
1287
1288 persist_touched_services(&self.snapshot_hooks, deleted_types).await;
1292
1293 Ok(AwsResponse::xml(
1294 StatusCode::OK,
1295 xml_responses::delete_stack_response(&req.request_id),
1296 ))
1297 }
1298
1299 fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1300 let stack_name = Self::get_param(req, "StackName");
1301
1302 let accounts = self.state.read();
1303 let empty = CloudFormationState::new(&req.account_id, &req.region);
1304 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1305 let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
1306 state
1307 .stacks
1308 .values()
1309 .filter(|s| {
1310 (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
1311 })
1312 .cloned()
1313 .collect()
1314 } else {
1315 state
1316 .stacks
1317 .values()
1318 .filter(|s| s.status != "DELETE_COMPLETE")
1319 .cloned()
1320 .collect()
1321 };
1322
1323 if let Some(ref name) = stack_name {
1334 if stacks.is_empty() {
1335 return Err(AwsServiceError::aws_error(
1336 StatusCode::BAD_REQUEST,
1337 "ValidationError",
1338 format!("Stack with id {name} does not exist"),
1339 ));
1340 }
1341 }
1342
1343 Ok(AwsResponse::xml(
1344 StatusCode::OK,
1345 xml_responses::describe_stacks_response(&stacks, &req.request_id),
1346 ))
1347 }
1348
1349 fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1350 let accounts = self.state.read();
1351 let empty = CloudFormationState::new(&req.account_id, &req.region);
1352 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1353 let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
1354
1355 Ok(AwsResponse::xml(
1356 StatusCode::OK,
1357 xml_responses::list_stacks_response(&stacks, &req.request_id),
1358 ))
1359 }
1360
1361 fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1362 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
1368 AwsServiceError::aws_error(
1369 StatusCode::BAD_REQUEST,
1370 "ValidationError",
1371 "StackName is required",
1372 )
1373 })?;
1374
1375 let accounts = self.state.read();
1376 let empty = CloudFormationState::new(&req.account_id, &req.region);
1377 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1378 let resources = state
1379 .stacks
1380 .values()
1381 .find(|s| {
1382 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1383 })
1384 .map(|s| s.resources.clone())
1385 .unwrap_or_default();
1386
1387 Ok(AwsResponse::xml(
1388 StatusCode::OK,
1389 xml_responses::list_stack_resources_response(&resources, &req.request_id),
1390 ))
1391 }
1392
1393 fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1394 let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
1397
1398 let accounts = self.state.read();
1399 let empty = CloudFormationState::new(&req.account_id, &req.region);
1400 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1401 let (resources, resolved_name) = state
1402 .stacks
1403 .values()
1404 .find(|s| {
1405 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1406 })
1407 .map(|s| (s.resources.clone(), s.name.clone()))
1408 .unwrap_or_else(|| (Vec::new(), stack_name.clone()));
1409
1410 Ok(AwsResponse::xml(
1411 StatusCode::OK,
1412 xml_responses::describe_stack_resources_response(
1413 &resources,
1414 &resolved_name,
1415 &req.request_id,
1416 ),
1417 ))
1418 }
1419
1420 async fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1421 let mut input = UpdateStackInput::from_params(req)?;
1422
1423 let found_stack_id = {
1425 let accounts = self.state.read();
1426 let empty = CloudFormationState::new(&req.account_id, &req.region);
1427 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1428 state
1429 .stacks
1430 .values()
1431 .find(|s| {
1432 (s.name == input.stack_name || s.stack_id == input.stack_name)
1433 && s.status != "DELETE_COMPLETE"
1434 })
1435 .map(|s| s.stack_id.clone())
1436 .unwrap_or_default()
1437 };
1438
1439 input
1443 .parameters
1444 .entry("AWS::Region".to_string())
1445 .or_insert_with(|| req.region.clone());
1446 input
1447 .parameters
1448 .entry("AWS::AccountId".to_string())
1449 .or_insert_with(|| req.account_id.clone());
1450 input
1451 .parameters
1452 .entry("AWS::StackId".to_string())
1453 .or_insert_with(|| found_stack_id.clone());
1454 input
1455 .parameters
1456 .entry("AWS::StackName".to_string())
1457 .or_insert_with(|| input.stack_name.clone());
1458 input
1459 .parameters
1460 .entry("AWS::Partition".to_string())
1461 .or_insert_with(|| template::partition_for_region(&req.region).to_string());
1462 input
1463 .parameters
1464 .entry("AWS::URLSuffix".to_string())
1465 .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
1466 if !input.notification_arns.is_empty() {
1471 input.parameters.insert(
1472 "AWS::NotificationARNs".to_string(),
1473 serde_json::to_string(&input.notification_arns)
1474 .unwrap_or_else(|_| "[]".to_string()),
1475 );
1476 } else {
1477 let existing: Vec<String> = {
1480 let accounts = self.state.read();
1481 accounts
1482 .get(&req.account_id)
1483 .and_then(|s| {
1484 s.stacks
1485 .values()
1486 .find(|st| st.stack_id == found_stack_id)
1487 .map(|st| st.notification_arns.clone())
1488 })
1489 .unwrap_or_default()
1490 };
1491 input.parameters.insert(
1492 "AWS::NotificationARNs".to_string(),
1493 serde_json::to_string(&existing).unwrap_or_else(|_| "[]".to_string()),
1494 );
1495 }
1496
1497 let parsed = template::parse_template(&input.template_body, &input.parameters)
1502 .unwrap_or_else(|_| template::ParsedTemplate {
1503 description: None,
1504 resources: Vec::new(),
1505 outputs: Vec::new(),
1506 });
1507
1508 let imported_names = Self::validate_import_values(
1509 &self.state,
1510 &req.account_id,
1511 &input.stack_name,
1512 &input.template_body,
1513 &input.parameters,
1514 )?;
1515
1516 let provisioner = self.provisioner(&found_stack_id, &req.account_id, &req.region);
1517
1518 let imports =
1522 Self::collect_account_imports(&self.state, &req.account_id, Some(&input.stack_name));
1523
1524 let (touched_types, stack_id, stack_name_for_notif, notification_arns, resources_snapshot) = {
1529 let mut accounts = self.state.write();
1530 let state = accounts.get_or_create(&req.account_id);
1531 let stack_exists = state.stacks.values().any(|s| {
1540 (s.name == input.stack_name || s.stack_id == input.stack_name)
1541 && s.status != "DELETE_COMPLETE"
1542 });
1543 if !stack_exists {
1544 let stack_id = if found_stack_id.is_empty() {
1545 format!(
1546 "arn:aws:cloudformation:{}:{}:stack/{}/{}",
1547 req.region,
1548 req.account_id,
1549 input.stack_name,
1550 uuid::Uuid::new_v4()
1551 )
1552 } else {
1553 found_stack_id.clone()
1554 };
1555 return Ok(AwsResponse::xml(
1556 StatusCode::OK,
1557 xml_responses::update_stack_response(&stack_id, &req.request_id),
1558 ));
1559 }
1560 let (update_result, stack_id, stack_name_owned, resources_snapshot, notification_arns) = {
1561 let stack = state
1562 .stacks
1563 .values_mut()
1564 .find(|s| {
1565 (s.name == input.stack_name || s.stack_id == input.stack_name)
1566 && s.status != "DELETE_COMPLETE"
1567 })
1568 .expect("stack existence checked above");
1569
1570 stack.status = "UPDATE_IN_PROGRESS".to_string();
1571 let update_result = apply_resource_updates(
1572 stack,
1573 &parsed.resources,
1574 &input.template_body,
1575 &input.parameters,
1576 &provisioner,
1577 &imports,
1578 );
1579
1580 let stack_id = stack.stack_id.clone();
1581 let stack_name_owned = stack.name.clone();
1582 stack.template = input.template_body.clone();
1583 stack.status = if update_result.is_err() {
1584 "UPDATE_ROLLBACK_COMPLETE".to_string()
1585 } else {
1586 "UPDATE_COMPLETE".to_string()
1587 };
1588 stack.parameters = input.parameters.clone();
1589 if !input.tags.is_empty() {
1590 stack.tags = input.tags;
1591 }
1592 stack.updated_at = Some(Utc::now());
1593 stack.description = parsed.description;
1594 if !input.notification_arns.is_empty() {
1595 stack.notification_arns = input.notification_arns.clone();
1596 }
1597 if update_result.is_ok() {
1598 stack.outputs.clear();
1599 }
1600 (
1601 update_result,
1602 stack_id,
1603 stack_name_owned,
1604 stack.resources.clone(),
1605 stack.notification_arns.clone(),
1606 )
1607 };
1608
1609 record_stack_status_event(
1611 state,
1612 &stack_id,
1613 &stack_name_owned,
1614 "AWS::CloudFormation::Stack",
1615 "UPDATE_IN_PROGRESS",
1616 );
1617 let update_result = match update_result {
1618 Ok(changes) => {
1619 let touched_types: Vec<String> =
1623 changes.iter().map(|c| c.resource_type.clone()).collect();
1624 record_stack_events(state, &stack_id, &stack_name_owned, &changes);
1625 record_stack_status_event(
1626 state,
1627 &stack_id,
1628 &stack_name_owned,
1629 "AWS::CloudFormation::Stack",
1630 "UPDATE_COMPLETE",
1631 );
1632 Ok(touched_types)
1633 }
1634 Err(e) => {
1635 record_stack_status_event(
1636 state,
1637 &stack_id,
1638 &stack_name_owned,
1639 "AWS::CloudFormation::Stack",
1640 "UPDATE_ROLLBACK_COMPLETE",
1641 );
1642 Err(e)
1643 }
1644 };
1645 let stack_name_for_notif = stack_name_owned.clone();
1646
1647 let touched_types = match update_result {
1648 Ok(types) => types,
1649 Err(error_msg) => {
1650 drop(accounts);
1651 Self::send_stack_notification(
1652 &self.deps.delivery,
1653 ¬ification_arns,
1654 &stack_name_for_notif,
1655 &stack_id,
1656 "UPDATE_FAILED",
1657 );
1658 return Err(AwsServiceError::aws_error(
1659 StatusCode::BAD_REQUEST,
1660 "InsufficientCapabilitiesException",
1661 error_msg,
1662 ));
1663 }
1664 };
1665
1666 drop(accounts);
1667 (
1668 touched_types,
1669 stack_id,
1670 stack_name_for_notif,
1671 notification_arns,
1672 resources_snapshot,
1673 )
1674 };
1675
1676 let outputs = Self::resolve_template_outputs(
1677 &input.template_body,
1678 &input.parameters,
1679 &resources_snapshot,
1680 &self.state,
1681 );
1682 Self::ensure_export_uniqueness(&self.state, &req.account_id, &input.stack_name, &outputs)?;
1683 {
1684 let mut accounts = self.state.write();
1685 let state = accounts.get_or_create(&req.account_id);
1686 if let Some(stack) = state
1687 .stacks
1688 .values_mut()
1689 .find(|s| s.stack_id == stack_id && s.status != "DELETE_COMPLETE")
1690 {
1691 stack.outputs = outputs.clone();
1692 }
1693 Self::sync_exports_imports(
1694 state,
1695 &stack_id,
1696 &input.stack_name,
1697 &outputs,
1698 &imported_names,
1699 );
1700 }
1701
1702 Self::send_stack_notification(
1703 &self.deps.delivery,
1704 ¬ification_arns,
1705 &stack_name_for_notif,
1706 &stack_id,
1707 "UPDATE_COMPLETE",
1708 );
1709
1710 persist_touched_services(&self.snapshot_hooks, touched_types).await;
1713
1714 Ok(AwsResponse::xml(
1715 StatusCode::OK,
1716 xml_responses::update_stack_response(&stack_id, &req.request_id),
1717 ))
1718 }
1719
1720 fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1721 let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
1723
1724 let accounts = self.state.read();
1725 let empty = CloudFormationState::new(&req.account_id, &req.region);
1726 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1727 let body = state
1732 .stacks
1733 .values()
1734 .find(|s| {
1735 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1736 })
1737 .map(|s| s.template.clone())
1738 .unwrap_or_default();
1739
1740 Ok(AwsResponse::xml(
1741 StatusCode::OK,
1742 xml_responses::get_template_response(&body, &req.request_id),
1743 ))
1744 }
1745}
1746
1747#[async_trait]
1748impl AwsService for CloudFormationService {
1749 fn service_name(&self) -> &str {
1750 "cloudformation"
1751 }
1752
1753 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1754 let action = req.action.as_str();
1755
1756 crate::input_constraints::validate_input(action, &Self::get_all_params(&req))?;
1763
1764 let mutates = matches!(
1768 action,
1769 "CreateStack"
1770 | "DeleteStack"
1771 | "UpdateStack"
1772 | "CreateChangeSet"
1773 | "DeleteChangeSet"
1774 | "ExecuteChangeSet"
1775 | "CreateStackSet"
1776 | "DeleteStackSet"
1777 | "CreateStackRefactor"
1778 | "CreateGeneratedTemplate"
1779 | "DeleteGeneratedTemplate"
1780 | "SetStackPolicy"
1781 | "UpdateTerminationProtection"
1782 | "ActivateOrganizationsAccess"
1783 | "DeactivateOrganizationsAccess"
1784 );
1785 let result = match action {
1786 "CreateStack" => self.create_stack(&req).await,
1787 "DeleteStack" => self.delete_stack(&req).await,
1788 "DescribeStacks" => self.describe_stacks(&req),
1789 "ListStacks" => self.list_stacks(&req),
1790 "ListStackResources" => self.list_stack_resources(&req),
1791 "DescribeStackResources" => self.describe_stack_resources(&req),
1792 "UpdateStack" => self.update_stack(&req).await,
1793 "GetTemplate" => self.get_template(&req),
1794 _ => self.handle_extra_action(&req),
1795 };
1796 if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
1797 self.save_snapshot().await;
1798 }
1799 if action == "ExecuteChangeSet"
1809 && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
1810 {
1811 for hook in self.snapshot_hooks.values() {
1812 hook().await;
1813 }
1814 }
1815 result
1816 }
1817
1818 fn supported_actions(&self) -> &[&str] {
1819 &[
1820 "ActivateOrganizationsAccess",
1821 "ActivateType",
1822 "BatchDescribeTypeConfigurations",
1823 "CancelUpdateStack",
1824 "ContinueUpdateRollback",
1825 "CreateChangeSet",
1826 "CreateGeneratedTemplate",
1827 "CreateStack",
1828 "CreateStackInstances",
1829 "CreateStackRefactor",
1830 "CreateStackSet",
1831 "DeactivateOrganizationsAccess",
1832 "DeactivateType",
1833 "DeleteChangeSet",
1834 "DeleteGeneratedTemplate",
1835 "DeleteStack",
1836 "DeleteStackInstances",
1837 "DeleteStackSet",
1838 "DeregisterType",
1839 "DescribeAccountLimits",
1840 "DescribeChangeSet",
1841 "DescribeChangeSetHooks",
1842 "DescribeEvents",
1843 "DescribeGeneratedTemplate",
1844 "DescribeOrganizationsAccess",
1845 "DescribePublisher",
1846 "DescribeResourceScan",
1847 "DescribeStackDriftDetectionStatus",
1848 "DescribeStackEvents",
1849 "DescribeStackInstance",
1850 "DescribeStackRefactor",
1851 "DescribeStackResource",
1852 "DescribeStackResourceDrifts",
1853 "DescribeStackResources",
1854 "DescribeStackSet",
1855 "DescribeStackSetOperation",
1856 "DescribeStacks",
1857 "DescribeType",
1858 "DescribeTypeRegistration",
1859 "DetectStackDrift",
1860 "DetectStackResourceDrift",
1861 "DetectStackSetDrift",
1862 "EstimateTemplateCost",
1863 "ExecuteChangeSet",
1864 "ExecuteStackRefactor",
1865 "GetGeneratedTemplate",
1866 "GetHookResult",
1867 "GetStackPolicy",
1868 "GetTemplate",
1869 "GetTemplateSummary",
1870 "ImportStacksToStackSet",
1871 "ListChangeSets",
1872 "ListExports",
1873 "ListGeneratedTemplates",
1874 "ListHookResults",
1875 "ListImports",
1876 "ListResourceScanRelatedResources",
1877 "ListResourceScanResources",
1878 "ListResourceScans",
1879 "ListStackInstanceResourceDrifts",
1880 "ListStackInstances",
1881 "ListStackRefactorActions",
1882 "ListStackRefactors",
1883 "ListStackResources",
1884 "ListStackSetAutoDeploymentTargets",
1885 "ListStackSetOperationResults",
1886 "ListStackSetOperations",
1887 "ListStackSets",
1888 "ListStacks",
1889 "ListTypeRegistrations",
1890 "ListTypeVersions",
1891 "ListTypes",
1892 "PublishType",
1893 "RecordHandlerProgress",
1894 "RegisterPublisher",
1895 "RegisterType",
1896 "RollbackStack",
1897 "SetStackPolicy",
1898 "SetTypeConfiguration",
1899 "SetTypeDefaultVersion",
1900 "SignalResource",
1901 "StartResourceScan",
1902 "StopStackSetOperation",
1903 "TestType",
1904 "UpdateGeneratedTemplate",
1905 "UpdateStack",
1906 "UpdateStackInstances",
1907 "UpdateStackSet",
1908 "UpdateTerminationProtection",
1909 "ValidateTemplate",
1910 ]
1911 }
1912}
1913
1914struct UpdateStackInput {
1916 stack_name: String,
1917 template_body: String,
1918 parameters: BTreeMap<String, String>,
1919 tags: BTreeMap<String, String>,
1920 notification_arns: Vec<String>,
1921}
1922
1923impl UpdateStackInput {
1924 fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
1925 let params = CloudFormationService::get_all_params(req);
1926
1927 let stack_name = params
1928 .get("StackName")
1929 .ok_or_else(|| {
1930 AwsServiceError::aws_error(
1931 StatusCode::BAD_REQUEST,
1932 "ValidationError",
1933 "StackName is required",
1934 )
1935 })?
1936 .to_string();
1937
1938 let template_body = params.get("TemplateBody").cloned().unwrap_or_default();
1943
1944 let mut parameters = CloudFormationService::extract_parameters(¶ms);
1945 CloudFormationService::merge_parameter_defaults(&mut parameters, &template_body);
1946 Ok(Self {
1947 stack_name,
1948 template_body,
1949 parameters,
1950 tags: CloudFormationService::extract_tags(¶ms),
1951 notification_arns: CloudFormationService::extract_notification_arns(¶ms),
1952 })
1953 }
1954}
1955
1956#[derive(Debug, Clone)]
1960pub(crate) struct ResourceChange {
1961 pub action: ResourceChangeAction,
1962 pub logical_id: String,
1963 pub physical_id: String,
1964 pub resource_type: String,
1965}
1966
1967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1968pub(crate) enum ResourceChangeAction {
1969 Create,
1970 Update,
1971 Delete,
1972}
1973
1974impl ResourceChangeAction {
1975 pub fn status_in_progress(self) -> &'static str {
1976 match self {
1977 Self::Create => "CREATE_IN_PROGRESS",
1978 Self::Update => "UPDATE_IN_PROGRESS",
1979 Self::Delete => "DELETE_IN_PROGRESS",
1980 }
1981 }
1982 pub fn status_complete(self) -> &'static str {
1983 match self {
1984 Self::Create => "CREATE_COMPLETE",
1985 Self::Update => "UPDATE_COMPLETE",
1986 Self::Delete => "DELETE_COMPLETE",
1987 }
1988 }
1989}
1990
1991pub(crate) fn apply_resource_updates(
1996 stack: &mut crate::state::Stack,
1997 new_resource_defs: &[template::ResourceDefinition],
1998 template_body: &str,
1999 parameters: &BTreeMap<String, String>,
2000 provisioner: &crate::resource_provisioner::ResourceProvisioner,
2001 imports: &BTreeMap<String, String>,
2002) -> Result<Vec<ResourceChange>, String> {
2003 let mut changes: Vec<ResourceChange> = Vec::new();
2004 let old_logical_ids: std::collections::HashSet<String> = stack
2005 .resources
2006 .iter()
2007 .map(|r| r.logical_id.clone())
2008 .collect();
2009 let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
2010 .iter()
2011 .map(|r| r.logical_id.clone())
2012 .collect();
2013
2014 let to_remove: Vec<_> = stack
2016 .resources
2017 .iter()
2018 .filter(|r| !new_logical_ids.contains(&r.logical_id))
2019 .cloned()
2020 .collect();
2021 for resource in &to_remove {
2022 let _ = provisioner.delete_resource(resource);
2023 changes.push(ResourceChange {
2024 action: ResourceChangeAction::Delete,
2025 logical_id: resource.logical_id.clone(),
2026 physical_id: resource.physical_id.clone(),
2027 resource_type: resource.resource_type.clone(),
2028 });
2029 }
2030 stack
2031 .resources
2032 .retain(|r| new_logical_ids.contains(&r.logical_id));
2033
2034 let mut physical_ids: BTreeMap<String, String> = stack
2036 .resources
2037 .iter()
2038 .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
2039 .collect();
2040 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = stack
2041 .resources
2042 .iter()
2043 .map(|r| (r.logical_id.clone(), r.attributes.clone()))
2044 .collect();
2045
2046 let order = template::dependency_order(template_body, parameters, new_resource_defs);
2052 for &idx in &order {
2053 let resource_def = &new_resource_defs[idx];
2054 let resolved_def = template::resolve_resource_properties_with_attrs(
2055 resource_def,
2056 template_body,
2057 parameters,
2058 &physical_ids,
2059 &attributes,
2060 imports,
2061 )
2062 .map_err(|e| {
2063 format!(
2064 "Failed to resolve resource {}: {e}",
2065 resource_def.logical_id
2066 )
2067 })?;
2068
2069 if !old_logical_ids.contains(&resource_def.logical_id) {
2070 match provisioner.create_resource(&resolved_def) {
2071 Ok(stack_resource) => {
2072 changes.push(ResourceChange {
2073 action: ResourceChangeAction::Create,
2074 logical_id: stack_resource.logical_id.clone(),
2075 physical_id: stack_resource.physical_id.clone(),
2076 resource_type: stack_resource.resource_type.clone(),
2077 });
2078 physical_ids.insert(
2079 stack_resource.logical_id.clone(),
2080 stack_resource.physical_id.clone(),
2081 );
2082 attributes.insert(
2083 stack_resource.logical_id.clone(),
2084 stack_resource.attributes.clone(),
2085 );
2086 stack.resources.push(stack_resource);
2087 }
2088 Err(e) => {
2089 tracing::warn!(
2090 "Failed to create resource {} during update: {e}",
2091 resource_def.logical_id
2092 );
2093 return Err(format!(
2094 "Failed to create resource {}: {e}",
2095 resource_def.logical_id
2096 ));
2097 }
2098 }
2099 } else {
2100 let existing = stack
2106 .resources
2107 .iter()
2108 .find(|r| r.logical_id == resource_def.logical_id)
2109 .cloned();
2110 if let Some(existing) = existing {
2111 match provisioner.update_resource(&existing, &resolved_def) {
2112 Ok(Some(updated)) => {
2113 changes.push(ResourceChange {
2114 action: ResourceChangeAction::Update,
2115 logical_id: updated.logical_id.clone(),
2116 physical_id: updated.physical_id.clone(),
2117 resource_type: updated.resource_type.clone(),
2118 });
2119 physical_ids
2120 .insert(updated.logical_id.clone(), updated.physical_id.clone());
2121 attributes.insert(updated.logical_id.clone(), updated.attributes.clone());
2122 if let Some(slot) = stack
2123 .resources
2124 .iter_mut()
2125 .find(|r| r.logical_id == updated.logical_id)
2126 {
2127 *slot = updated;
2128 }
2129 }
2130 Ok(None) => {
2131 }
2134 Err(e) => {
2135 tracing::warn!(
2136 "Failed to update resource {} during update: {e}",
2137 resource_def.logical_id
2138 );
2139 return Err(format!(
2140 "Failed to update resource {}: {e}",
2141 resource_def.logical_id
2142 ));
2143 }
2144 }
2145 }
2146 }
2147 }
2148
2149 Ok(changes)
2150}
2151
2152pub(crate) fn record_event(
2156 state: &mut crate::state::CloudFormationState,
2157 stack_id: &str,
2158 stack_name: &str,
2159 logical_id: &str,
2160 physical_id: &str,
2161 resource_type: &str,
2162 status: &str,
2163) {
2164 use serde_json::json;
2165 let event_id = format!(
2166 "{}-{:x}",
2167 logical_id,
2168 std::time::SystemTime::now()
2169 .duration_since(std::time::UNIX_EPOCH)
2170 .map(|d| d.as_nanos())
2171 .unwrap_or(0)
2172 );
2173 let log = state.events.entry(stack_id.to_string()).or_default();
2174
2175 let now = chrono::DateTime::from_timestamp_millis(Utc::now().timestamp_millis())
2188 .unwrap_or_else(Utc::now);
2189 let timestamp = match log.last().and_then(|e| e["Timestamp"].as_str()) {
2190 Some(prev) => match chrono::DateTime::parse_from_rfc3339(prev) {
2191 Ok(prev) => {
2192 let prev = prev.with_timezone(&Utc);
2193 if now > prev {
2194 now
2195 } else {
2196 prev + chrono::Duration::milliseconds(1)
2197 }
2198 }
2199 Err(_) => now,
2200 },
2201 None => now,
2202 };
2203
2204 log.push(json!({
2205 "EventId": event_id,
2206 "StackId": stack_id,
2207 "StackName": stack_name,
2208 "LogicalResourceId": logical_id,
2209 "PhysicalResourceId": physical_id,
2210 "ResourceType": resource_type,
2211 "ResourceStatus": status,
2212 "Timestamp": timestamp.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2213 }));
2214}
2215
2216async fn save_snapshot_static(
2224 state: SharedCloudFormationState,
2225 store: Option<Arc<dyn SnapshotStore>>,
2226 lock: Arc<AsyncMutex<()>>,
2227) {
2228 let Some(store) = store else {
2229 return;
2230 };
2231 let _guard = lock.lock().await;
2232 let snapshot = CloudFormationSnapshot {
2233 schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
2234 state: None,
2235 accounts: Some(state.read().clone()),
2236 };
2237 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2238 let bytes = serde_json::to_vec(&snapshot)
2239 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
2240 store.save(&bytes)
2241 })
2242 .await;
2243 match join {
2244 Ok(Ok(())) => {}
2245 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
2246 Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
2247 }
2248}
2249
2250pub(crate) fn record_stack_events(
2251 state: &mut crate::state::CloudFormationState,
2252 stack_id: &str,
2253 stack_name: &str,
2254 changes: &[ResourceChange],
2255) {
2256 for ch in changes {
2257 record_event(
2258 state,
2259 stack_id,
2260 stack_name,
2261 &ch.logical_id,
2262 &ch.physical_id,
2263 &ch.resource_type,
2264 ch.action.status_in_progress(),
2265 );
2266 record_event(
2267 state,
2268 stack_id,
2269 stack_name,
2270 &ch.logical_id,
2271 &ch.physical_id,
2272 &ch.resource_type,
2273 ch.action.status_complete(),
2274 );
2275 }
2276}
2277
2278pub(crate) fn record_stack_status_event(
2282 state: &mut crate::state::CloudFormationState,
2283 stack_id: &str,
2284 stack_name: &str,
2285 resource_type: &str,
2286 status: &str,
2287) {
2288 record_event(
2289 state,
2290 stack_id,
2291 stack_name,
2292 stack_name,
2293 stack_id,
2294 resource_type,
2295 status,
2296 );
2297}
2298
2299#[cfg(test)]
2300mod tests {
2301 use super::*;
2302 use http::HeaderMap;
2303 use parking_lot::RwLock;
2304 use std::collections::HashMap;
2305 use std::sync::Arc;
2306
2307 #[test]
2308 fn merge_parameter_defaults_fills_omitted_params() {
2309 let template = r#"{
2312 "Parameters": {
2313 "InstanceType": {"Type": "String", "Default": "t3.micro"},
2314 "Count": {"Type": "Number", "Default": 3},
2315 "Supplied": {"Type": "String", "Default": "dflt"}
2316 },
2317 "Resources": {}
2318 }"#;
2319 let mut params = BTreeMap::new();
2320 params.insert("Supplied".to_string(), "override".to_string());
2321 CloudFormationService::merge_parameter_defaults(&mut params, template);
2322 assert_eq!(
2323 params.get("InstanceType").map(String::as_str),
2324 Some("t3.micro")
2325 );
2326 assert_eq!(params.get("Count").map(String::as_str), Some("3"));
2327 assert_eq!(params.get("Supplied").map(String::as_str), Some("override"));
2329 }
2330
2331 fn make_service() -> CloudFormationService {
2332 let cf_state = Arc::new(RwLock::new(
2333 fakecloud_core::multi_account::MultiAccountState::new(
2334 "123456789012",
2335 "us-east-1",
2336 "http://localhost:4566",
2337 ),
2338 ));
2339 let deps = CloudFormationDeps {
2340 sqs: Arc::new(RwLock::new(
2341 fakecloud_core::multi_account::MultiAccountState::new(
2342 "123456789012",
2343 "us-east-1",
2344 "http://localhost:4566",
2345 ),
2346 )),
2347 sns: Arc::new(RwLock::new(
2348 fakecloud_core::multi_account::MultiAccountState::new(
2349 "123456789012",
2350 "us-east-1",
2351 "http://localhost:4566",
2352 ),
2353 )),
2354 ssm: Arc::new(RwLock::new(
2355 fakecloud_core::multi_account::MultiAccountState::new(
2356 "123456789012",
2357 "us-east-1",
2358 "http://localhost:4566",
2359 ),
2360 )),
2361 iam: Arc::new(RwLock::new(
2362 fakecloud_core::multi_account::MultiAccountState::new(
2363 "123456789012",
2364 "us-east-1",
2365 "",
2366 ),
2367 )),
2368 s3: Arc::new(RwLock::new(
2369 fakecloud_core::multi_account::MultiAccountState::new(
2370 "123456789012",
2371 "us-east-1",
2372 "",
2373 ),
2374 )),
2375 eventbridge: Arc::new(RwLock::new(
2376 fakecloud_core::multi_account::MultiAccountState::new(
2377 "123456789012",
2378 "us-east-1",
2379 "",
2380 ),
2381 )),
2382 dynamodb: Arc::new(RwLock::new(
2383 fakecloud_core::multi_account::MultiAccountState::new(
2384 "123456789012",
2385 "us-east-1",
2386 "",
2387 ),
2388 )),
2389 logs: Arc::new(RwLock::new(
2390 fakecloud_core::multi_account::MultiAccountState::new(
2391 "123456789012",
2392 "us-east-1",
2393 "",
2394 ),
2395 )),
2396 lambda: Arc::new(RwLock::new(
2397 fakecloud_core::multi_account::MultiAccountState::new(
2398 "123456789012",
2399 "us-east-1",
2400 "",
2401 ),
2402 )),
2403 secretsmanager: Arc::new(RwLock::new(
2404 fakecloud_core::multi_account::MultiAccountState::new(
2405 "123456789012",
2406 "us-east-1",
2407 "",
2408 ),
2409 )),
2410 kinesis: Arc::new(RwLock::new(
2411 fakecloud_core::multi_account::MultiAccountState::new(
2412 "123456789012",
2413 "us-east-1",
2414 "",
2415 ),
2416 )),
2417 kms: Arc::new(RwLock::new(
2418 fakecloud_core::multi_account::MultiAccountState::new(
2419 "123456789012",
2420 "us-east-1",
2421 "",
2422 ),
2423 )),
2424 ecr: Arc::new(RwLock::new(
2425 fakecloud_core::multi_account::MultiAccountState::new(
2426 "123456789012",
2427 "us-east-1",
2428 "",
2429 ),
2430 )),
2431 cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
2432 elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
2433 organizations: Arc::new(RwLock::new(None)),
2434 cognito: Arc::new(RwLock::new(
2435 fakecloud_core::multi_account::MultiAccountState::new(
2436 "123456789012",
2437 "us-east-1",
2438 "",
2439 ),
2440 )),
2441 rds: Arc::new(RwLock::new(
2442 fakecloud_core::multi_account::MultiAccountState::new(
2443 "123456789012",
2444 "us-east-1",
2445 "",
2446 ),
2447 )),
2448 ec2: Arc::new(RwLock::new(
2449 fakecloud_core::multi_account::MultiAccountState::new(
2450 "123456789012",
2451 "us-east-1",
2452 "",
2453 ),
2454 )),
2455 autoscaling: Arc::new(RwLock::new(
2456 fakecloud_autoscaling::AutoScalingAccounts::new(),
2457 )),
2458 batch: Arc::new(RwLock::new(fakecloud_batch::BatchAccounts::new())),
2459 ecs: Arc::new(RwLock::new(
2460 fakecloud_core::multi_account::MultiAccountState::new(
2461 "123456789012",
2462 "us-east-1",
2463 "",
2464 ),
2465 )),
2466 acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
2467 elasticache: Arc::new(RwLock::new(
2468 fakecloud_core::multi_account::MultiAccountState::new(
2469 "123456789012",
2470 "us-east-1",
2471 "",
2472 ),
2473 )),
2474 route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
2475 cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
2476 stepfunctions: Arc::new(RwLock::new(
2477 fakecloud_core::multi_account::MultiAccountState::new(
2478 "123456789012",
2479 "us-east-1",
2480 "",
2481 ),
2482 )),
2483 wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
2484 apigateway: Arc::new(RwLock::new(
2485 fakecloud_core::multi_account::MultiAccountState::new(
2486 "123456789012",
2487 "us-east-1",
2488 "",
2489 ),
2490 )),
2491 apigatewayv2: Arc::new(RwLock::new(
2492 fakecloud_core::multi_account::MultiAccountState::new(
2493 "123456789012",
2494 "us-east-1",
2495 "",
2496 ),
2497 )),
2498 ses: Arc::new(RwLock::new(
2499 fakecloud_core::multi_account::MultiAccountState::new(
2500 "123456789012",
2501 "us-east-1",
2502 "",
2503 ),
2504 )),
2505 application_autoscaling: Arc::new(parking_lot::RwLock::new(
2506 fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
2507 )),
2508 athena: Arc::new(parking_lot::RwLock::new(
2509 fakecloud_athena::AthenaAccounts::new(),
2510 )),
2511 firehose: Arc::new(parking_lot::RwLock::new(
2512 fakecloud_firehose::FirehoseAccounts::new(),
2513 )),
2514 glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
2515 delivery: Arc::new(DeliveryBus::new()),
2516 lambda_runtime: None,
2517 };
2518 CloudFormationService::new(cf_state, deps)
2519 }
2520
2521 fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
2522 AwsRequest {
2523 service: "cloudformation".to_string(),
2524 action: action.to_string(),
2525 region: "us-east-1".to_string(),
2526 account_id: "123456789012".to_string(),
2527 request_id: "test-request-id".to_string(),
2528 headers: HeaderMap::new(),
2529 query_params: params,
2530 body: bytes::Bytes::new(),
2531 body_stream: parking_lot::Mutex::new(None),
2532 path_segments: vec![],
2533 raw_path: "/".to_string(),
2534 raw_query: String::new(),
2535 method: http::Method::POST,
2536 is_query_protocol: true,
2537 access_key_id: None,
2538 principal: None,
2539 }
2540 }
2541
2542 #[tokio::test]
2543 async fn update_stack_sets_failed_status_on_resource_error() {
2544 let svc = make_service();
2545
2546 let mut create_params = HashMap::new();
2548 create_params.insert("StackName".to_string(), "test-stack".to_string());
2549 create_params.insert(
2550 "TemplateBody".to_string(),
2551 r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
2552 );
2553 let req = make_request("CreateStack", create_params);
2554 let result = svc.create_stack(&req).await;
2555 assert!(result.is_ok());
2556
2557 let mut update_params = HashMap::new();
2559 update_params.insert("StackName".to_string(), "test-stack".to_string());
2560 update_params.insert(
2561 "TemplateBody".to_string(),
2562 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(),
2563 );
2564 let req = make_request("UpdateStack", update_params);
2565 let result = svc.update_stack(&req).await;
2566
2567 assert!(result.is_err());
2569
2570 let accounts = svc.state.read();
2574 let state = accounts.get("123456789012").unwrap();
2575 let stack = state.stacks.get("test-stack").unwrap();
2576 assert_eq!(stack.status, "UPDATE_ROLLBACK_COMPLETE");
2577 }
2578
2579 #[tokio::test]
2580 async fn create_stack_resolves_ref_to_physical_id() {
2581 let svc = make_service();
2582
2583 let template = r#"{
2585 "Resources": {
2586 "MyTopic": {
2587 "Type": "AWS::SNS::Topic",
2588 "Properties": { "TopicName": "ref-test-topic" }
2589 },
2590 "MySub": {
2591 "Type": "AWS::SNS::Subscription",
2592 "Properties": {
2593 "TopicArn": { "Ref": "MyTopic" },
2594 "Protocol": "sqs",
2595 "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
2596 }
2597 }
2598 }
2599 }"#;
2600
2601 let mut params = HashMap::new();
2602 params.insert("StackName".to_string(), "ref-stack".to_string());
2603 params.insert("TemplateBody".to_string(), template.to_string());
2604 let req = make_request("CreateStack", params);
2605 let result = svc.create_stack(&req).await;
2606 assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
2607
2608 let accounts = svc.state.read();
2610 let state = accounts.get("123456789012").unwrap();
2611 let stack = state.stacks.get("ref-stack").unwrap();
2612 assert_eq!(stack.resources.len(), 2);
2613 assert_eq!(stack.status, "CREATE_COMPLETE");
2614
2615 let sub = stack
2617 .resources
2618 .iter()
2619 .find(|r| r.logical_id == "MySub")
2620 .unwrap();
2621 assert!(
2622 sub.physical_id.contains("ref-test-topic"),
2623 "Subscription physical ID should reference the topic ARN, got: {}",
2624 sub.physical_id
2625 );
2626 }
2627
2628 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2635 async fn create_stack_custom_resource_provisions_asynchronously() {
2636 let svc = make_service();
2637 let template = r#"{
2638 "Resources": {
2639 "MyCustom": {
2640 "Type": "Custom::Thing",
2641 "Properties": {
2642 "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:handler"
2643 }
2644 }
2645 }
2646 }"#;
2647 let mut params = HashMap::new();
2648 params.insert("StackName".to_string(), "async-stack".to_string());
2649 params.insert("TemplateBody".to_string(), template.to_string());
2650 let req = make_request("CreateStack", params);
2651
2652 let resp = svc
2659 .create_stack(&req)
2660 .await
2661 .expect("create returns StackId");
2662 assert!(resp.status.is_success());
2663 {
2664 let accounts = svc.state.read();
2665 let stack = accounts
2666 .get("123456789012")
2667 .unwrap()
2668 .stacks
2669 .get("async-stack")
2670 .expect("stack seeded synchronously");
2671 assert!(
2672 stack.status == "CREATE_IN_PROGRESS" || stack.status == "CREATE_COMPLETE",
2673 "unexpected status right after create: {}",
2674 stack.status
2675 );
2676 }
2677
2678 let mut status = String::new();
2681 for _ in 0..200 {
2682 {
2683 let accounts = svc.state.read();
2684 if let Some(stack) = accounts
2685 .get("123456789012")
2686 .and_then(|s| s.stacks.get("async-stack"))
2687 {
2688 status = stack.status.clone();
2689 if status != "CREATE_IN_PROGRESS" {
2690 break;
2691 }
2692 }
2693 }
2694 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2695 }
2696 assert_eq!(
2697 status, "CREATE_COMPLETE",
2698 "stack should reach CREATE_COMPLETE"
2699 );
2700
2701 let accounts = svc.state.read();
2702 let stack = accounts
2703 .get("123456789012")
2704 .unwrap()
2705 .stacks
2706 .get("async-stack")
2707 .unwrap();
2708 assert_eq!(stack.resources.len(), 1);
2709 assert_eq!(stack.resources[0].resource_type, "Custom::Thing");
2710 }
2711
2712 #[tokio::test]
2713 async fn output_getatt_resolves_well_known_attribute() {
2714 let svc = make_service();
2720 let template = r#"{
2721 "Resources": {
2722 "Queue": { "Type": "AWS::SQS::Queue", "Properties": { "QueueName": "out-q" } }
2723 },
2724 "Outputs": {
2725 "Url": { "Value": { "Fn::GetAtt": ["Queue", "QueueUrl"] } }
2726 }
2727 }"#;
2728 let mut params = HashMap::new();
2729 params.insert("StackName".to_string(), "out-stack".to_string());
2730 params.insert("TemplateBody".to_string(), template.to_string());
2731 svc.create_stack(&make_request("CreateStack", params))
2732 .await
2733 .expect("create returns StackId");
2734
2735 let mut url = String::new();
2736 for _ in 0..200 {
2737 {
2738 let accounts = svc.state.read();
2739 if let Some(stack) = accounts
2740 .get("123456789012")
2741 .and_then(|s| s.stacks.get("out-stack"))
2742 {
2743 if stack.status != "CREATE_IN_PROGRESS" {
2744 url = stack
2745 .outputs
2746 .iter()
2747 .find(|o| o.key == "Url")
2748 .map(|o| o.value.clone())
2749 .unwrap_or_default();
2750 break;
2751 }
2752 }
2753 }
2754 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2755 }
2756 assert!(
2757 url.contains("out-q") && url != "Queue.QueueUrl",
2758 "GetAtt QueueUrl output should resolve to the live url, got {url:?}"
2759 );
2760 }
2761
2762 #[tokio::test]
2765 async fn create_stack_missing_name_errors() {
2766 let svc = make_service();
2767 let mut params = HashMap::new();
2768 params.insert("TemplateBody".to_string(), "{}".to_string());
2769 let req = make_request("CreateStack", params);
2770 assert!(svc.create_stack(&req).await.is_err());
2771 }
2772
2773 #[tokio::test]
2774 async fn create_stack_missing_template_creates_empty_stack() {
2775 let svc = make_service();
2780 let mut params = HashMap::new();
2781 params.insert("StackName".to_string(), "s".to_string());
2782 let req = make_request("CreateStack", params);
2783 svc.create_stack(&req)
2784 .await
2785 .expect("empty-body create succeeds");
2786 }
2787
2788 #[tokio::test]
2789 async fn create_stack_duplicate_errors() {
2790 let svc = make_service();
2791 let mut params = HashMap::new();
2792 params.insert("StackName".to_string(), "dup".to_string());
2793 params.insert(
2794 "TemplateBody".to_string(),
2795 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
2796 .to_string(),
2797 );
2798 let req = make_request("CreateStack", params.clone());
2799 svc.create_stack(&req).await.unwrap();
2800 let req = make_request("CreateStack", params);
2801 assert!(svc.create_stack(&req).await.is_err());
2802 }
2803
2804 #[tokio::test]
2805 async fn create_stack_invalid_template_creates_empty_stack() {
2806 let svc = make_service();
2810 let mut params = HashMap::new();
2811 params.insert("StackName".to_string(), "bad".to_string());
2812 params.insert("TemplateBody".to_string(), "not json".to_string());
2813 let req = make_request("CreateStack", params);
2814 svc.create_stack(&req)
2815 .await
2816 .expect("bad-body create succeeds");
2817 }
2818
2819 #[tokio::test]
2820 async fn delete_stack_unknown_is_noop() {
2821 let svc = make_service();
2822 let mut params = HashMap::new();
2823 params.insert("StackName".to_string(), "ghost".to_string());
2824 let req = make_request("DeleteStack", params);
2825 assert!(svc.delete_stack(&req).await.is_ok());
2826 }
2827
2828 #[test]
2829 fn describe_stacks_nonexistent_errors() {
2830 let svc = make_service();
2835 let mut params = HashMap::new();
2836 params.insert("StackName".to_string(), "ghost".to_string());
2837 let req = make_request("DescribeStacks", params);
2838 match svc.describe_stacks(&req) {
2839 Ok(_) => panic!("ghost stack must return an error, not an empty list"),
2840 Err(e) => {
2841 assert_eq!(e.status(), StatusCode::BAD_REQUEST);
2842 assert_eq!(e.code(), "ValidationError");
2843 assert!(
2844 e.message().contains("does not exist"),
2845 "got: {}",
2846 e.message()
2847 );
2848 }
2849 }
2850 }
2851
2852 #[test]
2853 fn describe_stacks_empty_returns_all() {
2854 let svc = make_service();
2855 let req = make_request("DescribeStacks", HashMap::new());
2856 let resp = svc.describe_stacks(&req).unwrap();
2857 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2858 assert!(b.contains("DescribeStacksResult"));
2859 }
2860
2861 #[test]
2862 fn list_stacks_empty_returns_ok() {
2863 let svc = make_service();
2864 let req = make_request("ListStacks", HashMap::new());
2865 let resp = svc.list_stacks(&req).unwrap();
2866 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2867 assert!(b.contains("ListStacksResult"));
2868 }
2869
2870 #[test]
2871 fn list_stack_resources_missing_name_returns_validation_error() {
2872 let svc = make_service();
2878 let req = make_request("ListStackResources", HashMap::new());
2879 let err = match svc.list_stack_resources(&req) {
2880 Err(e) => e,
2881 Ok(_) => panic!("omitted StackName must be rejected"),
2882 };
2883 assert_eq!(err.code(), "ValidationError");
2884 }
2885
2886 #[test]
2887 fn list_stack_resources_unknown_stack_returns_empty() {
2888 let svc = make_service();
2889 let mut params = HashMap::new();
2890 params.insert("StackName".to_string(), "ghost".to_string());
2891 let req = make_request("ListStackResources", params);
2892 svc.list_stack_resources(&req).expect("unknown is empty");
2893 }
2894
2895 #[test]
2896 fn describe_stack_resources_missing_name_returns_empty() {
2897 let svc = make_service();
2898 let req = make_request("DescribeStackResources", HashMap::new());
2899 svc.describe_stack_resources(&req)
2900 .expect("missing name is ok");
2901 }
2902
2903 #[test]
2904 fn get_template_missing_name_returns_empty_body() {
2905 let svc = make_service();
2906 let req = make_request("GetTemplate", HashMap::new());
2907 svc.get_template(&req).expect("missing name is ok");
2908 }
2909
2910 #[test]
2911 fn get_template_unknown_stack_returns_empty_body() {
2912 let svc = make_service();
2913 let mut params = HashMap::new();
2914 params.insert("StackName".to_string(), "ghost".to_string());
2915 let req = make_request("GetTemplate", params);
2916 svc.get_template(&req).expect("unknown is empty");
2917 }
2918
2919 #[tokio::test]
2920 async fn update_stack_missing_name_errors() {
2921 let svc = make_service();
2922 let mut params = HashMap::new();
2923 params.insert("TemplateBody".to_string(), "{}".to_string());
2924 let req = make_request("UpdateStack", params);
2925 assert!(svc.update_stack(&req).await.is_err());
2926 }
2927
2928 #[tokio::test]
2929 async fn update_stack_unknown_stack_returns_synthetic_id() {
2930 let svc = make_service();
2937 let mut params = HashMap::new();
2938 params.insert("StackName".to_string(), "ghost".to_string());
2939 params.insert(
2940 "TemplateBody".to_string(),
2941 r#"{"Resources":{}}"#.to_string(),
2942 );
2943 let req = make_request("UpdateStack", params);
2944 let resp = svc
2945 .update_stack(&req)
2946 .await
2947 .expect("ghost update is synthetic");
2948 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2949 assert!(b.contains("UpdateStackResult"));
2950 }
2951
2952 #[tokio::test]
2953 async fn create_stack_resolves_outputs_and_records_export() {
2954 let svc = make_service();
2955 let template = r#"{
2956 "Resources": {
2957 "Q": {"Type":"AWS::SQS::Queue","Properties":{"QueueName":"out-q"}}
2958 },
2959 "Outputs": {
2960 "QueueUrl": {
2961 "Value": {"Ref": "Q"},
2962 "Description": "Url",
2963 "Export": {"Name": "TheQueueUrl"}
2964 }
2965 }
2966 }"#;
2967 let mut params = HashMap::new();
2968 params.insert("StackName".to_string(), "outs".to_string());
2969 params.insert("TemplateBody".to_string(), template.to_string());
2970 let req = make_request("CreateStack", params);
2971 svc.create_stack(&req).await.expect("create stack");
2972
2973 let accounts = svc.state.read();
2974 let stack = accounts
2975 .get("123456789012")
2976 .unwrap()
2977 .stacks
2978 .get("outs")
2979 .unwrap();
2980 assert_eq!(stack.outputs.len(), 1);
2981 assert_eq!(stack.outputs[0].key, "QueueUrl");
2982 assert_eq!(stack.outputs[0].export_name.as_deref(), Some("TheQueueUrl"));
2983 assert!(!stack.outputs[0].value.is_empty());
2984 }
2985
2986 #[tokio::test]
2987 async fn create_stack_rejects_duplicate_export_name() {
2988 let svc = make_service();
2989 let mk = |name: &str| {
2990 let template = format!(
2991 r#"{{
2992 "Resources": {{"Q":{{"Type":"AWS::SQS::Queue","Properties":{{"QueueName":"q-{name}"}}}}}},
2993 "Outputs": {{"QueueUrl":{{"Value":{{"Ref":"Q"}},"Export":{{"Name":"DupExport"}}}}}}
2994 }}"#
2995 );
2996 let mut params = HashMap::new();
2997 params.insert("StackName".to_string(), name.to_string());
2998 params.insert("TemplateBody".to_string(), template);
2999 make_request("CreateStack", params)
3000 };
3001 match svc.create_stack(&mk("first")).await {
3002 Ok(_) => {}
3003 Err(e) => panic!("first stack: {e:?}"),
3004 }
3005 svc.create_stack(&mk("second"))
3011 .await
3012 .expect("CreateStack returns StackId even when provisioning fails");
3013 let accounts = svc.state.read();
3014 let stack = accounts
3015 .get("123456789012")
3016 .unwrap()
3017 .stacks
3018 .get("second")
3019 .expect("second stack recorded");
3020 assert_eq!(stack.status, "CREATE_FAILED");
3021 let exports = &accounts.get("123456789012").unwrap().exports;
3023 assert_eq!(
3024 exports
3025 .get("DupExport")
3026 .map(|e| e.exporting_stack_name.as_str()),
3027 Some("first")
3028 );
3029 }
3030
3031 #[tokio::test]
3032 async fn import_value_resolves_against_other_stack_export() {
3033 let svc = make_service();
3034
3035 let producer_tpl = r#"{
3036 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod-q"}}},
3037 "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"SharedQueueUrl"}}}
3038 }"#;
3039 let mut p = HashMap::new();
3040 p.insert("StackName".to_string(), "producer".to_string());
3041 p.insert("TemplateBody".to_string(), producer_tpl.to_string());
3042 svc.create_stack(&make_request("CreateStack", p))
3043 .await
3044 .expect("producer");
3045
3046 let consumer_tpl = r#"{
3047 "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cons-q"}}},
3048 "Outputs": {"Imp":{"Value":{"Fn::ImportValue":"SharedQueueUrl"}}}
3049 }"#;
3050 let mut p = HashMap::new();
3051 p.insert("StackName".to_string(), "consumer".to_string());
3052 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3053 svc.create_stack(&make_request("CreateStack", p))
3054 .await
3055 .expect("consumer");
3056
3057 let accounts = svc.state.read();
3058 let prod_url = accounts
3059 .get("123456789012")
3060 .unwrap()
3061 .stacks
3062 .get("producer")
3063 .unwrap()
3064 .outputs[0]
3065 .value
3066 .clone();
3067 let cons = accounts
3068 .get("123456789012")
3069 .unwrap()
3070 .stacks
3071 .get("consumer")
3072 .unwrap();
3073 assert_eq!(cons.outputs[0].value, prod_url);
3074 }
3075
3076 #[tokio::test]
3077 async fn create_stack_records_export_in_state_registry() {
3078 let svc = make_service();
3079 let template = r#"{
3080 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"reg-q"}}},
3081 "Outputs": {"Url":{"Value":{"Ref":"Q"},"Export":{"Name":"reg-url"}}}
3082 }"#;
3083 let mut params = HashMap::new();
3084 params.insert("StackName".to_string(), "reg".to_string());
3085 params.insert("TemplateBody".to_string(), template.to_string());
3086 svc.create_stack(&make_request("CreateStack", params))
3087 .await
3088 .expect("create");
3089
3090 let accounts = svc.state.read();
3091 let state = accounts.get("123456789012").unwrap();
3092 let export = state
3093 .exports
3094 .get("reg-url")
3095 .expect("export registered in state.exports");
3096 assert_eq!(export.exporting_stack_name, "reg");
3097 assert!(!export.value.is_empty());
3098 assert!(export.exporting_stack_id.contains("reg"));
3099 }
3100
3101 #[tokio::test]
3102 async fn import_value_with_unknown_export_errors() {
3103 let svc = make_service();
3104 let consumer_tpl = r#"{
3105 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{
3106 "QueueName": {"Fn::ImportValue":"missing-export"}
3107 }}}
3108 }"#;
3109 let mut p = HashMap::new();
3110 p.insert("StackName".to_string(), "bad-consumer".to_string());
3111 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3112 match svc.create_stack(&make_request("CreateStack", p)).await {
3113 Ok(_) => panic!("expected ValidationError for unknown export"),
3114 Err(e) => {
3115 let msg = format!("{e:?}");
3116 assert!(msg.contains("No export named missing-export"), "got {msg}");
3117 }
3118 }
3119 }
3120
3121 #[tokio::test]
3122 async fn delete_stack_blocked_when_export_in_use_and_unblocked_after_consumer_delete() {
3123 let svc = make_service();
3124
3125 let producer_tpl = r#"{
3126 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod"}}},
3127 "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"my-arn"}}}
3128 }"#;
3129 let mut p = HashMap::new();
3130 p.insert("StackName".to_string(), "producer".to_string());
3131 p.insert("TemplateBody".to_string(), producer_tpl.to_string());
3132 svc.create_stack(&make_request("CreateStack", p))
3133 .await
3134 .expect("producer");
3135
3136 let consumer_tpl = r#"{
3137 "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{
3138 "QueueName": "cons-q",
3139 "Tags": [{"Key":"k","Value":{"Fn::ImportValue":"my-arn"}}]
3140 }}}
3141 }"#;
3142 let mut p = HashMap::new();
3143 p.insert("StackName".to_string(), "consumer".to_string());
3144 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3145 svc.create_stack(&make_request("CreateStack", p))
3146 .await
3147 .expect("consumer");
3148
3149 let mut p = HashMap::new();
3151 p.insert("StackName".to_string(), "producer".to_string());
3152 match svc.delete_stack(&make_request("DeleteStack", p)).await {
3153 Ok(_) => panic!("delete must fail while imports exist"),
3154 Err(e) => {
3155 let msg = format!("{e:?}");
3156 assert!(msg.contains("Export my-arn cannot be deleted"), "got {msg}");
3157 }
3158 }
3159
3160 let mut p = HashMap::new();
3162 p.insert("StackName".to_string(), "consumer".to_string());
3163 svc.delete_stack(&make_request("DeleteStack", p))
3164 .await
3165 .expect("consumer delete");
3166
3167 let mut p = HashMap::new();
3169 p.insert("StackName".to_string(), "producer".to_string());
3170 svc.delete_stack(&make_request("DeleteStack", p))
3171 .await
3172 .expect("producer delete after consumer gone");
3173
3174 let accounts = svc.state.read();
3175 let state = accounts.get("123456789012").unwrap();
3176 assert!(state.exports.is_empty(), "exports cleared after delete");
3177 assert!(state.imports.is_empty(), "imports cleared after delete");
3178 }
3179
3180 use std::sync::atomic::{AtomicUsize, Ordering};
3183
3184 fn counting_hook(counter: Arc<AtomicUsize>) -> fakecloud_persistence::SnapshotHook {
3187 Arc::new(move || {
3188 let counter = counter.clone();
3189 Box::pin(async move {
3190 counter.fetch_add(1, Ordering::SeqCst);
3191 })
3192 })
3193 }
3194
3195 fn disk_s3_store(tmp: &tempfile::TempDir) -> Arc<fakecloud_persistence::s3::DiskS3Store> {
3196 let cache = Arc::new(fakecloud_persistence::cache::BodyCache::new(1024 * 1024));
3197 Arc::new(fakecloud_persistence::s3::DiskS3Store::new(
3198 tmp.path().to_path_buf(),
3199 cache,
3200 ))
3201 }
3202
3203 const PERSIST_TEMPLATE: &str = r#"{"Resources":{
3207 "Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cfn-q"}},
3208 "T":{"Type":"AWS::SNS::Topic","Properties":{"TopicName":"cfn-t"}},
3209 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"cfn-bucket"}}
3210 }}"#;
3211
3212 fn create_req(stack: &str) -> AwsRequest {
3213 let mut p = HashMap::new();
3214 p.insert("StackName".to_string(), stack.to_string());
3215 p.insert("TemplateBody".to_string(), PERSIST_TEMPLATE.to_string());
3216 make_request("CreateStack", p)
3217 }
3218
3219 #[tokio::test]
3220 async fn cfn_create_persists_touched_services_and_writes_bucket_to_store() {
3221 let tmp = tempfile::tempdir().unwrap();
3222 let store = disk_s3_store(&tmp);
3223 let counter = Arc::new(AtomicUsize::new(0));
3224 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3225 BTreeMap::new();
3226 hooks.insert("sqs", counting_hook(counter.clone()));
3227 hooks.insert("sns", counting_hook(counter.clone()));
3228 hooks.insert("lambda", counting_hook(counter.clone()));
3230 let svc = make_service()
3231 .with_s3_store(store.clone())
3232 .with_snapshot_hooks(hooks);
3233
3234 svc.create_stack(&create_req("probe")).await.unwrap();
3235
3236 assert_eq!(counter.load(Ordering::SeqCst), 2);
3238 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3240 assert!(
3241 loaded.buckets.contains_key("cfn-bucket"),
3242 "CFN bucket should be persisted to the S3 store"
3243 );
3244 }
3245
3246 #[tokio::test]
3247 async fn cfn_delete_persists_touched_services_and_removes_bucket_from_store() {
3248 let tmp = tempfile::tempdir().unwrap();
3249 let store = disk_s3_store(&tmp);
3250 let counter = Arc::new(AtomicUsize::new(0));
3251 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3252 BTreeMap::new();
3253 hooks.insert("sqs", counting_hook(counter.clone()));
3254 hooks.insert("sns", counting_hook(counter.clone()));
3255 let svc = make_service()
3256 .with_s3_store(store.clone())
3257 .with_snapshot_hooks(hooks);
3258
3259 svc.create_stack(&create_req("probe")).await.unwrap();
3260 assert_eq!(counter.load(Ordering::SeqCst), 2, "create fired sqs + sns");
3261
3262 let mut p = HashMap::new();
3263 p.insert("StackName".to_string(), "probe".to_string());
3264 svc.delete_stack(&make_request("DeleteStack", p))
3265 .await
3266 .unwrap();
3267
3268 assert_eq!(counter.load(Ordering::SeqCst), 4, "delete fired sqs + sns");
3270 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3273 assert!(
3274 !loaded.buckets.contains_key("cfn-bucket"),
3275 "CFN-deleted bucket should be removed from the S3 store"
3276 );
3277 }
3278
3279 #[tokio::test]
3280 async fn cfn_persist_skips_services_without_a_registered_hook() {
3281 let tmp = tempfile::tempdir().unwrap();
3284 let store = disk_s3_store(&tmp);
3285 let counter = Arc::new(AtomicUsize::new(0));
3286 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3287 BTreeMap::new();
3288 hooks.insert("sqs", counting_hook(counter.clone()));
3289 let svc = make_service()
3290 .with_s3_store(store.clone())
3291 .with_snapshot_hooks(hooks);
3292
3293 svc.create_stack(&create_req("probe")).await.unwrap();
3294 assert_eq!(counter.load(Ordering::SeqCst), 1, "only sqs has a hook");
3295 }
3296
3297 #[tokio::test]
3298 async fn cfn_update_persists_touched_services() {
3299 let tmp = tempfile::tempdir().unwrap();
3302 let store = disk_s3_store(&tmp);
3303 let counter = Arc::new(AtomicUsize::new(0));
3304 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3305 BTreeMap::new();
3306 hooks.insert("sqs", counting_hook(counter.clone()));
3307 hooks.insert("sns", counting_hook(counter.clone()));
3308 let svc = make_service()
3309 .with_s3_store(store.clone())
3310 .with_snapshot_hooks(hooks);
3311
3312 let mut create = HashMap::new();
3313 create.insert("StackName".to_string(), "upd".to_string());
3314 create.insert(
3315 "TemplateBody".to_string(),
3316 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"u-q"}}}}"#
3317 .to_string(),
3318 );
3319 svc.create_stack(&make_request("CreateStack", create))
3320 .await
3321 .unwrap();
3322 let after_create = counter.load(Ordering::SeqCst);
3323
3324 let mut update = HashMap::new();
3325 update.insert("StackName".to_string(), "upd".to_string());
3326 update.insert("TemplateBody".to_string(), PERSIST_TEMPLATE.to_string());
3327 svc.update_stack(&make_request("UpdateStack", update))
3328 .await
3329 .unwrap();
3330
3331 assert!(
3333 counter.load(Ordering::SeqCst) > after_create,
3334 "update should persist the services it touched"
3335 );
3336 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3337 assert!(loaded.buckets.contains_key("cfn-bucket"));
3338 }
3339
3340 #[tokio::test]
3341 async fn cfn_execute_change_set_persists_touched_services() {
3342 let tmp = tempfile::tempdir().unwrap();
3348 let store = disk_s3_store(&tmp);
3349 let counter = Arc::new(AtomicUsize::new(0));
3350 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3351 BTreeMap::new();
3352 hooks.insert("sqs", counting_hook(counter.clone()));
3353 let svc = make_service()
3354 .with_s3_store(store.clone())
3355 .with_snapshot_hooks(hooks);
3356
3357 let mut create = HashMap::new();
3358 create.insert("StackName".to_string(), "cs-stack".to_string());
3359 create.insert("ChangeSetName".to_string(), "cs1".to_string());
3360 create.insert("ChangeSetType".to_string(), "CREATE".to_string());
3361 create.insert(
3362 "TemplateBody".to_string(),
3363 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cs-q"}}}}"#
3364 .to_string(),
3365 );
3366 svc.handle(make_request("CreateChangeSet", create))
3367 .await
3368 .unwrap();
3369 let before = counter.load(Ordering::SeqCst);
3371
3372 let mut exec = HashMap::new();
3373 exec.insert("StackName".to_string(), "cs-stack".to_string());
3374 exec.insert("ChangeSetName".to_string(), "cs1".to_string());
3375 svc.handle(make_request("ExecuteChangeSet", exec))
3376 .await
3377 .unwrap();
3378
3379 assert!(
3380 counter.load(Ordering::SeqCst) > before,
3381 "ExecuteChangeSet must fire the sqs snapshot hook so the provisioned \
3382 queue survives a restart"
3383 );
3384 }
3385
3386 #[test]
3387 fn service_key_for_type_maps_services_and_aliases() {
3388 assert_eq!(
3390 service_key_for_type("AWS::Lambda::Function"),
3391 Some("lambda")
3392 );
3393 assert_eq!(
3394 service_key_for_type("AWS::SecretsManager::Secret"),
3395 Some("secretsmanager")
3396 );
3397 assert_eq!(service_key_for_type("AWS::SQS::Queue"), Some("sqs"));
3398 assert_eq!(service_key_for_type("AWS::IAM::Role"), Some("iam"));
3399 assert_eq!(
3400 service_key_for_type("AWS::StepFunctions::StateMachine"),
3401 Some("stepfunctions")
3402 );
3403 assert_eq!(
3405 service_key_for_type("AWS::Events::Rule"),
3406 Some("eventbridge")
3407 );
3408 assert_eq!(service_key_for_type("AWS::Logs::LogGroup"), Some("logs"));
3409 assert_eq!(
3410 service_key_for_type("AWS::ElastiCache::CacheCluster"),
3411 Some("elasticache")
3412 );
3413 assert_eq!(service_key_for_type("AWS::S3::Bucket"), None);
3415 assert_eq!(
3418 service_key_for_type("AWS::CertificateManager::Certificate"),
3419 Some("acm")
3420 );
3421 assert_eq!(
3422 service_key_for_type("AWS::ElasticLoadBalancingV2::LoadBalancer"),
3423 Some("elbv2")
3424 );
3425 assert_eq!(
3426 service_key_for_type("AWS::CloudFront::Distribution"),
3427 Some("cloudfront")
3428 );
3429 assert_eq!(
3430 service_key_for_type("AWS::Route53::HostedZone"),
3431 Some("route53")
3432 );
3433 assert_eq!(
3434 service_key_for_type("AWS::KinesisFirehose::DeliveryStream"),
3435 Some("firehose")
3436 );
3437 assert_eq!(service_key_for_type("AWS::Glue::Database"), Some("glue"));
3438 assert_eq!(service_key_for_type("AWS::WAFv2::WebACL"), Some("wafv2"));
3439 assert_eq!(
3440 service_key_for_type("AWS::Athena::WorkGroup"),
3441 Some("athena")
3442 );
3443 assert_eq!(
3444 service_key_for_type("AWS::Organizations::Organization"),
3445 Some("organizations")
3446 );
3447 assert_eq!(service_key_for_type("AWS::Lambda"), None);
3449 assert_eq!(service_key_for_type("Custom::Thing::Resource"), None);
3450 assert_eq!(service_key_for_type("AWS"), None);
3451 assert_eq!(service_key_for_type(""), None);
3452 }
3453
3454 #[tokio::test]
3455 async fn persist_touched_services_noop_with_empty_hooks() {
3456 let hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> = BTreeMap::new();
3458 persist_touched_services(&hooks, vec!["AWS::SQS::Queue".to_string()]).await;
3459 }
3460
3461 #[tokio::test]
3462 async fn cfn_bucket_policy_write_through_create_update_delete() {
3463 let tmp = tempfile::tempdir().unwrap();
3464 let store = disk_s3_store(&tmp);
3465 let svc = make_service().with_s3_store(store.clone());
3466
3467 let mut create = HashMap::new();
3469 create.insert("StackName".to_string(), "pol".to_string());
3470 create.insert(
3471 "TemplateBody".to_string(),
3472 r#"{"Resources":{
3473 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"pol-bucket"}},
3474 "BP":{"Type":"AWS::S3::BucketPolicy","Properties":{"Bucket":"pol-bucket","PolicyDocument":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Principal":"*"}]}}}
3475 }}"#
3476 .to_string(),
3477 );
3478 svc.create_stack(&make_request("CreateStack", create))
3479 .await
3480 .unwrap();
3481 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3482 let policy = loaded.buckets["pol-bucket"]
3483 .subresources
3484 .get("policy.toml")
3485 .cloned()
3486 .expect("bucket policy persisted on create");
3487 assert!(policy.contains("s3:GetObject"));
3488
3489 let mut update = HashMap::new();
3491 update.insert("StackName".to_string(), "pol".to_string());
3492 update.insert(
3493 "TemplateBody".to_string(),
3494 r#"{"Resources":{
3495 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"pol-bucket"}},
3496 "BP":{"Type":"AWS::S3::BucketPolicy","Properties":{"Bucket":"pol-bucket","PolicyDocument":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"*","Principal":"*"}]}}}
3497 }}"#
3498 .to_string(),
3499 );
3500 svc.update_stack(&make_request("UpdateStack", update))
3501 .await
3502 .unwrap();
3503 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3504 let policy = loaded.buckets["pol-bucket"]
3505 .subresources
3506 .get("policy.toml")
3507 .cloned()
3508 .expect("bucket policy still persisted after update");
3509 assert!(
3510 policy.contains("s3:PutObject"),
3511 "updated policy should be written through"
3512 );
3513
3514 let mut del = HashMap::new();
3516 del.insert("StackName".to_string(), "pol".to_string());
3517 svc.delete_stack(&make_request("DeleteStack", del))
3518 .await
3519 .unwrap();
3520 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3521 assert!(
3522 !loaded.buckets.contains_key("pol-bucket"),
3523 "CFN-deleted bucket and policy should be gone from the store"
3524 );
3525 }
3526}