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 merged_resource_attributes(
41 provisioner: &ResourceProvisioner,
42 resource: &StackResource,
43 base: std::collections::BTreeMap<String, String>,
44) -> std::collections::BTreeMap<String, String> {
45 let mut attr_map = base;
46 for (k, v) in &resource.attributes {
47 attr_map.insert(k.clone(), v.clone());
48 }
49 for attr in well_known_attributes_for(&resource.resource_type) {
50 if attr_map.contains_key(*attr) {
51 continue;
52 }
53 if let Some(v) = provisioner.get_att(resource, attr) {
54 attr_map.insert((*attr).to_string(), v);
55 }
56 }
57 attr_map
58}
59
60fn well_known_attributes_for(resource_type: &str) -> &'static [&'static str] {
61 match resource_type {
62 "AWS::S3::Bucket" => &[
63 "Arn",
64 "DomainName",
65 "RegionalDomainName",
66 "DualStackDomainName",
67 "WebsiteURL",
68 ],
69 "AWS::Lambda::Function" => &["Arn", "FunctionUrl", "Version"],
70 "AWS::IAM::Role" => &["Arn", "RoleId"],
71 "AWS::SQS::Queue" => &["Arn", "QueueName", "QueueUrl"],
72 "AWS::SNS::Topic" => &["TopicArn", "TopicName"],
73 "AWS::DynamoDB::Table" => &["Arn", "StreamArn"],
74 "AWS::KMS::Key" => &["Arn", "KeyId"],
75 "AWS::SecretsManager::Secret" => &["Arn", "Id"],
76 "AWS::CloudFront::Distribution" => &["DomainName", "Id"],
77 "AWS::EC2::VPC" => &["VpcId", "CidrBlock"],
78 "AWS::EC2::Subnet" => &["SubnetId", "AvailabilityZone", "VpcId", "CidrBlock"],
79 "AWS::EC2::SecurityGroup" => &["GroupId", "VpcId"],
80 "AWS::EC2::InternetGateway" => &["InternetGatewayId"],
81 "AWS::EC2::RouteTable" => &["RouteTableId"],
82 "AWS::Pipes::Pipe" => &["Arn"],
83 "AWS::CodeArtifact::Domain" => &["Arn", "EncryptionKey", "Name", "Owner"],
84 "AWS::CodeArtifact::Repository" => &["Arn", "DomainName", "DomainOwner", "Name"],
85 "AWS::CodeCommit::Repository" => &["Arn", "CloneUrlHttp", "CloneUrlSsh", "Name"],
86 "AWS::EFS::FileSystem" => &["Arn", "FileSystemId"],
87 "AWS::EFS::MountTarget" => &["Id", "IpAddress"],
88 "AWS::EFS::AccessPoint" => &["Arn", "AccessPointId"],
89 "AWS::ElasticBeanstalk::Environment" => &["EndpointURL"],
90 "AWS::ACMPCA::CertificateAuthority" => &["Arn", "CertificateSigningRequest"],
91 "AWS::ACMPCA::Certificate" => &["Arn", "Certificate"],
92 "AWS::ACMPCA::CertificateAuthorityActivation" => &["CompleteCertificateChain"],
93 "AWS::Route53Resolver::ResolverEndpoint" => &[
94 "Arn",
95 "ResolverEndpointId",
96 "IpAddressCount",
97 "Direction",
98 "HostVPCId",
99 "Name",
100 ],
101 "AWS::Route53Resolver::ResolverRule" => {
102 &["Arn", "ResolverRuleId", "Name", "DomainName", "TargetIps"]
103 }
104 "AWS::Route53Resolver::ResolverRuleAssociation" => &["ResolverRuleAssociationId", "Name"],
105 "AWS::Route53Resolver::ResolverQueryLoggingConfig" => &["Arn", "Id"],
106 "AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation" => &["Id"],
107 "AWS::Route53Resolver::FirewallDomainList" => &["Arn", "Id"],
108 "AWS::Route53Resolver::FirewallRuleGroup" => &["Arn", "Id", "RuleCount"],
109 "AWS::Route53Resolver::FirewallRuleGroupAssociation" => &["Arn", "Id"],
110 "AWS::Route53Resolver::ResolverConfig" => &["Id", "OwnerId", "ResourceId"],
111 "AWS::Route53Resolver::ResolverDNSSECConfig" => &["Id", "OwnerId", "ResourceId"],
112 "AWS::Route53Resolver::FirewallConfig" => &["Id", "OwnerId", "ResourceId"],
113 "AWS::Config::ConfigRule" => &["Arn", "ComplianceType", "ConfigRuleId"],
114 "AWS::Config::ConformancePack" => &["Arn"],
115 "AWS::Config::AggregationAuthorization" => &["AggregationAuthorizationArn"],
116 "AWS::Config::OrganizationConfigRule" => &["Arn"],
117 _ => &[],
118 }
119}
120
121fn service_key_for_type(resource_type: &str) -> Option<&'static str> {
132 let mut parts = resource_type.split("::");
133 let vendor = parts.next()?;
134 let service = parts.next()?;
135 parts.next()?;
138 if vendor != "AWS" {
139 return None;
140 }
141 Some(match service {
142 "Lambda" => "lambda",
143 "SecretsManager" => "secretsmanager",
144 "SQS" => "sqs",
145 "SNS" => "sns",
146 "DynamoDB" => "dynamodb",
147 "StepFunctions" => "stepfunctions",
148 "Events" => "eventbridge",
149 "SSM" => "ssm",
150 "Logs" => "logs",
151 "KMS" => "kms",
152 "Kinesis" => "kinesis",
153 "SES" => "ses",
154 "Cognito" => "cognito",
155 "RDS" => "rds",
156 "ElastiCache" => "elasticache",
157 "ECR" => "ecr",
158 "ECS" => "ecs",
159 "CloudWatch" => "cloudwatch",
160 "ApiGateway" => "apigateway",
161 "ApiGatewayV2" => "apigatewayv2",
162 "Bedrock" => "bedrock",
163 "Scheduler" => "scheduler",
164 "IAM" => "iam",
165 "CertificateManager" => "acm",
172 "ACMPCA" => "acm-pca",
173 "Route53Resolver" => "route53resolver",
174 "Config" => "config",
175 "ElasticLoadBalancingV2" => "elbv2",
176 "CloudFront" => "cloudfront",
177 "Route53" => "route53",
178 "KinesisFirehose" => "firehose",
179 "Glue" => "glue",
180 "WAFv2" => "wafv2",
181 "Athena" => "athena",
182 "Organizations" => "organizations",
183 "EC2" => "ec2",
190 "AutoScaling" => "autoscaling",
191 "Batch" => "batch",
192 "ApplicationAutoScaling" => "application-autoscaling",
193 "Pipes" => "pipes",
194 "CodeArtifact" => "codeartifact",
195 "CodeCommit" => "codecommit",
196 "EFS" => "elasticfilesystem",
202 "ElasticBeanstalk" => "elasticbeanstalk",
206 "AmazonMQ" => "mq",
210 "MSK" => "kafka",
215 "KinesisAnalyticsV2" => "kinesisanalyticsv2",
220 "EKS" => "eks",
221 "ServiceDiscovery" => "servicediscovery",
222 _ => return None,
223 })
224}
225
226async fn persist_touched_services<I>(
235 hooks: &BTreeMap<&'static str, SnapshotHook>,
236 resource_types: I,
237) where
238 I: IntoIterator<Item = String>,
239{
240 if hooks.is_empty() {
241 return;
242 }
243 let mut keys: BTreeSet<&'static str> = BTreeSet::new();
244 for ty in resource_types {
245 if let Some(key) = service_key_for_type(&ty) {
246 keys.insert(key);
247 }
248 }
249 for key in keys {
250 if let Some(hook) = hooks.get(key) {
251 hook().await;
252 }
253 }
254}
255
256pub(crate) fn provision_stack_resources(
265 provisioner: &ResourceProvisioner,
266 resource_defs: &[template::ResourceDefinition],
267 template_body: &str,
268 parameters: &BTreeMap<String, String>,
269 imports: &BTreeMap<String, String>,
270) -> Result<Vec<StackResource>, AwsServiceError> {
271 let mut resources = Vec::new();
272 let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
273 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
274 let order = template::dependency_order(template_body, parameters, resource_defs);
279 let mut pending: Vec<&template::ResourceDefinition> =
280 order.iter().map(|&i| &resource_defs[i]).collect();
281 let max_passes = pending.len() + 1;
282
283 for _ in 0..max_passes {
284 if pending.is_empty() {
285 break;
286 }
287 let mut still_pending = Vec::new();
288 let mut made_progress = false;
289
290 for resource_def in pending {
291 let resolved_def = template::resolve_resource_properties_with_attrs(
292 resource_def,
293 template_body,
294 parameters,
295 &physical_ids,
296 &attributes,
297 imports,
298 )
299 .map_err(|e| {
300 AwsServiceError::aws_error(
304 StatusCode::BAD_REQUEST,
305 "InsufficientCapabilitiesException",
306 e,
307 )
308 })?;
309
310 match provisioner.create_resource(&resolved_def) {
311 Ok(mut stack_resource) => {
312 physical_ids.insert(
313 stack_resource.logical_id.clone(),
314 stack_resource.physical_id.clone(),
315 );
316 let attr_map = merged_resource_attributes(
321 provisioner,
322 &stack_resource,
323 std::collections::BTreeMap::new(),
324 );
325 attributes.insert(stack_resource.logical_id.clone(), attr_map.clone());
326 stack_resource.attributes = attr_map;
331 resources.push(stack_resource);
332 made_progress = true;
333 }
334 Err(_) => still_pending.push(resource_def),
335 }
336 }
337
338 pending = still_pending;
339 if !made_progress && !pending.is_empty() {
340 let resource_def = pending[0];
343 let resolved_def = template::resolve_resource_properties_with_attrs(
344 resource_def,
345 template_body,
346 parameters,
347 &physical_ids,
348 &attributes,
349 imports,
350 )
351 .unwrap_or_else(|_| resource_def.clone());
352 let err = provisioner.create_resource(&resolved_def).unwrap_err();
353 for r in &resources {
354 let _ = provisioner.delete_resource(r);
355 }
356 return Err(AwsServiceError::aws_error(
357 StatusCode::BAD_REQUEST,
358 "ValidationError",
359 format!(
360 "Failed to create resource {}: {err}",
361 resource_def.logical_id
362 ),
363 ));
364 }
365 }
366
367 Ok(resources)
368}
369
370#[derive(Debug, Clone)]
376pub struct CloudControlOutcome {
377 pub physical_id: String,
378 pub attributes: BTreeMap<String, String>,
379}
380
381impl CloudControlOutcome {
382 fn from(resource: &StackResource) -> Self {
383 Self {
384 physical_id: resource.physical_id.clone(),
385 attributes: resource.attributes.clone(),
386 }
387 }
388}
389
390fn reconstruct_stack_resource(
393 type_name: &str,
394 physical_id: &str,
395 attributes: &BTreeMap<String, String>,
396) -> StackResource {
397 StackResource {
398 logical_id: "Resource".to_string(),
399 physical_id: physical_id.to_string(),
400 resource_type: type_name.to_string(),
401 status: "CREATE_COMPLETE".to_string(),
402 service_token: None,
403 attributes: attributes.clone(),
404 }
405}
406
407pub struct CloudFormationDeps {
408 pub sqs: SharedSqsState,
409 pub sns: SharedSnsState,
410 pub ssm: SharedSsmState,
411 pub iam: SharedIamState,
412 pub s3: SharedS3State,
413 pub eventbridge: SharedEventBridgeState,
414 pub dynamodb: SharedDynamoDbState,
415 pub logs: SharedLogsState,
416 pub lambda: fakecloud_lambda::SharedLambdaState,
417 pub secretsmanager: fakecloud_secretsmanager::SharedSecretsManagerState,
418 pub kinesis: fakecloud_kinesis::SharedKinesisState,
419 pub kms: fakecloud_kms::SharedKmsState,
420 pub ecr: fakecloud_ecr::SharedEcrState,
421 pub cloudwatch: fakecloud_cloudwatch::SharedCloudWatchState,
422 pub elbv2: fakecloud_elbv2::SharedElbv2State,
423 pub organizations: fakecloud_organizations::SharedOrganizationsState,
424 pub cognito: fakecloud_cognito::SharedCognitoState,
425 pub rds: fakecloud_rds::SharedRdsState,
426 pub ec2: fakecloud_ec2::SharedEc2State,
427 pub autoscaling: fakecloud_autoscaling::SharedAutoScalingState,
428 pub batch: fakecloud_batch::SharedBatchState,
429 pub pipes: fakecloud_pipes::SharedPipesState,
430 pub ecs: fakecloud_ecs::SharedEcsState,
431 pub acm: fakecloud_acm::SharedAcmState,
432 pub acmpca: fakecloud_acmpca::SharedAcmPcaState,
433 pub config: fakecloud_config::SharedConfigState,
434 pub route53resolver: fakecloud_route53resolver::SharedRoute53ResolverState,
435 pub elasticache: fakecloud_elasticache::SharedElastiCacheState,
436 pub route53: fakecloud_route53::SharedRoute53State,
437 pub cloudfront: fakecloud_cloudfront::SharedCloudFrontState,
438 pub stepfunctions: fakecloud_stepfunctions::SharedStepFunctionsState,
439 pub wafv2: fakecloud_wafv2::SharedWafv2State,
440 pub apigateway: fakecloud_apigateway::SharedApiGatewayState,
441 pub apigatewayv2: fakecloud_apigatewayv2::SharedApiGatewayV2State,
442 pub ses: fakecloud_ses::SharedSesState,
443 pub application_autoscaling:
444 fakecloud_application_autoscaling::SharedApplicationAutoScalingState,
445 pub athena: fakecloud_athena::SharedAthenaState,
446 pub firehose: fakecloud_firehose::SharedFirehoseState,
447 pub glue: fakecloud_glue::SharedGlueState,
448 pub eks: fakecloud_eks::SharedEksState,
449 pub servicediscovery: fakecloud_servicediscovery::SharedServiceDiscoveryState,
450 pub codeartifact: fakecloud_codeartifact::SharedCodeArtifactState,
451 pub codecommit: fakecloud_codecommit::SharedCodeCommitState,
452 pub efs: fakecloud_efs::SharedEfsState,
453 pub elasticbeanstalk: fakecloud_elasticbeanstalk::SharedEbState,
454 pub mq: fakecloud_mq::SharedMqState,
455 pub kafka: fakecloud_kafka::SharedKafkaState,
456 pub kinesisanalyticsv2: fakecloud_kinesisanalyticsv2::SharedKa2State,
457 pub delivery: Arc<DeliveryBus>,
458 pub lambda_runtime: Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
465 pub rds_runtime: Option<Arc<fakecloud_rds::runtime::RdsRuntime>>,
473 pub ec2_runtime: Option<Arc<fakecloud_ec2::runtime::Ec2Runtime>>,
474 pub ecs_runtime: Option<Arc<fakecloud_ecs::runtime::EcsRuntime>>,
475 pub elasticache_runtime: Option<Arc<fakecloud_elasticache::runtime::ElastiCacheRuntime>>,
476 pub mq_runtime: Option<Arc<fakecloud_mq::MqRuntime>>,
477 pub kafka_runtime: Option<Arc<fakecloud_kafka::KafkaRuntime>>,
478}
479
480pub struct CloudFormationService {
481 pub(crate) state: SharedCloudFormationState,
482 pub(crate) deps: CloudFormationDeps,
483 snapshot_store: Option<Arc<dyn SnapshotStore>>,
484 snapshot_lock: Arc<AsyncMutex<()>>,
485 s3_store: Arc<dyn S3Store>,
491 snapshot_hooks: BTreeMap<&'static str, SnapshotHook>,
498}
499
500struct CreateStackContext {
504 state: SharedCloudFormationState,
505 delivery: Arc<DeliveryBus>,
506 snapshot_store: Option<Arc<dyn SnapshotStore>>,
507 snapshot_lock: Arc<AsyncMutex<()>>,
508 snapshot_hooks: BTreeMap<&'static str, SnapshotHook>,
509 provisioner: ResourceProvisioner,
510 account_id: String,
511 stack_name: String,
512 stack_id: String,
513 template_body: String,
514 parameters: BTreeMap<String, String>,
515 notification_arns: Vec<String>,
516 imported_names: Vec<String>,
517 resource_defs: Vec<template::ResourceDefinition>,
518}
519
520pub(crate) struct ContainerBackingHandles {
529 account_id: String,
530 region: String,
531 rds_state: fakecloud_rds::SharedRdsState,
532 rds_runtime: Option<Arc<fakecloud_rds::runtime::RdsRuntime>>,
533 ec2_state: fakecloud_ec2::SharedEc2State,
534 ec2_runtime: Option<Arc<fakecloud_ec2::runtime::Ec2Runtime>>,
535 autoscaling_state: fakecloud_autoscaling::SharedAutoScalingState,
536 elasticache_state: fakecloud_elasticache::SharedElastiCacheState,
537 elasticache_runtime: Option<Arc<fakecloud_elasticache::runtime::ElastiCacheRuntime>>,
538 ecs_state: fakecloud_ecs::SharedEcsState,
539 ecs_runtime: Option<Arc<fakecloud_ecs::runtime::EcsRuntime>>,
540 mq_state: fakecloud_mq::SharedMqState,
541 mq_runtime: Option<Arc<fakecloud_mq::MqRuntime>>,
542 kafka_state: fakecloud_kafka::SharedKafkaState,
543 kafka_runtime: Option<Arc<fakecloud_kafka::KafkaRuntime>>,
544}
545
546impl ContainerBackingHandles {
547 pub(crate) fn from_provisioner(p: &ResourceProvisioner) -> Self {
548 Self {
549 account_id: p.account_id.clone(),
550 region: p.region.clone(),
551 rds_state: p.rds_state.clone(),
552 rds_runtime: p.rds_runtime.clone(),
553 ec2_state: p.ec2_state.clone(),
554 ec2_runtime: p.ec2_runtime.clone(),
555 autoscaling_state: p.autoscaling_state.clone(),
556 elasticache_state: p.elasticache_state.clone(),
557 elasticache_runtime: p.elasticache_runtime.clone(),
558 ecs_state: p.ecs_state.clone(),
559 ecs_runtime: p.ecs_runtime.clone(),
560 mq_state: p.mq_state.clone(),
561 mq_runtime: p.mq_runtime.clone(),
562 kafka_state: p.kafka_state.clone(),
563 kafka_runtime: p.kafka_runtime.clone(),
564 }
565 }
566
567 pub(crate) fn spawn_container_intents(
571 &self,
572 intents: Vec<crate::resource_provisioner::ContainerSpawnIntent>,
573 ) {
574 use crate::resource_provisioner::ContainerSpawnIntent;
575 for intent in intents {
576 match intent {
577 ContainerSpawnIntent::RdsInstance { identifier } => {
578 if let Some(runtime) = self.rds_runtime.clone() {
579 let rds_state = self.rds_state.clone();
580 let account = self.account_id.clone();
581 let region = self.region.clone();
582 tokio::spawn(async move {
583 fakecloud_rds::cfn_provision::cfn_ensure_instance_container(
584 rds_state, runtime, identifier, account, region,
585 )
586 .await;
587 });
588 }
589 }
590 ContainerSpawnIntent::AsgInstances { group_name } => {
591 let asg_state = self.autoscaling_state.clone();
592 let ec2_state = self.ec2_state.clone();
593 let ec2_runtime = self.ec2_runtime.clone();
594 let account = self.account_id.clone();
595 let region = self.region.clone();
596 tokio::spawn(async move {
597 fakecloud_autoscaling::cfn_provision::cfn_reconcile_capacity(
598 asg_state,
599 ec2_state,
600 ec2_runtime,
601 group_name,
602 account,
603 region,
604 )
605 .await;
606 });
607 }
608 ContainerSpawnIntent::Ec2Instance { instance_id } => {
609 let ec2_state = self.ec2_state.clone();
610 let ec2_runtime = self.ec2_runtime.clone();
611 let account = self.account_id.clone();
612 tokio::spawn(async move {
613 fakecloud_ec2::cfn_provision::cfn_back_instance(
614 ec2_state,
615 ec2_runtime,
616 account,
617 instance_id,
618 )
619 .await;
620 });
621 }
622 ContainerSpawnIntent::ElastiCacheCluster { cache_cluster_id } => {
623 if let Some(runtime) = self.elasticache_runtime.clone() {
624 let ec_state = self.elasticache_state.clone();
625 let account = self.account_id.clone();
626 tokio::spawn(async move {
627 fakecloud_elasticache::cfn_provision::cfn_ensure_cluster_container(
628 ec_state,
629 runtime,
630 cache_cluster_id,
631 account,
632 )
633 .await;
634 });
635 }
636 }
637 ContainerSpawnIntent::ElastiCacheReplicationGroup {
638 replication_group_id,
639 } => {
640 if let Some(runtime) = self.elasticache_runtime.clone() {
641 let ec_state = self.elasticache_state.clone();
642 let account = self.account_id.clone();
643 tokio::spawn(async move {
644 fakecloud_elasticache::cfn_provision::cfn_ensure_replication_group_container(
645 ec_state,
646 runtime,
647 replication_group_id,
648 account,
649 )
650 .await;
651 });
652 }
653 }
654 ContainerSpawnIntent::EcsServiceTasks {
655 cluster_name,
656 service_name,
657 } => {
658 if let Some(runtime) = self.ecs_runtime.clone() {
659 let ecs_state = self.ecs_state.clone();
660 let account = self.account_id.clone();
661 tokio::spawn(async move {
662 fakecloud_ecs::cfn_provision::cfn_launch_service_tasks(
663 ecs_state,
664 runtime,
665 cluster_name,
666 service_name,
667 account,
668 )
669 .await;
670 });
671 }
672 }
673 ContainerSpawnIntent::MqBroker { broker_id } => {
674 if let Some(runtime) = self.mq_runtime.clone() {
675 let mq_state = self.mq_state.clone();
676 let account = self.account_id.clone();
677 tokio::spawn(async move {
678 fakecloud_mq::cfn_provision::cfn_ensure_broker_container(
679 mq_state, runtime, broker_id, account,
680 )
681 .await;
682 });
683 }
684 }
685 ContainerSpawnIntent::MskCluster { cluster_arn } => {
686 if let Some(runtime) = self.kafka_runtime.clone() {
687 let kafka_state = self.kafka_state.clone();
688 let account = self.account_id.clone();
689 tokio::spawn(async move {
690 fakecloud_kafka::cfn_provision::cfn_ensure_cluster_container(
691 kafka_state,
692 runtime,
693 cluster_arn,
694 account,
695 )
696 .await;
697 });
698 }
699 }
700 }
701 }
702 }
703
704 pub(crate) fn spawn_teardown_intents(
709 &self,
710 intents: Vec<crate::resource_provisioner::ContainerTeardownIntent>,
711 ) {
712 use crate::resource_provisioner::ContainerTeardownIntent;
713 for intent in intents {
714 match intent {
715 ContainerTeardownIntent::RdsInstance { identifier } => {
716 if let Some(runtime) = self.rds_runtime.clone() {
717 let account = self.account_id.clone();
718 tokio::spawn(async move {
719 fakecloud_rds::cfn_provision::cfn_teardown_instance_container(
720 runtime, identifier, account,
721 )
722 .await;
723 });
724 }
725 }
726 ContainerTeardownIntent::ElastiCacheCluster { cache_cluster_id } => {
727 if let Some(runtime) = self.elasticache_runtime.clone() {
728 tokio::spawn(async move {
729 fakecloud_elasticache::cfn_provision::cfn_teardown_cluster_container(
730 runtime,
731 cache_cluster_id,
732 )
733 .await;
734 });
735 }
736 }
737 ContainerTeardownIntent::ElastiCacheReplicationGroup {
738 replication_group_id,
739 } => {
740 if let Some(runtime) = self.elasticache_runtime.clone() {
741 tokio::spawn(async move {
742 fakecloud_elasticache::cfn_provision::cfn_teardown_replication_group_container(
743 runtime,
744 replication_group_id,
745 )
746 .await;
747 });
748 }
749 }
750 ContainerTeardownIntent::EcsService {
751 cluster_name,
752 service_name,
753 } => {
754 if let Some(runtime) = self.ecs_runtime.clone() {
755 let ecs_state = self.ecs_state.clone();
756 let account = self.account_id.clone();
757 tokio::spawn(async move {
758 fakecloud_ecs::cfn_provision::cfn_stop_service_tasks(
759 ecs_state,
760 runtime,
761 cluster_name,
762 service_name,
763 account,
764 )
765 .await;
766 });
767 }
768 }
769 ContainerTeardownIntent::Ec2Instance { instance_id } => {
770 let ec2_state = self.ec2_state.clone();
771 let ec2_runtime = self.ec2_runtime.clone();
772 let account = self.account_id.clone();
773 let region = self.region.clone();
774 tokio::spawn(async move {
775 fakecloud_ec2::cfn_provision::cfn_terminate(
776 ec2_state,
777 ec2_runtime,
778 account,
779 region,
780 instance_id,
781 )
782 .await;
783 });
784 }
785 ContainerTeardownIntent::AsgInstances { instance_ids } => {
786 let asg_state = self.autoscaling_state.clone();
787 let ec2_state = self.ec2_state.clone();
788 let ec2_runtime = self.ec2_runtime.clone();
789 let account = self.account_id.clone();
790 let region = self.region.clone();
791 tokio::spawn(async move {
792 fakecloud_autoscaling::cfn_provision::cfn_terminate_instances(
793 asg_state,
794 ec2_state,
795 ec2_runtime,
796 instance_ids,
797 account,
798 region,
799 )
800 .await;
801 });
802 }
803 ContainerTeardownIntent::MqBroker { broker_id } => {
804 if let Some(runtime) = self.mq_runtime.clone() {
805 tokio::spawn(async move {
806 fakecloud_mq::cfn_provision::cfn_teardown_broker_container(
807 runtime, broker_id,
808 )
809 .await;
810 });
811 }
812 }
813 ContainerTeardownIntent::MskCluster { cluster_arn } => {
814 if let Some(runtime) = self.kafka_runtime.clone() {
815 tokio::spawn(async move {
816 fakecloud_kafka::cfn_provision::cfn_teardown_cluster_container(
817 runtime,
818 cluster_arn,
819 )
820 .await;
821 });
822 }
823 }
824 }
825 }
826 }
827}
828
829pub(crate) fn spawn_custom_invokes(provisioner: &ResourceProvisioner) {
834 let intents = std::mem::take(&mut *provisioner.pending_custom_invokes.lock());
835 if intents.is_empty() {
836 return;
837 }
838 let delivery = provisioner.delivery.clone();
839 for intent in intents {
840 let delivery = delivery.clone();
841 tokio::spawn(async move {
842 match delivery
843 .invoke_lambda(&intent.service_token, &intent.payload)
844 .await
845 {
846 Some(Ok(_)) => {
847 tracing::info!(
848 "Custom resource Lambda {} invoked successfully",
849 intent.service_token
850 );
851 }
852 Some(Err(e)) => {
853 tracing::warn!(
854 "Custom resource Lambda {} invocation failed: {e}",
855 intent.service_token
856 );
857 }
858 None => {}
859 }
860 });
861 }
862}
863
864impl CloudFormationService {
865 pub fn new(state: SharedCloudFormationState, deps: CloudFormationDeps) -> Self {
866 Self {
867 state,
868 deps,
869 snapshot_store: None,
870 snapshot_lock: Arc::new(AsyncMutex::new(())),
871 s3_store: Arc::new(fakecloud_persistence::s3::MemoryS3Store::new()),
872 snapshot_hooks: BTreeMap::new(),
873 }
874 }
875
876 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
877 self.snapshot_store = Some(store);
878 self
879 }
880
881 pub fn with_s3_store(mut self, store: Arc<dyn S3Store>) -> Self {
884 self.s3_store = store;
885 self
886 }
887
888 pub fn with_snapshot_hooks(mut self, hooks: BTreeMap<&'static str, SnapshotHook>) -> Self {
891 self.snapshot_hooks = hooks;
892 self
893 }
894
895 async fn save_snapshot(&self) {
896 let Some(store) = self.snapshot_store.clone() else {
897 return;
898 };
899 let _guard = self.snapshot_lock.lock().await;
900 let snapshot = CloudFormationSnapshot {
901 schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
902 state: None,
903 accounts: Some(self.state.read().clone()),
904 };
905 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
906 let bytes = serde_json::to_vec(&snapshot)
907 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
908 store.save(&bytes)
909 })
910 .await;
911 match join {
912 Ok(Ok(())) => {}
913 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
914 Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
915 }
916 }
917
918 pub(crate) fn provisioner(
919 &self,
920 stack_id: &str,
921 account_id: &str,
922 region: &str,
923 ) -> ResourceProvisioner {
924 ResourceProvisioner {
925 sqs_state: self.deps.sqs.clone(),
926 sns_state: self.deps.sns.clone(),
927 ssm_state: self.deps.ssm.clone(),
928 iam_state: self.deps.iam.clone(),
929 s3_state: self.deps.s3.clone(),
930 eventbridge_state: self.deps.eventbridge.clone(),
931 dynamodb_state: self.deps.dynamodb.clone(),
932 logs_state: self.deps.logs.clone(),
933 lambda_state: self.deps.lambda.clone(),
934 secretsmanager_state: self.deps.secretsmanager.clone(),
935 kinesis_state: self.deps.kinesis.clone(),
936 kms_state: self.deps.kms.clone(),
937 ecr_state: self.deps.ecr.clone(),
938 cloudwatch_state: self.deps.cloudwatch.clone(),
939 elbv2_state: self.deps.elbv2.clone(),
940 organizations_state: self.deps.organizations.clone(),
941 cognito_state: self.deps.cognito.clone(),
942 rds_state: self.deps.rds.clone(),
943 ec2_state: self.deps.ec2.clone(),
944 autoscaling_state: self.deps.autoscaling.clone(),
945 batch_state: self.deps.batch.clone(),
946 pipes_state: self.deps.pipes.clone(),
947 ecs_state: self.deps.ecs.clone(),
948 acm_state: self.deps.acm.clone(),
949 acmpca_state: self.deps.acmpca.clone(),
950 config_state: self.deps.config.clone(),
951 route53resolver_state: self.deps.route53resolver.clone(),
952 elasticache_state: self.deps.elasticache.clone(),
953 route53_state: self.deps.route53.clone(),
954 cloudfront_state: self.deps.cloudfront.clone(),
955 stepfunctions_state: self.deps.stepfunctions.clone(),
956 wafv2_state: self.deps.wafv2.clone(),
957 apigateway_state: self.deps.apigateway.clone(),
958 apigatewayv2_state: self.deps.apigatewayv2.clone(),
959 ses_state: self.deps.ses.clone(),
960 app_autoscaling_state: self.deps.application_autoscaling.clone(),
961 athena_state: self.deps.athena.clone(),
962 firehose_state: self.deps.firehose.clone(),
963 glue_state: self.deps.glue.clone(),
964 eks_state: self.deps.eks.clone(),
965 servicediscovery_state: self.deps.servicediscovery.clone(),
966 codeartifact_state: self.deps.codeartifact.clone(),
967 codecommit_state: self.deps.codecommit.clone(),
968 efs_state: self.deps.efs.clone(),
969 elasticbeanstalk_state: self.deps.elasticbeanstalk.clone(),
970 mq_state: self.deps.mq.clone(),
971 kafka_state: self.deps.kafka.clone(),
972 ka2_state: self.deps.kinesisanalyticsv2.clone(),
973 cloudformation_state: self.state.clone(),
974 delivery: self.deps.delivery.clone(),
975 lambda_runtime: self.deps.lambda_runtime.clone(),
976 rds_runtime: self.deps.rds_runtime.clone(),
977 ec2_runtime: self.deps.ec2_runtime.clone(),
978 ecs_runtime: self.deps.ecs_runtime.clone(),
979 elasticache_runtime: self.deps.elasticache_runtime.clone(),
980 mq_runtime: self.deps.mq_runtime.clone(),
981 kafka_runtime: self.deps.kafka_runtime.clone(),
982 pending_container_spawns: Arc::new(parking_lot::Mutex::new(Vec::new())),
983 pending_container_teardowns: Arc::new(parking_lot::Mutex::new(Vec::new())),
984 pending_custom_invokes: Arc::new(parking_lot::Mutex::new(Vec::new())),
985 defer_custom_invokes: false,
991 s3_store: self.s3_store.clone(),
992 account_id: account_id.to_string(),
993 region: region.to_string(),
994 stack_id: stack_id.to_string(),
995 strict_unknown_types: false,
998 }
999 }
1000
1001 pub fn cloudcontrol_create(
1016 &self,
1017 type_name: &str,
1018 properties: serde_json::Value,
1019 account_id: &str,
1020 region: &str,
1021 ) -> Result<CloudControlOutcome, String> {
1022 let stack_id = format!("cloudcontrol-{}", uuid::Uuid::new_v4());
1023 let mut provisioner = self.provisioner(&stack_id, account_id, region);
1024 provisioner.strict_unknown_types = true;
1027 let backing = ContainerBackingHandles::from_provisioner(&provisioner);
1028 let spawns = provisioner.pending_container_spawns.clone();
1029 let def = template::ResourceDefinition {
1030 logical_id: "Resource".to_string(),
1031 resource_type: type_name.to_string(),
1032 properties,
1033 };
1034 let resource = provisioner.create_resource(&def)?;
1035 backing.spawn_container_intents(std::mem::take(&mut *spawns.lock()));
1038 Ok(CloudControlOutcome::from(&resource))
1039 }
1040
1041 pub fn cloudcontrol_update(
1046 &self,
1047 type_name: &str,
1048 physical_id: &str,
1049 prior_attributes: &BTreeMap<String, String>,
1050 new_properties: serde_json::Value,
1051 account_id: &str,
1052 region: &str,
1053 ) -> Result<CloudControlOutcome, String> {
1054 let stack_id = format!("cloudcontrol-{}", uuid::Uuid::new_v4());
1055 let mut provisioner = self.provisioner(&stack_id, account_id, region);
1056 provisioner.strict_unknown_types = true;
1057 let existing = reconstruct_stack_resource(type_name, physical_id, prior_attributes);
1058 let def = template::ResourceDefinition {
1059 logical_id: "Resource".to_string(),
1060 resource_type: type_name.to_string(),
1061 properties: new_properties,
1062 };
1063 match provisioner.update_resource(&existing, &def)? {
1066 Some(updated) => Ok(CloudControlOutcome::from(&updated)),
1067 None => Ok(CloudControlOutcome::from(&existing)),
1068 }
1069 }
1070
1071 pub fn cloudcontrol_delete(
1073 &self,
1074 type_name: &str,
1075 physical_id: &str,
1076 prior_attributes: &BTreeMap<String, String>,
1077 account_id: &str,
1078 region: &str,
1079 ) -> Result<(), String> {
1080 let stack_id = format!("cloudcontrol-{}", uuid::Uuid::new_v4());
1081 let provisioner = self.provisioner(&stack_id, account_id, region);
1082 let teardowns = provisioner.pending_container_teardowns.clone();
1083 let existing = reconstruct_stack_resource(type_name, physical_id, prior_attributes);
1084 provisioner.delete_resource(&existing)?;
1085 ContainerBackingHandles::from_provisioner(&provisioner)
1086 .spawn_teardown_intents(std::mem::take(&mut *teardowns.lock()));
1087 Ok(())
1088 }
1089
1090 pub async fn cloudcontrol_persist_type(&self, type_name: &str) {
1097 persist_touched_services(&self.snapshot_hooks, [type_name.to_string()]).await;
1098 }
1099
1100 pub(crate) fn provisioner_deferred(
1107 &self,
1108 stack_id: &str,
1109 account_id: &str,
1110 region: &str,
1111 ) -> ResourceProvisioner {
1112 ResourceProvisioner {
1113 defer_custom_invokes: true,
1114 ..self.provisioner(stack_id, account_id, region)
1115 }
1116 }
1117
1118 fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
1119 if let Some(v) = req.query_params.get(key) {
1121 return Some(v.clone());
1122 }
1123 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
1125 body_params.get(key).cloned()
1126 }
1127
1128 pub(crate) fn get_all_params(req: &AwsRequest) -> BTreeMap<String, String> {
1129 let mut params: BTreeMap<String, String> = req.query_params.clone().into_iter().collect();
1130 let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
1131 for (k, v) in body_params {
1132 params.entry(k).or_insert(v);
1133 }
1134 params
1135 }
1136
1137 pub(crate) fn extract_tags(params: &BTreeMap<String, String>) -> BTreeMap<String, String> {
1138 let mut tags = BTreeMap::new();
1139 for i in 1.. {
1140 let key_param = format!("Tags.member.{i}.Key");
1141 let value_param = format!("Tags.member.{i}.Value");
1142 match (params.get(&key_param), params.get(&value_param)) {
1143 (Some(k), Some(v)) => {
1144 tags.insert(k.clone(), v.clone());
1145 }
1146 _ => break,
1147 }
1148 }
1149 tags
1150 }
1151
1152 pub(crate) fn extract_parameters(
1153 params: &BTreeMap<String, String>,
1154 ) -> BTreeMap<String, String> {
1155 let mut result = BTreeMap::new();
1156 for i in 1.. {
1157 let key_param = format!("Parameters.member.{i}.ParameterKey");
1158 let value_param = format!("Parameters.member.{i}.ParameterValue");
1159 match (params.get(&key_param), params.get(&value_param)) {
1160 (Some(k), Some(v)) => {
1161 result.insert(k.clone(), v.clone());
1162 }
1163 _ => break,
1164 }
1165 }
1166 result
1167 }
1168
1169 pub(crate) fn merge_parameter_defaults(
1175 parameters: &mut BTreeMap<String, String>,
1176 template_body: &str,
1177 ) {
1178 let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
1179 match serde_json::from_str(template_body) {
1180 Ok(v) => v,
1181 Err(_) => return,
1182 }
1183 } else {
1184 match serde_yaml::from_str(template_body) {
1185 Ok(v) => v,
1186 Err(_) => return,
1187 }
1188 };
1189 let Some(decls) = value.get("Parameters").and_then(|v| v.as_object()) else {
1190 return;
1191 };
1192 for (name, spec) in decls {
1193 if parameters.contains_key(name) {
1194 continue;
1195 }
1196 if let Some(default) = spec.get("Default") {
1197 let s = default
1198 .as_str()
1199 .map(|s| s.to_string())
1200 .unwrap_or_else(|| default.to_string());
1201 parameters.insert(name.clone(), s);
1202 }
1203 }
1204 }
1205
1206 pub(crate) fn extract_notification_arns(params: &BTreeMap<String, String>) -> Vec<String> {
1207 let mut arns = Vec::new();
1208 for i in 1.. {
1209 let key = format!("NotificationARNs.member.{i}");
1210 match params.get(&key) {
1211 Some(arn) => arns.push(arn.clone()),
1212 None => break,
1213 }
1214 }
1215 arns
1216 }
1217
1218 fn send_stack_notification(
1219 delivery: &DeliveryBus,
1220 notification_arns: &[String],
1221 stack_name: &str,
1222 stack_id: &str,
1223 status: &str,
1224 ) {
1225 if notification_arns.is_empty() {
1226 return;
1227 }
1228 let message = format!(
1229 "StackId='{}'\nTimestamp='{}'\nEventId='{}'\nLogicalResourceId='{}'\nResourceStatus='{}'\nResourceType='AWS::CloudFormation::Stack'\nStackName='{}'",
1230 stack_id,
1231 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
1232 uuid::Uuid::new_v4(),
1233 stack_name,
1234 status,
1235 stack_name,
1236 );
1237 for arn in notification_arns {
1238 delivery.publish_to_sns(arn, &message, Some("AWS CloudFormation Notification"));
1239 }
1240 }
1241
1242 pub(crate) fn collect_account_imports(
1247 state: &SharedCloudFormationState,
1248 account_id: &str,
1249 skip_stack: Option<&str>,
1250 ) -> BTreeMap<String, String> {
1251 let mut imports = BTreeMap::new();
1252 let accounts = state.read();
1253 let Some(state) = accounts.get(account_id) else {
1254 return imports;
1255 };
1256 for (name, export) in &state.exports {
1257 if matches!(skip_stack, Some(skip) if skip == export.exporting_stack_name) {
1258 continue;
1259 }
1260 imports.insert(name.clone(), export.value.clone());
1261 }
1262 imports
1263 }
1264
1265 fn validate_import_values(
1270 state: &SharedCloudFormationState,
1271 account_id: &str,
1272 stack_name: &str,
1273 template_body: &str,
1274 parameters: &BTreeMap<String, String>,
1275 ) -> Result<Vec<String>, AwsServiceError> {
1276 let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
1277 match serde_json::from_str(template_body) {
1278 Ok(v) => v,
1279 Err(_) => return Ok(Vec::new()),
1280 }
1281 } else {
1282 match serde_yaml::from_str(template_body) {
1283 Ok(v) => v,
1284 Err(_) => return Ok(Vec::new()),
1285 }
1286 };
1287 let names = template::collect_import_value_names(&value, parameters);
1288 let known = Self::collect_account_imports(state, account_id, Some(stack_name));
1289 for n in &names {
1290 if !known.contains_key(n) {
1291 return Err(AwsServiceError::aws_error(
1296 StatusCode::BAD_REQUEST,
1297 "InsufficientCapabilitiesException",
1298 format!("No export named {n} found."),
1299 ));
1300 }
1301 }
1302 Ok(names)
1303 }
1304
1305 pub(crate) fn sync_exports_imports(
1309 state: &mut CloudFormationState,
1310 stack_id: &str,
1311 stack_name: &str,
1312 outputs: &[state::StackOutput],
1313 imported_names: &[String],
1314 ) {
1315 let stale_exports: Vec<String> = state
1317 .exports
1318 .iter()
1319 .filter(|(_, e)| e.exporting_stack_name == stack_name)
1320 .map(|(k, _)| k.clone())
1321 .collect();
1322 for k in stale_exports {
1323 state.exports.remove(&k);
1324 }
1325 for entries in state.imports.values_mut() {
1327 entries.retain(|s| s != stack_name);
1328 }
1329 state.imports.retain(|_, v| !v.is_empty());
1330
1331 for o in outputs {
1333 if let Some(export) = &o.export_name {
1334 state.exports.insert(
1335 export.clone(),
1336 state::StackExport {
1337 value: o.value.clone(),
1338 exporting_stack_id: stack_id.to_string(),
1339 exporting_stack_name: stack_name.to_string(),
1340 },
1341 );
1342 }
1343 }
1344 for name in imported_names {
1346 let entry = state.imports.entry(name.clone()).or_default();
1347 if !entry.iter().any(|s| s == stack_name) {
1348 entry.push(stack_name.to_string());
1349 }
1350 }
1351 }
1352
1353 pub(crate) fn resolve_template_outputs(
1358 template_body: &str,
1359 parameters: &BTreeMap<String, String>,
1360 resources: &[StackResource],
1361 state: &SharedCloudFormationState,
1362 ) -> Vec<state::StackOutput> {
1363 let value: serde_json::Value = if template_body.trim_start().starts_with('{') {
1364 match serde_json::from_str(template_body) {
1365 Ok(v) => v,
1366 Err(_) => return Vec::new(),
1367 }
1368 } else {
1369 match serde_yaml::from_str(template_body) {
1370 Ok(v) => v,
1371 Err(_) => return Vec::new(),
1372 }
1373 };
1374
1375 let resources_obj = match value.get("Resources").and_then(|v| v.as_object()) {
1376 Some(o) => o.clone(),
1377 None => return Vec::new(),
1378 };
1379
1380 let mut physical_ids: BTreeMap<String, String> = BTreeMap::new();
1381 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
1382 for r in resources {
1383 physical_ids.insert(r.logical_id.clone(), r.physical_id.clone());
1384 attributes.insert(r.logical_id.clone(), r.attributes.clone());
1385 }
1386
1387 let imports = {
1388 let accounts = state.read();
1389 let mut out = BTreeMap::new();
1390 for (_account, st) in accounts.iter() {
1393 for (name, export) in &st.exports {
1394 out.insert(name.clone(), export.value.clone());
1395 }
1396 }
1397 out
1398 };
1399
1400 let parsed = match template::parse_outputs(
1401 &value,
1402 parameters,
1403 &resources_obj,
1404 &physical_ids,
1405 &attributes,
1406 &imports,
1407 ) {
1408 Ok(o) => o,
1409 Err(_) => return Vec::new(),
1410 };
1411
1412 parsed
1413 .into_iter()
1414 .map(|o| state::StackOutput {
1415 key: o.logical_id,
1416 value: o.value,
1417 description: o.description,
1418 export_name: o.export_name,
1419 })
1420 .collect()
1421 }
1422
1423 fn ensure_export_uniqueness(
1426 state: &SharedCloudFormationState,
1427 account_id: &str,
1428 stack_name: &str,
1429 outputs: &[state::StackOutput],
1430 ) -> Result<(), AwsServiceError> {
1431 let existing = Self::collect_account_imports(state, account_id, Some(stack_name));
1432 for o in outputs {
1433 if let Some(export) = &o.export_name {
1434 if existing.contains_key(export) {
1435 return Err(AwsServiceError::aws_error(
1439 StatusCode::BAD_REQUEST,
1440 "AlreadyExistsException",
1441 format!("Export with name {export} is already exported by another stack"),
1442 ));
1443 }
1444 }
1445 }
1446 Ok(())
1447 }
1448
1449 async fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1450 let params = Self::get_all_params(req);
1451
1452 let stack_name = params.get("StackName").ok_or_else(|| {
1455 AwsServiceError::aws_error(
1456 StatusCode::BAD_REQUEST,
1457 "ValidationError",
1458 "StackName is required",
1459 )
1460 })?;
1461
1462 let empty = String::new();
1466 let template_body = params.get("TemplateBody").unwrap_or(&empty);
1467
1468 {
1470 let accounts = self.state.read();
1471 let empty = CloudFormationState::new(&req.account_id, &req.region);
1472 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1473 if let Some(existing) = state.stacks.get(stack_name.as_str()) {
1474 if existing.status != "DELETE_COMPLETE" {
1475 return Err(AwsServiceError::aws_error(
1476 StatusCode::BAD_REQUEST,
1477 "AlreadyExistsException",
1478 format!("Stack [{stack_name}] already exists"),
1479 ));
1480 }
1481 }
1482 }
1483
1484 let tags = Self::extract_tags(¶ms);
1485 let mut parameters = Self::extract_parameters(¶ms);
1486 Self::merge_parameter_defaults(&mut parameters, template_body);
1487 let notification_arns = Self::extract_notification_arns(¶ms);
1488
1489 let stack_id = format!(
1492 "arn:aws:cloudformation:{}:{}:stack/{}/{}",
1493 req.region,
1494 req.account_id,
1495 stack_name,
1496 uuid::Uuid::new_v4()
1497 );
1498 parameters
1499 .entry("AWS::Region".to_string())
1500 .or_insert_with(|| req.region.clone());
1501 parameters
1502 .entry("AWS::AccountId".to_string())
1503 .or_insert_with(|| req.account_id.clone());
1504 parameters
1505 .entry("AWS::StackId".to_string())
1506 .or_insert_with(|| stack_id.clone());
1507 parameters
1508 .entry("AWS::StackName".to_string())
1509 .or_insert_with(|| stack_name.clone());
1510 parameters
1511 .entry("AWS::Partition".to_string())
1512 .or_insert_with(|| template::partition_for_region(&req.region).to_string());
1513 parameters
1514 .entry("AWS::URLSuffix".to_string())
1515 .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
1516 parameters.insert(
1520 "AWS::NotificationARNs".to_string(),
1521 serde_json::to_string(¬ification_arns).unwrap_or_else(|_| "[]".to_string()),
1522 );
1523
1524 let parsed = template::parse_template(template_body, ¶meters).unwrap_or_else(|_| {
1529 template::ParsedTemplate {
1530 description: None,
1531 resources: Vec::new(),
1532 outputs: Vec::new(),
1533 }
1534 });
1535
1536 let imported_names = Self::validate_import_values(
1540 &self.state,
1541 &req.account_id,
1542 stack_name,
1543 template_body,
1544 ¶meters,
1545 )?;
1546
1547 {
1554 let mut accounts = self.state.write();
1555 let state = accounts.get_or_create(&req.account_id);
1556 state.stacks.insert(
1557 stack_name.clone(),
1558 Stack {
1559 name: stack_name.clone(),
1560 stack_id: stack_id.clone(),
1561 template: template_body.clone(),
1562 status: "CREATE_IN_PROGRESS".to_string(),
1563 resources: Vec::new(),
1564 parameters: parameters.clone(),
1565 tags: tags.clone(),
1566 created_at: Utc::now(),
1567 updated_at: None,
1568 description: parsed.description.clone(),
1569 notification_arns: notification_arns.clone(),
1570 outputs: Vec::new(),
1571 },
1572 );
1573 record_stack_status_event(
1574 state,
1575 &stack_id,
1576 stack_name,
1577 "AWS::CloudFormation::Stack",
1578 "CREATE_IN_PROGRESS",
1579 );
1580 }
1581
1582 let ctx = CreateStackContext {
1583 state: self.state.clone(),
1584 delivery: self.deps.delivery.clone(),
1585 snapshot_store: self.snapshot_store.clone(),
1586 snapshot_lock: self.snapshot_lock.clone(),
1587 snapshot_hooks: self.snapshot_hooks.clone(),
1588 provisioner: self.provisioner(&stack_id, &req.account_id, &req.region),
1589 account_id: req.account_id.clone(),
1590 stack_name: stack_name.clone(),
1591 stack_id: stack_id.clone(),
1592 template_body: template_body.clone(),
1593 parameters,
1594 notification_arns,
1595 imported_names,
1596 resource_defs: parsed.resources,
1597 };
1598
1599 let has_custom_resource = ctx.resource_defs.iter().any(|r| {
1615 r.resource_type.starts_with("Custom::")
1616 || r.resource_type == "AWS::CloudFormation::CustomResource"
1617 });
1618 let multi_thread = matches!(
1619 tokio::runtime::Handle::try_current().map(|h| h.runtime_flavor()),
1620 Ok(tokio::runtime::RuntimeFlavor::MultiThread)
1621 );
1622 if has_custom_resource && multi_thread {
1623 Self::send_stack_notification(
1628 &self.deps.delivery,
1629 &ctx.notification_arns,
1630 stack_name,
1631 &stack_id,
1632 "CREATE_IN_PROGRESS",
1633 );
1634 tokio::spawn(async move {
1635 Self::finish_create_stack(ctx).await;
1636 });
1637 } else {
1638 Self::finish_create_stack(ctx).await;
1639 }
1640
1641 Ok(AwsResponse::xml(
1642 StatusCode::OK,
1643 xml_responses::create_stack_response(&stack_id, &req.request_id),
1644 ))
1645 }
1646
1647 async fn finish_create_stack(ctx: CreateStackContext) {
1653 let CreateStackContext {
1654 state,
1655 delivery,
1656 snapshot_store,
1657 snapshot_lock,
1658 snapshot_hooks,
1659 provisioner,
1660 account_id,
1661 stack_name,
1662 stack_id,
1663 template_body,
1664 parameters,
1665 notification_arns,
1666 imported_names,
1667 resource_defs,
1668 } = ctx;
1669
1670 let container_spawns = provisioner.pending_container_spawns.clone();
1674 let backing_handles = ContainerBackingHandles::from_provisioner(&provisioner);
1675
1676 let provision_result = {
1680 let template_body = template_body.clone();
1681 let parameters = parameters.clone();
1682 let imports = Self::collect_account_imports(&state, &account_id, Some(&stack_name));
1686 tokio::task::spawn_blocking(move || {
1687 provision_stack_resources(
1688 &provisioner,
1689 &resource_defs,
1690 &template_body,
1691 ¶meters,
1692 &imports,
1693 )
1694 })
1695 .await
1696 };
1697
1698 let provisioned = match provision_result {
1701 Ok(Ok(resources)) => Ok(resources),
1702 Ok(Err(err)) => Err(err.message()),
1703 Err(join_err) => Err(format!("provisioning task failed: {join_err}")),
1704 };
1705
1706 let resources = match provisioned {
1707 Ok(resources) => resources,
1708 Err(reason) => {
1709 Self::mark_create_failed(
1710 &state,
1711 &delivery,
1712 &account_id,
1713 &stack_name,
1714 &stack_id,
1715 ¬ification_arns,
1716 &reason,
1717 );
1718 save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
1719 return;
1720 }
1721 };
1722
1723 backing_handles.spawn_container_intents(std::mem::take(&mut *container_spawns.lock()));
1729
1730 let outputs =
1731 Self::resolve_template_outputs(&template_body, ¶meters, &resources, &state);
1732
1733 if let Err(err) = Self::ensure_export_uniqueness(&state, &account_id, &stack_name, &outputs)
1736 {
1737 Self::mark_create_failed(
1738 &state,
1739 &delivery,
1740 &account_id,
1741 &stack_name,
1742 &stack_id,
1743 ¬ification_arns,
1744 &err.message(),
1745 );
1746 save_snapshot_static(state.clone(), snapshot_store, snapshot_lock).await;
1747 return;
1748 }
1749
1750 {
1751 let mut accounts = state.write();
1752 let st = accounts.get_or_create(&account_id);
1753 if let Some(stack) = st.stacks.get_mut(&stack_name) {
1754 stack.status = "CREATE_COMPLETE".to_string();
1755 stack.resources = resources.clone();
1756 stack.outputs = outputs.clone();
1757 }
1758 Self::sync_exports_imports(st, &stack_id, &stack_name, &outputs, &imported_names);
1759
1760 let changes: Vec<ResourceChange> = resources
1761 .iter()
1762 .map(|r| ResourceChange {
1763 action: ResourceChangeAction::Create,
1764 logical_id: r.logical_id.clone(),
1765 physical_id: r.physical_id.clone(),
1766 resource_type: r.resource_type.clone(),
1767 })
1768 .collect();
1769 record_stack_events(st, &stack_id, &stack_name, &changes);
1770 record_stack_status_event(
1771 st,
1772 &stack_id,
1773 &stack_name,
1774 "AWS::CloudFormation::Stack",
1775 "CREATE_COMPLETE",
1776 );
1777 }
1778
1779 Self::send_stack_notification(
1780 &delivery,
1781 ¬ification_arns,
1782 &stack_name,
1783 &stack_id,
1784 "CREATE_COMPLETE",
1785 );
1786
1787 save_snapshot_static(state, snapshot_store, snapshot_lock).await;
1788 persist_touched_services(
1793 &snapshot_hooks,
1794 resources.iter().map(|r| r.resource_type.clone()),
1795 )
1796 .await;
1797 }
1798
1799 fn mark_create_failed(
1803 state: &SharedCloudFormationState,
1804 delivery: &DeliveryBus,
1805 account_id: &str,
1806 stack_name: &str,
1807 stack_id: &str,
1808 notification_arns: &[String],
1809 reason: &str,
1810 ) {
1811 tracing::warn!(%stack_name, %reason, "CreateStack provisioning failed");
1812 {
1813 let mut accounts = state.write();
1814 let st = accounts.get_or_create(account_id);
1815 if let Some(stack) = st.stacks.get_mut(stack_name) {
1816 stack.status = "CREATE_FAILED".to_string();
1817 }
1818 record_stack_status_event(
1819 st,
1820 stack_id,
1821 stack_name,
1822 "AWS::CloudFormation::Stack",
1823 "CREATE_FAILED",
1824 );
1825 }
1826 Self::send_stack_notification(
1827 delivery,
1828 notification_arns,
1829 stack_name,
1830 stack_id,
1831 "CREATE_FAILED",
1832 );
1833 }
1834
1835 async fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1836 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
1837 AwsServiceError::aws_error(
1838 StatusCode::BAD_REQUEST,
1839 "ValidationError",
1840 "StackName is required",
1841 )
1842 })?;
1843
1844 let mut deleted_types: Vec<String> = Vec::new();
1850 {
1851 let mut accounts = self.state.write();
1852 let state = accounts.get_or_create(&req.account_id);
1853
1854 let stack = state.stacks.values_mut().find(|s| {
1856 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
1857 });
1858
1859 if let Some(stack) = stack {
1860 let stack_id = stack.stack_id.clone();
1861 let stack_name_for_notif = stack.name.clone();
1862 let notification_arns = stack.notification_arns.clone();
1863 let resources: Vec<_> = stack.resources.clone();
1864
1865 let owned_exports: Vec<String> = state
1868 .exports
1869 .iter()
1870 .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
1871 .map(|(k, _)| k.clone())
1872 .collect();
1873 for export in &owned_exports {
1874 if let Some(consumers) = state.imports.get(export) {
1875 let consumers: Vec<&String> = consumers
1876 .iter()
1877 .filter(|c| **c != stack_name_for_notif)
1878 .collect();
1879 if !consumers.is_empty() {
1880 let names: Vec<&str> = consumers.iter().map(|s| s.as_str()).collect();
1881 return Err(AwsServiceError::aws_error(
1888 StatusCode::BAD_REQUEST,
1889 "TokenAlreadyExistsException",
1890 format!(
1891 "Export {export} cannot be deleted as it is in use by {}",
1892 names.join(", ")
1893 ),
1894 ));
1895 }
1896 }
1897 }
1898
1899 drop(accounts);
1902 let provisioner =
1906 self.provisioner_deferred(&stack_id, &req.account_id, &req.region);
1907
1908 for resource in resources.iter().rev() {
1910 let _ = provisioner.delete_resource(resource);
1911 }
1912
1913 ContainerBackingHandles::from_provisioner(&provisioner).spawn_teardown_intents(
1919 std::mem::take(&mut *provisioner.pending_container_teardowns.lock()),
1920 );
1921 spawn_custom_invokes(&provisioner);
1922
1923 let mut accounts = self.state.write();
1925 let state = accounts.get_or_create(&req.account_id);
1926 if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
1927 stack.status = "DELETE_COMPLETE".to_string();
1928 stack.resources.clear();
1929 stack.outputs.clear();
1930 }
1931 let stale_exports: Vec<String> = state
1933 .exports
1934 .iter()
1935 .filter(|(_, e)| e.exporting_stack_name == stack_name_for_notif)
1936 .map(|(k, _)| k.clone())
1937 .collect();
1938 for k in stale_exports {
1939 state.exports.remove(&k);
1940 }
1941 for entries in state.imports.values_mut() {
1942 entries.retain(|s| s != &stack_name_for_notif);
1943 }
1944 state.imports.retain(|_, v| !v.is_empty());
1945 drop(accounts);
1946
1947 Self::send_stack_notification(
1948 &self.deps.delivery,
1949 ¬ification_arns,
1950 &stack_name_for_notif,
1951 &stack_id,
1952 "DELETE_COMPLETE",
1953 );
1954
1955 deleted_types = resources.iter().map(|r| r.resource_type.clone()).collect();
1956 }
1957 }
1958
1959 persist_touched_services(&self.snapshot_hooks, deleted_types).await;
1963
1964 Ok(AwsResponse::xml(
1965 StatusCode::OK,
1966 xml_responses::delete_stack_response(&req.request_id),
1967 ))
1968 }
1969
1970 fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1971 let stack_name = Self::get_param(req, "StackName");
1972
1973 let accounts = self.state.read();
1974 let empty = CloudFormationState::new(&req.account_id, &req.region);
1975 let state = accounts.get(&req.account_id).unwrap_or(&empty);
1976 let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
1977 state
1978 .stacks
1979 .values()
1980 .filter(|s| {
1981 (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
1982 })
1983 .cloned()
1984 .collect()
1985 } else {
1986 state
1987 .stacks
1988 .values()
1989 .filter(|s| s.status != "DELETE_COMPLETE")
1990 .cloned()
1991 .collect()
1992 };
1993
1994 if let Some(ref name) = stack_name {
2005 if stacks.is_empty() {
2006 return Err(AwsServiceError::aws_error(
2007 StatusCode::BAD_REQUEST,
2008 "ValidationError",
2009 format!("Stack with id {name} does not exist"),
2010 ));
2011 }
2012 }
2013
2014 Ok(AwsResponse::xml(
2015 StatusCode::OK,
2016 xml_responses::describe_stacks_response(&stacks, &req.request_id),
2017 ))
2018 }
2019
2020 fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2021 let accounts = self.state.read();
2022 let empty = CloudFormationState::new(&req.account_id, &req.region);
2023 let state = accounts.get(&req.account_id).unwrap_or(&empty);
2024 let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
2025
2026 Ok(AwsResponse::xml(
2027 StatusCode::OK,
2028 xml_responses::list_stacks_response(&stacks, &req.request_id),
2029 ))
2030 }
2031
2032 fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2033 let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
2039 AwsServiceError::aws_error(
2040 StatusCode::BAD_REQUEST,
2041 "ValidationError",
2042 "StackName is required",
2043 )
2044 })?;
2045
2046 let accounts = self.state.read();
2047 let empty = CloudFormationState::new(&req.account_id, &req.region);
2048 let state = accounts.get(&req.account_id).unwrap_or(&empty);
2049 let resources = state
2050 .stacks
2051 .values()
2052 .find(|s| {
2053 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
2054 })
2055 .map(|s| s.resources.clone())
2056 .unwrap_or_default();
2057
2058 Ok(AwsResponse::xml(
2059 StatusCode::OK,
2060 xml_responses::list_stack_resources_response(&resources, &req.request_id),
2061 ))
2062 }
2063
2064 fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2065 let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
2068
2069 let accounts = self.state.read();
2070 let empty = CloudFormationState::new(&req.account_id, &req.region);
2071 let state = accounts.get(&req.account_id).unwrap_or(&empty);
2072 let (resources, resolved_name) = state
2073 .stacks
2074 .values()
2075 .find(|s| {
2076 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
2077 })
2078 .map(|s| (s.resources.clone(), s.name.clone()))
2079 .unwrap_or_else(|| (Vec::new(), stack_name.clone()));
2080
2081 Ok(AwsResponse::xml(
2082 StatusCode::OK,
2083 xml_responses::describe_stack_resources_response(
2084 &resources,
2085 &resolved_name,
2086 &req.request_id,
2087 ),
2088 ))
2089 }
2090
2091 async fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2092 let mut input = UpdateStackInput::from_params(req)?;
2093
2094 let found_stack_id = {
2096 let accounts = self.state.read();
2097 let empty = CloudFormationState::new(&req.account_id, &req.region);
2098 let state = accounts.get(&req.account_id).unwrap_or(&empty);
2099 state
2100 .stacks
2101 .values()
2102 .find(|s| {
2103 (s.name == input.stack_name || s.stack_id == input.stack_name)
2104 && s.status != "DELETE_COMPLETE"
2105 })
2106 .map(|s| s.stack_id.clone())
2107 .unwrap_or_default()
2108 };
2109
2110 input
2114 .parameters
2115 .entry("AWS::Region".to_string())
2116 .or_insert_with(|| req.region.clone());
2117 input
2118 .parameters
2119 .entry("AWS::AccountId".to_string())
2120 .or_insert_with(|| req.account_id.clone());
2121 input
2122 .parameters
2123 .entry("AWS::StackId".to_string())
2124 .or_insert_with(|| found_stack_id.clone());
2125 input
2126 .parameters
2127 .entry("AWS::StackName".to_string())
2128 .or_insert_with(|| input.stack_name.clone());
2129 input
2130 .parameters
2131 .entry("AWS::Partition".to_string())
2132 .or_insert_with(|| template::partition_for_region(&req.region).to_string());
2133 input
2134 .parameters
2135 .entry("AWS::URLSuffix".to_string())
2136 .or_insert_with(|| template::url_suffix_for_region(&req.region).to_string());
2137 if !input.notification_arns.is_empty() {
2142 input.parameters.insert(
2143 "AWS::NotificationARNs".to_string(),
2144 serde_json::to_string(&input.notification_arns)
2145 .unwrap_or_else(|_| "[]".to_string()),
2146 );
2147 } else {
2148 let existing: Vec<String> = {
2151 let accounts = self.state.read();
2152 accounts
2153 .get(&req.account_id)
2154 .and_then(|s| {
2155 s.stacks
2156 .values()
2157 .find(|st| st.stack_id == found_stack_id)
2158 .map(|st| st.notification_arns.clone())
2159 })
2160 .unwrap_or_default()
2161 };
2162 input.parameters.insert(
2163 "AWS::NotificationARNs".to_string(),
2164 serde_json::to_string(&existing).unwrap_or_else(|_| "[]".to_string()),
2165 );
2166 }
2167
2168 let parsed = template::parse_template(&input.template_body, &input.parameters)
2173 .unwrap_or_else(|_| template::ParsedTemplate {
2174 description: None,
2175 resources: Vec::new(),
2176 outputs: Vec::new(),
2177 });
2178
2179 let imported_names = Self::validate_import_values(
2180 &self.state,
2181 &req.account_id,
2182 &input.stack_name,
2183 &input.template_body,
2184 &input.parameters,
2185 )?;
2186
2187 let provisioner = self.provisioner_deferred(&found_stack_id, &req.account_id, &req.region);
2193
2194 let imports =
2198 Self::collect_account_imports(&self.state, &req.account_id, Some(&input.stack_name));
2199
2200 let (touched_types, stack_id, stack_name_for_notif, notification_arns, resources_snapshot) = {
2205 let mut accounts = self.state.write();
2206 let state = accounts.get_or_create(&req.account_id);
2207 let stack_exists = state.stacks.values().any(|s| {
2216 (s.name == input.stack_name || s.stack_id == input.stack_name)
2217 && s.status != "DELETE_COMPLETE"
2218 });
2219 if !stack_exists {
2220 let stack_id = if found_stack_id.is_empty() {
2221 format!(
2222 "arn:aws:cloudformation:{}:{}:stack/{}/{}",
2223 req.region,
2224 req.account_id,
2225 input.stack_name,
2226 uuid::Uuid::new_v4()
2227 )
2228 } else {
2229 found_stack_id.clone()
2230 };
2231 return Ok(AwsResponse::xml(
2232 StatusCode::OK,
2233 xml_responses::update_stack_response(&stack_id, &req.request_id),
2234 ));
2235 }
2236 let (update_result, stack_id, stack_name_owned, resources_snapshot, notification_arns) = {
2237 let stack = state
2238 .stacks
2239 .values_mut()
2240 .find(|s| {
2241 (s.name == input.stack_name || s.stack_id == input.stack_name)
2242 && s.status != "DELETE_COMPLETE"
2243 })
2244 .expect("stack existence checked above");
2245
2246 stack.status = "UPDATE_IN_PROGRESS".to_string();
2247 let update_result = apply_resource_updates(
2248 stack,
2249 &parsed.resources,
2250 &input.template_body,
2251 &input.parameters,
2252 &provisioner,
2253 &imports,
2254 );
2255
2256 let stack_id = stack.stack_id.clone();
2257 let stack_name_owned = stack.name.clone();
2258 stack.template = input.template_body.clone();
2259 stack.status = if update_result.is_err() {
2260 "UPDATE_ROLLBACK_COMPLETE".to_string()
2261 } else {
2262 "UPDATE_COMPLETE".to_string()
2263 };
2264 stack.parameters = input.parameters.clone();
2265 if !input.tags.is_empty() {
2266 stack.tags = input.tags;
2267 }
2268 stack.updated_at = Some(Utc::now());
2269 stack.description = parsed.description;
2270 if !input.notification_arns.is_empty() {
2271 stack.notification_arns = input.notification_arns.clone();
2272 }
2273 if update_result.is_ok() {
2274 stack.outputs.clear();
2275 }
2276 (
2277 update_result,
2278 stack_id,
2279 stack_name_owned,
2280 stack.resources.clone(),
2281 stack.notification_arns.clone(),
2282 )
2283 };
2284
2285 record_stack_status_event(
2287 state,
2288 &stack_id,
2289 &stack_name_owned,
2290 "AWS::CloudFormation::Stack",
2291 "UPDATE_IN_PROGRESS",
2292 );
2293 let update_result = match update_result {
2294 Ok(changes) => {
2295 let touched_types: Vec<String> =
2299 changes.iter().map(|c| c.resource_type.clone()).collect();
2300 record_stack_events(state, &stack_id, &stack_name_owned, &changes);
2301 record_stack_status_event(
2302 state,
2303 &stack_id,
2304 &stack_name_owned,
2305 "AWS::CloudFormation::Stack",
2306 "UPDATE_COMPLETE",
2307 );
2308 Ok(touched_types)
2309 }
2310 Err(e) => {
2311 record_stack_status_event(
2312 state,
2313 &stack_id,
2314 &stack_name_owned,
2315 "AWS::CloudFormation::Stack",
2316 "UPDATE_ROLLBACK_COMPLETE",
2317 );
2318 Err(e)
2319 }
2320 };
2321 let stack_name_for_notif = stack_name_owned.clone();
2322
2323 let touched_types = match update_result {
2324 Ok(types) => types,
2325 Err(error_msg) => {
2326 drop(accounts);
2327 Self::send_stack_notification(
2328 &self.deps.delivery,
2329 ¬ification_arns,
2330 &stack_name_for_notif,
2331 &stack_id,
2332 "UPDATE_FAILED",
2333 );
2334 return Err(AwsServiceError::aws_error(
2335 StatusCode::BAD_REQUEST,
2336 "InsufficientCapabilitiesException",
2337 error_msg,
2338 ));
2339 }
2340 };
2341
2342 drop(accounts);
2343 (
2344 touched_types,
2345 stack_id,
2346 stack_name_for_notif,
2347 notification_arns,
2348 resources_snapshot,
2349 )
2350 };
2351
2352 {
2358 let handles = ContainerBackingHandles::from_provisioner(&provisioner);
2359 handles.spawn_container_intents(std::mem::take(
2360 &mut *provisioner.pending_container_spawns.lock(),
2361 ));
2362 handles.spawn_teardown_intents(std::mem::take(
2363 &mut *provisioner.pending_container_teardowns.lock(),
2364 ));
2365 spawn_custom_invokes(&provisioner);
2366 }
2367
2368 let outputs = Self::resolve_template_outputs(
2369 &input.template_body,
2370 &input.parameters,
2371 &resources_snapshot,
2372 &self.state,
2373 );
2374 Self::ensure_export_uniqueness(&self.state, &req.account_id, &input.stack_name, &outputs)?;
2375 {
2376 let mut accounts = self.state.write();
2377 let state = accounts.get_or_create(&req.account_id);
2378 if let Some(stack) = state
2379 .stacks
2380 .values_mut()
2381 .find(|s| s.stack_id == stack_id && s.status != "DELETE_COMPLETE")
2382 {
2383 stack.outputs = outputs.clone();
2384 }
2385 Self::sync_exports_imports(
2386 state,
2387 &stack_id,
2388 &input.stack_name,
2389 &outputs,
2390 &imported_names,
2391 );
2392 }
2393
2394 Self::send_stack_notification(
2395 &self.deps.delivery,
2396 ¬ification_arns,
2397 &stack_name_for_notif,
2398 &stack_id,
2399 "UPDATE_COMPLETE",
2400 );
2401
2402 persist_touched_services(&self.snapshot_hooks, touched_types).await;
2405
2406 Ok(AwsResponse::xml(
2407 StatusCode::OK,
2408 xml_responses::update_stack_response(&stack_id, &req.request_id),
2409 ))
2410 }
2411
2412 fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2413 let stack_name = Self::get_param(req, "StackName").unwrap_or_default();
2415
2416 let accounts = self.state.read();
2417 let empty = CloudFormationState::new(&req.account_id, &req.region);
2418 let state = accounts.get(&req.account_id).unwrap_or(&empty);
2419 let body = state
2424 .stacks
2425 .values()
2426 .find(|s| {
2427 (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
2428 })
2429 .map(|s| s.template.clone())
2430 .unwrap_or_default();
2431
2432 Ok(AwsResponse::xml(
2433 StatusCode::OK,
2434 xml_responses::get_template_response(&body, &req.request_id),
2435 ))
2436 }
2437}
2438
2439#[async_trait]
2440impl AwsService for CloudFormationService {
2441 fn service_name(&self) -> &str {
2442 "cloudformation"
2443 }
2444
2445 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2446 let action = req.action.as_str();
2447
2448 crate::input_constraints::validate_input(action, &Self::get_all_params(&req))?;
2455
2456 let mutates = matches!(
2460 action,
2461 "CreateStack"
2462 | "DeleteStack"
2463 | "UpdateStack"
2464 | "CreateChangeSet"
2465 | "DeleteChangeSet"
2466 | "ExecuteChangeSet"
2467 | "CreateStackSet"
2468 | "DeleteStackSet"
2469 | "CreateStackRefactor"
2470 | "CreateGeneratedTemplate"
2471 | "DeleteGeneratedTemplate"
2472 | "SetStackPolicy"
2473 | "UpdateTerminationProtection"
2474 | "ActivateOrganizationsAccess"
2475 | "DeactivateOrganizationsAccess"
2476 );
2477 let result = match action {
2478 "CreateStack" => self.create_stack(&req).await,
2479 "DeleteStack" => self.delete_stack(&req).await,
2480 "DescribeStacks" => self.describe_stacks(&req),
2481 "ListStacks" => self.list_stacks(&req),
2482 "ListStackResources" => self.list_stack_resources(&req),
2483 "DescribeStackResources" => self.describe_stack_resources(&req),
2484 "UpdateStack" => self.update_stack(&req).await,
2485 "GetTemplate" => self.get_template(&req),
2486 _ => self.handle_extra_action(&req),
2487 };
2488 if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
2489 self.save_snapshot().await;
2490 }
2491 if action == "ExecuteChangeSet"
2501 && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
2502 {
2503 for hook in self.snapshot_hooks.values() {
2504 hook().await;
2505 }
2506 }
2507 result
2508 }
2509
2510 fn supported_actions(&self) -> &[&str] {
2511 &[
2512 "ActivateOrganizationsAccess",
2513 "ActivateType",
2514 "BatchDescribeTypeConfigurations",
2515 "CancelUpdateStack",
2516 "ContinueUpdateRollback",
2517 "CreateChangeSet",
2518 "CreateGeneratedTemplate",
2519 "CreateStack",
2520 "CreateStackInstances",
2521 "CreateStackRefactor",
2522 "CreateStackSet",
2523 "DeactivateOrganizationsAccess",
2524 "DeactivateType",
2525 "DeleteChangeSet",
2526 "DeleteGeneratedTemplate",
2527 "DeleteStack",
2528 "DeleteStackInstances",
2529 "DeleteStackSet",
2530 "DeregisterType",
2531 "DescribeAccountLimits",
2532 "DescribeChangeSet",
2533 "DescribeChangeSetHooks",
2534 "DescribeEvents",
2535 "DescribeGeneratedTemplate",
2536 "DescribeOrganizationsAccess",
2537 "DescribePublisher",
2538 "DescribeResourceScan",
2539 "DescribeStackDriftDetectionStatus",
2540 "DescribeStackEvents",
2541 "DescribeStackInstance",
2542 "DescribeStackRefactor",
2543 "DescribeStackResource",
2544 "DescribeStackResourceDrifts",
2545 "DescribeStackResources",
2546 "DescribeStackSet",
2547 "DescribeStackSetOperation",
2548 "DescribeStacks",
2549 "DescribeType",
2550 "DescribeTypeRegistration",
2551 "DetectStackDrift",
2552 "DetectStackResourceDrift",
2553 "DetectStackSetDrift",
2554 "EstimateTemplateCost",
2555 "ExecuteChangeSet",
2556 "ExecuteStackRefactor",
2557 "GetGeneratedTemplate",
2558 "GetHookResult",
2559 "GetStackPolicy",
2560 "GetTemplate",
2561 "GetTemplateSummary",
2562 "ImportStacksToStackSet",
2563 "ListChangeSets",
2564 "ListExports",
2565 "ListGeneratedTemplates",
2566 "ListHookResults",
2567 "ListImports",
2568 "ListResourceScanRelatedResources",
2569 "ListResourceScanResources",
2570 "ListResourceScans",
2571 "ListStackInstanceResourceDrifts",
2572 "ListStackInstances",
2573 "ListStackRefactorActions",
2574 "ListStackRefactors",
2575 "ListStackResources",
2576 "ListStackSetAutoDeploymentTargets",
2577 "ListStackSetOperationResults",
2578 "ListStackSetOperations",
2579 "ListStackSets",
2580 "ListStacks",
2581 "ListTypeRegistrations",
2582 "ListTypeVersions",
2583 "ListTypes",
2584 "PublishType",
2585 "RecordHandlerProgress",
2586 "RegisterPublisher",
2587 "RegisterType",
2588 "RollbackStack",
2589 "SetStackPolicy",
2590 "SetTypeConfiguration",
2591 "SetTypeDefaultVersion",
2592 "SignalResource",
2593 "StartResourceScan",
2594 "StopStackSetOperation",
2595 "TestType",
2596 "UpdateGeneratedTemplate",
2597 "UpdateStack",
2598 "UpdateStackInstances",
2599 "UpdateStackSet",
2600 "UpdateTerminationProtection",
2601 "ValidateTemplate",
2602 ]
2603 }
2604}
2605
2606struct UpdateStackInput {
2608 stack_name: String,
2609 template_body: String,
2610 parameters: BTreeMap<String, String>,
2611 tags: BTreeMap<String, String>,
2612 notification_arns: Vec<String>,
2613}
2614
2615impl UpdateStackInput {
2616 fn from_params(req: &AwsRequest) -> Result<Self, AwsServiceError> {
2617 let params = CloudFormationService::get_all_params(req);
2618
2619 let stack_name = params
2620 .get("StackName")
2621 .ok_or_else(|| {
2622 AwsServiceError::aws_error(
2623 StatusCode::BAD_REQUEST,
2624 "ValidationError",
2625 "StackName is required",
2626 )
2627 })?
2628 .to_string();
2629
2630 let template_body = params.get("TemplateBody").cloned().unwrap_or_default();
2635
2636 let mut parameters = CloudFormationService::extract_parameters(¶ms);
2637 CloudFormationService::merge_parameter_defaults(&mut parameters, &template_body);
2638 Ok(Self {
2639 stack_name,
2640 template_body,
2641 parameters,
2642 tags: CloudFormationService::extract_tags(¶ms),
2643 notification_arns: CloudFormationService::extract_notification_arns(¶ms),
2644 })
2645 }
2646}
2647
2648#[derive(Debug, Clone)]
2652pub(crate) struct ResourceChange {
2653 pub action: ResourceChangeAction,
2654 pub logical_id: String,
2655 pub physical_id: String,
2656 pub resource_type: String,
2657}
2658
2659#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2660pub(crate) enum ResourceChangeAction {
2661 Create,
2662 Update,
2663 Delete,
2664}
2665
2666impl ResourceChangeAction {
2667 pub fn status_in_progress(self) -> &'static str {
2668 match self {
2669 Self::Create => "CREATE_IN_PROGRESS",
2670 Self::Update => "UPDATE_IN_PROGRESS",
2671 Self::Delete => "DELETE_IN_PROGRESS",
2672 }
2673 }
2674 pub fn status_complete(self) -> &'static str {
2675 match self {
2676 Self::Create => "CREATE_COMPLETE",
2677 Self::Update => "UPDATE_COMPLETE",
2678 Self::Delete => "DELETE_COMPLETE",
2679 }
2680 }
2681}
2682
2683pub(crate) fn apply_resource_updates(
2688 stack: &mut crate::state::Stack,
2689 new_resource_defs: &[template::ResourceDefinition],
2690 template_body: &str,
2691 parameters: &BTreeMap<String, String>,
2692 provisioner: &crate::resource_provisioner::ResourceProvisioner,
2693 imports: &BTreeMap<String, String>,
2694) -> Result<Vec<ResourceChange>, String> {
2695 let mut changes: Vec<ResourceChange> = Vec::new();
2696 let old_logical_ids: std::collections::HashSet<String> = stack
2697 .resources
2698 .iter()
2699 .map(|r| r.logical_id.clone())
2700 .collect();
2701 let new_logical_ids: std::collections::HashSet<String> = new_resource_defs
2702 .iter()
2703 .map(|r| r.logical_id.clone())
2704 .collect();
2705
2706 let to_remove: Vec<_> = stack
2708 .resources
2709 .iter()
2710 .filter(|r| !new_logical_ids.contains(&r.logical_id))
2711 .cloned()
2712 .collect();
2713 for resource in &to_remove {
2714 let _ = provisioner.delete_resource(resource);
2715 changes.push(ResourceChange {
2716 action: ResourceChangeAction::Delete,
2717 logical_id: resource.logical_id.clone(),
2718 physical_id: resource.physical_id.clone(),
2719 resource_type: resource.resource_type.clone(),
2720 });
2721 }
2722 stack
2723 .resources
2724 .retain(|r| new_logical_ids.contains(&r.logical_id));
2725
2726 let mut physical_ids: BTreeMap<String, String> = stack
2728 .resources
2729 .iter()
2730 .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
2731 .collect();
2732 let mut attributes: BTreeMap<String, BTreeMap<String, String>> = stack
2733 .resources
2734 .iter()
2735 .map(|r| (r.logical_id.clone(), r.attributes.clone()))
2736 .collect();
2737
2738 let order = template::dependency_order(template_body, parameters, new_resource_defs);
2744 for &idx in &order {
2745 let resource_def = &new_resource_defs[idx];
2746 let resolved_def = template::resolve_resource_properties_with_attrs(
2747 resource_def,
2748 template_body,
2749 parameters,
2750 &physical_ids,
2751 &attributes,
2752 imports,
2753 )
2754 .map_err(|e| {
2755 format!(
2756 "Failed to resolve resource {}: {e}",
2757 resource_def.logical_id
2758 )
2759 })?;
2760
2761 if !old_logical_ids.contains(&resource_def.logical_id) {
2762 match provisioner.create_resource(&resolved_def) {
2763 Ok(stack_resource) => {
2764 changes.push(ResourceChange {
2765 action: ResourceChangeAction::Create,
2766 logical_id: stack_resource.logical_id.clone(),
2767 physical_id: stack_resource.physical_id.clone(),
2768 resource_type: stack_resource.resource_type.clone(),
2769 });
2770 physical_ids.insert(
2771 stack_resource.logical_id.clone(),
2772 stack_resource.physical_id.clone(),
2773 );
2774 attributes.insert(
2775 stack_resource.logical_id.clone(),
2776 stack_resource.attributes.clone(),
2777 );
2778 stack.resources.push(stack_resource);
2779 }
2780 Err(e) => {
2781 tracing::warn!(
2782 "Failed to create resource {} during update: {e}",
2783 resource_def.logical_id
2784 );
2785 return Err(format!(
2786 "Failed to create resource {}: {e}",
2787 resource_def.logical_id
2788 ));
2789 }
2790 }
2791 } else {
2792 let existing = stack
2798 .resources
2799 .iter()
2800 .find(|r| r.logical_id == resource_def.logical_id)
2801 .cloned();
2802 if let Some(existing) = existing {
2803 match provisioner.update_resource(&existing, &resolved_def) {
2804 Ok(Some(mut updated)) => {
2805 changes.push(ResourceChange {
2806 action: ResourceChangeAction::Update,
2807 logical_id: updated.logical_id.clone(),
2808 physical_id: updated.physical_id.clone(),
2809 resource_type: updated.resource_type.clone(),
2810 });
2811 physical_ids
2812 .insert(updated.logical_id.clone(), updated.physical_id.clone());
2813 let merged = merged_resource_attributes(
2819 provisioner,
2820 &updated,
2821 existing.attributes.clone(),
2822 );
2823 attributes.insert(updated.logical_id.clone(), merged.clone());
2824 updated.attributes = merged;
2825 if let Some(slot) = stack
2826 .resources
2827 .iter_mut()
2828 .find(|r| r.logical_id == updated.logical_id)
2829 {
2830 *slot = updated;
2831 }
2832 }
2833 Ok(None) => {
2834 }
2837 Err(e) => {
2838 tracing::warn!(
2839 "Failed to update resource {} during update: {e}",
2840 resource_def.logical_id
2841 );
2842 return Err(format!(
2843 "Failed to update resource {}: {e}",
2844 resource_def.logical_id
2845 ));
2846 }
2847 }
2848 }
2849 }
2850 }
2851
2852 Ok(changes)
2853}
2854
2855pub(crate) fn record_event(
2859 state: &mut crate::state::CloudFormationState,
2860 stack_id: &str,
2861 stack_name: &str,
2862 logical_id: &str,
2863 physical_id: &str,
2864 resource_type: &str,
2865 status: &str,
2866) {
2867 use serde_json::json;
2868 let event_id = format!(
2869 "{}-{:x}",
2870 logical_id,
2871 std::time::SystemTime::now()
2872 .duration_since(std::time::UNIX_EPOCH)
2873 .map(|d| d.as_nanos())
2874 .unwrap_or(0)
2875 );
2876 let log = state.events.entry(stack_id.to_string()).or_default();
2877
2878 let now = chrono::DateTime::from_timestamp_millis(Utc::now().timestamp_millis())
2891 .unwrap_or_else(Utc::now);
2892 let timestamp = match log.last().and_then(|e| e["Timestamp"].as_str()) {
2893 Some(prev) => match chrono::DateTime::parse_from_rfc3339(prev) {
2894 Ok(prev) => {
2895 let prev = prev.with_timezone(&Utc);
2896 if now > prev {
2897 now
2898 } else {
2899 prev + chrono::Duration::milliseconds(1)
2900 }
2901 }
2902 Err(_) => now,
2903 },
2904 None => now,
2905 };
2906
2907 log.push(json!({
2908 "EventId": event_id,
2909 "StackId": stack_id,
2910 "StackName": stack_name,
2911 "LogicalResourceId": logical_id,
2912 "PhysicalResourceId": physical_id,
2913 "ResourceType": resource_type,
2914 "ResourceStatus": status,
2915 "Timestamp": timestamp.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2916 }));
2917}
2918
2919async fn save_snapshot_static(
2927 state: SharedCloudFormationState,
2928 store: Option<Arc<dyn SnapshotStore>>,
2929 lock: Arc<AsyncMutex<()>>,
2930) {
2931 let Some(store) = store else {
2932 return;
2933 };
2934 let _guard = lock.lock().await;
2935 let snapshot = CloudFormationSnapshot {
2936 schema_version: CLOUDFORMATION_SNAPSHOT_SCHEMA_VERSION,
2937 state: None,
2938 accounts: Some(state.read().clone()),
2939 };
2940 let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2941 let bytes = serde_json::to_vec(&snapshot)
2942 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
2943 store.save(&bytes)
2944 })
2945 .await;
2946 match join {
2947 Ok(Ok(())) => {}
2948 Ok(Err(err)) => tracing::error!(%err, "failed to write cloudformation snapshot"),
2949 Err(err) => tracing::error!(%err, "cloudformation snapshot task panicked"),
2950 }
2951}
2952
2953pub(crate) fn record_stack_events(
2954 state: &mut crate::state::CloudFormationState,
2955 stack_id: &str,
2956 stack_name: &str,
2957 changes: &[ResourceChange],
2958) {
2959 for ch in changes {
2960 record_event(
2961 state,
2962 stack_id,
2963 stack_name,
2964 &ch.logical_id,
2965 &ch.physical_id,
2966 &ch.resource_type,
2967 ch.action.status_in_progress(),
2968 );
2969 record_event(
2970 state,
2971 stack_id,
2972 stack_name,
2973 &ch.logical_id,
2974 &ch.physical_id,
2975 &ch.resource_type,
2976 ch.action.status_complete(),
2977 );
2978 }
2979}
2980
2981pub(crate) fn record_stack_status_event(
2985 state: &mut crate::state::CloudFormationState,
2986 stack_id: &str,
2987 stack_name: &str,
2988 resource_type: &str,
2989 status: &str,
2990) {
2991 record_event(
2992 state,
2993 stack_id,
2994 stack_name,
2995 stack_name,
2996 stack_id,
2997 resource_type,
2998 status,
2999 );
3000}
3001
3002#[cfg(test)]
3003mod tests {
3004 use super::*;
3005 use http::HeaderMap;
3006 use parking_lot::RwLock;
3007 use std::collections::HashMap;
3008 use std::sync::Arc;
3009
3010 #[test]
3011 fn merge_parameter_defaults_fills_omitted_params() {
3012 let template = r#"{
3015 "Parameters": {
3016 "InstanceType": {"Type": "String", "Default": "t3.micro"},
3017 "Count": {"Type": "Number", "Default": 3},
3018 "Supplied": {"Type": "String", "Default": "dflt"}
3019 },
3020 "Resources": {}
3021 }"#;
3022 let mut params = BTreeMap::new();
3023 params.insert("Supplied".to_string(), "override".to_string());
3024 CloudFormationService::merge_parameter_defaults(&mut params, template);
3025 assert_eq!(
3026 params.get("InstanceType").map(String::as_str),
3027 Some("t3.micro")
3028 );
3029 assert_eq!(params.get("Count").map(String::as_str), Some("3"));
3030 assert_eq!(params.get("Supplied").map(String::as_str), Some("override"));
3032 }
3033
3034 fn make_service() -> CloudFormationService {
3035 let cf_state = Arc::new(RwLock::new(
3036 fakecloud_core::multi_account::MultiAccountState::new(
3037 "123456789012",
3038 "us-east-1",
3039 "http://localhost:4566",
3040 ),
3041 ));
3042 let deps = CloudFormationDeps {
3043 sqs: Arc::new(RwLock::new(
3044 fakecloud_core::multi_account::MultiAccountState::new(
3045 "123456789012",
3046 "us-east-1",
3047 "http://localhost:4566",
3048 ),
3049 )),
3050 sns: Arc::new(RwLock::new(
3051 fakecloud_core::multi_account::MultiAccountState::new(
3052 "123456789012",
3053 "us-east-1",
3054 "http://localhost:4566",
3055 ),
3056 )),
3057 ssm: Arc::new(RwLock::new(
3058 fakecloud_core::multi_account::MultiAccountState::new(
3059 "123456789012",
3060 "us-east-1",
3061 "http://localhost:4566",
3062 ),
3063 )),
3064 iam: Arc::new(RwLock::new(
3065 fakecloud_core::multi_account::MultiAccountState::new(
3066 "123456789012",
3067 "us-east-1",
3068 "",
3069 ),
3070 )),
3071 s3: Arc::new(RwLock::new(
3072 fakecloud_core::multi_account::MultiAccountState::new(
3073 "123456789012",
3074 "us-east-1",
3075 "",
3076 ),
3077 )),
3078 eventbridge: Arc::new(RwLock::new(
3079 fakecloud_core::multi_account::MultiAccountState::new(
3080 "123456789012",
3081 "us-east-1",
3082 "",
3083 ),
3084 )),
3085 dynamodb: Arc::new(RwLock::new(
3086 fakecloud_core::multi_account::MultiAccountState::new(
3087 "123456789012",
3088 "us-east-1",
3089 "",
3090 ),
3091 )),
3092 logs: Arc::new(RwLock::new(
3093 fakecloud_core::multi_account::MultiAccountState::new(
3094 "123456789012",
3095 "us-east-1",
3096 "",
3097 ),
3098 )),
3099 lambda: Arc::new(RwLock::new(
3100 fakecloud_core::multi_account::MultiAccountState::new(
3101 "123456789012",
3102 "us-east-1",
3103 "",
3104 ),
3105 )),
3106 secretsmanager: Arc::new(RwLock::new(
3107 fakecloud_core::multi_account::MultiAccountState::new(
3108 "123456789012",
3109 "us-east-1",
3110 "",
3111 ),
3112 )),
3113 kinesis: Arc::new(RwLock::new(
3114 fakecloud_core::multi_account::MultiAccountState::new(
3115 "123456789012",
3116 "us-east-1",
3117 "",
3118 ),
3119 )),
3120 kms: Arc::new(RwLock::new(
3121 fakecloud_core::multi_account::MultiAccountState::new(
3122 "123456789012",
3123 "us-east-1",
3124 "",
3125 ),
3126 )),
3127 ecr: Arc::new(RwLock::new(
3128 fakecloud_core::multi_account::MultiAccountState::new(
3129 "123456789012",
3130 "us-east-1",
3131 "",
3132 ),
3133 )),
3134 cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
3135 elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
3136 organizations: Arc::new(RwLock::new(None)),
3137 cognito: Arc::new(RwLock::new(
3138 fakecloud_core::multi_account::MultiAccountState::new(
3139 "123456789012",
3140 "us-east-1",
3141 "",
3142 ),
3143 )),
3144 rds: Arc::new(RwLock::new(
3145 fakecloud_core::multi_account::MultiAccountState::new(
3146 "123456789012",
3147 "us-east-1",
3148 "",
3149 ),
3150 )),
3151 ec2: Arc::new(RwLock::new(
3152 fakecloud_core::multi_account::MultiAccountState::new(
3153 "123456789012",
3154 "us-east-1",
3155 "",
3156 ),
3157 )),
3158 autoscaling: Arc::new(RwLock::new(
3159 fakecloud_autoscaling::AutoScalingAccounts::new(),
3160 )),
3161 batch: Arc::new(RwLock::new(fakecloud_batch::BatchAccounts::new())),
3162 pipes: Arc::new(RwLock::new(fakecloud_pipes::PipesAccounts::new())),
3163 ecs: Arc::new(RwLock::new(
3164 fakecloud_core::multi_account::MultiAccountState::new(
3165 "123456789012",
3166 "us-east-1",
3167 "",
3168 ),
3169 )),
3170 acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
3171 acmpca: Arc::new(RwLock::new(fakecloud_acmpca::AcmPcaAccounts::new())),
3172 config: Arc::new(RwLock::new(fakecloud_config::ConfigAccounts::new())),
3173 route53resolver: Arc::new(RwLock::new(
3174 fakecloud_route53resolver::Route53ResolverAccounts::new(),
3175 )),
3176 elasticache: Arc::new(RwLock::new(
3177 fakecloud_core::multi_account::MultiAccountState::new(
3178 "123456789012",
3179 "us-east-1",
3180 "",
3181 ),
3182 )),
3183 route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
3184 cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
3185 stepfunctions: Arc::new(RwLock::new(
3186 fakecloud_core::multi_account::MultiAccountState::new(
3187 "123456789012",
3188 "us-east-1",
3189 "",
3190 ),
3191 )),
3192 wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
3193 apigateway: Arc::new(RwLock::new(
3194 fakecloud_core::multi_account::MultiAccountState::new(
3195 "123456789012",
3196 "us-east-1",
3197 "",
3198 ),
3199 )),
3200 apigatewayv2: Arc::new(RwLock::new(
3201 fakecloud_core::multi_account::MultiAccountState::new(
3202 "123456789012",
3203 "us-east-1",
3204 "",
3205 ),
3206 )),
3207 ses: Arc::new(RwLock::new(
3208 fakecloud_core::multi_account::MultiAccountState::new(
3209 "123456789012",
3210 "us-east-1",
3211 "",
3212 ),
3213 )),
3214 application_autoscaling: Arc::new(parking_lot::RwLock::new(
3215 fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
3216 )),
3217 athena: Arc::new(parking_lot::RwLock::new(
3218 fakecloud_athena::AthenaAccounts::new(),
3219 )),
3220 firehose: Arc::new(parking_lot::RwLock::new(
3221 fakecloud_firehose::FirehoseAccounts::new(),
3222 )),
3223 glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
3224 eks: Arc::new(parking_lot::RwLock::new(
3225 fakecloud_core::multi_account::MultiAccountState::new(
3226 "123456789012",
3227 "us-east-1",
3228 "",
3229 ),
3230 )),
3231 servicediscovery: Arc::new(parking_lot::RwLock::new(
3232 fakecloud_core::multi_account::MultiAccountState::new(
3233 "123456789012",
3234 "us-east-1",
3235 "",
3236 ),
3237 )),
3238 codeartifact: Arc::new(parking_lot::RwLock::new(
3239 fakecloud_core::multi_account::MultiAccountState::new(
3240 "123456789012",
3241 "us-east-1",
3242 "",
3243 ),
3244 )),
3245 codecommit: Arc::new(parking_lot::RwLock::new(
3246 fakecloud_core::multi_account::MultiAccountState::new(
3247 "123456789012",
3248 "us-east-1",
3249 "",
3250 ),
3251 )),
3252 efs: Arc::new(parking_lot::RwLock::new(
3253 fakecloud_core::multi_account::MultiAccountState::new(
3254 "123456789012",
3255 "us-east-1",
3256 "",
3257 ),
3258 )),
3259 elasticbeanstalk: Arc::new(parking_lot::RwLock::new(
3260 fakecloud_elasticbeanstalk::EbAccounts::new(),
3261 )),
3262 mq: Arc::new(parking_lot::RwLock::new(
3263 fakecloud_core::multi_account::MultiAccountState::new(
3264 "123456789012",
3265 "us-east-1",
3266 "",
3267 ),
3268 )),
3269 kafka: Arc::new(parking_lot::RwLock::new(
3270 fakecloud_core::multi_account::MultiAccountState::new(
3271 "123456789012",
3272 "us-east-1",
3273 "",
3274 ),
3275 )),
3276 kinesisanalyticsv2: Arc::new(parking_lot::RwLock::new(
3277 fakecloud_core::multi_account::MultiAccountState::new(
3278 "123456789012",
3279 "us-east-1",
3280 "",
3281 ),
3282 )),
3283 delivery: Arc::new(DeliveryBus::new()),
3284 lambda_runtime: None,
3285 rds_runtime: None,
3286 ec2_runtime: None,
3287 ecs_runtime: None,
3288 elasticache_runtime: None,
3289 mq_runtime: None,
3290 kafka_runtime: None,
3291 };
3292 CloudFormationService::new(cf_state, deps)
3293 }
3294
3295 fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
3296 AwsRequest {
3297 service: "cloudformation".to_string(),
3298 action: action.to_string(),
3299 region: "us-east-1".to_string(),
3300 account_id: "123456789012".to_string(),
3301 request_id: "test-request-id".to_string(),
3302 headers: HeaderMap::new(),
3303 query_params: params,
3304 body: bytes::Bytes::new(),
3305 body_stream: parking_lot::Mutex::new(None),
3306 path_segments: vec![],
3307 raw_path: "/".to_string(),
3308 raw_query: String::new(),
3309 method: http::Method::POST,
3310 is_query_protocol: true,
3311 access_key_id: None,
3312 principal: None,
3313 }
3314 }
3315
3316 #[tokio::test]
3317 async fn update_stack_sets_failed_status_on_resource_error() {
3318 let svc = make_service();
3319
3320 let mut create_params = HashMap::new();
3322 create_params.insert("StackName".to_string(), "test-stack".to_string());
3323 create_params.insert(
3324 "TemplateBody".to_string(),
3325 r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
3326 );
3327 let req = make_request("CreateStack", create_params);
3328 let result = svc.create_stack(&req).await;
3329 assert!(result.is_ok());
3330
3331 let mut update_params = HashMap::new();
3333 update_params.insert("StackName".to_string(), "test-stack".to_string());
3334 update_params.insert(
3335 "TemplateBody".to_string(),
3336 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(),
3337 );
3338 let req = make_request("UpdateStack", update_params);
3339 let result = svc.update_stack(&req).await;
3340
3341 assert!(result.is_err());
3343
3344 let accounts = svc.state.read();
3348 let state = accounts.get("123456789012").unwrap();
3349 let stack = state.stacks.get("test-stack").unwrap();
3350 assert_eq!(stack.status, "UPDATE_ROLLBACK_COMPLETE");
3351 }
3352
3353 #[tokio::test]
3354 async fn create_stack_resolves_ref_to_physical_id() {
3355 let svc = make_service();
3356
3357 let template = r#"{
3359 "Resources": {
3360 "MyTopic": {
3361 "Type": "AWS::SNS::Topic",
3362 "Properties": { "TopicName": "ref-test-topic" }
3363 },
3364 "MySub": {
3365 "Type": "AWS::SNS::Subscription",
3366 "Properties": {
3367 "TopicArn": { "Ref": "MyTopic" },
3368 "Protocol": "sqs",
3369 "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
3370 }
3371 }
3372 }
3373 }"#;
3374
3375 let mut params = HashMap::new();
3376 params.insert("StackName".to_string(), "ref-stack".to_string());
3377 params.insert("TemplateBody".to_string(), template.to_string());
3378 let req = make_request("CreateStack", params);
3379 let result = svc.create_stack(&req).await;
3380 assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
3381
3382 let accounts = svc.state.read();
3384 let state = accounts.get("123456789012").unwrap();
3385 let stack = state.stacks.get("ref-stack").unwrap();
3386 assert_eq!(stack.resources.len(), 2);
3387 assert_eq!(stack.status, "CREATE_COMPLETE");
3388
3389 let sub = stack
3391 .resources
3392 .iter()
3393 .find(|r| r.logical_id == "MySub")
3394 .unwrap();
3395 assert!(
3396 sub.physical_id.contains("ref-test-topic"),
3397 "Subscription physical ID should reference the topic ARN, got: {}",
3398 sub.physical_id
3399 );
3400 }
3401
3402 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3409 async fn create_stack_custom_resource_provisions_asynchronously() {
3410 let svc = make_service();
3411 let template = r#"{
3412 "Resources": {
3413 "MyCustom": {
3414 "Type": "Custom::Thing",
3415 "Properties": {
3416 "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:handler"
3417 }
3418 }
3419 }
3420 }"#;
3421 let mut params = HashMap::new();
3422 params.insert("StackName".to_string(), "async-stack".to_string());
3423 params.insert("TemplateBody".to_string(), template.to_string());
3424 let req = make_request("CreateStack", params);
3425
3426 let resp = svc
3433 .create_stack(&req)
3434 .await
3435 .expect("create returns StackId");
3436 assert!(resp.status.is_success());
3437 {
3438 let accounts = svc.state.read();
3439 let stack = accounts
3440 .get("123456789012")
3441 .unwrap()
3442 .stacks
3443 .get("async-stack")
3444 .expect("stack seeded synchronously");
3445 assert!(
3446 stack.status == "CREATE_IN_PROGRESS" || stack.status == "CREATE_COMPLETE",
3447 "unexpected status right after create: {}",
3448 stack.status
3449 );
3450 }
3451
3452 let mut status = String::new();
3455 for _ in 0..200 {
3456 {
3457 let accounts = svc.state.read();
3458 if let Some(stack) = accounts
3459 .get("123456789012")
3460 .and_then(|s| s.stacks.get("async-stack"))
3461 {
3462 status = stack.status.clone();
3463 if status != "CREATE_IN_PROGRESS" {
3464 break;
3465 }
3466 }
3467 }
3468 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3469 }
3470 assert_eq!(
3471 status, "CREATE_COMPLETE",
3472 "stack should reach CREATE_COMPLETE"
3473 );
3474
3475 let accounts = svc.state.read();
3476 let stack = accounts
3477 .get("123456789012")
3478 .unwrap()
3479 .stacks
3480 .get("async-stack")
3481 .unwrap();
3482 assert_eq!(stack.resources.len(), 1);
3483 assert_eq!(stack.resources[0].resource_type, "Custom::Thing");
3484 }
3485
3486 #[tokio::test]
3487 async fn output_getatt_resolves_well_known_attribute() {
3488 let svc = make_service();
3494 let template = r#"{
3495 "Resources": {
3496 "Queue": { "Type": "AWS::SQS::Queue", "Properties": { "QueueName": "out-q" } }
3497 },
3498 "Outputs": {
3499 "Url": { "Value": { "Fn::GetAtt": ["Queue", "QueueUrl"] } }
3500 }
3501 }"#;
3502 let mut params = HashMap::new();
3503 params.insert("StackName".to_string(), "out-stack".to_string());
3504 params.insert("TemplateBody".to_string(), template.to_string());
3505 svc.create_stack(&make_request("CreateStack", params))
3506 .await
3507 .expect("create returns StackId");
3508
3509 let mut url = String::new();
3510 for _ in 0..200 {
3511 {
3512 let accounts = svc.state.read();
3513 if let Some(stack) = accounts
3514 .get("123456789012")
3515 .and_then(|s| s.stacks.get("out-stack"))
3516 {
3517 if stack.status != "CREATE_IN_PROGRESS" {
3518 url = stack
3519 .outputs
3520 .iter()
3521 .find(|o| o.key == "Url")
3522 .map(|o| o.value.clone())
3523 .unwrap_or_default();
3524 break;
3525 }
3526 }
3527 }
3528 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3529 }
3530 assert!(
3531 url.contains("out-q") && url != "Queue.QueueUrl",
3532 "GetAtt QueueUrl output should resolve to the live url, got {url:?}"
3533 );
3534 }
3535
3536 #[tokio::test]
3539 async fn create_stack_missing_name_errors() {
3540 let svc = make_service();
3541 let mut params = HashMap::new();
3542 params.insert("TemplateBody".to_string(), "{}".to_string());
3543 let req = make_request("CreateStack", params);
3544 assert!(svc.create_stack(&req).await.is_err());
3545 }
3546
3547 #[tokio::test]
3548 async fn create_stack_missing_template_creates_empty_stack() {
3549 let svc = make_service();
3554 let mut params = HashMap::new();
3555 params.insert("StackName".to_string(), "s".to_string());
3556 let req = make_request("CreateStack", params);
3557 svc.create_stack(&req)
3558 .await
3559 .expect("empty-body create succeeds");
3560 }
3561
3562 #[tokio::test]
3563 async fn create_stack_duplicate_errors() {
3564 let svc = make_service();
3565 let mut params = HashMap::new();
3566 params.insert("StackName".to_string(), "dup".to_string());
3567 params.insert(
3568 "TemplateBody".to_string(),
3569 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"dq"}}}}"#
3570 .to_string(),
3571 );
3572 let req = make_request("CreateStack", params.clone());
3573 svc.create_stack(&req).await.unwrap();
3574 let req = make_request("CreateStack", params);
3575 assert!(svc.create_stack(&req).await.is_err());
3576 }
3577
3578 #[tokio::test]
3579 async fn create_stack_invalid_template_creates_empty_stack() {
3580 let svc = make_service();
3584 let mut params = HashMap::new();
3585 params.insert("StackName".to_string(), "bad".to_string());
3586 params.insert("TemplateBody".to_string(), "not json".to_string());
3587 let req = make_request("CreateStack", params);
3588 svc.create_stack(&req)
3589 .await
3590 .expect("bad-body create succeeds");
3591 }
3592
3593 #[tokio::test]
3594 async fn delete_stack_unknown_is_noop() {
3595 let svc = make_service();
3596 let mut params = HashMap::new();
3597 params.insert("StackName".to_string(), "ghost".to_string());
3598 let req = make_request("DeleteStack", params);
3599 assert!(svc.delete_stack(&req).await.is_ok());
3600 }
3601
3602 #[test]
3603 fn describe_stacks_nonexistent_errors() {
3604 let svc = make_service();
3609 let mut params = HashMap::new();
3610 params.insert("StackName".to_string(), "ghost".to_string());
3611 let req = make_request("DescribeStacks", params);
3612 match svc.describe_stacks(&req) {
3613 Ok(_) => panic!("ghost stack must return an error, not an empty list"),
3614 Err(e) => {
3615 assert_eq!(e.status(), StatusCode::BAD_REQUEST);
3616 assert_eq!(e.code(), "ValidationError");
3617 assert!(
3618 e.message().contains("does not exist"),
3619 "got: {}",
3620 e.message()
3621 );
3622 }
3623 }
3624 }
3625
3626 #[test]
3627 fn describe_stacks_empty_returns_all() {
3628 let svc = make_service();
3629 let req = make_request("DescribeStacks", HashMap::new());
3630 let resp = svc.describe_stacks(&req).unwrap();
3631 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
3632 assert!(b.contains("DescribeStacksResult"));
3633 }
3634
3635 #[test]
3636 fn list_stacks_empty_returns_ok() {
3637 let svc = make_service();
3638 let req = make_request("ListStacks", HashMap::new());
3639 let resp = svc.list_stacks(&req).unwrap();
3640 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
3641 assert!(b.contains("ListStacksResult"));
3642 }
3643
3644 #[test]
3645 fn list_stack_resources_missing_name_returns_validation_error() {
3646 let svc = make_service();
3652 let req = make_request("ListStackResources", HashMap::new());
3653 let err = match svc.list_stack_resources(&req) {
3654 Err(e) => e,
3655 Ok(_) => panic!("omitted StackName must be rejected"),
3656 };
3657 assert_eq!(err.code(), "ValidationError");
3658 }
3659
3660 #[test]
3661 fn list_stack_resources_unknown_stack_returns_empty() {
3662 let svc = make_service();
3663 let mut params = HashMap::new();
3664 params.insert("StackName".to_string(), "ghost".to_string());
3665 let req = make_request("ListStackResources", params);
3666 svc.list_stack_resources(&req).expect("unknown is empty");
3667 }
3668
3669 #[test]
3670 fn describe_stack_resources_missing_name_returns_empty() {
3671 let svc = make_service();
3672 let req = make_request("DescribeStackResources", HashMap::new());
3673 svc.describe_stack_resources(&req)
3674 .expect("missing name is ok");
3675 }
3676
3677 #[test]
3678 fn get_template_missing_name_returns_empty_body() {
3679 let svc = make_service();
3680 let req = make_request("GetTemplate", HashMap::new());
3681 svc.get_template(&req).expect("missing name is ok");
3682 }
3683
3684 #[test]
3685 fn get_template_unknown_stack_returns_empty_body() {
3686 let svc = make_service();
3687 let mut params = HashMap::new();
3688 params.insert("StackName".to_string(), "ghost".to_string());
3689 let req = make_request("GetTemplate", params);
3690 svc.get_template(&req).expect("unknown is empty");
3691 }
3692
3693 #[tokio::test]
3694 async fn update_stack_missing_name_errors() {
3695 let svc = make_service();
3696 let mut params = HashMap::new();
3697 params.insert("TemplateBody".to_string(), "{}".to_string());
3698 let req = make_request("UpdateStack", params);
3699 assert!(svc.update_stack(&req).await.is_err());
3700 }
3701
3702 #[tokio::test]
3703 async fn update_stack_unknown_stack_returns_synthetic_id() {
3704 let svc = make_service();
3711 let mut params = HashMap::new();
3712 params.insert("StackName".to_string(), "ghost".to_string());
3713 params.insert(
3714 "TemplateBody".to_string(),
3715 r#"{"Resources":{}}"#.to_string(),
3716 );
3717 let req = make_request("UpdateStack", params);
3718 let resp = svc
3719 .update_stack(&req)
3720 .await
3721 .expect("ghost update is synthetic");
3722 let b = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
3723 assert!(b.contains("UpdateStackResult"));
3724 }
3725
3726 #[tokio::test]
3727 async fn create_stack_resolves_outputs_and_records_export() {
3728 let svc = make_service();
3729 let template = r#"{
3730 "Resources": {
3731 "Q": {"Type":"AWS::SQS::Queue","Properties":{"QueueName":"out-q"}}
3732 },
3733 "Outputs": {
3734 "QueueUrl": {
3735 "Value": {"Ref": "Q"},
3736 "Description": "Url",
3737 "Export": {"Name": "TheQueueUrl"}
3738 }
3739 }
3740 }"#;
3741 let mut params = HashMap::new();
3742 params.insert("StackName".to_string(), "outs".to_string());
3743 params.insert("TemplateBody".to_string(), template.to_string());
3744 let req = make_request("CreateStack", params);
3745 svc.create_stack(&req).await.expect("create stack");
3746
3747 let accounts = svc.state.read();
3748 let stack = accounts
3749 .get("123456789012")
3750 .unwrap()
3751 .stacks
3752 .get("outs")
3753 .unwrap();
3754 assert_eq!(stack.outputs.len(), 1);
3755 assert_eq!(stack.outputs[0].key, "QueueUrl");
3756 assert_eq!(stack.outputs[0].export_name.as_deref(), Some("TheQueueUrl"));
3757 assert!(!stack.outputs[0].value.is_empty());
3758 }
3759
3760 #[tokio::test]
3761 async fn create_stack_rejects_duplicate_export_name() {
3762 let svc = make_service();
3763 let mk = |name: &str| {
3764 let template = format!(
3765 r#"{{
3766 "Resources": {{"Q":{{"Type":"AWS::SQS::Queue","Properties":{{"QueueName":"q-{name}"}}}}}},
3767 "Outputs": {{"QueueUrl":{{"Value":{{"Ref":"Q"}},"Export":{{"Name":"DupExport"}}}}}}
3768 }}"#
3769 );
3770 let mut params = HashMap::new();
3771 params.insert("StackName".to_string(), name.to_string());
3772 params.insert("TemplateBody".to_string(), template);
3773 make_request("CreateStack", params)
3774 };
3775 match svc.create_stack(&mk("first")).await {
3776 Ok(_) => {}
3777 Err(e) => panic!("first stack: {e:?}"),
3778 }
3779 svc.create_stack(&mk("second"))
3785 .await
3786 .expect("CreateStack returns StackId even when provisioning fails");
3787 let accounts = svc.state.read();
3788 let stack = accounts
3789 .get("123456789012")
3790 .unwrap()
3791 .stacks
3792 .get("second")
3793 .expect("second stack recorded");
3794 assert_eq!(stack.status, "CREATE_FAILED");
3795 let exports = &accounts.get("123456789012").unwrap().exports;
3797 assert_eq!(
3798 exports
3799 .get("DupExport")
3800 .map(|e| e.exporting_stack_name.as_str()),
3801 Some("first")
3802 );
3803 }
3804
3805 #[tokio::test]
3806 async fn import_value_resolves_against_other_stack_export() {
3807 let svc = make_service();
3808
3809 let producer_tpl = r#"{
3810 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod-q"}}},
3811 "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"SharedQueueUrl"}}}
3812 }"#;
3813 let mut p = HashMap::new();
3814 p.insert("StackName".to_string(), "producer".to_string());
3815 p.insert("TemplateBody".to_string(), producer_tpl.to_string());
3816 svc.create_stack(&make_request("CreateStack", p))
3817 .await
3818 .expect("producer");
3819
3820 let consumer_tpl = r#"{
3821 "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cons-q"}}},
3822 "Outputs": {"Imp":{"Value":{"Fn::ImportValue":"SharedQueueUrl"}}}
3823 }"#;
3824 let mut p = HashMap::new();
3825 p.insert("StackName".to_string(), "consumer".to_string());
3826 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3827 svc.create_stack(&make_request("CreateStack", p))
3828 .await
3829 .expect("consumer");
3830
3831 let accounts = svc.state.read();
3832 let prod_url = accounts
3833 .get("123456789012")
3834 .unwrap()
3835 .stacks
3836 .get("producer")
3837 .unwrap()
3838 .outputs[0]
3839 .value
3840 .clone();
3841 let cons = accounts
3842 .get("123456789012")
3843 .unwrap()
3844 .stacks
3845 .get("consumer")
3846 .unwrap();
3847 assert_eq!(cons.outputs[0].value, prod_url);
3848 }
3849
3850 #[tokio::test]
3851 async fn create_stack_records_export_in_state_registry() {
3852 let svc = make_service();
3853 let template = r#"{
3854 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"reg-q"}}},
3855 "Outputs": {"Url":{"Value":{"Ref":"Q"},"Export":{"Name":"reg-url"}}}
3856 }"#;
3857 let mut params = HashMap::new();
3858 params.insert("StackName".to_string(), "reg".to_string());
3859 params.insert("TemplateBody".to_string(), template.to_string());
3860 svc.create_stack(&make_request("CreateStack", params))
3861 .await
3862 .expect("create");
3863
3864 let accounts = svc.state.read();
3865 let state = accounts.get("123456789012").unwrap();
3866 let export = state
3867 .exports
3868 .get("reg-url")
3869 .expect("export registered in state.exports");
3870 assert_eq!(export.exporting_stack_name, "reg");
3871 assert!(!export.value.is_empty());
3872 assert!(export.exporting_stack_id.contains("reg"));
3873 }
3874
3875 #[tokio::test]
3876 async fn import_value_with_unknown_export_errors() {
3877 let svc = make_service();
3878 let consumer_tpl = r#"{
3879 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{
3880 "QueueName": {"Fn::ImportValue":"missing-export"}
3881 }}}
3882 }"#;
3883 let mut p = HashMap::new();
3884 p.insert("StackName".to_string(), "bad-consumer".to_string());
3885 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3886 match svc.create_stack(&make_request("CreateStack", p)).await {
3887 Ok(_) => panic!("expected ValidationError for unknown export"),
3888 Err(e) => {
3889 let msg = format!("{e:?}");
3890 assert!(msg.contains("No export named missing-export"), "got {msg}");
3891 }
3892 }
3893 }
3894
3895 #[tokio::test]
3896 async fn delete_stack_blocked_when_export_in_use_and_unblocked_after_consumer_delete() {
3897 let svc = make_service();
3898
3899 let producer_tpl = r#"{
3900 "Resources": {"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"prod"}}},
3901 "Outputs": {"Out":{"Value":{"Ref":"Q"},"Export":{"Name":"my-arn"}}}
3902 }"#;
3903 let mut p = HashMap::new();
3904 p.insert("StackName".to_string(), "producer".to_string());
3905 p.insert("TemplateBody".to_string(), producer_tpl.to_string());
3906 svc.create_stack(&make_request("CreateStack", p))
3907 .await
3908 .expect("producer");
3909
3910 let consumer_tpl = r#"{
3911 "Resources": {"Q2":{"Type":"AWS::SQS::Queue","Properties":{
3912 "QueueName": "cons-q",
3913 "Tags": [{"Key":"k","Value":{"Fn::ImportValue":"my-arn"}}]
3914 }}}
3915 }"#;
3916 let mut p = HashMap::new();
3917 p.insert("StackName".to_string(), "consumer".to_string());
3918 p.insert("TemplateBody".to_string(), consumer_tpl.to_string());
3919 svc.create_stack(&make_request("CreateStack", p))
3920 .await
3921 .expect("consumer");
3922
3923 let mut p = HashMap::new();
3925 p.insert("StackName".to_string(), "producer".to_string());
3926 match svc.delete_stack(&make_request("DeleteStack", p)).await {
3927 Ok(_) => panic!("delete must fail while imports exist"),
3928 Err(e) => {
3929 let msg = format!("{e:?}");
3930 assert!(msg.contains("Export my-arn cannot be deleted"), "got {msg}");
3931 }
3932 }
3933
3934 let mut p = HashMap::new();
3936 p.insert("StackName".to_string(), "consumer".to_string());
3937 svc.delete_stack(&make_request("DeleteStack", p))
3938 .await
3939 .expect("consumer delete");
3940
3941 let mut p = HashMap::new();
3943 p.insert("StackName".to_string(), "producer".to_string());
3944 svc.delete_stack(&make_request("DeleteStack", p))
3945 .await
3946 .expect("producer delete after consumer gone");
3947
3948 let accounts = svc.state.read();
3949 let state = accounts.get("123456789012").unwrap();
3950 assert!(state.exports.is_empty(), "exports cleared after delete");
3951 assert!(state.imports.is_empty(), "imports cleared after delete");
3952 }
3953
3954 use std::sync::atomic::{AtomicUsize, Ordering};
3957
3958 fn counting_hook(counter: Arc<AtomicUsize>) -> fakecloud_persistence::SnapshotHook {
3961 Arc::new(move || {
3962 let counter = counter.clone();
3963 Box::pin(async move {
3964 counter.fetch_add(1, Ordering::SeqCst);
3965 })
3966 })
3967 }
3968
3969 fn disk_s3_store(tmp: &tempfile::TempDir) -> Arc<fakecloud_persistence::s3::DiskS3Store> {
3970 let cache = Arc::new(fakecloud_persistence::cache::BodyCache::new(1024 * 1024));
3971 Arc::new(fakecloud_persistence::s3::DiskS3Store::new(
3972 tmp.path().to_path_buf(),
3973 cache,
3974 ))
3975 }
3976
3977 const PERSIST_TEMPLATE: &str = r#"{"Resources":{
3981 "Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cfn-q"}},
3982 "T":{"Type":"AWS::SNS::Topic","Properties":{"TopicName":"cfn-t"}},
3983 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"cfn-bucket"}}
3984 }}"#;
3985
3986 fn create_req(stack: &str) -> AwsRequest {
3987 let mut p = HashMap::new();
3988 p.insert("StackName".to_string(), stack.to_string());
3989 p.insert("TemplateBody".to_string(), PERSIST_TEMPLATE.to_string());
3990 make_request("CreateStack", p)
3991 }
3992
3993 #[tokio::test]
3994 async fn cfn_create_persists_touched_services_and_writes_bucket_to_store() {
3995 let tmp = tempfile::tempdir().unwrap();
3996 let store = disk_s3_store(&tmp);
3997 let counter = Arc::new(AtomicUsize::new(0));
3998 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
3999 BTreeMap::new();
4000 hooks.insert("sqs", counting_hook(counter.clone()));
4001 hooks.insert("sns", counting_hook(counter.clone()));
4002 hooks.insert("lambda", counting_hook(counter.clone()));
4004 let svc = make_service()
4005 .with_s3_store(store.clone())
4006 .with_snapshot_hooks(hooks);
4007
4008 svc.create_stack(&create_req("probe")).await.unwrap();
4009
4010 assert_eq!(counter.load(Ordering::SeqCst), 2);
4012 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
4014 assert!(
4015 loaded.buckets.contains_key("cfn-bucket"),
4016 "CFN bucket should be persisted to the S3 store"
4017 );
4018 }
4019
4020 #[tokio::test]
4021 async fn cfn_delete_persists_touched_services_and_removes_bucket_from_store() {
4022 let tmp = tempfile::tempdir().unwrap();
4023 let store = disk_s3_store(&tmp);
4024 let counter = Arc::new(AtomicUsize::new(0));
4025 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
4026 BTreeMap::new();
4027 hooks.insert("sqs", counting_hook(counter.clone()));
4028 hooks.insert("sns", counting_hook(counter.clone()));
4029 let svc = make_service()
4030 .with_s3_store(store.clone())
4031 .with_snapshot_hooks(hooks);
4032
4033 svc.create_stack(&create_req("probe")).await.unwrap();
4034 assert_eq!(counter.load(Ordering::SeqCst), 2, "create fired sqs + sns");
4035
4036 let mut p = HashMap::new();
4037 p.insert("StackName".to_string(), "probe".to_string());
4038 svc.delete_stack(&make_request("DeleteStack", p))
4039 .await
4040 .unwrap();
4041
4042 assert_eq!(counter.load(Ordering::SeqCst), 4, "delete fired sqs + sns");
4044 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
4047 assert!(
4048 !loaded.buckets.contains_key("cfn-bucket"),
4049 "CFN-deleted bucket should be removed from the S3 store"
4050 );
4051 }
4052
4053 #[tokio::test]
4054 async fn cfn_persist_skips_services_without_a_registered_hook() {
4055 let tmp = tempfile::tempdir().unwrap();
4058 let store = disk_s3_store(&tmp);
4059 let counter = Arc::new(AtomicUsize::new(0));
4060 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
4061 BTreeMap::new();
4062 hooks.insert("sqs", counting_hook(counter.clone()));
4063 let svc = make_service()
4064 .with_s3_store(store.clone())
4065 .with_snapshot_hooks(hooks);
4066
4067 svc.create_stack(&create_req("probe")).await.unwrap();
4068 assert_eq!(counter.load(Ordering::SeqCst), 1, "only sqs has a hook");
4069 }
4070
4071 #[tokio::test]
4072 async fn cfn_update_persists_touched_services() {
4073 let tmp = tempfile::tempdir().unwrap();
4076 let store = disk_s3_store(&tmp);
4077 let counter = Arc::new(AtomicUsize::new(0));
4078 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
4079 BTreeMap::new();
4080 hooks.insert("sqs", counting_hook(counter.clone()));
4081 hooks.insert("sns", counting_hook(counter.clone()));
4082 let svc = make_service()
4083 .with_s3_store(store.clone())
4084 .with_snapshot_hooks(hooks);
4085
4086 let mut create = HashMap::new();
4087 create.insert("StackName".to_string(), "upd".to_string());
4088 create.insert(
4089 "TemplateBody".to_string(),
4090 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"u-q"}}}}"#
4091 .to_string(),
4092 );
4093 svc.create_stack(&make_request("CreateStack", create))
4094 .await
4095 .unwrap();
4096 let after_create = counter.load(Ordering::SeqCst);
4097
4098 let mut update = HashMap::new();
4099 update.insert("StackName".to_string(), "upd".to_string());
4100 update.insert("TemplateBody".to_string(), PERSIST_TEMPLATE.to_string());
4101 svc.update_stack(&make_request("UpdateStack", update))
4102 .await
4103 .unwrap();
4104
4105 assert!(
4107 counter.load(Ordering::SeqCst) > after_create,
4108 "update should persist the services it touched"
4109 );
4110 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
4111 assert!(loaded.buckets.contains_key("cfn-bucket"));
4112 }
4113
4114 #[tokio::test]
4115 async fn cfn_execute_change_set_persists_touched_services() {
4116 let tmp = tempfile::tempdir().unwrap();
4122 let store = disk_s3_store(&tmp);
4123 let counter = Arc::new(AtomicUsize::new(0));
4124 let mut hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> =
4125 BTreeMap::new();
4126 hooks.insert("sqs", counting_hook(counter.clone()));
4127 let svc = make_service()
4128 .with_s3_store(store.clone())
4129 .with_snapshot_hooks(hooks);
4130
4131 let mut create = HashMap::new();
4132 create.insert("StackName".to_string(), "cs-stack".to_string());
4133 create.insert("ChangeSetName".to_string(), "cs1".to_string());
4134 create.insert("ChangeSetType".to_string(), "CREATE".to_string());
4135 create.insert(
4136 "TemplateBody".to_string(),
4137 r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cs-q"}}}}"#
4138 .to_string(),
4139 );
4140 svc.handle(make_request("CreateChangeSet", create))
4141 .await
4142 .unwrap();
4143 let before = counter.load(Ordering::SeqCst);
4145
4146 let mut exec = HashMap::new();
4147 exec.insert("StackName".to_string(), "cs-stack".to_string());
4148 exec.insert("ChangeSetName".to_string(), "cs1".to_string());
4149 svc.handle(make_request("ExecuteChangeSet", exec))
4150 .await
4151 .unwrap();
4152
4153 assert!(
4154 counter.load(Ordering::SeqCst) > before,
4155 "ExecuteChangeSet must fire the sqs snapshot hook so the provisioned \
4156 queue survives a restart"
4157 );
4158 }
4159
4160 #[test]
4161 fn service_key_for_type_maps_services_and_aliases() {
4162 assert_eq!(
4164 service_key_for_type("AWS::Lambda::Function"),
4165 Some("lambda")
4166 );
4167 assert_eq!(
4168 service_key_for_type("AWS::SecretsManager::Secret"),
4169 Some("secretsmanager")
4170 );
4171 assert_eq!(service_key_for_type("AWS::SQS::Queue"), Some("sqs"));
4172 assert_eq!(service_key_for_type("AWS::IAM::Role"), Some("iam"));
4173 assert_eq!(
4174 service_key_for_type("AWS::StepFunctions::StateMachine"),
4175 Some("stepfunctions")
4176 );
4177 assert_eq!(
4179 service_key_for_type("AWS::Events::Rule"),
4180 Some("eventbridge")
4181 );
4182 assert_eq!(service_key_for_type("AWS::Logs::LogGroup"), Some("logs"));
4183 assert_eq!(
4184 service_key_for_type("AWS::ElastiCache::CacheCluster"),
4185 Some("elasticache")
4186 );
4187 assert_eq!(service_key_for_type("AWS::S3::Bucket"), None);
4189 assert_eq!(
4192 service_key_for_type("AWS::CertificateManager::Certificate"),
4193 Some("acm")
4194 );
4195 assert_eq!(
4196 service_key_for_type("AWS::ElasticLoadBalancingV2::LoadBalancer"),
4197 Some("elbv2")
4198 );
4199 assert_eq!(
4200 service_key_for_type("AWS::CloudFront::Distribution"),
4201 Some("cloudfront")
4202 );
4203 assert_eq!(
4204 service_key_for_type("AWS::Route53::HostedZone"),
4205 Some("route53")
4206 );
4207 assert_eq!(
4208 service_key_for_type("AWS::KinesisFirehose::DeliveryStream"),
4209 Some("firehose")
4210 );
4211 assert_eq!(service_key_for_type("AWS::Glue::Database"), Some("glue"));
4212 assert_eq!(service_key_for_type("AWS::WAFv2::WebACL"), Some("wafv2"));
4213 assert_eq!(
4214 service_key_for_type("AWS::Athena::WorkGroup"),
4215 Some("athena")
4216 );
4217 assert_eq!(
4218 service_key_for_type("AWS::Organizations::Organization"),
4219 Some("organizations")
4220 );
4221 assert_eq!(service_key_for_type("AWS::Lambda"), None);
4223 assert_eq!(service_key_for_type("Custom::Thing::Resource"), None);
4224 assert_eq!(service_key_for_type("AWS"), None);
4225 assert_eq!(service_key_for_type(""), None);
4226 }
4227
4228 #[tokio::test]
4229 async fn persist_touched_services_noop_with_empty_hooks() {
4230 let hooks: BTreeMap<&'static str, fakecloud_persistence::SnapshotHook> = BTreeMap::new();
4232 persist_touched_services(&hooks, vec!["AWS::SQS::Queue".to_string()]).await;
4233 }
4234
4235 #[tokio::test]
4236 async fn cfn_bucket_policy_write_through_create_update_delete() {
4237 let tmp = tempfile::tempdir().unwrap();
4238 let store = disk_s3_store(&tmp);
4239 let svc = make_service().with_s3_store(store.clone());
4240
4241 let mut create = HashMap::new();
4243 create.insert("StackName".to_string(), "pol".to_string());
4244 create.insert(
4245 "TemplateBody".to_string(),
4246 r#"{"Resources":{
4247 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"pol-bucket"}},
4248 "BP":{"Type":"AWS::S3::BucketPolicy","Properties":{"Bucket":"pol-bucket","PolicyDocument":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*","Principal":"*"}]}}}
4249 }}"#
4250 .to_string(),
4251 );
4252 svc.create_stack(&make_request("CreateStack", create))
4253 .await
4254 .unwrap();
4255 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
4256 let policy = loaded.buckets["pol-bucket"]
4257 .subresources
4258 .get("policy.toml")
4259 .cloned()
4260 .expect("bucket policy persisted on create");
4261 assert!(policy.contains("s3:GetObject"));
4262
4263 let mut update = HashMap::new();
4265 update.insert("StackName".to_string(), "pol".to_string());
4266 update.insert(
4267 "TemplateBody".to_string(),
4268 r#"{"Resources":{
4269 "B":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":"pol-bucket"}},
4270 "BP":{"Type":"AWS::S3::BucketPolicy","Properties":{"Bucket":"pol-bucket","PolicyDocument":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"*","Principal":"*"}]}}}
4271 }}"#
4272 .to_string(),
4273 );
4274 svc.update_stack(&make_request("UpdateStack", update))
4275 .await
4276 .unwrap();
4277 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
4278 let policy = loaded.buckets["pol-bucket"]
4279 .subresources
4280 .get("policy.toml")
4281 .cloned()
4282 .expect("bucket policy still persisted after update");
4283 assert!(
4284 policy.contains("s3:PutObject"),
4285 "updated policy should be written through"
4286 );
4287
4288 let mut del = HashMap::new();
4290 del.insert("StackName".to_string(), "pol".to_string());
4291 svc.delete_stack(&make_request("DeleteStack", del))
4292 .await
4293 .unwrap();
4294 let loaded = fakecloud_persistence::S3Store::load(store.as_ref()).unwrap();
4295 assert!(
4296 !loaded.buckets.contains_key("pol-bucket"),
4297 "CFN-deleted bucket and policy should be gone from the store"
4298 );
4299 }
4300}