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