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 "AWS::Pipes::Pipe" => &["Arn"],
56 "AWS::CodeArtifact::Domain" => &["Arn", "EncryptionKey", "Name", "Owner"],
57 "AWS::CodeArtifact::Repository" => &["Arn", "DomainName", "DomainOwner", "Name"],
58 "AWS::CodeCommit::Repository" => &["Arn", "CloneUrlHttp", "CloneUrlSsh", "Name"],
59 "AWS::EFS::FileSystem" => &["Arn", "FileSystemId"],
60 "AWS::EFS::MountTarget" => &["Id", "IpAddress"],
61 "AWS::EFS::AccessPoint" => &["Arn", "AccessPointId"],
62 "AWS::ElasticBeanstalk::Environment" => &["EndpointURL"],
63 _ => &[],
64 }
65}
66
67fn service_key_for_type(resource_type: &str) -> Option<&'static str> {
78 let mut parts = resource_type.split("::");
79 let vendor = parts.next()?;
80 let service = parts.next()?;
81 parts.next()?;
84 if vendor != "AWS" {
85 return None;
86 }
87 Some(match service {
88 "Lambda" => "lambda",
89 "SecretsManager" => "secretsmanager",
90 "SQS" => "sqs",
91 "SNS" => "sns",
92 "DynamoDB" => "dynamodb",
93 "StepFunctions" => "stepfunctions",
94 "Events" => "eventbridge",
95 "SSM" => "ssm",
96 "Logs" => "logs",
97 "KMS" => "kms",
98 "Kinesis" => "kinesis",
99 "SES" => "ses",
100 "Cognito" => "cognito",
101 "RDS" => "rds",
102 "ElastiCache" => "elasticache",
103 "ECR" => "ecr",
104 "ECS" => "ecs",
105 "CloudWatch" => "cloudwatch",
106 "ApiGateway" => "apigateway",
107 "ApiGatewayV2" => "apigatewayv2",
108 "Bedrock" => "bedrock",
109 "Scheduler" => "scheduler",
110 "IAM" => "iam",
111 "CertificateManager" => "acm",
118 "ElasticLoadBalancingV2" => "elbv2",
119 "CloudFront" => "cloudfront",
120 "Route53" => "route53",
121 "KinesisFirehose" => "firehose",
122 "Glue" => "glue",
123 "WAFv2" => "wafv2",
124 "Athena" => "athena",
125 "Organizations" => "organizations",
126 "EC2" => "ec2",
133 "AutoScaling" => "autoscaling",
134 "Batch" => "batch",
135 "ApplicationAutoScaling" => "application-autoscaling",
136 "Pipes" => "pipes",
137 "CodeArtifact" => "codeartifact",
138 "CodeCommit" => "codecommit",
139 "EFS" => "elasticfilesystem",
145 "ElasticBeanstalk" => "elasticbeanstalk",
149 "AmazonMQ" => "mq",
153 "EKS" => "eks",
154 "ServiceDiscovery" => "servicediscovery",
155 _ => return None,
156 })
157}
158
159async fn persist_touched_services<I>(
168 hooks: &BTreeMap<&'static str, SnapshotHook>,
169 resource_types: I,
170) where
171 I: IntoIterator<Item = String>,
172{
173 if hooks.is_empty() {
174 return;
175 }
176 let mut keys: BTreeSet<&'static str> = BTreeSet::new();
177 for ty in resource_types {
178 if let Some(key) = service_key_for_type(&ty) {
179 keys.insert(key);
180 }
181 }
182 for key in keys {
183 if let Some(hook) = hooks.get(key) {
184 hook().await;
185 }
186 }
187}
188
189pub(crate) fn provision_stack_resources(
198 provisioner: &ResourceProvisioner,
199 resource_defs: &[template::ResourceDefinition],
200 template_body: &str,
201 parameters: &BTreeMap<String, String>,
202 imports: &BTreeMap<String, String>,
203) -> Result<Vec<StackResource>, AwsServiceError> {
204 let mut resources = Vec::new();
205 let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
206 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
207 let order = template::dependency_order(template_body, parameters, resource_defs);
212 let mut pending: Vec<&template::ResourceDefinition> =
213 order.iter().map(|&i| &resource_defs[i]).collect();
214 let max_passes = pending.len() + 1;
215
216 for _ in 0..max_passes {
217 if pending.is_empty() {
218 break;
219 }
220 let mut still_pending = Vec::new();
221 let mut made_progress = false;
222
223 for resource_def in pending {
224 let resolved_def = template::resolve_resource_properties_with_attrs(
225 resource_def,
226 template_body,
227 parameters,
228 &physical_ids,
229 &attributes,
230 imports,
231 )
232 .map_err(|e| {
233 AwsServiceError::aws_error(
237 StatusCode::BAD_REQUEST,
238 "InsufficientCapabilitiesException",
239 e,
240 )
241 })?;
242
243 match provisioner.create_resource(&resolved_def) {
244 Ok(mut stack_resource) => {
245 physical_ids.insert(
246 stack_resource.logical_id.clone(),
247 stack_resource.physical_id.clone(),
248 );
249 let mut attr_map = stack_resource.attributes.clone();
254 for attr in well_known_attributes_for(&stack_resource.resource_type) {
255 if attr_map.contains_key(*attr) {
256 continue;
257 }
258 if let Some(v) = provisioner.get_att(&stack_resource, attr) {
259 attr_map.insert((*attr).to_string(), v);
260 }
261 }
262 attributes.insert(stack_resource.logical_id.clone(), attr_map.clone());
263 stack_resource.attributes = attr_map;
268 resources.push(stack_resource);
269 made_progress = true;
270 }
271 Err(_) => still_pending.push(resource_def),
272 }
273 }
274
275 pending = still_pending;
276 if !made_progress && !pending.is_empty() {
277 let resource_def = pending[0];
280 let resolved_def = template::resolve_resource_properties_with_attrs(
281 resource_def,
282 template_body,
283 parameters,
284 &physical_ids,
285 &attributes,
286 imports,
287 )
288 .unwrap_or_else(|_| resource_def.clone());
289 let err = provisioner.create_resource(&resolved_def).unwrap_err();
290 for r in &resources {
291 let _ = provisioner.delete_resource(r);
292 }
293 return Err(AwsServiceError::aws_error(
294 StatusCode::BAD_REQUEST,
295 "ValidationError",
296 format!(
297 "Failed to create resource {}: {err}",
298 resource_def.logical_id
299 ),
300 ));
301 }
302 }
303
304 Ok(resources)
305}
306
307#[derive(Debug, Clone)]
313pub struct CloudControlOutcome {
314 pub physical_id: String,
315 pub attributes: BTreeMap<String, String>,
316}
317
318impl CloudControlOutcome {
319 fn from(resource: &StackResource) -> Self {
320 Self {
321 physical_id: resource.physical_id.clone(),
322 attributes: resource.attributes.clone(),
323 }
324 }
325}
326
327fn reconstruct_stack_resource(
330 type_name: &str,
331 physical_id: &str,
332 attributes: &BTreeMap<String, String>,
333) -> StackResource {
334 StackResource {
335 logical_id: "Resource".to_string(),
336 physical_id: physical_id.to_string(),
337 resource_type: type_name.to_string(),
338 status: "CREATE_COMPLETE".to_string(),
339 service_token: None,
340 attributes: attributes.clone(),
341 }
342}
343
344pub struct CloudFormationDeps {
345 pub sqs: SharedSqsState,
346 pub sns: SharedSnsState,
347 pub ssm: SharedSsmState,
348 pub iam: SharedIamState,
349 pub s3: SharedS3State,
350 pub eventbridge: SharedEventBridgeState,
351 pub dynamodb: SharedDynamoDbState,
352 pub logs: SharedLogsState,
353 pub lambda: fakecloud_lambda::SharedLambdaState,
354 pub secretsmanager: fakecloud_secretsmanager::SharedSecretsManagerState,
355 pub kinesis: fakecloud_kinesis::SharedKinesisState,
356 pub kms: fakecloud_kms::SharedKmsState,
357 pub ecr: fakecloud_ecr::SharedEcrState,
358 pub cloudwatch: fakecloud_cloudwatch::SharedCloudWatchState,
359 pub elbv2: fakecloud_elbv2::SharedElbv2State,
360 pub organizations: fakecloud_organizations::SharedOrganizationsState,
361 pub cognito: fakecloud_cognito::SharedCognitoState,
362 pub rds: fakecloud_rds::SharedRdsState,
363 pub ec2: fakecloud_ec2::SharedEc2State,
364 pub autoscaling: fakecloud_autoscaling::SharedAutoScalingState,
365 pub batch: fakecloud_batch::SharedBatchState,
366 pub pipes: fakecloud_pipes::SharedPipesState,
367 pub ecs: fakecloud_ecs::SharedEcsState,
368 pub acm: fakecloud_acm::SharedAcmState,
369 pub elasticache: fakecloud_elasticache::SharedElastiCacheState,
370 pub route53: fakecloud_route53::SharedRoute53State,
371 pub cloudfront: fakecloud_cloudfront::SharedCloudFrontState,
372 pub stepfunctions: fakecloud_stepfunctions::SharedStepFunctionsState,
373 pub wafv2: fakecloud_wafv2::SharedWafv2State,
374 pub apigateway: fakecloud_apigateway::SharedApiGatewayState,
375 pub apigatewayv2: fakecloud_apigatewayv2::SharedApiGatewayV2State,
376 pub ses: fakecloud_ses::SharedSesState,
377 pub application_autoscaling:
378 fakecloud_application_autoscaling::SharedApplicationAutoScalingState,
379 pub athena: fakecloud_athena::SharedAthenaState,
380 pub firehose: fakecloud_firehose::SharedFirehoseState,
381 pub glue: fakecloud_glue::SharedGlueState,
382 pub eks: fakecloud_eks::state::SharedEksState,
383 pub servicediscovery: fakecloud_servicediscovery::state::SharedServiceDiscoveryState,
384 pub codeartifact: fakecloud_codeartifact::SharedCodeArtifactState,
385 pub codecommit: fakecloud_codecommit::SharedCodeCommitState,
386 pub efs: fakecloud_efs::SharedEfsState,
387 pub elasticbeanstalk: fakecloud_elasticbeanstalk::SharedEbState,
388 pub mq: fakecloud_mq::SharedMqState,
389 pub delivery: Arc<DeliveryBus>,
390 pub lambda_runtime: Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
397 pub rds_runtime: Option<Arc<fakecloud_rds::runtime::RdsRuntime>>,
405 pub ec2_runtime: Option<Arc<fakecloud_ec2::runtime::Ec2Runtime>>,
406 pub ecs_runtime: Option<Arc<fakecloud_ecs::runtime::EcsRuntime>>,
407 pub elasticache_runtime: Option<Arc<fakecloud_elasticache::runtime::ElastiCacheRuntime>>,
408}
409
410pub struct CloudFormationService {
411 pub(crate) state: SharedCloudFormationState,
412 pub(crate) deps: CloudFormationDeps,
413 snapshot_store: Option<Arc<dyn SnapshotStore>>,
414 snapshot_lock: Arc<AsyncMutex<()>>,
415 s3_store: Arc<dyn S3Store>,
421 snapshot_hooks: BTreeMap<&'static str, SnapshotHook>,
428}
429
430struct CreateStackContext {
434 state: SharedCloudFormationState,
435 delivery: Arc<DeliveryBus>,
436 snapshot_store: Option<Arc<dyn SnapshotStore>>,
437 snapshot_lock: Arc<AsyncMutex<()>>,
438 snapshot_hooks: BTreeMap<&'static str, SnapshotHook>,
439 provisioner: ResourceProvisioner,
440 account_id: String,
441 stack_name: String,
442 stack_id: String,
443 template_body: String,
444 parameters: BTreeMap<String, String>,
445 notification_arns: Vec<String>,
446 imported_names: Vec<String>,
447 resource_defs: Vec<template::ResourceDefinition>,
448}
449
450pub(crate) struct ContainerBackingHandles {
459 account_id: String,
460 region: String,
461 rds_state: fakecloud_rds::SharedRdsState,
462 rds_runtime: Option<Arc<fakecloud_rds::runtime::RdsRuntime>>,
463 ec2_state: fakecloud_ec2::SharedEc2State,
464 ec2_runtime: Option<Arc<fakecloud_ec2::runtime::Ec2Runtime>>,
465 autoscaling_state: fakecloud_autoscaling::SharedAutoScalingState,
466 elasticache_state: fakecloud_elasticache::SharedElastiCacheState,
467 elasticache_runtime: Option<Arc<fakecloud_elasticache::runtime::ElastiCacheRuntime>>,
468 ecs_state: fakecloud_ecs::SharedEcsState,
469 ecs_runtime: Option<Arc<fakecloud_ecs::runtime::EcsRuntime>>,
470}
471
472impl ContainerBackingHandles {
473 pub(crate) fn from_provisioner(p: &ResourceProvisioner) -> Self {
474 Self {
475 account_id: p.account_id.clone(),
476 region: p.region.clone(),
477 rds_state: p.rds_state.clone(),
478 rds_runtime: p.rds_runtime.clone(),
479 ec2_state: p.ec2_state.clone(),
480 ec2_runtime: p.ec2_runtime.clone(),
481 autoscaling_state: p.autoscaling_state.clone(),
482 elasticache_state: p.elasticache_state.clone(),
483 elasticache_runtime: p.elasticache_runtime.clone(),
484 ecs_state: p.ecs_state.clone(),
485 ecs_runtime: p.ecs_runtime.clone(),
486 }
487 }
488
489 pub(crate) fn spawn_container_intents(
493 &self,
494 intents: Vec<crate::resource_provisioner::ContainerSpawnIntent>,
495 ) {
496 use crate::resource_provisioner::ContainerSpawnIntent;
497 for intent in intents {
498 match intent {
499 ContainerSpawnIntent::RdsInstance { identifier } => {
500 if let Some(runtime) = self.rds_runtime.clone() {
501 let rds_state = self.rds_state.clone();
502 let account = self.account_id.clone();
503 let region = self.region.clone();
504 tokio::spawn(async move {
505 fakecloud_rds::cfn_provision::cfn_ensure_instance_container(
506 rds_state, runtime, identifier, account, region,
507 )
508 .await;
509 });
510 }
511 }
512 ContainerSpawnIntent::AsgInstances { group_name } => {
513 let asg_state = self.autoscaling_state.clone();
514 let ec2_state = self.ec2_state.clone();
515 let ec2_runtime = self.ec2_runtime.clone();
516 let account = self.account_id.clone();
517 let region = self.region.clone();
518 tokio::spawn(async move {
519 fakecloud_autoscaling::cfn_provision::cfn_reconcile_capacity(
520 asg_state,
521 ec2_state,
522 ec2_runtime,
523 group_name,
524 account,
525 region,
526 )
527 .await;
528 });
529 }
530 ContainerSpawnIntent::Ec2Instance { instance_id } => {
531 let ec2_state = self.ec2_state.clone();
532 let ec2_runtime = self.ec2_runtime.clone();
533 let account = self.account_id.clone();
534 tokio::spawn(async move {
535 fakecloud_ec2::cfn_provision::cfn_back_instance(
536 ec2_state,
537 ec2_runtime,
538 account,
539 instance_id,
540 )
541 .await;
542 });
543 }
544 ContainerSpawnIntent::ElastiCacheCluster { cache_cluster_id } => {
545 if let Some(runtime) = self.elasticache_runtime.clone() {
546 let ec_state = self.elasticache_state.clone();
547 let account = self.account_id.clone();
548 tokio::spawn(async move {
549 fakecloud_elasticache::cfn_provision::cfn_ensure_cluster_container(
550 ec_state,
551 runtime,
552 cache_cluster_id,
553 account,
554 )
555 .await;
556 });
557 }
558 }
559 ContainerSpawnIntent::ElastiCacheReplicationGroup {
560 replication_group_id,
561 } => {
562 if let Some(runtime) = self.elasticache_runtime.clone() {
563 let ec_state = self.elasticache_state.clone();
564 let account = self.account_id.clone();
565 tokio::spawn(async move {
566 fakecloud_elasticache::cfn_provision::cfn_ensure_replication_group_container(
567 ec_state,
568 runtime,
569 replication_group_id,
570 account,
571 )
572 .await;
573 });
574 }
575 }
576 ContainerSpawnIntent::EcsServiceTasks {
577 cluster_name,
578 service_name,
579 } => {
580 if let Some(runtime) = self.ecs_runtime.clone() {
581 let ecs_state = self.ecs_state.clone();
582 let account = self.account_id.clone();
583 tokio::spawn(async move {
584 fakecloud_ecs::cfn_provision::cfn_launch_service_tasks(
585 ecs_state,
586 runtime,
587 cluster_name,
588 service_name,
589 account,
590 )
591 .await;
592 });
593 }
594 }
595 }
596 }
597 }
598
599 pub(crate) fn spawn_teardown_intents(
604 &self,
605 intents: Vec<crate::resource_provisioner::ContainerTeardownIntent>,
606 ) {
607 use crate::resource_provisioner::ContainerTeardownIntent;
608 for intent in intents {
609 match intent {
610 ContainerTeardownIntent::RdsInstance { identifier } => {
611 if let Some(runtime) = self.rds_runtime.clone() {
612 let account = self.account_id.clone();
613 tokio::spawn(async move {
614 fakecloud_rds::cfn_provision::cfn_teardown_instance_container(
615 runtime, identifier, account,
616 )
617 .await;
618 });
619 }
620 }
621 ContainerTeardownIntent::ElastiCacheCluster { cache_cluster_id } => {
622 if let Some(runtime) = self.elasticache_runtime.clone() {
623 tokio::spawn(async move {
624 fakecloud_elasticache::cfn_provision::cfn_teardown_cluster_container(
625 runtime,
626 cache_cluster_id,
627 )
628 .await;
629 });
630 }
631 }
632 ContainerTeardownIntent::ElastiCacheReplicationGroup {
633 replication_group_id,
634 } => {
635 if let Some(runtime) = self.elasticache_runtime.clone() {
636 tokio::spawn(async move {
637 fakecloud_elasticache::cfn_provision::cfn_teardown_replication_group_container(
638 runtime,
639 replication_group_id,
640 )
641 .await;
642 });
643 }
644 }
645 ContainerTeardownIntent::EcsService {
646 cluster_name,
647 service_name,
648 } => {
649 if let Some(runtime) = self.ecs_runtime.clone() {
650 let ecs_state = self.ecs_state.clone();
651 let account = self.account_id.clone();
652 tokio::spawn(async move {
653 fakecloud_ecs::cfn_provision::cfn_stop_service_tasks(
654 ecs_state,
655 runtime,
656 cluster_name,
657 service_name,
658 account,
659 )
660 .await;
661 });
662 }
663 }
664 ContainerTeardownIntent::Ec2Instance { instance_id } => {
665 let ec2_state = self.ec2_state.clone();
666 let ec2_runtime = self.ec2_runtime.clone();
667 let account = self.account_id.clone();
668 let region = self.region.clone();
669 tokio::spawn(async move {
670 fakecloud_ec2::cfn_provision::cfn_terminate(
671 ec2_state,
672 ec2_runtime,
673 account,
674 region,
675 instance_id,
676 )
677 .await;
678 });
679 }
680 ContainerTeardownIntent::AsgInstances { instance_ids } => {
681 let asg_state = self.autoscaling_state.clone();
682 let ec2_state = self.ec2_state.clone();
683 let ec2_runtime = self.ec2_runtime.clone();
684 let account = self.account_id.clone();
685 let region = self.region.clone();
686 tokio::spawn(async move {
687 fakecloud_autoscaling::cfn_provision::cfn_terminate_instances(
688 asg_state,
689 ec2_state,
690 ec2_runtime,
691 instance_ids,
692 account,
693 region,
694 )
695 .await;
696 });
697 }
698 }
699 }
700 }
701}
702
703pub(crate) fn spawn_custom_invokes(provisioner: &ResourceProvisioner) {
708 let intents = std::mem::take(&mut *provisioner.pending_custom_invokes.lock());
709 if intents.is_empty() {
710 return;
711 }
712 let delivery = provisioner.delivery.clone();
713 for intent in intents {
714 let delivery = delivery.clone();
715 tokio::spawn(async move {
716 match delivery
717 .invoke_lambda(&intent.service_token, &intent.payload)
718 .await
719 {
720 Some(Ok(_)) => {
721 tracing::info!(
722 "Custom resource Lambda {} invoked successfully",
723 intent.service_token
724 );
725 }
726 Some(Err(e)) => {
727 tracing::warn!(
728 "Custom resource Lambda {} invocation failed: {e}",
729 intent.service_token
730 );
731 }
732 None => {}
733 }
734 });
735 }
736}
737
738impl CloudFormationService {
739 pub fn new(state: SharedCloudFormationState, deps: CloudFormationDeps) -> Self {
740 Self {
741 state,
742 deps,
743 snapshot_store: None,
744 snapshot_lock: Arc::new(AsyncMutex::new(())),
745 s3_store: Arc::new(fakecloud_persistence::s3::MemoryS3Store::new()),
746 snapshot_hooks: BTreeMap::new(),
747 }
748 }
749
750 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
751 self.snapshot_store = Some(store);
752 self
753 }
754
755 pub fn with_s3_store(mut self, store: Arc<dyn S3Store>) -> Self {
758 self.s3_store = store;
759 self
760 }
761
762 pub fn with_snapshot_hooks(mut self, hooks: BTreeMap<&'static str, SnapshotHook>) -> Self {
765 self.snapshot_hooks = hooks;
766 self
767 }
768
769 async fn save_snapshot(&self) {
770 let Some(store) = self.snapshot_store.clone() else {
771 return;
772 };
773 let _guard = self.snapshot_lock.lock().await;
774 let snapshot = CloudFormationSnapshot {
775 schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
776 state: None,
777 accounts: Some(self.state.read().clone()),
778 };
779 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
780 let bytes = serde_json::to_vec(&snapshot)
781 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
782 store.save(&bytes)
783 })
784 .await;
785 match join {
786 Ok(Ok(())) => {}
787 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
788 Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
789 }
790 }
791
792 pub(crate) fn provisioner(
793 &self,
794 stack_id: &str,
795 account_id: &str,
796 region: &str,
797 ) -> ResourceProvisioner {
798 ResourceProvisioner {
799 sqs_state: self.deps.sqs.clone(),
800 sns_state: self.deps.sns.clone(),
801 ssm_state: self.deps.ssm.clone(),
802 iam_state: self.deps.iam.clone(),
803 s3_state: self.deps.s3.clone(),
804 eventbridge_state: self.deps.eventbridge.clone(),
805 dynamodb_state: self.deps.dynamodb.clone(),
806 logs_state: self.deps.logs.clone(),
807 lambda_state: self.deps.lambda.clone(),
808 secretsmanager_state: self.deps.secretsmanager.clone(),
809 kinesis_state: self.deps.kinesis.clone(),
810 kms_state: self.deps.kms.clone(),
811 ecr_state: self.deps.ecr.clone(),
812 cloudwatch_state: self.deps.cloudwatch.clone(),
813 elbv2_state: self.deps.elbv2.clone(),
814 organizations_state: self.deps.organizations.clone(),
815 cognito_state: self.deps.cognito.clone(),
816 rds_state: self.deps.rds.clone(),
817 ec2_state: self.deps.ec2.clone(),
818 autoscaling_state: self.deps.autoscaling.clone(),
819 batch_state: self.deps.batch.clone(),
820 pipes_state: self.deps.pipes.clone(),
821 ecs_state: self.deps.ecs.clone(),
822 acm_state: self.deps.acm.clone(),
823 elasticache_state: self.deps.elasticache.clone(),
824 route53_state: self.deps.route53.clone(),
825 cloudfront_state: self.deps.cloudfront.clone(),
826 stepfunctions_state: self.deps.stepfunctions.clone(),
827 wafv2_state: self.deps.wafv2.clone(),
828 apigateway_state: self.deps.apigateway.clone(),
829 apigatewayv2_state: self.deps.apigatewayv2.clone(),
830 ses_state: self.deps.ses.clone(),
831 app_autoscaling_state: self.deps.application_autoscaling.clone(),
832 athena_state: self.deps.athena.clone(),
833 firehose_state: self.deps.firehose.clone(),
834 glue_state: self.deps.glue.clone(),
835 eks_state: self.deps.eks.clone(),
836 servicediscovery_state: self.deps.servicediscovery.clone(),
837 codeartifact_state: self.deps.codeartifact.clone(),
838 codecommit_state: self.deps.codecommit.clone(),
839 efs_state: self.deps.efs.clone(),
840 elasticbeanstalk_state: self.deps.elasticbeanstalk.clone(),
841 mq_state: self.deps.mq.clone(),
842 cloudformation_state: self.state.clone(),
843 delivery: self.deps.delivery.clone(),
844 lambda_runtime: self.deps.lambda_runtime.clone(),
845 rds_runtime: self.deps.rds_runtime.clone(),
846 ec2_runtime: self.deps.ec2_runtime.clone(),
847 ecs_runtime: self.deps.ecs_runtime.clone(),
848 elasticache_runtime: self.deps.elasticache_runtime.clone(),
849 pending_container_spawns: Arc::new(parking_lot::Mutex::new(Vec::new())),
850 pending_container_teardowns: Arc::new(parking_lot::Mutex::new(Vec::new())),
851 pending_custom_invokes: Arc::new(parking_lot::Mutex::new(Vec::new())),
852 defer_custom_invokes: false,
858 s3_store: self.s3_store.clone(),
859 account_id: account_id.to_string(),
860 region: region.to_string(),
861 stack_id: stack_id.to_string(),
862 strict_unknown_types: false,
865 }
866 }
867
868 pub fn cloudcontrol_create(
883 &self,
884 type_name: &str,
885 properties: serde_json::Value,
886 account_id: &str,
887 region: &str,
888 ) -> Result<CloudControlOutcome, String> {
889 let stack_id = format!("cloudcontrol-{}", uuid::Uuid::new_v4());
890 let mut provisioner = self.provisioner(&stack_id, account_id, region);
891 provisioner.strict_unknown_types = true;
894 let backing = ContainerBackingHandles::from_provisioner(&provisioner);
895 let spawns = provisioner.pending_container_spawns.clone();
896 let def = template::ResourceDefinition {
897 logical_id: "Resource".to_string(),
898 resource_type: type_name.to_string(),
899 properties,
900 };
901 let resource = provisioner.create_resource(&def)?;
902 backing.spawn_container_intents(std::mem::take(&mut *spawns.lock()));
905 Ok(CloudControlOutcome::from(&resource))
906 }
907
908 pub fn cloudcontrol_update(
913 &self,
914 type_name: &str,
915 physical_id: &str,
916 prior_attributes: &BTreeMap<String, String>,
917 new_properties: serde_json::Value,
918 account_id: &str,
919 region: &str,
920 ) -> Result<CloudControlOutcome, String> {
921 let stack_id = format!("cloudcontrol-{}", uuid::Uuid::new_v4());
922 let mut provisioner = self.provisioner(&stack_id, account_id, region);
923 provisioner.strict_unknown_types = true;
924 let existing = reconstruct_stack_resource(type_name, physical_id, prior_attributes);
925 let def = template::ResourceDefinition {
926 logical_id: "Resource".to_string(),
927 resource_type: type_name.to_string(),
928 properties: new_properties,
929 };
930 match provisioner.update_resource(&existing, &def)? {
933 Some(updated) => Ok(CloudControlOutcome::from(&updated)),
934 None => Ok(CloudControlOutcome::from(&existing)),
935 }
936 }
937
938 pub fn cloudcontrol_delete(
940 &self,
941 type_name: &str,
942 physical_id: &str,
943 prior_attributes: &BTreeMap<String, String>,
944 account_id: &str,
945 region: &str,
946 ) -> Result<(), String> {
947 let stack_id = format!("cloudcontrol-{}", uuid::Uuid::new_v4());
948 let provisioner = self.provisioner(&stack_id, account_id, region);
949 let teardowns = provisioner.pending_container_teardowns.clone();
950 let existing = reconstruct_stack_resource(type_name, physical_id, prior_attributes);
951 provisioner.delete_resource(&existing)?;
952 ContainerBackingHandles::from_provisioner(&provisioner)
953 .spawn_teardown_intents(std::mem::take(&mut *teardowns.lock()));
954 Ok(())
955 }
956
957 pub async fn cloudcontrol_persist_type(&self, type_name: &str) {
964 persist_touched_services(&self.snapshot_hooks, [type_name.to_string()]).await;
965 }
966
967 pub(crate) fn provisioner_deferred(
974 &self,
975 stack_id: &str,
976 account_id: &str,
977 region: &str,
978 ) -> ResourceProvisioner {
979 ResourceProvisioner {
980 defer_custom_invokes: true,
981 ..self.provisioner(stack_id, account_id, region)
982 }
983 }
984
985 fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
986 if let Some(v) = req.query_params.get(key) {
988 return Some(v.clone());
989 }
990 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
992 body_params.get(key).cloned()
993 }
994
995 pub(crate) fn get_all_params(req: &AwsRequest) -> BTreeMap<String, String> {
996 let mut params: BTreeMap<String, String> = req.query_params.clone().into_iter().collect();
997 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
998 for (k, v) in body_params {
999 params.entry(k).or_insert(v);
1000 }
1001 params
1002 }
1003
1004 pub(crate) fn extract_tags(params: &BTreeMap<String, String>) -> BTreeMap<String, String> {
1005 let mut tags = BTreeMap::new();
1006 for i in 1.. {
1007 let key_param = format!("Tags.member.{i}.Key");
1008 let value_param = format!("Tags.member.{i}.Value");
1009 match (params.get(&key_param), params.get(&value_param)) {
1010 (Some(k), Some(v)) => {
1011 tags.insert(k.clone(), v.clone());
1012 }
1013 _ => break,
1014 }
1015 }
1016 tags
1017 }
1018
1019 pub(crate) fn extract_parameters(
1020 params: &BTreeMap<String, String>,
1021 ) -> BTreeMap<String, String> {
1022 let mut result = BTreeMap::new();
1023 for i in 1.. {
1024 let key_param = format!("Parameters.member.{i}.ParameterKey");
1025 let value_param = format!("Parameters.member.{i}.ParameterValue");
1026 match (params.get(&key_param), params.get(&value_param)) {
1027 (Some(k), Some(v)) => {
1028 result.insert(k.clone(), v.clone());
1029 }
1030 _ => break,
1031 }
1032 }
1033 result
1034 }
1035
1036 pub(crate) fn merge_parameter_defaults(
1042 parameters: &mut BTreeMap<String, String>,
1043 template_body: &str,
1044 ) {
1045 let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
1046 match serde_json::from_str(template_body) {
1047 Ok(v) => v,
1048 Err(_) => return,
1049 }
1050 } else {
1051 match serde_yaml::from_str(template_body) {
1052 Ok(v) => v,
1053 Err(_) => return,
1054 }
1055 };
1056 let Some(decls) = value.get("Parameters").and_then(|v| v.as_object()) else {
1057 return;
1058 };
1059 for (name, spec) in decls {
1060 if parameters.contains_key(name) {
1061 continue;
1062 }
1063 if let Some(default) = spec.get("Default") {
1064 let s = default
1065 .as_str()
1066 .map(|s| s.to_string())
1067 .unwrap_or_else(|| default.to_string());
1068 parameters.insert(name.clone(), s);
1069 }
1070 }
1071 }
1072
1073 pub(crate) fn extract_notification_arns(params: &BTreeMap<String, String>) -> Vec<String> {
1074 let mut arns = Vec::new();
1075 for i in 1.. {
1076 let key = format!("NotificationARNs.member.{i}");
1077 match params.get(&key) {
1078 Some(arn) => arns.push(arn.clone()),
1079 None => break,
1080 }
1081 }
1082 arns
1083 }
1084
1085 fn send_stack_notification(
1086 delivery: &DeliveryBus,
1087 notification_arns: &[String],
1088 stack_name: &str,
1089 stack_id: &str,
1090 status: &str,
1091 ) {
1092 if notification_arns.is_empty() {
1093 return;
1094 }
1095 let message = format!(
1096 "StackId='{}'\nTimestamp='{}'\nEventId='{}'\nLogicalResourceId='{}'\nResourceStatus='{}'\nResourceType='AWS::CloudFormation::Stack'\nStackName='{}'",
1097 stack_id,
1098 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
1099 uuid::Uuid::new_v4(),
1100 stack_name,
1101 status,
1102 stack_name,
1103 );
1104 for arn in notification_arns {
1105 delivery.publish_to_sns(arn, &message, Some("AWS CloudFormation Notification"));
1106 }
1107 }
1108
1109 pub(crate) fn collect_account_imports(
1114 state: &SharedCloudFormationState,
1115 account_id: &str,
1116 skip_stack: Option<&str>,
1117 ) -> BTreeMap<String, String> {
1118 let mut imports = BTreeMap::new();
1119 let accounts = state.read();
1120 let Some(state) = accounts.get(account_id) else {
1121 return imports;
1122 };
1123 for (name, export) in &state.exports {
1124 if matches!(skip_stack, Some(skip) if skip == export.exporting_stack_name) {
1125 continue;
1126 }
1127 imports.insert(name.clone(), export.value.clone());
1128 }
1129 imports
1130 }
1131
1132 fn validate_import_values(
1137 state: &SharedCloudFormationState,
1138 account_id: &str,
1139 stack_name: &str,
1140 template_body: &str,
1141 parameters: &BTreeMap<String, String>,
1142 ) -> Result<Vec<String>, AwsServiceError> {
1143 let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
1144 match serde_json::from_str(template_body) {
1145 Ok(v) => v,
1146 Err(_) => return Ok(Vec::new()),
1147 }
1148 } else {
1149 match serde_yaml::from_str(template_body) {
1150 Ok(v) => v,
1151 Err(_) => return Ok(Vec::new()),
1152 }
1153 };
1154 let names = template::collect_import_value_names(&value, parameters);
1155 let known = Self::collect_account_imports(state, account_id, Some(stack_name));
1156 for n in &names {
1157 if !known.contains_key(n) {
1158 return Err(AwsServiceError::aws_error(
1163 StatusCode::BAD_REQUEST,
1164 "InsufficientCapabilitiesException",
1165 format!("No export named {n} found."),
1166 ));
1167 }
1168 }
1169 Ok(names)
1170 }
1171
1172 pub(crate) fn sync_exports_imports(
1176 state: &mut CloudFormationState,
1177 stack_id: &str,
1178 stack_name: &str,
1179 outputs: &[state::StackOutput],
1180 imported_names: &[String],
1181 ) {
1182 let stale_exports: Vec<String> = state
1184 .exports
1185 .iter()
1186 .filter(|(_, e)| e.exporting_stack_name == stack_name)
1187 .map(|(k, _)| k.clone())
1188 .collect();
1189 for k in stale_exports {
1190 state.exports.remove(&k);
1191 }
1192 for entries in state.imports.values_mut() {
1194 entries.retain(|s| s != stack_name);
1195 }
1196 state.imports.retain(|_, v| !v.is_empty());
1197
1198 for o in outputs {
1200 if let Some(export) = &o.export_name {
1201 state.exports.insert(
1202 export.clone(),
1203 state::StackExport {
1204 value: o.value.clone(),
1205 exporting_stack_id: stack_id.to_string(),
1206 exporting_stack_name: stack_name.to_string(),
1207 },
1208 );
1209 }
1210 }
1211 for name in imported_names {
1213 let entry = state.imports.entry(name.clone()).or_default();
1214 if !entry.iter().any(|s| s == stack_name) {
1215 entry.push(stack_name.to_string());
1216 }
1217 }
1218 }
1219
1220 pub(crate) fn resolve_template_outputs(
1225 template_body: &str,
1226 parameters: &BTreeMap<String, String>,
1227 resources: &[StackResource],
1228 state: &SharedCloudFormationState,
1229 ) -> Vec<state::StackOutput> {
1230 let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
1231 match serde_json::from_str(template_body) {
1232 Ok(v) => v,
1233 Err(_) => return Vec::new(),
1234 }
1235 } else {
1236 match serde_yaml::from_str(template_body) {
1237 Ok(v) => v,
1238 Err(_) => return Vec::new(),
1239 }
1240 };
1241
1242 let resources_obj = match value.get("Resources").and_then(|v| v.as_object()) {
1243 Some(o) => o.clone(),
1244 None => return Vec::new(),
1245 };
1246
1247 let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
1248 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
1249 for r in resources {
1250 physical_ids.insert(r.logical_id.clone(), r.physical_id.clone());
1251 attributes.insert(r.logical_id.clone(), r.attributes.clone());
1252 }
1253
1254 let imports = {
1255 let accounts = state.read();
1256 let mut out = BTreeMap::new();
1257 for (_account, st) in accounts.iter() {
1260 for (name, export) in &st.exports {
1261 out.insert(name.clone(), export.value.clone());
1262 }
1263 }
1264 out
1265 };
1266
1267 let parsed = match template::parse_outputs(
1268 &value,
1269 parameters,
1270 &resources_obj,
1271 &physical_ids,
1272 &attributes,
1273 &imports,
1274 ) {
1275 Ok(o) => o,
1276 Err(_) => return Vec::new(),
1277 };
1278
1279 parsed
1280 .into_iter()
1281 .map(|o| state::StackOutput {
1282 key: o.logical_id,
1283 value: o.value,
1284 description: o.description,
1285 export_name: o.export_name,
1286 })
1287 .collect()
1288 }
1289
1290 fn ensure_export_uniqueness(
1293 state: &SharedCloudFormationState,
1294 account_id: &str,
1295 stack_name: &str,
1296 outputs: &[state::StackOutput],
1297 ) -> Result<(), AwsServiceError> {
1298 let existing = Self::collect_account_imports(state, account_id, Some(stack_name));
1299 for o in outputs {
1300 if let Some(export) = &o.export_name {
1301 if existing.contains_key(export) {
1302 return Err(AwsServiceError::aws_error(
1306 StatusCode::BAD_REQUEST,
1307 "AlreadyExistsException",
1308 format!("Export with name {export} is already exported by another stack"),
1309 ));
1310 }
1311 }
1312 }
1313 Ok(())
1314 }
1315
1316 async fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1317 let params = Self::get_all_params(req);
1318
1319 let stack_name = params.get("StackName").ok_or_else(|| {
1322 AwsServiceError::aws_error(
1323 StatusCode::BAD_REQUEST,
1324 "ValidationError",
1325 "StackName is required",
1326 )
1327 })?;
1328
1329 let empty = String::new();
1333 let template_body = params.get("TemplateBody").unwrap_or(&empty);
1334
1335 {
1337 let accounts = self.state.read();
1338 let empty = CloudFormationState::new(&req.account_id, &req.region);
1339 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1340 if let Some(existing) = state.stacks.get(stack_name.as_str()) {
1341 if existing.status != "DELETE_COMPLETE" {
1342 return Err(AwsServiceError::aws_error(
1343 StatusCode::BAD_REQUEST,
1344 "AlreadyExistsException",
1345 format!("Stack [{stack_name}] already exists"),
1346 ));
1347 }
1348 }
1349 }
1350
1351 let tags = Self::extract_tags(¶ms);
1352 let mut parameters = Self::extract_parameters(¶ms);
1353 Self::merge_parameter_defaults(&mut parameters, template_body);
1354 let notification_arns = Self::extract_notification_arns(¶ms);
1355
1356 let stack_id = format!(
1359 "arn:aws:cloudformation:{}:{}:stack/{}/{}",
1360 req.region,
1361 req.account_id,
1362 stack_name,
1363 uuid::Uuid::new_v4()
1364 );
1365 parameters
1366 .entry("AWS::Region".to_string())
1367 .or_insert_with(|| req.region.clone());
1368 parameters
1369 .entry("AWS::AccountId".to_string())
1370 .or_insert_with(|| req.account_id.clone());
1371 parameters
1372 .entry("AWS::StackId".to_string())
1373 .or_insert_with(|| stack_id.clone());
1374 parameters
1375 .entry("AWS::StackName".to_string())
1376 .or_insert_with(|| stack_name.clone());
1377 parameters
1378 .entry("AWS::Partition".to_string())
1379 .or_insert_with(|| template::partition_for_region(&req.region).to_string());
1380 parameters
1381 .entry("AWS::URLSuffix".to_string())
1382 .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
1383 parameters.insert(
1387 "AWS::NotificationARNs".to_string(),
1388 serde_json::to_string(¬ification_arns).unwrap_or_else(|_| "[]".to_string()),
1389 );
1390
1391 let parsed = template::parse_template(template_body, ¶meters).unwrap_or_else(|_| {
1396 template::ParsedTemplate {
1397 description: None,
1398 resources: Vec::new(),
1399 outputs: Vec::new(),
1400 }
1401 });
1402
1403 let imported_names = Self::validate_import_values(
1407 &self.state,
1408 &req.account_id,
1409 stack_name,
1410 template_body,
1411 ¶meters,
1412 )?;
1413
1414 {
1421 let mut accounts = self.state.write();
1422 let state = accounts.get_or_create(&req.account_id);
1423 state.stacks.insert(
1424 stack_name.clone(),
1425 Stack {
1426 name: stack_name.clone(),
1427 stack_id: stack_id.clone(),
1428 template: template_body.clone(),
1429 status: "CREATE_IN_PROGRESS".to_string(),
1430 resources: Vec::new(),
1431 parameters: parameters.clone(),
1432 tags: tags.clone(),
1433 created_at: Utc::now(),
1434 updated_at: None,
1435 description: parsed.description.clone(),
1436 notification_arns: notification_arns.clone(),
1437 outputs: Vec::new(),
1438 },
1439 );
1440 record_stack_status_event(
1441 state,
1442 &stack_id,
1443 stack_name,
1444 "AWS::CloudFormation::Stack",
1445 "CREATE_IN_PROGRESS",
1446 );
1447 }
1448
1449 let ctx = CreateStackContext {
1450 state: self.state.clone(),
1451 delivery: self.deps.delivery.clone(),
1452 snapshot_store: self.snapshot_store.clone(),
1453 snapshot_lock: self.snapshot_lock.clone(),
1454 snapshot_hooks: self.snapshot_hooks.clone(),
1455 provisioner: self.provisioner(&stack_id, &req.account_id, &req.region),
1456 account_id: req.account_id.clone(),
1457 stack_name: stack_name.clone(),
1458 stack_id: stack_id.clone(),
1459 template_body: template_body.clone(),
1460 parameters,
1461 notification_arns,
1462 imported_names,
1463 resource_defs: parsed.resources,
1464 };
1465
1466 let has_custom_resource = ctx.resource_defs.iter().any(|r| {
1482 r.resource_type.starts_with("Custom::")
1483 || r.resource_type == "AWS::CloudFormation::CustomResource"
1484 });
1485 let multi_thread = matches!(
1486 tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()),
1487 Ok(tokio::runtime::RuntimeFlavor::MultiThread)
1488 );
1489 if has_custom_resource && multi_thread {
1490 Self::send_stack_notification(
1495 &self.deps.delivery,
1496 &ctx.notification_arns,
1497 stack_name,
1498 &stack_id,
1499 "CREATE_IN_PROGRESS",
1500 );
1501 tokio::spawn(async move {
1502 Self::finish_create_stack(ctx).await;
1503 });
1504 } else {
1505 Self::finish_create_stack(ctx).await;
1506 }
1507
1508 Ok(AwsResponse::xml(
1509 StatusCode::OK,
1510 xml_responses::create_stack_response(&stack_id, &req.request_id),
1511 ))
1512 }
1513
1514 async fn finish_create_stack(ctx: CreateStackContext) {
1520 let CreateStackContext {
1521 state,
1522 delivery,
1523 snapshot_store,
1524 snapshot_lock,
1525 snapshot_hooks,
1526 provisioner,
1527 account_id,
1528 stack_name,
1529 stack_id,
1530 template_body,
1531 parameters,
1532 notification_arns,
1533 imported_names,
1534 resource_defs,
1535 } = ctx;
1536
1537 let container_spawns = provisioner.pending_container_spawns.clone();
1541 let backing_handles = ContainerBackingHandles::from_provisioner(&provisioner);
1542
1543 let provision_result = {
1547 let template_body = template_body.clone();
1548 let parameters = parameters.clone();
1549 let imports = Self::collect_account_imports(&state, &account_id, Some(&stack_name));
1553 tokio::task::spawn_blocking(move || {
1554 provision_stack_resources(
1555 &provisioner,
1556 &resource_defs,
1557 &template_body,
1558 ¶meters,
1559 &imports,
1560 )
1561 })
1562 .await
1563 };
1564
1565 let provisioned = match provision_result {
1568 Ok(Ok(resources)) => Ok(resources),
1569 Ok(Err(err)) => Err(err.message()),
1570 Err(join_err) => Err(format!("provisioning task failed: {join_err}")),
1571 };
1572
1573 let resources = match provisioned {
1574 Ok(resources) => resources,
1575 Err(reason) => {
1576 Self::mark_create_failed(
1577 &state,
1578 &delivery,
1579 &account_id,
1580 &stack_name,
1581 &stack_id,
1582 ¬ification_arns,
1583 &reason,
1584 );
1585 save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
1586 return;
1587 }
1588 };
1589
1590 backing_handles.spawn_container_intents(std::mem::take(&mut *container_spawns.lock()));
1596
1597 let outputs =
1598 Self::resolve_template_outputs(&template_body, ¶meters, &resources, &state);
1599
1600 if let Err(err) = Self::ensure_export_uniqueness(&state, &account_id, &stack_name, &outputs)
1603 {
1604 Self::mark_create_failed(
1605 &state,
1606 &delivery,
1607 &account_id,
1608 &stack_name,
1609 &stack_id,
1610 ¬ification_arns,
1611 &err.message(),
1612 );
1613 save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
1614 return;
1615 }
1616
1617 {
1618 let mut accounts = state.write();
1619 let st = accounts.get_or_create(&account_id);
1620 if let Some(stack) = st.stacks.get_mut(&stack_name) {
1621 stack.status = "CREATE_COMPLETE".to_string();
1622 stack.resources = resources.clone();
1623 stack.outputs = outputs.clone();
1624 }
1625 Self::sync_exports_imports(st, &stack_id, &stack_name, &outputs, &imported_names);
1626
1627 let changes: Vec<ResourceChange> = resources
1628 .iter()
1629 .map(|r| ResourceChange {
1630 action: ResourceChangeAction::Create,
1631 logical_id: r.logical_id.clone(),
1632 physical_id: r.physical_id.clone(),
1633 resource_type: r.resource_type.clone(),
1634 })
1635 .collect();
1636 record_stack_events(st, &stack_id, &stack_name, &changes);
1637 record_stack_status_event(
1638 st,
1639 &stack_id,
1640 &stack_name,
1641 "AWS::CloudFormation::Stack",
1642 "CREATE_COMPLETE",
1643 );
1644 }
1645
1646 Self::send_stack_notification(
1647 &delivery,
1648 ¬ification_arns,
1649 &stack_name,
1650 &stack_id,
1651 "CREATE_COMPLETE",
1652 );
1653
1654 save_snapshot_static(state, snapshot_store, snapshot_lock).await;
1655 persist_touched_services(
1660 &snapshot_hooks,
1661 resources.iter().map(|r| r.resource_type.clone()),
1662 )
1663 .await;
1664 }
1665
1666 fn mark_create_failed(
1670 state: &SharedCloudFormationState,
1671 delivery: &DeliveryBus,
1672 account_id: &str,
1673 stack_name: &str,
1674 stack_id: &str,
1675 notification_arns: &[String],
1676 reason: &str,
1677 ) {
1678 tracing::warn!(%stack_name, %reason, "CreateStack provisioning failed");
1679 {
1680 let mut accounts = state.write();
1681 let st = accounts.get_or_create(account_id);
1682 if let Some(stack) = st.stacks.get_mut(stack_name) {
1683 stack.status = "CREATE_FAILED".to_string();
1684 }
1685 record_stack_status_event(
1686 st,
1687 stack_id,
1688 stack_name,
1689 "AWS::CloudFormation::Stack",
1690 "CREATE_FAILED",
1691 );
1692 }
1693 Self::send_stack_notification(
1694 delivery,
1695 notification_arns,
1696 stack_name,
1697 stack_id,
1698 "CREATE_FAILED",
1699 );
1700 }
1701
1702 async fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1703 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
1704 AwsServiceError::aws_error(
1705 StatusCode::BAD_REQUEST,
1706 "ValidationError",
1707 "StackName is required",
1708 )
1709 })?;
1710
1711 let mut deleted_types: Vec<String> = Vec::new();
1717 {
1718 let mut accounts = self.state.write();
1719 let state = accounts.get_or_create(&req.account_id);
1720
1721 let stack = state.stacks.values_mut().find(|s| {
1723 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1724 });
1725
1726 if let Some(stack) = stack {
1727 let stack_id = stack.stack_id.clone();
1728 let stack_name_for_notif = stack.name.clone();
1729 let notification_arns = stack.notification_arns.clone();
1730 let resources: Vec<_> = stack.resources.clone();
1731
1732 let owned_exports: Vec<String> = state
1735 .exports
1736 .iter()
1737 .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
1738 .map(|(k, _)| k.clone())
1739 .collect();
1740 for export in &owned_exports {
1741 if let Some(consumers) = state.imports.get(export) {
1742 let consumers: Vec<&String> = consumers
1743 .iter()
1744 .filter(|c| **c != stack_name_for_notif)
1745 .collect();
1746 if !consumers.is_empty() {
1747 let names: Vec<&str> = consumers.iter().map(|s| s.as_str()).collect();
1748 return Err(AwsServiceError::aws_error(
1755 StatusCode::BAD_REQUEST,
1756 "TokenAlreadyExistsException",
1757 format!(
1758 "Export {export} cannot be deleted as it is in use by {}",
1759 names.join(", ")
1760 ),
1761 ));
1762 }
1763 }
1764 }
1765
1766 drop(accounts);
1769 let provisioner =
1773 self.provisioner_deferred(&stack_id, &req.account_id, &req.region);
1774
1775 for resource in resources.iter().rev() {
1777 let _ = provisioner.delete_resource(resource);
1778 }
1779
1780 ContainerBackingHandles::from_provisioner(&provisioner).spawn_teardown_intents(
1786 std::mem::take(&mut *provisioner.pending_container_teardowns.lock()),
1787 );
1788 spawn_custom_invokes(&provisioner);
1789
1790 let mut accounts = self.state.write();
1792 let state = accounts.get_or_create(&req.account_id);
1793 if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
1794 stack.status = "DELETE_COMPLETE".to_string();
1795 stack.resources.clear();
1796 stack.outputs.clear();
1797 }
1798 let stale_exports: Vec<String> = state
1800 .exports
1801 .iter()
1802 .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
1803 .map(|(k, _)| k.clone())
1804 .collect();
1805 for k in stale_exports {
1806 state.exports.remove(&k);
1807 }
1808 for entries in state.imports.values_mut() {
1809 entries.retain(|s| s != &stack_name_for_notif);
1810 }
1811 state.imports.retain(|_, v| !v.is_empty());
1812 drop(accounts);
1813
1814 Self::send_stack_notification(
1815 &self.deps.delivery,
1816 ¬ification_arns,
1817 &stack_name_for_notif,
1818 &stack_id,
1819 "DELETE_COMPLETE",
1820 );
1821
1822 deleted_types = resources.iter().map(|r| r.resource_type.clone()).collect();
1823 }
1824 }
1825
1826 persist_touched_services(&self.snapshot_hooks, deleted_types).await;
1830
1831 Ok(AwsResponse::xml(
1832 StatusCode::OK,
1833 xml_responses::delete_stack_response(&req.request_id),
1834 ))
1835 }
1836
1837 fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1838 let stack_name = Self::get_param(req, "StackName");
1839
1840 let accounts = self.state.read();
1841 let empty = CloudFormationState::new(&req.account_id, &req.region);
1842 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1843 let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
1844 state
1845 .stacks
1846 .values()
1847 .filter(|s| {
1848 (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
1849 })
1850 .cloned()
1851 .collect()
1852 } else {
1853 state
1854 .stacks
1855 .values()
1856 .filter(|s| s.status != "DELETE_COMPLETE")
1857 .cloned()
1858 .collect()
1859 };
1860
1861 if let Some(ref name) = stack_name {
1872 if stacks.is_empty() {
1873 return Err(AwsServiceError::aws_error(
1874 StatusCode::BAD_REQUEST,
1875 "ValidationError",
1876 format!("Stack with id {name} does not exist"),
1877 ));
1878 }
1879 }
1880
1881 Ok(AwsResponse::xml(
1882 StatusCode::OK,
1883 xml_responses::describe_stacks_response(&stacks, &req.request_id),
1884 ))
1885 }
1886
1887 fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1888 let accounts = self.state.read();
1889 let empty = CloudFormationState::new(&req.account_id, &req.region);
1890 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1891 let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
1892
1893 Ok(AwsResponse::xml(
1894 StatusCode::OK,
1895 xml_responses::list_stacks_response(&stacks, &req.request_id),
1896 ))
1897 }
1898
1899 fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1900 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
1906 AwsServiceError::aws_error(
1907 StatusCode::BAD_REQUEST,
1908 "ValidationError",
1909 "StackName is required",
1910 )
1911 })?;
1912
1913 let accounts = self.state.read();
1914 let empty = CloudFormationState::new(&req.account_id, &req.region);
1915 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1916 let resources = state
1917 .stacks
1918 .values()
1919 .find(|s| {
1920 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1921 })
1922 .map(|s| s.resources.clone())
1923 .unwrap_or_default();
1924
1925 Ok(AwsResponse::xml(
1926 StatusCode::OK,
1927 xml_responses::list_stack_resources_response(&resources, &req.request_id),
1928 ))
1929 }
1930
1931 fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1932 let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
1935
1936 let accounts = self.state.read();
1937 let empty = CloudFormationState::new(&req.account_id, &req.region);
1938 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1939 let (resources, resolved_name) = state
1940 .stacks
1941 .values()
1942 .find(|s| {
1943 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1944 })
1945 .map(|s| (s.resources.clone(), s.name.clone()))
1946 .unwrap_or_else(|| (Vec::new(), stack_name.clone()));
1947
1948 Ok(AwsResponse::xml(
1949 StatusCode::OK,
1950 xml_responses::describe_stack_resources_response(
1951 &resources,
1952 &resolved_name,
1953 &req.request_id,
1954 ),
1955 ))
1956 }
1957
1958 async fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1959 let mut input = UpdateStackInput::from_params(req)?;
1960
1961 let found_stack_id = {
1963 let accounts = self.state.read();
1964 let empty = CloudFormationState::new(&req.account_id, &req.region);
1965 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1966 state
1967 .stacks
1968 .values()
1969 .find(|s| {
1970 (s.name == input.stack_name || s.stack_id == input.stack_name)
1971 && s.status != "DELETE_COMPLETE"
1972 })
1973 .map(|s| s.stack_id.clone())
1974 .unwrap_or_default()
1975 };
1976
1977 input
1981 .parameters
1982 .entry("AWS::Region".to_string())
1983 .or_insert_with(|| req.region.clone());
1984 input
1985 .parameters
1986 .entry("AWS::AccountId".to_string())
1987 .or_insert_with(|| req.account_id.clone());
1988 input
1989 .parameters
1990 .entry("AWS::StackId".to_string())
1991 .or_insert_with(|| found_stack_id.clone());
1992 input
1993 .parameters
1994 .entry("AWS::StackName".to_string())
1995 .or_insert_with(|| input.stack_name.clone());
1996 input
1997 .parameters
1998 .entry("AWS::Partition".to_string())
1999 .or_insert_with(|| template::partition_for_region(&req.region).to_string());
2000 input
2001 .parameters
2002 .entry("AWS::URLSuffix".to_string())
2003 .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
2004 if !input.notification_arns.is_empty() {
2009 input.parameters.insert(
2010 "AWS::NotificationARNs".to_string(),
2011 serde_json::to_string(&input.notification_arns)
2012 .unwrap_or_else(|_| "[]".to_string()),
2013 );
2014 } else {
2015 let existing: Vec<String> = {
2018 let accounts = self.state.read();
2019 accounts
2020 .get(&req.account_id)
2021 .and_then(|s| {
2022 s.stacks
2023 .values()
2024 .find(|st| st.stack_id == found_stack_id)
2025 .map(|st| st.notification_arns.clone())
2026 })
2027 .unwrap_or_default()
2028 };
2029 input.parameters.insert(
2030 "AWS::NotificationARNs".to_string(),
2031 serde_json::to_string(&existing).unwrap_or_else(|_| "[]".to_string()),
2032 );
2033 }
2034
2035 let parsed = template::parse_template(&input.template_body, &input.parameters)
2040 .unwrap_or_else(|_| template::ParsedTemplate {
2041 description: None,
2042 resources: Vec::new(),
2043 outputs: Vec::new(),
2044 });
2045
2046 let imported_names = Self::validate_import_values(
2047 &self.state,
2048 &req.account_id,
2049 &input.stack_name,
2050 &input.template_body,
2051 &input.parameters,
2052 )?;
2053
2054 let provisioner = self.provisioner_deferred(&found_stack_id, &req.account_id, &req.region);
2060
2061 let imports =
2065 Self::collect_account_imports(&self.state, &req.account_id, Some(&input.stack_name));
2066
2067 let (touched_types, stack_id, stack_name_for_notif, notification_arns, resources_snapshot) = {
2072 let mut accounts = self.state.write();
2073 let state = accounts.get_or_create(&req.account_id);
2074 let stack_exists = state.stacks.values().any(|s| {
2083 (s.name == input.stack_name || s.stack_id == input.stack_name)
2084 && s.status != "DELETE_COMPLETE"
2085 });
2086 if !stack_exists {
2087 let stack_id = if found_stack_id.is_empty() {
2088 format!(
2089 "arn:aws:cloudformation:{}:{}:stack/{}/{}",
2090 req.region,
2091 req.account_id,
2092 input.stack_name,
2093 uuid::Uuid::new_v4()
2094 )
2095 } else {
2096 found_stack_id.clone()
2097 };
2098 return Ok(AwsResponse::xml(
2099 StatusCode::OK,
2100 xml_responses::update_stack_response(&stack_id, &req.request_id),
2101 ));
2102 }
2103 let (update_result, stack_id, stack_name_owned, resources_snapshot, notification_arns) = {
2104 let stack = state
2105 .stacks
2106 .values_mut()
2107 .find(|s| {
2108 (s.name == input.stack_name || s.stack_id == input.stack_name)
2109 && s.status != "DELETE_COMPLETE"
2110 })
2111 .expect("stack existence checked above");
2112
2113 stack.status = "UPDATE_IN_PROGRESS".to_string();
2114 let update_result = apply_resource_updates(
2115 stack,
2116 &parsed.resources,
2117 &input.template_body,
2118 &input.parameters,
2119 &provisioner,
2120 &imports,
2121 );
2122
2123 let stack_id = stack.stack_id.clone();
2124 let stack_name_owned = stack.name.clone();
2125 stack.template = input.template_body.clone();
2126 stack.status = if update_result.is_err() {
2127 "UPDATE_ROLLBACK_COMPLETE".to_string()
2128 } else {
2129 "UPDATE_COMPLETE".to_string()
2130 };
2131 stack.parameters = input.parameters.clone();
2132 if !input.tags.is_empty() {
2133 stack.tags = input.tags;
2134 }
2135 stack.updated_at = Some(Utc::now());
2136 stack.description = parsed.description;
2137 if !input.notification_arns.is_empty() {
2138 stack.notification_arns = input.notification_arns.clone();
2139 }
2140 if update_result.is_ok() {
2141 stack.outputs.clear();
2142 }
2143 (
2144 update_result,
2145 stack_id,
2146 stack_name_owned,
2147 stack.resources.clone(),
2148 stack.notification_arns.clone(),
2149 )
2150 };
2151
2152 record_stack_status_event(
2154 state,
2155 &stack_id,
2156 &stack_name_owned,
2157 "AWS::CloudFormation::Stack",
2158 "UPDATE_IN_PROGRESS",
2159 );
2160 let update_result = match update_result {
2161 Ok(changes) => {
2162 let touched_types: Vec<String> =
2166 changes.iter().map(|c| c.resource_type.clone()).collect();
2167 record_stack_events(state, &stack_id, &stack_name_owned, &changes);
2168 record_stack_status_event(
2169 state,
2170 &stack_id,
2171 &stack_name_owned,
2172 "AWS::CloudFormation::Stack",
2173 "UPDATE_COMPLETE",
2174 );
2175 Ok(touched_types)
2176 }
2177 Err(e) => {
2178 record_stack_status_event(
2179 state,
2180 &stack_id,
2181 &stack_name_owned,
2182 "AWS::CloudFormation::Stack",
2183 "UPDATE_ROLLBACK_COMPLETE",
2184 );
2185 Err(e)
2186 }
2187 };
2188 let stack_name_for_notif = stack_name_owned.clone();
2189
2190 let touched_types = match update_result {
2191 Ok(types) => types,
2192 Err(error_msg) => {
2193 drop(accounts);
2194 Self::send_stack_notification(
2195 &self.deps.delivery,
2196 ¬ification_arns,
2197 &stack_name_for_notif,
2198 &stack_id,
2199 "UPDATE_FAILED",
2200 );
2201 return Err(AwsServiceError::aws_error(
2202 StatusCode::BAD_REQUEST,
2203 "InsufficientCapabilitiesException",
2204 error_msg,
2205 ));
2206 }
2207 };
2208
2209 drop(accounts);
2210 (
2211 touched_types,
2212 stack_id,
2213 stack_name_for_notif,
2214 notification_arns,
2215 resources_snapshot,
2216 )
2217 };
2218
2219 {
2225 let handles = ContainerBackingHandles::from_provisioner(&provisioner);
2226 handles.spawn_container_intents(std::mem::take(
2227 &mut *provisioner.pending_container_spawns.lock(),
2228 ));
2229 handles.spawn_teardown_intents(std::mem::take(
2230 &mut *provisioner.pending_container_teardowns.lock(),
2231 ));
2232 spawn_custom_invokes(&provisioner);
2233 }
2234
2235 let outputs = Self::resolve_template_outputs(
2236 &input.template_body,
2237 &input.parameters,
2238 &resources_snapshot,
2239 &self.state,
2240 );
2241 Self::ensure_export_uniqueness(&self.state, &req.account_id, &input.stack_name, &outputs)?;
2242 {
2243 let mut accounts = self.state.write();
2244 let state = accounts.get_or_create(&req.account_id);
2245 if let Some(stack) = state
2246 .stacks
2247 .values_mut()
2248 .find(|s| s.stack_id == stack_id && s.status != "DELETE_COMPLETE")
2249 {
2250 stack.outputs = outputs.clone();
2251 }
2252 Self::sync_exports_imports(
2253 state,
2254 &stack_id,
2255 &input.stack_name,
2256 &outputs,
2257 &imported_names,
2258 );
2259 }
2260
2261 Self::send_stack_notification(
2262 &self.deps.delivery,
2263 ¬ification_arns,
2264 &stack_name_for_notif,
2265 &stack_id,
2266 "UPDATE_COMPLETE",
2267 );
2268
2269 persist_touched_services(&self.snapshot_hooks, touched_types).await;
2272
2273 Ok(AwsResponse::xml(
2274 StatusCode::OK,
2275 xml_responses::update_stack_response(&stack_id, &req.request_id),
2276 ))
2277 }
2278
2279 fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2280 let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
2282
2283 let accounts = self.state.read();
2284 let empty = CloudFormationState::new(&req.account_id, &req.region);
2285 let state = accounts.get(&req.account_id).unwrap_or(&empty);
2286 let body = state
2291 .stacks
2292 .values()
2293 .find(|s| {
2294 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
2295 })
2296 .map(|s| s.template.clone())
2297 .unwrap_or_default();
2298
2299 Ok(AwsResponse::xml(
2300 StatusCode::OK,
2301 xml_responses::get_template_response(&body, &req.request_id),
2302 ))
2303 }
2304}
2305
2306#[async_trait]
2307impl AwsService for CloudFormationService {
2308 fn service_name(&self) -> &str {
2309 "cloudformation"
2310 }
2311
2312 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2313 let action = req.action.as_str();
2314
2315 crate::input_constraints::validate_input(action, &Self::get_all_params(&req))?;
2322
2323 let mutates = matches!(
2327 action,
2328 "CreateStack"
2329 | "DeleteStack"
2330 | "UpdateStack"
2331 | "CreateChangeSet"
2332 | "DeleteChangeSet"
2333 | "ExecuteChangeSet"
2334 | "CreateStackSet"
2335 | "DeleteStackSet"
2336 | "CreateStackRefactor"
2337 | "CreateGeneratedTemplate"
2338 | "DeleteGeneratedTemplate"
2339 | "SetStackPolicy"
2340 | "UpdateTerminationProtection"
2341 | "ActivateOrganizationsAccess"
2342 | "DeactivateOrganizationsAccess"
2343 );
2344 let result = match action {
2345 "CreateStack" => self.create_stack(&req).await,
2346 "DeleteStack" => self.delete_stack(&req).await,
2347 "DescribeStacks" => self.describe_stacks(&req),
2348 "ListStacks" => self.list_stacks(&req),
2349 "ListStackResources" => self.list_stack_resources(&req),
2350 "DescribeStackResources" => self.describe_stack_resources(&req),
2351 "UpdateStack" => self.update_stack(&req).await,
2352 "GetTemplate" => self.get_template(&req),
2353 _ => self.handle_extra_action(&req),
2354 };
2355 if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
2356 self.save_snapshot().await;
2357 }
2358 if action == "ExecuteChangeSet"
2368 && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
2369 {
2370 for hook in self.snapshot_hooks.values() {
2371 hook().await;
2372 }
2373 }
2374 result
2375 }
2376
2377 fn supported_actions(&self) -> &[&str] {
2378 &[
2379 "ActivateOrganizationsAccess",
2380 "ActivateType",
2381 "BatchDescribeTypeConfigurations",
2382 "CancelUpdateStack",
2383 "ContinueUpdateRollback",
2384 "CreateChangeSet",
2385 "CreateGeneratedTemplate",
2386 "CreateStack",
2387 "CreateStackInstances",
2388 "CreateStackRefactor",
2389 "CreateStackSet",
2390 "DeactivateOrganizationsAccess",
2391 "DeactivateType",
2392 "DeleteChangeSet",
2393 "DeleteGeneratedTemplate",
2394 "DeleteStack",
2395 "DeleteStackInstances",
2396 "DeleteStackSet",
2397 "DeregisterType",
2398 "DescribeAccountLimits",
2399 "DescribeChangeSet",
2400 "DescribeChangeSetHooks",
2401 "DescribeEvents",
2402 "DescribeGeneratedTemplate",
2403 "DescribeOrganizationsAccess",
2404 "DescribePublisher",
2405 "DescribeResourceScan",
2406 "DescribeStackDriftDetectionStatus",
2407 "DescribeStackEvents",
2408 "DescribeStackInstance",
2409 "DescribeStackRefactor",
2410 "DescribeStackResource",
2411 "DescribeStackResourceDrifts",
2412 "DescribeStackResources",
2413 "DescribeStackSet",
2414 "DescribeStackSetOperation",
2415 "DescribeStacks",
2416 "DescribeType",
2417 "DescribeTypeRegistration",
2418 "DetectStackDrift",
2419 "DetectStackResourceDrift",
2420 "DetectStackSetDrift",
2421 "EstimateTemplateCost",
2422 "ExecuteChangeSet",
2423 "ExecuteStackRefactor",
2424 "GetGeneratedTemplate",
2425 "GetHookResult",
2426 "GetStackPolicy",
2427 "GetTemplate",
2428 "GetTemplateSummary",
2429 "ImportStacksToStackSet",
2430 "ListChangeSets",
2431 "ListExports",
2432 "ListGeneratedTemplates",
2433 "ListHookResults",
2434 "ListImports",
2435 "ListResourceScanRelatedResources",
2436 "ListResourceScanResources",
2437 "ListResourceScans",
2438 "ListStackInstanceResourceDrifts",
2439 "ListStackInstances",
2440 "ListStackRefactorActions",
2441 "ListStackRefactors",
2442 "ListStackResources",
2443 "ListStackSetAutoDeploymentTargets",
2444 "ListStackSetOperationResults",
2445 "ListStackSetOperations",
2446 "ListStackSets",
2447 "ListStacks",
2448 "ListTypeRegistrations",
2449 "ListTypeVersions",
2450 "ListTypes",
2451 "PublishType",
2452 "RecordHandlerProgress",
2453 "RegisterPublisher",
2454 "RegisterType",
2455 "RollbackStack",
2456 "SetStackPolicy",
2457 "SetTypeConfiguration",
2458 "SetTypeDefaultVersion",
2459 "SignalResource",
2460 "StartResourceScan",
2461 "StopStackSetOperation",
2462 "TestType",
2463 "UpdateGeneratedTemplate",
2464 "UpdateStack",
2465 "UpdateStackInstances",
2466 "UpdateStackSet",
2467 "UpdateTerminationProtection",
2468 "ValidateTemplate",
2469 ]
2470 }
2471}
2472
2473struct UpdateStackInput {
2475 stack_name: String,
2476 template_body: String,
2477 parameters: BTreeMap<String, String>,
2478 tags: BTreeMap<String, String>,
2479 notification_arns: Vec<String>,
2480}
2481
2482impl UpdateStackInput {
2483 fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
2484 let params = CloudFormationService::get_all_params(req);
2485
2486 let stack_name = params
2487 .get("StackName")
2488 .ok_or_else(|| {
2489 AwsServiceError::aws_error(
2490 StatusCode::BAD_REQUEST,
2491 "ValidationError",
2492 "StackName is required",
2493 )
2494 })?
2495 .to_string();
2496
2497 let template_body = params.get("TemplateBody").cloned().unwrap_or_default();
2502
2503 let mut parameters = CloudFormationService::extract_parameters(¶ms);
2504 CloudFormationService::merge_parameter_defaults(&mut parameters, &template_body);
2505 Ok(Self {
2506 stack_name,
2507 template_body,
2508 parameters,
2509 tags: CloudFormationService::extract_tags(¶ms),
2510 notification_arns: CloudFormationService::extract_notification_arns(¶ms),
2511 })
2512 }
2513}
2514
2515#[derive(Debug, Clone)]
2519pub(crate) struct ResourceChange {
2520 pub action: ResourceChangeAction,
2521 pub logical_id: String,
2522 pub physical_id: String,
2523 pub resource_type: String,
2524}
2525
2526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2527pub(crate) enum ResourceChangeAction {
2528 Create,
2529 Update,
2530 Delete,
2531}
2532
2533impl ResourceChangeAction {
2534 pub fn status_in_progress(self) -> &'static str {
2535 match self {
2536 Self::Create => "CREATE_IN_PROGRESS",
2537 Self::Update => "UPDATE_IN_PROGRESS",
2538 Self::Delete => "DELETE_IN_PROGRESS",
2539 }
2540 }
2541 pub fn status_complete(self) -> &'static str {
2542 match self {
2543 Self::Create => "CREATE_COMPLETE",
2544 Self::Update => "UPDATE_COMPLETE",
2545 Self::Delete => "DELETE_COMPLETE",
2546 }
2547 }
2548}
2549
2550pub(crate) fn apply_resource_updates(
2555 stack: &mut crate::state::Stack,
2556 new_resource_defs: &[template::ResourceDefinition],
2557 template_body: &str,
2558 parameters: &BTreeMap<String, String>,
2559 provisioner: &crate::resource_provisioner::ResourceProvisioner,
2560 imports: &BTreeMap<String, String>,
2561) -> Result<Vec<ResourceChange>, String> {
2562 let mut changes: Vec<ResourceChange> = Vec::new();
2563 let old_logical_ids: std::collections::HashSet<String> = stack
2564 .resources
2565 .iter()
2566 .map(|r| r.logical_id.clone())
2567 .collect();
2568 let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
2569 .iter()
2570 .map(|r| r.logical_id.clone())
2571 .collect();
2572
2573 let to_remove: Vec<_> = stack
2575 .resources
2576 .iter()
2577 .filter(|r| !new_logical_ids.contains(&r.logical_id))
2578 .cloned()
2579 .collect();
2580 for resource in &to_remove {
2581 let _ = provisioner.delete_resource(resource);
2582 changes.push(ResourceChange {
2583 action: ResourceChangeAction::Delete,
2584 logical_id: resource.logical_id.clone(),
2585 physical_id: resource.physical_id.clone(),
2586 resource_type: resource.resource_type.clone(),
2587 });
2588 }
2589 stack
2590 .resources
2591 .retain(|r| new_logical_ids.contains(&r.logical_id));
2592
2593 let mut physical_ids: BTreeMap<String, String> = stack
2595 .resources
2596 .iter()
2597 .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
2598 .collect();
2599 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = stack
2600 .resources
2601 .iter()
2602 .map(|r| (r.logical_id.clone(), r.attributes.clone()))
2603 .collect();
2604
2605 let order = template::dependency_order(template_body, parameters, new_resource_defs);
2611 for &idx in &order {
2612 let resource_def = &new_resource_defs[idx];
2613 let resolved_def = template::resolve_resource_properties_with_attrs(
2614 resource_def,
2615 template_body,
2616 parameters,
2617 &physical_ids,
2618 &attributes,
2619 imports,
2620 )
2621 .map_err(|e| {
2622 format!(
2623 "Failed to resolve resource {}: {e}",
2624 resource_def.logical_id
2625 )
2626 })?;
2627
2628 if !old_logical_ids.contains(&resource_def.logical_id) {
2629 match provisioner.create_resource(&resolved_def) {
2630 Ok(stack_resource) => {
2631 changes.push(ResourceChange {
2632 action: ResourceChangeAction::Create,
2633 logical_id: stack_resource.logical_id.clone(),
2634 physical_id: stack_resource.physical_id.clone(),
2635 resource_type: stack_resource.resource_type.clone(),
2636 });
2637 physical_ids.insert(
2638 stack_resource.logical_id.clone(),
2639 stack_resource.physical_id.clone(),
2640 );
2641 attributes.insert(
2642 stack_resource.logical_id.clone(),
2643 stack_resource.attributes.clone(),
2644 );
2645 stack.resources.push(stack_resource);
2646 }
2647 Err(e) => {
2648 tracing::warn!(
2649 "Failed to create resource {} during update: {e}",
2650 resource_def.logical_id
2651 );
2652 return Err(format!(
2653 "Failed to create resource {}: {e}",
2654 resource_def.logical_id
2655 ));
2656 }
2657 }
2658 } else {
2659 let existing = stack
2665 .resources
2666 .iter()
2667 .find(|r| r.logical_id == resource_def.logical_id)
2668 .cloned();
2669 if let Some(existing) = existing {
2670 match provisioner.update_resource(&existing, &resolved_def) {
2671 Ok(Some(updated)) => {
2672 changes.push(ResourceChange {
2673 action: ResourceChangeAction::Update,
2674 logical_id: updated.logical_id.clone(),
2675 physical_id: updated.physical_id.clone(),
2676 resource_type: updated.resource_type.clone(),
2677 });
2678 physical_ids
2679 .insert(updated.logical_id.clone(), updated.physical_id.clone());
2680 attributes.insert(updated.logical_id.clone(), updated.attributes.clone());
2681 if let Some(slot) = stack
2682 .resources
2683 .iter_mut()
2684 .find(|r| r.logical_id == updated.logical_id)
2685 {
2686 *slot = updated;
2687 }
2688 }
2689 Ok(None) => {
2690 }
2693 Err(e) => {
2694 tracing::warn!(
2695 "Failed to update resource {} during update: {e}",
2696 resource_def.logical_id
2697 );
2698 return Err(format!(
2699 "Failed to update resource {}: {e}",
2700 resource_def.logical_id
2701 ));
2702 }
2703 }
2704 }
2705 }
2706 }
2707
2708 Ok(changes)
2709}
2710
2711pub(crate) fn record_event(
2715 state: &mut crate::state::CloudFormationState,
2716 stack_id: &str,
2717 stack_name: &str,
2718 logical_id: &str,
2719 physical_id: &str,
2720 resource_type: &str,
2721 status: &str,
2722) {
2723 use serde_json::json;
2724 let event_id = format!(
2725 "{}-{:x}",
2726 logical_id,
2727 std::time::SystemTime::now()
2728 .duration_since(std::time::UNIX_EPOCH)
2729 .map(|d| d.as_nanos())
2730 .unwrap_or(0)
2731 );
2732 let log = state.events.entry(stack_id.to_string()).or_default();
2733
2734 let now = chrono::DateTime::from_timestamp_millis(Utc::now().timestamp_millis())
2747 .unwrap_or_else(Utc::now);
2748 let timestamp = match log.last().and_then(|e| e["Timestamp"].as_str()) {
2749 Some(prev) => match chrono::DateTime::parse_from_rfc3339(prev) {
2750 Ok(prev) => {
2751 let prev = prev.with_timezone(&Utc);
2752 if now > prev {
2753 now
2754 } else {
2755 prev + chrono::Duration::milliseconds(1)
2756 }
2757 }
2758 Err(_) => now,
2759 },
2760 None => now,
2761 };
2762
2763 log.push(json!({
2764 "EventId": event_id,
2765 "StackId": stack_id,
2766 "StackName": stack_name,
2767 "LogicalResourceId": logical_id,
2768 "PhysicalResourceId": physical_id,
2769 "ResourceType": resource_type,
2770 "ResourceStatus": status,
2771 "Timestamp": timestamp.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2772 }));
2773}
2774
2775async fn save_snapshot_static(
2783 state: SharedCloudFormationState,
2784 store: Option<Arc<dyn SnapshotStore>>,
2785 lock: Arc<AsyncMutex<()>>,
2786) {
2787 let Some(store) = store else {
2788 return;
2789 };
2790 let _guard = lock.lock().await;
2791 let snapshot = CloudFormationSnapshot {
2792 schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
2793 state: None,
2794 accounts: Some(state.read().clone()),
2795 };
2796 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2797 let bytes = serde_json::to_vec(&snapshot)
2798 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
2799 store.save(&bytes)
2800 })
2801 .await;
2802 match join {
2803 Ok(Ok(())) => {}
2804 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
2805 Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
2806 }
2807}
2808
2809pub(crate) fn record_stack_events(
2810 state: &mut crate::state::CloudFormationState,
2811 stack_id: &str,
2812 stack_name: &str,
2813 changes: &[ResourceChange],
2814) {
2815 for ch in changes {
2816 record_event(
2817 state,
2818 stack_id,
2819 stack_name,
2820 &ch.logical_id,
2821 &ch.physical_id,
2822 &ch.resource_type,
2823 ch.action.status_in_progress(),
2824 );
2825 record_event(
2826 state,
2827 stack_id,
2828 stack_name,
2829 &ch.logical_id,
2830 &ch.physical_id,
2831 &ch.resource_type,
2832 ch.action.status_complete(),
2833 );
2834 }
2835}
2836
2837pub(crate) fn record_stack_status_event(
2841 state: &mut crate::state::CloudFormationState,
2842 stack_id: &str,
2843 stack_name: &str,
2844 resource_type: &str,
2845 status: &str,
2846) {
2847 record_event(
2848 state,
2849 stack_id,
2850 stack_name,
2851 stack_name,
2852 stack_id,
2853 resource_type,
2854 status,
2855 );
2856}
2857
2858#[cfg(test)]
2859mod tests {
2860 use super::*;
2861 use http::HeaderMap;
2862 use parking_lot::RwLock;
2863 use std::collections::HashMap;
2864 use std::sync::Arc;
2865
2866 #[test]
2867 fn merge_parameter_defaults_fills_omitted_params() {
2868 let template = r#"{
2871 "Parameters": {
2872 "InstanceType": {"Type": "String", "Default": "t3.micro"},
2873 "Count": {"Type": "Number", "Default": 3},
2874 "Supplied": {"Type": "String", "Default": "dflt"}
2875 },
2876 "Resources": {}
2877 }"#;
2878 let mut params = BTreeMap::new();
2879 params.insert("Supplied".to_string(), "override".to_string());
2880 CloudFormationService::merge_parameter_defaults(&mut params, template);
2881 assert_eq!(
2882 params.get("InstanceType").map(String::as_str),
2883 Some("t3.micro")
2884 );
2885 assert_eq!(params.get("Count").map(String::as_str), Some("3"));
2886 assert_eq!(params.get("Supplied").map(String::as_str), Some("override"));
2888 }
2889
2890 fn make_service() -> CloudFormationService {
2891 let cf_state = Arc::new(RwLock::new(
2892 fakecloud_core::multi_account::MultiAccountState::new(
2893 "123456789012",
2894 "us-east-1",
2895 "http://localhost:4566",
2896 ),
2897 ));
2898 let deps = CloudFormationDeps {
2899 sqs: Arc::new(RwLock::new(
2900 fakecloud_core::multi_account::MultiAccountState::new(
2901 "123456789012",
2902 "us-east-1",
2903 "http://localhost:4566",
2904 ),
2905 )),
2906 sns: Arc::new(RwLock::new(
2907 fakecloud_core::multi_account::MultiAccountState::new(
2908 "123456789012",
2909 "us-east-1",
2910 "http://localhost:4566",
2911 ),
2912 )),
2913 ssm: Arc::new(RwLock::new(
2914 fakecloud_core::multi_account::MultiAccountState::new(
2915 "123456789012",
2916 "us-east-1",
2917 "http://localhost:4566",
2918 ),
2919 )),
2920 iam: Arc::new(RwLock::new(
2921 fakecloud_core::multi_account::MultiAccountState::new(
2922 "123456789012",
2923 "us-east-1",
2924 "",
2925 ),
2926 )),
2927 s3: Arc::new(RwLock::new(
2928 fakecloud_core::multi_account::MultiAccountState::new(
2929 "123456789012",
2930 "us-east-1",
2931 "",
2932 ),
2933 )),
2934 eventbridge: Arc::new(RwLock::new(
2935 fakecloud_core::multi_account::MultiAccountState::new(
2936 "123456789012",
2937 "us-east-1",
2938 "",
2939 ),
2940 )),
2941 dynamodb: Arc::new(RwLock::new(
2942 fakecloud_core::multi_account::MultiAccountState::new(
2943 "123456789012",
2944 "us-east-1",
2945 "",
2946 ),
2947 )),
2948 logs: Arc::new(RwLock::new(
2949 fakecloud_core::multi_account::MultiAccountState::new(
2950 "123456789012",
2951 "us-east-1",
2952 "",
2953 ),
2954 )),
2955 lambda: Arc::new(RwLock::new(
2956 fakecloud_core::multi_account::MultiAccountState::new(
2957 "123456789012",
2958 "us-east-1",
2959 "",
2960 ),
2961 )),
2962 secretsmanager: Arc::new(RwLock::new(
2963 fakecloud_core::multi_account::MultiAccountState::new(
2964 "123456789012",
2965 "us-east-1",
2966 "",
2967 ),
2968 )),
2969 kinesis: Arc::new(RwLock::new(
2970 fakecloud_core::multi_account::MultiAccountState::new(
2971 "123456789012",
2972 "us-east-1",
2973 "",
2974 ),
2975 )),
2976 kms: Arc::new(RwLock::new(
2977 fakecloud_core::multi_account::MultiAccountState::new(
2978 "123456789012",
2979 "us-east-1",
2980 "",
2981 ),
2982 )),
2983 ecr: Arc::new(RwLock::new(
2984 fakecloud_core::multi_account::MultiAccountState::new(
2985 "123456789012",
2986 "us-east-1",
2987 "",
2988 ),
2989 )),
2990 cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
2991 elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
2992 organizations: Arc::new(RwLock::new(None)),
2993 cognito: Arc::new(RwLock::new(
2994 fakecloud_core::multi_account::MultiAccountState::new(
2995 "123456789012",
2996 "us-east-1",
2997 "",
2998 ),
2999 )),
3000 rds: Arc::new(RwLock::new(
3001 fakecloud_core::multi_account::MultiAccountState::new(
3002 "123456789012",
3003 "us-east-1",
3004 "",
3005 ),
3006 )),
3007 ec2: Arc::new(RwLock::new(
3008 fakecloud_core::multi_account::MultiAccountState::new(
3009 "123456789012",
3010 "us-east-1",
3011 "",
3012 ),
3013 )),
3014 autoscaling: Arc::new(RwLock::new(
3015 fakecloud_autoscaling::AutoScalingAccounts::new(),
3016 )),
3017 batch: Arc::new(RwLock::new(fakecloud_batch::BatchAccounts::new())),
3018 pipes: Arc::new(RwLock::new(fakecloud_pipes::PipesAccounts::new())),
3019 ecs: Arc::new(RwLock::new(
3020 fakecloud_core::multi_account::MultiAccountState::new(
3021 "123456789012",
3022 "us-east-1",
3023 "",
3024 ),
3025 )),
3026 acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
3027 elasticache: Arc::new(RwLock::new(
3028 fakecloud_core::multi_account::MultiAccountState::new(
3029 "123456789012",
3030 "us-east-1",
3031 "",
3032 ),
3033 )),
3034 route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
3035 cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
3036 stepfunctions: Arc::new(RwLock::new(
3037 fakecloud_core::multi_account::MultiAccountState::new(
3038 "123456789012",
3039 "us-east-1",
3040 "",
3041 ),
3042 )),
3043 wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
3044 apigateway: Arc::new(RwLock::new(
3045 fakecloud_core::multi_account::MultiAccountState::new(
3046 "123456789012",
3047 "us-east-1",
3048 "",
3049 ),
3050 )),
3051 apigatewayv2: Arc::new(RwLock::new(
3052 fakecloud_core::multi_account::MultiAccountState::new(
3053 "123456789012",
3054 "us-east-1",
3055 "",
3056 ),
3057 )),
3058 ses: Arc::new(RwLock::new(
3059 fakecloud_core::multi_account::MultiAccountState::new(
3060 "123456789012",
3061 "us-east-1",
3062 "",
3063 ),
3064 )),
3065 application_autoscaling: Arc::new(parking_lot::RwLock::new(
3066 fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
3067 )),
3068 athena: Arc::new(parking_lot::RwLock::new(
3069 fakecloud_athena::AthenaAccounts::new(),
3070 )),
3071 firehose: Arc::new(parking_lot::RwLock::new(
3072 fakecloud_firehose::FirehoseAccounts::new(),
3073 )),
3074 glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
3075 eks: Arc::new(parking_lot::RwLock::new(
3076 fakecloud_core::multi_account::MultiAccountState::new(
3077 "123456789012",
3078 "us-east-1",
3079 "",
3080 ),
3081 )),
3082 servicediscovery: Arc::new(parking_lot::RwLock::new(
3083 fakecloud_core::multi_account::MultiAccountState::new(
3084 "123456789012",
3085 "us-east-1",
3086 "",
3087 ),
3088 )),
3089 codeartifact: Arc::new(parking_lot::RwLock::new(
3090 fakecloud_core::multi_account::MultiAccountState::new(
3091 "123456789012",
3092 "us-east-1",
3093 "",
3094 ),
3095 )),
3096 codecommit: Arc::new(parking_lot::RwLock::new(
3097 fakecloud_core::multi_account::MultiAccountState::new(
3098 "123456789012",
3099 "us-east-1",
3100 "",
3101 ),
3102 )),
3103 efs: Arc::new(parking_lot::RwLock::new(
3104 fakecloud_core::multi_account::MultiAccountState::new(
3105 "123456789012",
3106 "us-east-1",
3107 "",
3108 ),
3109 )),
3110 elasticbeanstalk: Arc::new(parking_lot::RwLock::new(
3111 fakecloud_elasticbeanstalk::EbAccounts::new(),
3112 )),
3113 mq: Arc::new(parking_lot::RwLock::new(
3114 fakecloud_core::multi_account::MultiAccountState::new(
3115 "123456789012",
3116 "us-east-1",
3117 "",
3118 ),
3119 )),
3120 delivery: Arc::new(DeliveryBus::new()),
3121 lambda_runtime: None,
3122 rds_runtime: None,
3123 ec2_runtime: None,
3124 ecs_runtime: None,
3125 elasticache_runtime: None,
3126 };
3127 CloudFormationService::new(cf_state, deps)
3128 }
3129
3130 fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
3131 AwsRequest {
3132 service: "cloudformation".to_string(),
3133 action: action.to_string(),
3134 region: "us-east-1".to_string(),
3135 account_id: "123456789012".to_string(),
3136 request_id: "test-request-id".to_string(),
3137 headers: HeaderMap::new(),
3138 query_params: params,
3139 body: bytes::Bytes::new(),
3140 body_stream: parking_lot::Mutex::new(None),
3141 path_segments: vec![],
3142 raw_path: "/".to_string(),
3143 raw_query: String::new(),
3144 method: http::Method::POST,
3145 is_query_protocol: true,
3146 access_key_id: None,
3147 principal: None,
3148 }
3149 }
3150
3151 #[tokio::test]
3152 async fn update_stack_sets_failed_status_on_resource_error() {
3153 let svc = make_service();
3154
3155 let mut create_params = HashMap::new();
3157 create_params.insert("StackName".to_string(), "test-stack".to_string());
3158 create_params.insert(
3159 "TemplateBody".to_string(),
3160 r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
3161 );
3162 let req = make_request("CreateStack", create_params);
3163 let result = svc.create_stack(&req).await;
3164 assert!(result.is_ok());
3165
3166 let mut update_params = HashMap::new();
3168 update_params.insert("StackName".to_string(), "test-stack".to_string());
3169 update_params.insert(
3170 "TemplateBody".to_string(),
3171 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(),
3172 );
3173 let req = make_request("UpdateStack", update_params);
3174 let result = svc.update_stack(&req).await;
3175
3176 assert!(result.is_err());
3178
3179 let accounts = svc.state.read();
3183 let state = accounts.get("123456789012").unwrap();
3184 let stack = state.stacks.get("test-stack").unwrap();
3185 assert_eq!(stack.status, "UPDATE_ROLLBACK_COMPLETE");
3186 }
3187
3188 #[tokio::test]
3189 async fn create_stack_resolves_ref_to_physical_id() {
3190 let svc = make_service();
3191
3192 let template = r#"{
3194 "Resources": {
3195 "MyTopic": {
3196 "Type": "AWS::SNS::Topic",
3197 "Properties": { "TopicName": "ref-test-topic" }
3198 },
3199 "MySub": {
3200 "Type": "AWS::SNS::Subscription",
3201 "Properties": {
3202 "TopicArn": { "Ref": "MyTopic" },
3203 "Protocol": "sqs",
3204 "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
3205 }
3206 }
3207 }
3208 }"#;
3209
3210 let mut params = HashMap::new();
3211 params.insert("StackName".to_string(), "ref-stack".to_string());
3212 params.insert("TemplateBody".to_string(), template.to_string());
3213 let req = make_request("CreateStack", params);
3214 let result = svc.create_stack(&req).await;
3215 assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
3216
3217 let accounts = svc.state.read();
3219 let state = accounts.get("123456789012").unwrap();
3220 let stack = state.stacks.get("ref-stack").unwrap();
3221 assert_eq!(stack.resources.len(), 2);
3222 assert_eq!(stack.status, "CREATE_COMPLETE");
3223
3224 let sub = stack
3226 .resources
3227 .iter()
3228 .find(|r| r.logical_id == "MySub")
3229 .unwrap();
3230 assert!(
3231 sub.physical_id.contains("ref-test-topic"),
3232 "Subscription physical ID should reference the topic ARN, got: {}",
3233 sub.physical_id
3234 );
3235 }
3236
3237 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3244 async fn create_stack_custom_resource_provisions_asynchronously() {
3245 let svc = make_service();
3246 let template = r#"{
3247 "Resources": {
3248 "MyCustom": {
3249 "Type": "Custom::Thing",
3250 "Properties": {
3251 "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:handler"
3252 }
3253 }
3254 }
3255 }"#;
3256 let mut params = HashMap::new();
3257 params.insert("StackName".to_string(), "async-stack".to_string());
3258 params.insert("TemplateBody".to_string(), template.to_string());
3259 let req = make_request("CreateStack", params);
3260
3261 let resp = svc
3268 .create_stack(&req)
3269 .await
3270 .expect("create returns StackId");
3271 assert!(resp.status.is_success());
3272 {
3273 let accounts = svc.state.read();
3274 let stack = accounts
3275 .get("123456789012")
3276 .unwrap()
3277 .stacks
3278 .get("async-stack")
3279 .expect("stack seeded synchronously");
3280 assert!(
3281 stack.status == "CREATE_IN_PROGRESS" || stack.status == "CREATE_COMPLETE",
3282 "unexpected status right after create: {}",
3283 stack.status
3284 );
3285 }
3286
3287 let mut status = String::new();
3290 for _ in 0..200 {
3291 {
3292 let accounts = svc.state.read();
3293 if let Some(stack) = accounts
3294 .get("123456789012")
3295 .and_then(|s| s.stacks.get("async-stack"))
3296 {
3297 status = stack.status.clone();
3298 if status != "CREATE_IN_PROGRESS" {
3299 break;
3300 }
3301 }
3302 }
3303 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3304 }
3305 assert_eq!(
3306 status, "CREATE_COMPLETE",
3307 "stack should reach CREATE_COMPLETE"
3308 );
3309
3310 let accounts = svc.state.read();
3311 let stack = accounts
3312 .get("123456789012")
3313 .unwrap()
3314 .stacks
3315 .get("async-stack")
3316 .unwrap();
3317 assert_eq!(stack.resources.len(), 1);
3318 assert_eq!(stack.resources[0].resource_type, "Custom::Thing");
3319 }
3320
3321 #[tokio::test]
3322 async fn output_getatt_resolves_well_known_attribute() {
3323 let svc = make_service();
3329 let template = r#"{
3330 "Resources": {
3331 "Queue": { "Type": "AWS::SQS::Queue", "Properties": { "QueueName": "out-q" } }
3332 },
3333 "Outputs": {
3334 "Url": { "Value": { "Fn::GetAtt": ["Queue", "QueueUrl"] } }
3335 }
3336 }"#;
3337 let mut params = HashMap::new();
3338 params.insert("StackName".to_string(), "out-stack".to_string());
3339 params.insert("TemplateBody".to_string(), template.to_string());
3340 svc.create_stack(&make_request("CreateStack", params))
3341 .await
3342 .expect("create returns StackId");
3343
3344 let mut url = String::new();
3345 for _ in 0..200 {
3346 {
3347 let accounts = svc.state.read();
3348 if let Some(stack) = accounts
3349 .get("123456789012")
3350 .and_then(|s| s.stacks.get("out-stack"))
3351 {
3352 if stack.status != "CREATE_IN_PROGRESS" {
3353 url = stack
3354 .outputs
3355 .iter()
3356 .find(|o| o.key == "Url")
3357 .map(|o| o.value.clone())
3358 .unwrap_or_default();
3359 break;
3360 }
3361 }
3362 }
3363 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3364 }
3365 assert!(
3366 url.contains("out-q") && url != "Queue.QueueUrl",
3367 "GetAtt QueueUrl output should resolve to the live url, got {url:?}"
3368 );
3369 }
3370
3371 #[tokio::test]
3374 async fn create_stack_missing_name_errors() {
3375 let svc = make_service();
3376 let mut params = HashMap::new();
3377 params.insert("TemplateBody".to_string(), "{}".to_string());
3378 let req = make_request("CreateStack", params);
3379 assert!(svc.create_stack(&req).await.is_err());
3380 }
3381
3382 #[tokio::test]
3383 async fn create_stack_missing_template_creates_empty_stack() {
3384 let svc = make_service();
3389 let mut params = HashMap::new();
3390 params.insert("StackName".to_string(), "s".to_string());
3391 let req = make_request("CreateStack", params);
3392 svc.create_stack(&req)
3393 .await
3394 .expect("empty-body create succeeds");
3395 }
3396
3397 #[tokio::test]
3398 async fn create_stack_duplicate_errors() {
3399 let svc = make_service();
3400 let mut params = HashMap::new();
3401 params.insert("StackName".to_string(), "dup".to_string());
3402 params.insert(
3403 "TemplateBody".to_string(),
3404 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
3405 .to_string(),
3406 );
3407 let req = make_request("CreateStack", params.clone());
3408 svc.create_stack(&req).await.unwrap();
3409 let req = make_request("CreateStack", params);
3410 assert!(svc.create_stack(&req).await.is_err());
3411 }
3412
3413 #[tokio::test]
3414 async fn create_stack_invalid_template_creates_empty_stack() {
3415 let svc = make_service();
3419 let mut params = HashMap::new();
3420 params.insert("StackName".to_string(), "bad".to_string());
3421 params.insert("TemplateBody".to_string(), "not json".to_string());
3422 let req = make_request("CreateStack", params);
3423 svc.create_stack(&req)
3424 .await
3425 .expect("bad-body create succeeds");
3426 }
3427
3428 #[tokio::test]
3429 async fn delete_stack_unknown_is_noop() {
3430 let svc = make_service();
3431 let mut params = HashMap::new();
3432 params.insert("StackName".to_string(), "ghost".to_string());
3433 let req = make_request("DeleteStack", params);
3434 assert!(svc.delete_stack(&req).await.is_ok());
3435 }
3436
3437 #[test]
3438 fn describe_stacks_nonexistent_errors() {
3439 let svc = make_service();
3444 let mut params = HashMap::new();
3445 params.insert("StackName".to_string(), "ghost".to_string());
3446 let req = make_request("DescribeStacks", params);
3447 match svc.describe_stacks(&req) {
3448 Ok(_) => panic!("ghost stack must return an error, not an empty list"),
3449 Err(e) => {
3450 assert_eq!(e.status(), StatusCode::BAD_REQUEST);
3451 assert_eq!(e.code(), "ValidationError");
3452 assert!(
3453 e.message().contains("does not exist"),
3454 "got: {}",
3455 e.message()
3456 );
3457 }
3458 }
3459 }
3460
3461 #[test]
3462 fn describe_stacks_empty_returns_all() {
3463 let svc = make_service();
3464 let req = make_request("DescribeStacks", HashMap::new());
3465 let resp = svc.describe_stacks(&req).unwrap();
3466 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
3467 assert!(b.contains("DescribeStacksResult"));
3468 }
3469
3470 #[test]
3471 fn list_stacks_empty_returns_ok() {
3472 let svc = make_service();
3473 let req = make_request("ListStacks", HashMap::new());
3474 let resp = svc.list_stacks(&req).unwrap();
3475 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
3476 assert!(b.contains("ListStacksResult"));
3477 }
3478
3479 #[test]
3480 fn list_stack_resources_missing_name_returns_validation_error() {
3481 let svc = make_service();
3487 let req = make_request("ListStackResources", HashMap::new());
3488 let err = match svc.list_stack_resources(&req) {
3489 Err(e) => e,
3490 Ok(_) => panic!("omitted StackName must be rejected"),
3491 };
3492 assert_eq!(err.code(), "ValidationError");
3493 }
3494
3495 #[test]
3496 fn list_stack_resources_unknown_stack_returns_empty() {
3497 let svc = make_service();
3498 let mut params = HashMap::new();
3499 params.insert("StackName".to_string(), "ghost".to_string());
3500 let req = make_request("ListStackResources", params);
3501 svc.list_stack_resources(&req).expect("unknown is empty");
3502 }
3503
3504 #[test]
3505 fn describe_stack_resources_missing_name_returns_empty() {
3506 let svc = make_service();
3507 let req = make_request("DescribeStackResources", HashMap::new());
3508 svc.describe_stack_resources(&req)
3509 .expect("missing name is ok");
3510 }
3511
3512 #[test]
3513 fn get_template_missing_name_returns_empty_body() {
3514 let svc = make_service();
3515 let req = make_request("GetTemplate", HashMap::new());
3516 svc.get_template(&req).expect("missing name is ok");
3517 }
3518
3519 #[test]
3520 fn get_template_unknown_stack_returns_empty_body() {
3521 let svc = make_service();
3522 let mut params = HashMap::new();
3523 params.insert("StackName".to_string(), "ghost".to_string());
3524 let req = make_request("GetTemplate", params);
3525 svc.get_template(&req).expect("unknown is empty");
3526 }
3527
3528 #[tokio::test]
3529 async fn update_stack_missing_name_errors() {
3530 let svc = make_service();
3531 let mut params = HashMap::new();
3532 params.insert("TemplateBody".to_string(), "{}".to_string());
3533 let req = make_request("UpdateStack", params);
3534 assert!(svc.update_stack(&req).await.is_err());
3535 }
3536
3537 #[tokio::test]
3538 async fn update_stack_unknown_stack_returns_synthetic_id() {
3539 let svc = make_service();
3546 let mut params = HashMap::new();
3547 params.insert("StackName".to_string(), "ghost".to_string());
3548 params.insert(
3549 "TemplateBody".to_string(),
3550 r#"{"Resources":{}}"#.to_string(),
3551 );
3552 let req = make_request("UpdateStack", params);
3553 let resp = svc
3554 .update_stack(&req)
3555 .await
3556 .expect("ghost update is synthetic");
3557 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
3558 assert!(b.contains("UpdateStackResult"));
3559 }
3560
3561 #[tokio::test]
3562 async fn create_stack_resolves_outputs_and_records_export() {
3563 let svc = make_service();
3564 let template = r#"{
3565 "Resources": {
3566 "Q": {"Type":"AWS::SQS::Queue","Properties":{"QueueName":"out-q"}}
3567 },
3568 "Outputs": {
3569 "QueueUrl": {
3570 "Value": {"Ref": "Q"},
3571 "Description": "Url",
3572 "Export": {"Name": "TheQueueUrl"}
3573 }
3574 }
3575 }"#;
3576 let mut params = HashMap::new();
3577 params.insert("StackName".to_string(), "outs".to_string());
3578 params.insert("TemplateBody".to_string(), template.to_string());
3579 let req = make_request("CreateStack", params);
3580 svc.create_stack(&req).await.expect("create stack");
3581
3582 let accounts = svc.state.read();
3583 let stack = accounts
3584 .get("123456789012")
3585 .unwrap()
3586 .stacks
3587 .get("outs")
3588 .unwrap();
3589 assert_eq!(stack.outputs.len(), 1);
3590 assert_eq!(stack.outputs[0].key, "QueueUrl");
3591 assert_eq!(stack.outputs[0].export_name.as_deref(), Some("TheQueueUrl"));
3592 assert!(!stack.outputs[0].value.is_empty());
3593 }
3594
3595 #[tokio::test]
3596 async fn create_stack_rejects_duplicate_export_name() {
3597 let svc = make_service();
3598 let mk = |name: &str| {
3599 let template = format!(
3600 r#"{{
3601 "Resources": {{"Q":{{"Type":"AWS::SQS::Queue","Properties":{{"QueueName":"q-{name}"}}}}}},
3602 "Outputs": {{"QueueUrl":{{"Value":{{"Ref":"Q"}},"Export":{{"Name":"DupExport"}}}}}}
3603 }}"#
3604 );
3605 let mut params = HashMap::new();
3606 params.insert("StackName".to_string(), name.to_string());
3607 params.insert("TemplateBody".to_string(), template);
3608 make_request("CreateStack", params)
3609 };
3610 match svc.create_stack(&mk("first")).await {
3611 Ok(_) => {}
3612 Err(e) => panic!("first stack: {e:?}"),
3613 }
3614 svc.create_stack(&mk("second"))
3620 .await
3621 .expect("CreateStack returns StackId even when provisioning fails");
3622 let accounts = svc.state.read();
3623 let stack = accounts
3624 .get("123456789012")
3625 .unwrap()
3626 .stacks
3627 .get("second")
3628 .expect("second stack recorded");
3629 assert_eq!(stack.status, "CREATE_FAILED");
3630 let exports = &accounts.get("123456789012").unwrap().exports;
3632 assert_eq!(
3633 exports
3634 .get("DupExport")
3635 .map(|e| e.exporting_stack_name.as_str()),
3636 Some("first")
3637 );
3638 }
3639
3640 #[tokio::test]
3641 async fn import_value_resolves_against_other_stack_export() {
3642 let svc = make_service();
3643
3644 let producer_tpl = r#"{
3645 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod-q"}}},
3646 "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"SharedQueueUrl"}}}
3647 }"#;
3648 let mut p = HashMap::new();
3649 p.insert("StackName".to_string(), "producer".to_string());
3650 p.insert("TemplateBody".to_string(), producer_tpl.to_string());
3651 svc.create_stack(&make_request("CreateStack", p))
3652 .await
3653 .expect("producer");
3654
3655 let consumer_tpl = r#"{
3656 "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cons-q"}}},
3657 "Outputs": {"Imp":{"Value":{"Fn::ImportValue":"SharedQueueUrl"}}}
3658 }"#;
3659 let mut p = HashMap::new();
3660 p.insert("StackName".to_string(), "consumer".to_string());
3661 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3662 svc.create_stack(&make_request("CreateStack", p))
3663 .await
3664 .expect("consumer");
3665
3666 let accounts = svc.state.read();
3667 let prod_url = accounts
3668 .get("123456789012")
3669 .unwrap()
3670 .stacks
3671 .get("producer")
3672 .unwrap()
3673 .outputs[0]
3674 .value
3675 .clone();
3676 let cons = accounts
3677 .get("123456789012")
3678 .unwrap()
3679 .stacks
3680 .get("consumer")
3681 .unwrap();
3682 assert_eq!(cons.outputs[0].value, prod_url);
3683 }
3684
3685 #[tokio::test]
3686 async fn create_stack_records_export_in_state_registry() {
3687 let svc = make_service();
3688 let template = r#"{
3689 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"reg-q"}}},
3690 "Outputs": {"Url":{"Value":{"Ref":"Q"},"Export":{"Name":"reg-url"}}}
3691 }"#;
3692 let mut params = HashMap::new();
3693 params.insert("StackName".to_string(), "reg".to_string());
3694 params.insert("TemplateBody".to_string(), template.to_string());
3695 svc.create_stack(&make_request("CreateStack", params))
3696 .await
3697 .expect("create");
3698
3699 let accounts = svc.state.read();
3700 let state = accounts.get("123456789012").unwrap();
3701 let export = state
3702 .exports
3703 .get("reg-url")
3704 .expect("export registered in state.exports");
3705 assert_eq!(export.exporting_stack_name, "reg");
3706 assert!(!export.value.is_empty());
3707 assert!(export.exporting_stack_id.contains("reg"));
3708 }
3709
3710 #[tokio::test]
3711 async fn import_value_with_unknown_export_errors() {
3712 let svc = make_service();
3713 let consumer_tpl = r#"{
3714 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{
3715 "QueueName": {"Fn::ImportValue":"missing-export"}
3716 }}}
3717 }"#;
3718 let mut p = HashMap::new();
3719 p.insert("StackName".to_string(), "bad-consumer".to_string());
3720 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3721 match svc.create_stack(&make_request("CreateStack", p)).await {
3722 Ok(_) => panic!("expected ValidationError for unknown export"),
3723 Err(e) => {
3724 let msg = format!("{e:?}");
3725 assert!(msg.contains("No export named missing-export"), "got {msg}");
3726 }
3727 }
3728 }
3729
3730 #[tokio::test]
3731 async fn delete_stack_blocked_when_export_in_use_and_unblocked_after_consumer_delete() {
3732 let svc = make_service();
3733
3734 let producer_tpl = r#"{
3735 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod"}}},
3736 "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"my-arn"}}}
3737 }"#;
3738 let mut p = HashMap::new();
3739 p.insert("StackName".to_string(), "producer".to_string());
3740 p.insert("TemplateBody".to_string(), producer_tpl.to_string());
3741 svc.create_stack(&make_request("CreateStack", p))
3742 .await
3743 .expect("producer");
3744
3745 let consumer_tpl = r#"{
3746 "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{
3747 "QueueName": "cons-q",
3748 "Tags": [{"Key":"k","Value":{"Fn::ImportValue":"my-arn"}}]
3749 }}}
3750 }"#;
3751 let mut p = HashMap::new();
3752 p.insert("StackName".to_string(), "consumer".to_string());
3753 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3754 svc.create_stack(&make_request("CreateStack", p))
3755 .await
3756 .expect("consumer");
3757
3758 let mut p = HashMap::new();
3760 p.insert("StackName".to_string(), "producer".to_string());
3761 match svc.delete_stack(&make_request("DeleteStack", p)).await {
3762 Ok(_) => panic!("delete must fail while imports exist"),
3763 Err(e) => {
3764 let msg = format!("{e:?}");
3765 assert!(msg.contains("Export my-arn cannot be deleted"), "got {msg}");
3766 }
3767 }
3768
3769 let mut p = HashMap::new();
3771 p.insert("StackName".to_string(), "consumer".to_string());
3772 svc.delete_stack(&make_request("DeleteStack", p))
3773 .await
3774 .expect("consumer delete");
3775
3776 let mut p = HashMap::new();
3778 p.insert("StackName".to_string(), "producer".to_string());
3779 svc.delete_stack(&make_request("DeleteStack", p))
3780 .await
3781 .expect("producer delete after consumer gone");
3782
3783 let accounts = svc.state.read();
3784 let state = accounts.get("123456789012").unwrap();
3785 assert!(state.exports.is_empty(), "exports cleared after delete");
3786 assert!(state.imports.is_empty(), "imports cleared after delete");
3787 }
3788
3789 use std::sync::atomic::{AtomicUsize, Ordering};
3792
3793 fn counting_hook(counter: Arc<AtomicUsize>) -> fakecloud_persistence::SnapshotHook {
3796 Arc::new(move || {
3797 let counter = counter.clone();
3798 Box::pin(async move {
3799 counter.fetch_add(1, Ordering::SeqCst);
3800 })
3801 })
3802 }
3803
3804 fn disk_s3_store(tmp: &tempfile::TempDir) -> Arc<fakecloud_persistence::s3::DiskS3Store> {
3805 let cache = Arc::new(fakecloud_persistence::cache::BodyCache::new(1024 * 1024));
3806 Arc::new(fakecloud_persistence::s3::DiskS3Store::new(
3807 tmp.path().to_path_buf(),
3808 cache,
3809 ))
3810 }
3811
3812 const PERSIST_TEMPLATE: &str = r#"{"Resources":{
3816 "Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cfn-q"}},
3817 "T":{"Type":"AWS::SNS::Topic","Properties":{"TopicName":"cfn-t"}},
3818 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"cfn-bucket"}}
3819 }}"#;
3820
3821 fn create_req(stack: &str) -> AwsRequest {
3822 let mut p = HashMap::new();
3823 p.insert("StackName".to_string(), stack.to_string());
3824 p.insert("TemplateBody".to_string(), PERSIST_TEMPLATE.to_string());
3825 make_request("CreateStack", p)
3826 }
3827
3828 #[tokio::test]
3829 async fn cfn_create_persists_touched_services_and_writes_bucket_to_store() {
3830 let tmp = tempfile::tempdir().unwrap();
3831 let store = disk_s3_store(&tmp);
3832 let counter = Arc::new(AtomicUsize::new(0));
3833 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3834 BTreeMap::new();
3835 hooks.insert("sqs", counting_hook(counter.clone()));
3836 hooks.insert("sns", counting_hook(counter.clone()));
3837 hooks.insert("lambda", counting_hook(counter.clone()));
3839 let svc = make_service()
3840 .with_s3_store(store.clone())
3841 .with_snapshot_hooks(hooks);
3842
3843 svc.create_stack(&create_req("probe")).await.unwrap();
3844
3845 assert_eq!(counter.load(Ordering::SeqCst), 2);
3847 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3849 assert!(
3850 loaded.buckets.contains_key("cfn-bucket"),
3851 "CFN bucket should be persisted to the S3 store"
3852 );
3853 }
3854
3855 #[tokio::test]
3856 async fn cfn_delete_persists_touched_services_and_removes_bucket_from_store() {
3857 let tmp = tempfile::tempdir().unwrap();
3858 let store = disk_s3_store(&tmp);
3859 let counter = Arc::new(AtomicUsize::new(0));
3860 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3861 BTreeMap::new();
3862 hooks.insert("sqs", counting_hook(counter.clone()));
3863 hooks.insert("sns", counting_hook(counter.clone()));
3864 let svc = make_service()
3865 .with_s3_store(store.clone())
3866 .with_snapshot_hooks(hooks);
3867
3868 svc.create_stack(&create_req("probe")).await.unwrap();
3869 assert_eq!(counter.load(Ordering::SeqCst), 2, "create fired sqs + sns");
3870
3871 let mut p = HashMap::new();
3872 p.insert("StackName".to_string(), "probe".to_string());
3873 svc.delete_stack(&make_request("DeleteStack", p))
3874 .await
3875 .unwrap();
3876
3877 assert_eq!(counter.load(Ordering::SeqCst), 4, "delete fired sqs + sns");
3879 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3882 assert!(
3883 !loaded.buckets.contains_key("cfn-bucket"),
3884 "CFN-deleted bucket should be removed from the S3 store"
3885 );
3886 }
3887
3888 #[tokio::test]
3889 async fn cfn_persist_skips_services_without_a_registered_hook() {
3890 let tmp = tempfile::tempdir().unwrap();
3893 let store = disk_s3_store(&tmp);
3894 let counter = Arc::new(AtomicUsize::new(0));
3895 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3896 BTreeMap::new();
3897 hooks.insert("sqs", counting_hook(counter.clone()));
3898 let svc = make_service()
3899 .with_s3_store(store.clone())
3900 .with_snapshot_hooks(hooks);
3901
3902 svc.create_stack(&create_req("probe")).await.unwrap();
3903 assert_eq!(counter.load(Ordering::SeqCst), 1, "only sqs has a hook");
3904 }
3905
3906 #[tokio::test]
3907 async fn cfn_update_persists_touched_services() {
3908 let tmp = tempfile::tempdir().unwrap();
3911 let store = disk_s3_store(&tmp);
3912 let counter = Arc::new(AtomicUsize::new(0));
3913 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3914 BTreeMap::new();
3915 hooks.insert("sqs", counting_hook(counter.clone()));
3916 hooks.insert("sns", counting_hook(counter.clone()));
3917 let svc = make_service()
3918 .with_s3_store(store.clone())
3919 .with_snapshot_hooks(hooks);
3920
3921 let mut create = HashMap::new();
3922 create.insert("StackName".to_string(), "upd".to_string());
3923 create.insert(
3924 "TemplateBody".to_string(),
3925 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"u-q"}}}}"#
3926 .to_string(),
3927 );
3928 svc.create_stack(&make_request("CreateStack", create))
3929 .await
3930 .unwrap();
3931 let after_create = counter.load(Ordering::SeqCst);
3932
3933 let mut update = HashMap::new();
3934 update.insert("StackName".to_string(), "upd".to_string());
3935 update.insert("TemplateBody".to_string(), PERSIST_TEMPLATE.to_string());
3936 svc.update_stack(&make_request("UpdateStack", update))
3937 .await
3938 .unwrap();
3939
3940 assert!(
3942 counter.load(Ordering::SeqCst) > after_create,
3943 "update should persist the services it touched"
3944 );
3945 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
3946 assert!(loaded.buckets.contains_key("cfn-bucket"));
3947 }
3948
3949 #[tokio::test]
3950 async fn cfn_execute_change_set_persists_touched_services() {
3951 let tmp = tempfile::tempdir().unwrap();
3957 let store = disk_s3_store(&tmp);
3958 let counter = Arc::new(AtomicUsize::new(0));
3959 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3960 BTreeMap::new();
3961 hooks.insert("sqs", counting_hook(counter.clone()));
3962 let svc = make_service()
3963 .with_s3_store(store.clone())
3964 .with_snapshot_hooks(hooks);
3965
3966 let mut create = HashMap::new();
3967 create.insert("StackName".to_string(), "cs-stack".to_string());
3968 create.insert("ChangeSetName".to_string(), "cs1".to_string());
3969 create.insert("ChangeSetType".to_string(), "CREATE".to_string());
3970 create.insert(
3971 "TemplateBody".to_string(),
3972 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cs-q"}}}}"#
3973 .to_string(),
3974 );
3975 svc.handle(make_request("CreateChangeSet", create))
3976 .await
3977 .unwrap();
3978 let before = counter.load(Ordering::SeqCst);
3980
3981 let mut exec = HashMap::new();
3982 exec.insert("StackName".to_string(), "cs-stack".to_string());
3983 exec.insert("ChangeSetName".to_string(), "cs1".to_string());
3984 svc.handle(make_request("ExecuteChangeSet", exec))
3985 .await
3986 .unwrap();
3987
3988 assert!(
3989 counter.load(Ordering::SeqCst) > before,
3990 "ExecuteChangeSet must fire the sqs snapshot hook so the provisioned \
3991 queue survives a restart"
3992 );
3993 }
3994
3995 #[test]
3996 fn service_key_for_type_maps_services_and_aliases() {
3997 assert_eq!(
3999 service_key_for_type("AWS::Lambda::Function"),
4000 Some("lambda")
4001 );
4002 assert_eq!(
4003 service_key_for_type("AWS::SecretsManager::Secret"),
4004 Some("secretsmanager")
4005 );
4006 assert_eq!(service_key_for_type("AWS::SQS::Queue"), Some("sqs"));
4007 assert_eq!(service_key_for_type("AWS::IAM::Role"), Some("iam"));
4008 assert_eq!(
4009 service_key_for_type("AWS::StepFunctions::StateMachine"),
4010 Some("stepfunctions")
4011 );
4012 assert_eq!(
4014 service_key_for_type("AWS::Events::Rule"),
4015 Some("eventbridge")
4016 );
4017 assert_eq!(service_key_for_type("AWS::Logs::LogGroup"), Some("logs"));
4018 assert_eq!(
4019 service_key_for_type("AWS::ElastiCache::CacheCluster"),
4020 Some("elasticache")
4021 );
4022 assert_eq!(service_key_for_type("AWS::S3::Bucket"), None);
4024 assert_eq!(
4027 service_key_for_type("AWS::CertificateManager::Certificate"),
4028 Some("acm")
4029 );
4030 assert_eq!(
4031 service_key_for_type("AWS::ElasticLoadBalancingV2::LoadBalancer"),
4032 Some("elbv2")
4033 );
4034 assert_eq!(
4035 service_key_for_type("AWS::CloudFront::Distribution"),
4036 Some("cloudfront")
4037 );
4038 assert_eq!(
4039 service_key_for_type("AWS::Route53::HostedZone"),
4040 Some("route53")
4041 );
4042 assert_eq!(
4043 service_key_for_type("AWS::KinesisFirehose::DeliveryStream"),
4044 Some("firehose")
4045 );
4046 assert_eq!(service_key_for_type("AWS::Glue::Database"), Some("glue"));
4047 assert_eq!(service_key_for_type("AWS::WAFv2::WebACL"), Some("wafv2"));
4048 assert_eq!(
4049 service_key_for_type("AWS::Athena::WorkGroup"),
4050 Some("athena")
4051 );
4052 assert_eq!(
4053 service_key_for_type("AWS::Organizations::Organization"),
4054 Some("organizations")
4055 );
4056 assert_eq!(service_key_for_type("AWS::Lambda"), None);
4058 assert_eq!(service_key_for_type("Custom::Thing::Resource"), None);
4059 assert_eq!(service_key_for_type("AWS"), None);
4060 assert_eq!(service_key_for_type(""), None);
4061 }
4062
4063 #[tokio::test]
4064 async fn persist_touched_services_noop_with_empty_hooks() {
4065 let hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> = BTreeMap::new();
4067 persist_touched_services(&hooks, vec!["AWS::SQS::Queue".to_string()]).await;
4068 }
4069
4070 #[tokio::test]
4071 async fn cfn_bucket_policy_write_through_create_update_delete() {
4072 let tmp = tempfile::tempdir().unwrap();
4073 let store = disk_s3_store(&tmp);
4074 let svc = make_service().with_s3_store(store.clone());
4075
4076 let mut create = HashMap::new();
4078 create.insert("StackName".to_string(), "pol".to_string());
4079 create.insert(
4080 "TemplateBody".to_string(),
4081 r#"{"Resources":{
4082 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"pol-bucket"}},
4083 "BP":{"Type":"AWS::S3::BucketPolicy","Properties":{"Bucket":"pol-bucket","PolicyDocument":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Principal":"*"}]}}}
4084 }}"#
4085 .to_string(),
4086 );
4087 svc.create_stack(&make_request("CreateStack", create))
4088 .await
4089 .unwrap();
4090 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
4091 let policy = loaded.buckets["pol-bucket"]
4092 .subresources
4093 .get("policy.toml")
4094 .cloned()
4095 .expect("bucket policy persisted on create");
4096 assert!(policy.contains("s3:GetObject"));
4097
4098 let mut update = HashMap::new();
4100 update.insert("StackName".to_string(), "pol".to_string());
4101 update.insert(
4102 "TemplateBody".to_string(),
4103 r#"{"Resources":{
4104 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"pol-bucket"}},
4105 "BP":{"Type":"AWS::S3::BucketPolicy","Properties":{"Bucket":"pol-bucket","PolicyDocument":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"*","Principal":"*"}]}}}
4106 }}"#
4107 .to_string(),
4108 );
4109 svc.update_stack(&make_request("UpdateStack", update))
4110 .await
4111 .unwrap();
4112 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
4113 let policy = loaded.buckets["pol-bucket"]
4114 .subresources
4115 .get("policy.toml")
4116 .cloned()
4117 .expect("bucket policy still persisted after update");
4118 assert!(
4119 policy.contains("s3:PutObject"),
4120 "updated policy should be written through"
4121 );
4122
4123 let mut del = HashMap::new();
4125 del.insert("StackName".to_string(), "pol".to_string());
4126 svc.delete_stack(&make_request("DeleteStack", del))
4127 .await
4128 .unwrap();
4129 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
4130 assert!(
4131 !loaded.buckets.contains_key("pol-bucket"),
4132 "CFN-deleted bucket and policy should be gone from the store"
4133 );
4134 }
4135}