diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/amplify.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/amplify.rs index 3af526f39..7b026e0b0 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/amplify.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/amplify.rs @@ -26,7 +26,7 @@ impl ResourceProvisioner { .get("Name") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let region = &self.region; let account = &self.account_id; let app_id = new_app_id(); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/apigw.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/apigw.rs index ad5975207..7e60ac67e 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/apigw.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/apigw.rs @@ -991,21 +991,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; - let generate_distinct_id = props - .get("GenerateDistinctId") - .and_then(|v| v.as_bool()) - .unwrap_or(false); let name = props .get("Name") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| { - if generate_distinct_id { - format!("cfn-key-{}-{}", resource.logical_id, apigw_make_id()) - } else { - format!("cfn-key-{}", resource.logical_id) - } - }); + .unwrap_or_else(|| self.physical_name(resource)); let value = props .get("Value") .and_then(|v| v.as_str()) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/appconfig.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/appconfig.rs index f78ecea5c..e7bcc1b9d 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/appconfig.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/appconfig.rs @@ -30,7 +30,7 @@ impl ResourceProvisioner { .get("Name") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let id = fakecloud_core::ids::short_id(7); let arn = application_arn(&self.region, &self.account_id, &id); @@ -79,7 +79,7 @@ impl ResourceProvisioner { .get("Name") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let id = fakecloud_core::ids::short_id(7); let arn = environment_arn(&self.region, &self.account_id, &app_id, &id); @@ -154,7 +154,7 @@ impl ResourceProvisioner { .get("Name") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let location_uri = props .get("LocationUri") .and_then(Value::as_str) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/appsync.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/appsync.rs index 84cefd0d7..bac21946a 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/appsync.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/appsync.rs @@ -192,7 +192,7 @@ impl ResourceProvisioner { .get("Name") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let region = &self.region; let account = &self.account_id; let arn = format!("arn:aws:appsync:{region}:{account}:apis/{api_id}/datasources/{name}"); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/athena.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/athena.rs index b384e39ad..4d34ee22b 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/athena.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/athena.rs @@ -13,10 +13,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let description = props .get("Description") @@ -142,10 +143,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let cat_type = props .get("Type") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/autoscaling.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/autoscaling.rs index 81c9f01d6..956f23f76 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/autoscaling.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/autoscaling.rs @@ -71,7 +71,7 @@ impl ResourceProvisioner { let props = &resource.properties; let name = prop_str(props, "LaunchConfigurationName") .map(String::from) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let image_id = prop_str(props, "ImageId") .ok_or("AWS::AutoScaling::LaunchConfiguration requires ImageId")? .to_string(); @@ -117,7 +117,7 @@ impl ResourceProvisioner { let props = &resource.properties; let name = prop_str(props, "AutoScalingGroupName") .map(String::from) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let min_size = prop_i64(props, "MinSize").unwrap_or(0); let max_size = prop_i64(props, "MaxSize").unwrap_or(min_size); let desired = prop_i64(props, "DesiredCapacity").unwrap_or(min_size); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/backup.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/backup.rs index c53649ad7..3c10e9549 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/backup.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/backup.rs @@ -27,7 +27,7 @@ impl ResourceProvisioner { .get("BackupVaultName") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let arn = vault_arn(&self.region, &self.account_id, &name); let encryption = props .get("EncryptionKeyArn") @@ -152,10 +152,11 @@ impl ResourceProvisioner { .get("BackupPlan") .filter(|v| v.is_object()) .ok_or("AWS::Backup::BackupPlan requires BackupPlan")?; + let generated_name = self.physical_name(resource); let plan_name = plan_src .get("BackupPlanName") .and_then(Value::as_str) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); // The CFN `BackupPlan` carries its rules under `BackupPlanRule`; the API @@ -225,11 +226,11 @@ impl ResourceProvisioner { .get("BackupPlan") .filter(|v| v.is_object()) .ok_or("AWS::Backup::BackupPlan requires BackupPlan")?; + // Leaving BackupPlanName out of an update keeps the plan's name. let plan_name = plan_src .get("BackupPlanName") .and_then(Value::as_str) - .unwrap_or(&resource.logical_id) - .to_string(); + .map(str::to_string); let plan = normalize_backup_plan(plan_src); let advanced = plan_src .get("AdvancedBackupSettings") @@ -251,6 +252,13 @@ impl ResourceProvisioner { record.plan = plan; record.advanced_backup_settings = advanced; record.version_id = version.clone(); + let plan_name = plan_name.unwrap_or_else(|| { + record + .versions + .last() + .map(|v| v.plan_name.clone()) + .unwrap_or_default() + }); record.versions.push(PlanVersion { version_id: version.clone(), creation_date: now, diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/batch.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/batch.rs index 03ee9e27a..7e75aae4d 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/batch.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/batch.rs @@ -59,7 +59,7 @@ impl ResourceProvisioner { let props = &resource.properties; let name = prop_str(props, "ComputeEnvironmentName") .map(String::from) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let arn = self.batch_arn("compute-environment", &name); let uuid = Uuid::new_v4().to_string(); let mut stored = Map::new(); @@ -113,7 +113,7 @@ impl ResourceProvisioner { let props = &resource.properties; let name = prop_str(props, "JobQueueName") .map(String::from) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let arn = self.batch_arn("job-queue", &name); let mut stored = Map::new(); stored.insert("jobQueueName".into(), json!(name)); @@ -152,7 +152,7 @@ impl ResourceProvisioner { let props = &resource.properties; let name = prop_str(props, "JobDefinitionName") .map(String::from) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let arn; { let mut state = self.batch_state.write(); @@ -219,7 +219,7 @@ impl ResourceProvisioner { let props = &resource.properties; let name = prop_str(props, "Name") .map(String::from) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let arn = format!( "arn:aws:batch:{}:{}:scheduling-policy/{name}", self.region, self.account_id diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/cloudformation.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudformation.rs index 8434531c6..af2de9445 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/cloudformation.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudformation.rs @@ -54,14 +54,8 @@ impl ResourceProvisioner { let parsed = crate::template::parse_template(&template_body, &child_parameters) .map_err(|e| format!("Failed to parse nested template: {e}"))?; - let child_stack_name = format!( - "{}-Nested-{}", - resource.logical_id, - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - ); + // AWS names a nested stack `{ParentStack}-{LogicalId}-{SUFFIX}`. + let child_stack_name = self.physical_name(resource); let child_stack_id = format!( "arn:aws:cloudformation:{}:{}:stack/{}/{}", self.region, @@ -155,6 +149,7 @@ impl ResourceProvisioner { region: self.region.clone(), stack_id: child_stack_id.clone(), strict_unknown_types: self.strict_unknown_types, + reused_names: Default::default(), }; let child_resources = crate::service::provision_stack_resources( diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs index bf2d908b1..86d0a0799 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudfront.rs @@ -18,7 +18,7 @@ impl ResourceProvisioner { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let caller_reference = format!("cfn-{}", resource.logical_id); + let caller_reference = self.physical_name(resource); let id = format!("E{}", fakecloud_core::ids::short_id(13).to_uppercase()); let etag = format!("E{}", fakecloud_core::ids::short_id(7).to_uppercase()); @@ -504,7 +504,7 @@ impl ResourceProvisioner { .unwrap_or("") .to_string(); let caller_reference = if caller_reference.is_empty() { - format!("cfn-{}", resource.logical_id) + self.physical_name(resource) } else { caller_reference }; diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/cloudwatch.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudwatch.rs index 12c449cb0..fc270cd10 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/cloudwatch.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/cloudwatch.rs @@ -73,10 +73,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let alarm_name = props .get("AlarmName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let alarm_description = props .get("AlarmDescription") @@ -219,10 +220,7 @@ impl ResourceProvisioner { .get("DashboardName") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| { - let suffix = Uuid::new_v4().simple().to_string(); - format!("{}-{}", resource.logical_id, &suffix[..8]) - }); + .unwrap_or_else(|| self.physical_name(resource)); // CFN passes DashboardBody as a JSON string (Fn::Sub friendly). let body = props .get("DashboardBody") @@ -272,7 +270,7 @@ impl ResourceProvisioner { let new_alarm_name = props .get("AlarmName") .and_then(|v| v.as_str()) - .unwrap_or(&new_def.logical_id); + .unwrap_or(&existing.physical_id); if new_alarm_name != existing.physical_id { return Err( "AWS::CloudWatch::Alarm updates that change AlarmName require replacement" diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/codeartifact.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/codeartifact.rs index 14de4ab3b..eaedec41b 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/codeartifact.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/codeartifact.rs @@ -27,7 +27,7 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; - let name = ca_str(props, "DomainName").unwrap_or_else(|| resource.logical_id.clone()); + let name = ca_str(props, "DomainName").unwrap_or_else(|| self.physical_name(resource)); let owner = self.account_id.clone(); let region = self.region.clone(); let arn = format!("arn:aws:codeartifact:{region}:{owner}:domain/{name}"); @@ -90,7 +90,7 @@ impl ResourceProvisioner { let old_name = ca_suffix(&existing.physical_id, "domain/") .ok_or_else(|| "corrupt CodeArtifact domain physical id".to_string())? .to_string(); - let new_name = ca_str(props, "DomainName").unwrap_or_else(|| existing.logical_id.clone()); + let new_name = ca_str(props, "DomainName").unwrap_or_else(|| old_name.clone()); let new_enc = ca_str(props, "EncryptionKey"); // DomainName and EncryptionKey are both replacement-required on the CFN @@ -183,7 +183,7 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; - let repo = ca_str(props, "RepositoryName").unwrap_or_else(|| resource.logical_id.clone()); + let repo = ca_str(props, "RepositoryName").unwrap_or_else(|| self.physical_name(resource)); let domain = ca_str(props, "DomainName") .ok_or_else(|| "AWS::CodeArtifact::Repository requires DomainName".to_string())?; let owner = ca_str(props, "DomainOwner").unwrap_or_else(|| self.account_id.clone()); @@ -253,8 +253,7 @@ impl ResourceProvisioner { .ok_or_else(|| "corrupt CodeArtifact repository key".to_string())?; let old_owner = ca_arn_account(&existing.physical_id).unwrap_or(&self.account_id); - let new_repo = - ca_str(props, "RepositoryName").unwrap_or_else(|| existing.logical_id.clone()); + let new_repo = ca_str(props, "RepositoryName").unwrap_or_else(|| old_repo.to_string()); let new_domain = ca_str(props, "DomainName") .ok_or_else(|| "AWS::CodeArtifact::Repository requires DomainName".to_string())?; let new_owner = ca_str(props, "DomainOwner").unwrap_or_else(|| self.account_id.clone()); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/codebuild.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/codebuild.rs index cfbaccfd7..cd769e5ee 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/codebuild.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/codebuild.rs @@ -21,7 +21,7 @@ impl ResourceProvisioner { .get("Name") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let region = &self.region; let account = &self.account_id; let arn = format!("arn:aws:codebuild:{region}:{account}:project/{name}"); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/codecommit.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/codecommit.rs index 2eedf6260..d4142c8fd 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/codecommit.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/codecommit.rs @@ -26,7 +26,7 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; - let name = cc_str(props, "RepositoryName").unwrap_or_else(|| resource.logical_id.clone()); + let name = cc_str(props, "RepositoryName").unwrap_or_else(|| self.physical_name(resource)); let account = self.account_id.clone(); let region = self.region.clone(); let arn = format!("arn:aws:codecommit:{region}:{account}:{name}"); @@ -101,8 +101,7 @@ impl ResourceProvisioner { ) -> Result { let props = &resource.properties; let old_name = existing.physical_id.clone(); - let new_name = - cc_str(props, "RepositoryName").unwrap_or_else(|| existing.logical_id.clone()); + let new_name = cc_str(props, "RepositoryName").unwrap_or_else(|| old_name.clone()); // RepositoryName is replacement-required on the CFN resource -- a rename // re-provisions the repository from scratch. diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/codedeploy.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/codedeploy.rs index 19147bf67..9a12e8f78 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/codedeploy.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/codedeploy.rs @@ -28,7 +28,7 @@ impl ResourceProvisioner { .get("ApplicationName") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let compute = props .get("ComputePlatform") .and_then(Value::as_str) @@ -96,7 +96,7 @@ impl ResourceProvisioner { .get("DeploymentGroupName") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let service_role = props .get("ServiceRoleArn") .and_then(Value::as_str) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/codepipeline.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/codepipeline.rs index 3dfb9d610..5967a65a9 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/codepipeline.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/codepipeline.rs @@ -29,7 +29,7 @@ impl ResourceProvisioner { .or_else(|| props.get("Name")) .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); // Build the camelCase PipelineDeclaration the service stores. Action // `Configuration` maps are provider-defined free-form keys AWS preserves diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/cognito.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/cognito.rs index 51b56f731..1ea9c1905 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/cognito.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/cognito.rs @@ -13,10 +13,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let pool_name = props .get("PoolName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let pool_id = format!( @@ -235,10 +236,11 @@ impl ResourceProvisioner { .and_then(|v| v.as_str()) .ok_or_else(|| "UserPoolId is required".to_string())? .to_string(); + let generated_name = self.physical_name(resource); let client_name = props .get("ClientName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let mut accounts = self.cognito_state.write(); @@ -499,10 +501,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let identity_pool_name = props .get("IdentityPoolName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let allow_unauth = props .get("AllowUnauthenticatedIdentities") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/cwlogs.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/cwlogs.rs index db8da91cb..820d27848 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/cwlogs.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/cwlogs.rs @@ -31,10 +31,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let log_group_name = props .get("LogGroupName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id); + .unwrap_or(&generated_name); let retention_in_days = retention_in_days_property(props)?; @@ -137,10 +138,11 @@ impl ResourceProvisioner { .and_then(|v| v.as_str()) .map(parse_log_group_name) .ok_or_else(|| "LogGroupName is required".to_string())?; + let generated_name = self.physical_name(resource); let log_stream_name = props .get("LogStreamName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let mut logs_accounts = self.logs_state.write(); @@ -200,10 +202,11 @@ impl ResourceProvisioner { .and_then(|v| v.as_str()) .map(parse_log_group_name) .ok_or_else(|| "LogGroupName is required".to_string())?; + let generated_name = self.physical_name(resource); let filter_name = props .get("FilterName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let filter_pattern = props .get("FilterPattern") @@ -295,10 +298,11 @@ impl ResourceProvisioner { .and_then(|v| v.as_str()) .map(parse_log_group_name) .ok_or_else(|| "LogGroupName is required".to_string())?; + let generated_name = self.physical_name(resource); let filter_name = props .get("FilterName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let filter_pattern = props .get("FilterPattern") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/dynamodb.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/dynamodb.rs index bcd1e9371..15c8542df 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/dynamodb.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/dynamodb.rs @@ -28,10 +28,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let table_name = props .get("TableName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id); + .unwrap_or(&generated_name); let mut key_schema = Vec::new(); if let Some(ks) = props.get("KeySchema").and_then(|v| v.as_array()) { diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/ec2.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/ec2.rs index 4b437091c..0a2c23a36 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/ec2.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/ec2.rs @@ -246,7 +246,8 @@ impl ResourceProvisioner { let mut params = HashMap::new(); let desc = prop_str(props, "GroupDescription").unwrap_or("Managed by CloudFormation"); params.insert("GroupDescription".to_string(), desc.to_string()); - let name = prop_str(props, "GroupName").unwrap_or(&resource.logical_id); + let generated_name = self.physical_name(resource); + let name = prop_str(props, "GroupName").unwrap_or(&generated_name); params.insert("GroupName".to_string(), name.to_string()); if let Some(vpc) = prop_str(props, "VpcId") { params.insert("VpcId".to_string(), vpc.to_string()); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/ecr.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/ecr.rs index 88b16acdd..efe979d6a 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/ecr.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/ecr.rs @@ -13,10 +13,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let repository_name = props .get("RepositoryName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let image_tag_mutability = props .get("ImageTagMutability") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs index 06032e89b..401ef39dd 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs @@ -13,10 +13,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let cluster_name = props .get("ClusterName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let cluster_arn = format!( "arn:aws:ecs:{}:{}:cluster/{}", @@ -72,10 +73,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let family = props .get("Family") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); // ECS DescribeTaskDefinition emits camelCase keys; CFN ships PascalCase. // Recursively lower the leading char so SDKs deserialize cleanly. @@ -222,10 +224,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let service_name = props .get("ServiceName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); // Cluster: default to "default" if missing; accept name or ARN. let cluster_name = props @@ -462,10 +465,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let arn = format!( "arn:aws:ecs:{}:{}:capacity-provider/{}", @@ -642,13 +646,14 @@ impl ResourceProvisioner { /// new ARN tells CFN's update path that the resource was replaced. pub(super) fn update_ecs_task_definition( &self, - _existing: &StackResource, + existing: &StackResource, resource: &ResourceDefinition, ) -> Result { // Delegating to create_ecs_task_definition is safe because each // call bumps the revision counter — semantically the right - // behaviour for ECS. - self.create_ecs_task_definition(resource) + // behaviour for ECS. An unnamed family keeps its generated name, so + // the update registers the family's next revision. + self.with_existing_name(existing, || self.create_ecs_task_definition(resource)) } /// In-place update for AWS::ECS::CapacityProvider. Name is immutable; diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/efs.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/efs.rs index 35a3096ae..04db23f64 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/efs.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/efs.rs @@ -76,13 +76,14 @@ impl ResourceProvisioner { .and_then(Value::as_bool) .unwrap_or(false); + let generated_name = self.physical_name(resource); let mut fs = Map::new(); fs.insert("OwnerId".into(), json!(self.account_id)); // CloudFormation does not expose a CreationToken; synthesize one so the // stored description carries the same field the direct API always sets. fs.insert( "CreationToken".into(), - json!(efs_str(props, "CreationToken").unwrap_or(&resource.logical_id)), + json!(efs_str(props, "CreationToken").unwrap_or(&generated_name)), ); fs.insert("FileSystemId".into(), json!(fsid)); fs.insert("FileSystemArn".into(), json!(arn)); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/eks.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/eks.rs index bc2b0eacc..35726ce4f 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/eks.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/eks.rs @@ -59,10 +59,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let role_arn = props .get("RoleArn") @@ -274,10 +275,11 @@ impl ResourceProvisioner { .and_then(|v| v.as_str()) .ok_or("ClusterName is required")? .to_string(); + let generated_name = self.physical_name(resource); let name = props .get("NodegroupName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let node_role = props .get("NodeRole") @@ -413,10 +415,11 @@ impl ResourceProvisioner { .and_then(|v| v.as_str()) .ok_or("ClusterName is required")? .to_string(); + let generated_name = self.physical_name(resource); let name = props .get("FargateProfileName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let pod_execution_role_arn = props .get("PodExecutionRoleArn") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/elasticache.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/elasticache.rs index 89cdceb67..87c716ea9 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/elasticache.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/elasticache.rs @@ -10,10 +10,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("CacheParameterGroupName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let family = props .get("CacheParameterGroupFamily") @@ -60,10 +61,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("CacheSubnetGroupName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let description = props .get("Description") @@ -108,10 +110,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("CacheSecurityGroupName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let description = props .get("Description") @@ -147,10 +150,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let user_id = props .get("UserId") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let user_name = props .get("UserName") @@ -239,10 +243,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let user_group_id = props .get("UserGroupId") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let engine = props .get("Engine") @@ -322,7 +327,7 @@ impl ResourceProvisioner { .get("ClusterName") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| format!("cfn-cc-{}", resource.logical_id.to_lowercase())); + .unwrap_or_else(|| self.physical_name(resource)); let cache_node_type = props .get("CacheNodeType") .and_then(|v| v.as_str()) @@ -652,7 +657,7 @@ impl ResourceProvisioner { .get("ReplicationGroupId") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| format!("cfn-rg-{}", resource.logical_id.to_lowercase())); + .unwrap_or_else(|| self.physical_name(resource)); let description = props .get("ReplicationGroupDescription") .and_then(|v| v.as_str()) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/elasticbeanstalk.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/elasticbeanstalk.rs index bdcb40d71..38564a803 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/elasticbeanstalk.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/elasticbeanstalk.rs @@ -69,7 +69,7 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; - let name = eb_str(props, "ApplicationName").unwrap_or_else(|| resource.logical_id.clone()); + let name = eb_str(props, "ApplicationName").unwrap_or_else(|| self.physical_name(resource)); let arn = application_arn(&self.region, &self.account_id, &name); let now = Utc::now(); @@ -106,8 +106,7 @@ impl ResourceProvisioner { ) -> Result { let props = &resource.properties; let old_name = existing.physical_id.clone(); - let new_name = - eb_str(props, "ApplicationName").unwrap_or_else(|| existing.logical_id.clone()); + let new_name = eb_str(props, "ApplicationName").unwrap_or_else(|| old_name.clone()); // ApplicationName is create-only on the CFN resource -- a rename // re-provisions the application from scratch. @@ -197,7 +196,7 @@ impl ResourceProvisioner { let app_name = eb_str(props, "ApplicationName").ok_or_else(|| { "AWS::ElasticBeanstalk::ApplicationVersion requires ApplicationName".to_string() })?; - let label = eb_str(props, "VersionLabel").unwrap_or_else(|| resource.logical_id.clone()); + let label = eb_str(props, "VersionLabel").unwrap_or_else(|| self.physical_name(resource)); let arn = application_version_arn(&self.region, &self.account_id, &app_name, &label); let now = Utc::now(); let (bucket, key) = eb_source_bundle(props); @@ -250,8 +249,7 @@ impl ResourceProvisioner { let new_app = eb_str(props, "ApplicationName").ok_or_else(|| { "AWS::ElasticBeanstalk::ApplicationVersion requires ApplicationName".to_string() })?; - let new_label = - eb_str(props, "VersionLabel").unwrap_or_else(|| existing.logical_id.clone()); + let new_label = eb_str(props, "VersionLabel").unwrap_or_else(|| old_label.clone()); // ApplicationName, VersionLabel and SourceBundle are all create-only on // the CFN resource -- any change re-provisions the version. @@ -293,7 +291,7 @@ impl ResourceProvisioner { "AWS::ElasticBeanstalk::Environment requires ApplicationName".to_string() })?; let env_name = - eb_str(props, "EnvironmentName").unwrap_or_else(|| resource.logical_id.clone()); + eb_str(props, "EnvironmentName").unwrap_or_else(|| self.physical_name(resource)); let cname_prefix = eb_str(props, "CNAMEPrefix").unwrap_or_else(|| env_name.clone()); let (tier_name, tier_type, tier_version) = eb_tier(props); let is_worker = tier_name.eq_ignore_ascii_case("Worker"); @@ -396,8 +394,7 @@ impl ResourceProvisioner { let props = &resource.properties; let old_name = existing.physical_id.clone(); let old_app = captured_application_name(existing); - let new_name = - eb_str(props, "EnvironmentName").unwrap_or_else(|| existing.logical_id.clone()); + let new_name = eb_str(props, "EnvironmentName").unwrap_or_else(|| old_name.clone()); // EnvironmentName and ApplicationName are create-only -- a change to // either re-provisions the environment. @@ -504,7 +501,7 @@ impl ResourceProvisioner { "AWS::ElasticBeanstalk::ConfigurationTemplate requires ApplicationName".to_string() })?; let template_name = - eb_str(props, "TemplateName").unwrap_or_else(|| resource.logical_id.clone()); + eb_str(props, "TemplateName").unwrap_or_else(|| self.physical_name(resource)); let arn = configuration_template_arn(&self.region, &self.account_id, &app_name, &template_name); let now = Utc::now(); @@ -599,8 +596,7 @@ impl ResourceProvisioner { let new_app = eb_str(props, "ApplicationName").ok_or_else(|| { "AWS::ElasticBeanstalk::ConfigurationTemplate requires ApplicationName".to_string() })?; - let new_template = - eb_str(props, "TemplateName").unwrap_or_else(|| existing.logical_id.clone()); + let new_template = eb_str(props, "TemplateName").unwrap_or_else(|| old_template.clone()); // ApplicationName, TemplateName and SolutionStackName are create-only -- // a change to any re-provisions the template. diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/elbv2.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/elbv2.rs index 03e680fd3..f0e333e4e 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/elbv2.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/elbv2.rs @@ -10,10 +10,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let scheme = props .get("Scheme") @@ -147,10 +148,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let protocol = props .get("Protocol") @@ -505,10 +507,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let bucket = props .get("CaCertificatesBundleS3Bucket") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/emr.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/emr.rs index 1d922a3c4..39e2b881e 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/emr.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/emr.rs @@ -22,7 +22,7 @@ impl ResourceProvisioner { .get("Name") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let region = &self.region; let account = &self.account_id; let id = format!( diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/eventbridge.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/eventbridge.rs index 01f7db193..5c1e4dda9 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/eventbridge.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/eventbridge.rs @@ -13,10 +13,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let rule_name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id); + .unwrap_or(&generated_name); let event_bus_name = props .get("EventBusName") .and_then(|v| v.as_str()) @@ -177,10 +178,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let description = props .get("Description") @@ -247,10 +249,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let description = props .get("Description") @@ -322,10 +325,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("ArchiveName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let event_source_arn = props .get("SourceArn") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/firehose.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/firehose.rs index e96f154aa..083013c16 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/firehose.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/firehose.rs @@ -13,10 +13,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("DeliveryStreamName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let arn = format!( diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/glue.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/glue.rs index 9c3a7e73c..73056c0a4 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/glue.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/glue.rs @@ -14,10 +14,11 @@ impl ResourceProvisioner { let input = props .get("DatabaseInput") .ok_or("DatabaseInput is required")?; + let generated_name = self.physical_name(resource); let name = input .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let description = input .get("Description") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/iam.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/iam.rs index a6a3cbbfd..621c7fe24 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/iam.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/iam.rs @@ -29,10 +29,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let role_name = props .get("RoleName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id); + .unwrap_or(&generated_name); let assume_role_policy = props .get("AssumeRolePolicyDocument") @@ -229,10 +230,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let policy_name = props .get("PolicyName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id); + .unwrap_or(&generated_name); let policy_document = props .get("PolicyDocument") @@ -352,10 +354,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let user_name = props .get("UserName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let path = props .get("Path") @@ -535,10 +538,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let group_name = props .get("GroupName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let path = props .get("Path") @@ -666,10 +670,11 @@ impl ResourceProvisioner { // Same shape as AWS::IAM::Policy minus the inline-attach knobs; // ManagedPolicy is a standalone policy, attached separately. let props = &resource.properties; + let generated_name = self.physical_name(resource); let policy_name = props .get("ManagedPolicyName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let policy_document = props .get("PolicyDocument") @@ -894,10 +899,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("InstanceProfileName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let path = props .get("Path") @@ -1042,10 +1048,7 @@ impl ResourceProvisioner { .get("Name") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| { - let suffix = Uuid::new_v4().simple().to_string(); - format!("{}-{}", resource.logical_id, &suffix[..8]) - }); + .unwrap_or_else(|| self.physical_name(resource)); let saml_metadata_document = props .get("SamlMetadataDocument") .and_then(|v| v.as_str()) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/kafka.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/kafka.rs index af118f0a8..a4fd36d58 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/kafka.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/kafka.rs @@ -196,7 +196,7 @@ impl super::ResourceProvisioner { ) -> Result { let props = &resource.properties; let body = json!({ - "name": msk_str(props, "Name").unwrap_or_else(|| resource.logical_id.clone()), + "name": msk_str(props, "Name").unwrap_or_else(|| self.physical_name(resource)), "description": msk_str(props, "Description").unwrap_or_default(), "serverProperties": msk_str(props, "ServerProperties").unwrap_or_default(), "kafkaVersions": props.get("KafkaVersionsList").cloned().unwrap_or(json!(null)), @@ -476,7 +476,7 @@ impl super::ResourceProvisioner { ) -> Result { let props = &resource.properties; let mut body = json!({ - "replicatorName": msk_str(props, "ReplicatorName").unwrap_or_else(|| resource.logical_id.clone()), + "replicatorName": msk_str(props, "ReplicatorName").unwrap_or_else(|| self.physical_name(resource)), "description": msk_str(props, "Description").unwrap_or_default(), "serviceExecutionRoleArn": msk_str(props, "ServiceExecutionRoleArn").unwrap_or_default(), "kafkaClusters": pascal_to_camel(props.get("KafkaClusters").unwrap_or(&json!([]))), diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/kinesis.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/kinesis.rs index fb639cb66..d10da0ffb 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/kinesis.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/kinesis.rs @@ -13,10 +13,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let stream_name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let shard_count = props .get("ShardCount") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/lambda.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/lambda.rs index f0591fce4..ec1ad709c 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/lambda.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/lambda.rs @@ -40,21 +40,7 @@ impl ResourceProvisioner { .get("FunctionName") .and_then(|v| v.as_str()) .map(|s| s.to_string()) - .unwrap_or_else(|| { - format!( - "{}-{}-{}", - self.stack_id - .rsplit('/') - .nth(1) - .unwrap_or(&resource.logical_id), - resource.logical_id, - Uuid::new_v4() - .to_string() - .split('-') - .next() - .unwrap_or("rand") - ) - }); + .unwrap_or_else(|| self.physical_name(resource)); let cfg = parse_lambda_function_props(props)?; let function_arn = format!( @@ -472,10 +458,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let layer_name = props .get("LayerName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let description = props .get("Description") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs index 47049c049..455cf31ee 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/mod.rs @@ -1049,6 +1049,9 @@ pub struct ResourceProvisioner { /// templates (SAM/CDK output routinely includes types fakecloud does not /// model). pub strict_unknown_types: bool, + /// Names to give resources re-created in place of an existing one, keyed + /// by logical id (see `ResourceProvisioner::with_existing_name`). + pub reused_names: Arc>>, } /// A container-backed resource the synchronous provisioning pass inserted as a @@ -1183,6 +1186,7 @@ mod lambda; mod logs; mod mq; mod mwaa; +pub(crate) mod naming; mod opensearch; mod organizations; mod pipes; @@ -2063,10 +2067,9 @@ impl ResourceProvisioner { /// Mirroring CloudFormation replacement semantics, this tears down the /// old backing resource and re-provisions it from the new definition /// through the exact same write-through path `create_resource` uses, so - /// the stored resource reflects the update. For name-keyed services the - /// physical id is derived from an unchanged `Name`/`Id` property and stays - /// stable (an effectively in-place update); for others it is regenerated, - /// matching replacement. Genuinely-unmodeled types (no backing state) have + /// the stored resource reflects the update. A resource whose name the + /// template sets keeps that name; an unnamed one keeps the name it was + /// generated, so the update stays effectively in place. Genuinely-unmodeled types (no backing state) have /// a no-op delete and a create that records `physical_id == logical_id`; /// they still yield `Some(..)` here so the caller records a /// `ResourceChange` rather than a silent no-op. @@ -2086,10 +2089,18 @@ impl ResourceProvisioner { policy = existing.update_replace_policy.as_deref().unwrap_or(""), "CloudFormation: UpdateReplacePolicy retains the old physical resource on replacement; not deleting it" ); - } else { - self.delete_resource(existing)?; + // The old resource stays, holding its name, so the replacement + // generates a new one rather than overwriting what was retained. + let created = self.create_resource(new_def)?; + return Ok(Some(ProvisionResult { + physical_id: created.physical_id, + attributes: created.attributes, + })); } - let created = self.create_resource(new_def)?; + self.delete_resource(existing)?; + // The old resource is gone, so an unnamed resource keeps its name, as + // the in-place update this often stands in for would. + let created = self.with_existing_name(existing, || self.create_resource(new_def))?; Ok(Some(ProvisionResult { physical_id: created.physical_id, attributes: created.attributes, @@ -4270,6 +4281,7 @@ mod tests { region: "us-east-1".to_string(), stack_id: "arn:aws:cloudformation:us-east-1:123456789012:stack/test/00000000-0000-0000-0000-000000000000".to_string(), strict_unknown_types: false, + reused_names: Default::default(), } } @@ -9176,4 +9188,243 @@ mod tests { let env_id = env.physical_id.split('|').nth(1).unwrap(); assert_eq!(a.environments.get(env_id).unwrap().name, "prod"); } + + #[test] + fn unnamed_resources_get_generated_names_that_updates_keep() { + let prov = make_provisioner(); + + let repo = prov + .create_resource(&make_resource( + "AWS::CodeCommit::Repository", + "Repo", + serde_json::json!({}), + )) + .expect("create repository"); + assert!( + repo.physical_id.starts_with("test-Repo-"), + "{}", + repo.physical_id + ); + let updated = prov + .update_resource( + &repo, + &make_resource( + "AWS::CodeCommit::Repository", + "Repo", + serde_json::json!({"RepositoryDescription": "now described"}), + ), + ) + .expect("update succeeds") + .expect("repository is updatable"); + assert_eq!(updated.physical_id, repo.physical_id); + + let alarm_props = |threshold: u32| { + serde_json::json!({ + "ComparisonOperator": "GreaterThanThreshold", + "EvaluationPeriods": 1, + "MetricName": "CPUUtilization", + "Namespace": "AWS/EC2", + "Period": 60, + "Statistic": "Average", + "Threshold": threshold + }) + }; + let alarm = prov + .create_resource(&make_resource( + "AWS::CloudWatch::Alarm", + "HighCpu", + alarm_props(80), + )) + .expect("create alarm"); + assert!( + alarm.physical_id.starts_with("test-HighCpu-"), + "{}", + alarm.physical_id + ); + // An alarm without AlarmName is not renamed (and so not a replacement) + // by an update that still leaves it out. + let updated = prov + .update_resource( + &alarm, + &make_resource("AWS::CloudWatch::Alarm", "HighCpu", alarm_props(90)), + ) + .expect("update does not require replacement") + .expect("alarm is updatable"); + assert_eq!(updated.physical_id, alarm.physical_id); + } + + #[test] + fn a_resource_created_twice_never_reuses_a_name() { + let prov = make_provisioner(); + let def = make_resource("AWS::SQS::Queue", "Jobs", serde_json::json!({})); + let first = prov.create_resource(&def).expect("first queue"); + // A replacement recreates the resource in the same stack. + let second = prov.create_resource(&def).expect("replacement queue"); + assert_ne!(first.physical_id, second.physical_id); + } + + #[test] + fn a_reprovisioned_unnamed_resource_keeps_its_name() { + let prov = make_provisioner(); + // No dedicated update arm: an update re-provisions the resource. + let group = prov + .create_resource(&make_resource( + "AWS::RDS::DBSubnetGroup", + "Subnets", + serde_json::json!({ + "DBSubnetGroupDescription": "first", + "SubnetIds": ["subnet-1", "subnet-2"] + }), + )) + .expect("create subnet group"); + assert!( + group.physical_id.starts_with("test-subnets-"), + "{}", + group.physical_id + ); + let updated = prov + .update_resource( + &group, + &make_resource( + "AWS::RDS::DBSubnetGroup", + "Subnets", + serde_json::json!({ + "DBSubnetGroupDescription": "second", + "SubnetIds": ["subnet-1", "subnet-2"] + }), + ), + ) + .expect("update succeeds") + .expect("subnet group is updatable"); + assert_eq!(updated.physical_id, group.physical_id); + + // A retained old resource keeps its name, so the replacement gets a + // new one instead of overwriting it. + let retained = StackResource { + update_replace_policy: Some("Retain".to_string()), + ..group.clone() + }; + let updated = prov + .update_resource( + &retained, + &make_resource( + "AWS::RDS::DBSubnetGroup", + "Subnets", + serde_json::json!({ + "DBSubnetGroupDescription": "third", + "SubnetIds": ["subnet-1", "subnet-2"] + }), + ), + ) + .expect("update succeeds") + .expect("subnet group is updatable"); + assert_ne!(updated.physical_id, group.physical_id); + assert!(updated.physical_id.starts_with("test-subnets-")); + { + let rds = prov.rds_state.read(); + let groups = &rds.get("123456789012").unwrap().subnet_groups; + assert!( + groups.contains_key(&group.physical_id), + "retained group kept" + ); + assert!(groups.contains_key(&updated.physical_id)); + } + + // A resource created before names were generated keeps its + // logical-id name the same way. + let legacy = StackResource { + physical_id: "Subnets".to_string(), + ..group.clone() + }; + assert_eq!(prov.existing_name(&legacy).as_deref(), Some("Subnets")); + } + + #[test] + fn an_unnamed_task_definition_update_registers_the_next_revision() { + let prov = make_provisioner(); + let def = |image: &str| { + make_resource( + "AWS::ECS::TaskDefinition", + "Task", + serde_json::json!({ + "ContainerDefinitions": [{"Name": "app", "Image": image, "Memory": 256}] + }), + ) + }; + let first = prov.create_resource(&def("nginx:1")).expect("register"); + let updated = prov + .update_resource(&first, &def("nginx:2")) + .expect("update succeeds") + .expect("task definitions are updatable"); + let family = |arn: &str| { + arn.rsplit('/') + .next() + .and_then(|f| f.rsplit_once(':')) + .map(|(family, rev)| (family.to_string(), rev.to_string())) + .unwrap() + }; + let (family_1, rev_1) = family(&first.physical_id); + let (family_2, rev_2) = family(&updated.physical_id); + assert!(family_1.starts_with("test-Task-"), "{family_1}"); + assert_eq!(family_1, family_2); + assert_eq!(rev_1, "1"); + assert_eq!(rev_2, "2"); + } + + #[test] + fn unnamed_fifo_topics_and_queues_keep_the_fifo_ending() { + let prov = make_provisioner(); + let topic = prov + .create_resource(&make_resource( + "AWS::SNS::Topic", + "Orders", + serde_json::json!({"FifoTopic": true}), + )) + .expect("create FIFO topic"); + assert!( + topic.physical_id.ends_with(".fifo"), + "{}", + topic.physical_id + ); + let queue = prov + .create_resource(&make_resource( + "AWS::SQS::Queue", + "Orders", + serde_json::json!({"FifoQueue": true}), + )) + .expect("create FIFO queue"); + assert!( + queue.physical_id.ends_with(".fifo"), + "{}", + queue.physical_id + ); + // A long stack name truncates a FIFO name, and it is still recovered. + let mut long = make_provisioner(); + long.stack_id = format!( + "arn:aws:cloudformation:us-east-1:123456789012:stack/{}/0000", + "s".repeat(90) + ); + let long_queue = long + .create_resource(&make_resource( + "AWS::SQS::Queue", + "Orders", + serde_json::json!({"FifoQueue": true}), + )) + .expect("create truncated FIFO queue"); + let name = long_queue.physical_id.rsplit('/').next().unwrap(); + assert_eq!(name.len(), 80, "{name}"); + assert_eq!( + long.existing_name(&long_queue) + .map(|n| format!("{n}.fifo")) + .as_deref(), + Some(name) + ); + assert_eq!( + prov.with_existing_name(&queue, || prov.physical_name_ending( + &make_resource("AWS::SQS::Queue", "Orders", serde_json::json!({})), + ".fifo" + )), + queue.physical_id.rsplit('/').next().unwrap() + ); + } } diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/mq.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/mq.rs index b551a1c90..606c7fdb1 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/mq.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/mq.rs @@ -31,7 +31,7 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; - let name = mq_str(props, "BrokerName").unwrap_or_else(|| resource.logical_id.clone()); + let name = mq_str(props, "BrokerName").unwrap_or_else(|| self.physical_name(resource)); let region = self.region.clone(); let account = self.account_id.clone(); @@ -104,7 +104,7 @@ impl ResourceProvisioner { ) -> Result { let props = &resource.properties; let id = existing.physical_id.clone(); - let new_name = mq_str(props, "BrokerName").unwrap_or_else(|| existing.logical_id.clone()); + let new_name = mq_str(props, "BrokerName"); let new_engine = mq_str(props, "EngineType") .unwrap_or_else(|| "ACTIVEMQ".to_string()) .to_uppercase(); @@ -129,6 +129,8 @@ impl ResourceProvisioner { .to_string(), ) }; + // Leaving BrokerName out of an update keeps the broker's name. + let new_name = new_name.unwrap_or_else(|| old_name.clone()); // BrokerName, EngineType, and DeploymentMode are replacement-required. if new_name != old_name || new_engine != old_engine || new_deployment != old_deployment { self.delete_mq_broker(&id); @@ -289,7 +291,7 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; - let name = mq_str(props, "Name").unwrap_or_else(|| resource.logical_id.clone()); + let name = mq_str(props, "Name").unwrap_or_else(|| self.physical_name(resource)); let engine = mq_str(props, "EngineType") .unwrap_or_else(|| "ACTIVEMQ".to_string()) .to_uppercase(); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/mwaa.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/mwaa.rs index 93da81801..748f864c9 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/mwaa.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/mwaa.rs @@ -24,7 +24,7 @@ impl ResourceProvisioner { .get("Name") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let region = &self.region; let account = &self.account_id; let arn = environment_arn(region, account, &name); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/naming.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/naming.rs new file mode 100644 index 000000000..335ec5c2d --- /dev/null +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/naming.rs @@ -0,0 +1,509 @@ +//! Physical names CloudFormation generates for resources whose name property +//! the template leaves out. +//! +//! AWS names such a resource `{StackName}-{LogicalId}-{SUFFIX}`: the stack +//! name and logical id are truncated to fit the resource type's name limit, +//! and the suffix is a random 13-character string. Using the bare logical id +//! instead made two stacks built from one template in the same account (a +//! stack set deployed to two regions, a dev and a prod stack) collide on every +//! unnamed resource. +//! +//! The suffix is random, as in AWS, so a resource replaced by an update gets a +//! new name (and never collides with an old one its UpdateReplacePolicy +//! retained). Updates that leave the name property out keep the resource's +//! current name rather than generating another. + +use super::ResourceProvisioner; +use crate::state::StackResource; +use crate::template::ResourceDefinition; + +const SUFFIX_LEN: usize = 13; +const ALPHABET: &[u8; 36] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +/// How a resource type's generated name is shaped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct NameRule { + /// Longest name the type accepts. + pub max_len: usize, + /// The type only accepts lowercase names (S3 buckets, RDS and ElastiCache + /// identifiers, ...). + pub lowercase: bool, + /// Whether the stack name leads the name. A few types are named + /// `{LogicalId}-{SUFFIX}` only. + pub include_stack: bool, + /// Character joining the parts, for types that do not accept `-`. + pub separator: char, +} + +impl NameRule { + const fn new(max_len: usize) -> Self { + Self { + max_len, + lowercase: false, + include_stack: true, + separator: '-', + } + } + + const fn lower(self) -> Self { + Self { + lowercase: true, + ..self + } + } + + const fn without_stack(self) -> Self { + Self { + include_stack: false, + ..self + } + } + + const fn separated_by(self, separator: char) -> Self { + Self { separator, ..self } + } +} + +/// The name rule for a resource type, from the type's documented name +/// constraints. +pub(crate) fn name_rule(resource_type: &str) -> NameRule { + match resource_type { + "AWS::S3::Bucket" => NameRule::new(63).lower(), + "AWS::SQS::Queue" => NameRule::new(80), + "AWS::SNS::Topic" => NameRule::new(256), + "AWS::Lambda::Function" => NameRule::new(64), + "AWS::Lambda::LayerVersion" => NameRule::new(140), + "AWS::DynamoDB::Table" => NameRule::new(255), + "AWS::Kinesis::Stream" => NameRule::new(128), + "AWS::KinesisFirehose::DeliveryStream" => NameRule::new(64), + "AWS::IAM::Role" | "AWS::IAM::User" => NameRule::new(64), + "AWS::IAM::Policy" + | "AWS::IAM::Group" + | "AWS::IAM::ManagedPolicy" + | "AWS::IAM::InstanceProfile" + | "AWS::IAM::SAMLProvider" => NameRule::new(128), + "AWS::Logs::LogGroup" + | "AWS::Logs::LogStream" + | "AWS::Logs::MetricFilter" + | "AWS::Logs::SubscriptionFilter" => NameRule::new(512), + "AWS::Events::Rule" | "AWS::Events::Connection" | "AWS::Events::ApiDestination" => { + NameRule::new(64) + } + "AWS::Events::Archive" => NameRule::new(48), + "AWS::ElasticLoadBalancingV2::LoadBalancer" + | "AWS::ElasticLoadBalancingV2::TargetGroup" + | "AWS::ElasticLoadBalancingV2::TrustStore" => NameRule::new(32), + "AWS::EC2::SecurityGroup" => NameRule::new(255), + "AWS::ECR::Repository" => NameRule::new(256).lower(), + "AWS::ECS::Cluster" + | "AWS::ECS::TaskDefinition" + | "AWS::ECS::Service" + | "AWS::ECS::CapacityProvider" => NameRule::new(255), + "AWS::EKS::Cluster" | "AWS::EKS::FargateProfile" => NameRule::new(100), + "AWS::EKS::Nodegroup" => NameRule::new(63), + "AWS::Cognito::UserPool" | "AWS::Cognito::UserPoolClient" => NameRule::new(128), + // Identity pool names allow only word characters and spaces. + "AWS::Cognito::IdentityPool" => NameRule::new(128).separated_by('_'), + "AWS::RDS::DBSubnetGroup" + | "AWS::RDS::DBParameterGroup" + | "AWS::RDS::DBClusterParameterGroup" + | "AWS::RDS::OptionGroup" + | "AWS::RDS::DBSecurityGroup" + | "AWS::RDS::EventSubscription" => NameRule::new(255).lower(), + "AWS::RDS::DBProxy" | "AWS::RDS::DBInstance" | "AWS::RDS::DBCluster" => { + NameRule::new(63).lower() + } + "AWS::Redshift::Cluster" | "AWS::DocDB::DBCluster" | "AWS::Neptune::DBCluster" => { + NameRule::new(63).lower() + } + "AWS::CloudFormation::Stack" => NameRule::new(128), + "AWS::ElastiCache::ParameterGroup" + | "AWS::ElastiCache::SubnetGroup" + | "AWS::ElastiCache::SecurityGroup" => NameRule::new(255).lower(), + "AWS::ElastiCache::User" + | "AWS::ElastiCache::UserGroup" + | "AWS::ElastiCache::ReplicationGroup" => NameRule::new(40).lower(), + "AWS::ElastiCache::CacheCluster" => NameRule::new(40).lower(), + "AWS::ElasticBeanstalk::Environment" => NameRule::new(40), + "AWS::ElasticBeanstalk::Application" + | "AWS::ElasticBeanstalk::ApplicationVersion" + | "AWS::ElasticBeanstalk::ConfigurationTemplate" => NameRule::new(100), + "AWS::EFS::FileSystem" => NameRule::new(64), + "AWS::CodeCommit::Repository" | "AWS::CodeDeploy::Application" => NameRule::new(100), + "AWS::CodeDeploy::DeploymentGroup" | "AWS::CodePipeline::Pipeline" => NameRule::new(100), + "AWS::CodeBuild::Project" => NameRule::new(150), + "AWS::CodeArtifact::Domain" => NameRule::new(50).lower(), + "AWS::CodeArtifact::Repository" => NameRule::new(100), + "AWS::Batch::ComputeEnvironment" + | "AWS::Batch::JobQueue" + | "AWS::Batch::JobDefinition" + | "AWS::Batch::SchedulingPolicy" => NameRule::new(128), + "AWS::Backup::BackupVault" | "AWS::Backup::BackupPlan" => NameRule::new(50), + // Data source names must be valid GraphQL identifiers. + "AWS::AppSync::DataSource" => NameRule::new(255).separated_by('_'), + "AWS::AppConfig::Application" + | "AWS::AppConfig::Environment" + | "AWS::AppConfig::ConfigurationProfile" => NameRule::new(64), + "AWS::Athena::WorkGroup" => NameRule::new(128), + "AWS::Athena::DataCatalog" => NameRule::new(129), + "AWS::CloudWatch::Alarm" | "AWS::CloudWatch::Dashboard" => NameRule::new(255), + "AWS::CloudFront::PublicKey" | "AWS::CloudFront::CloudFrontOriginAccessIdentity" => { + NameRule::new(128) + } + // Caller references. + "AWS::Route53::HostedZone" => NameRule::new(128), + "AWS::Route53::HealthCheck" => NameRule::new(64), + "AWS::MSK::Configuration" => NameRule::new(64), + "AWS::MSK::Replicator" => NameRule::new(128), + "AWS::Glue::Database" => NameRule::new(255).lower(), + "AWS::MWAA::Environment" => NameRule::new(80), + "AWS::AmazonMQ::Broker" => NameRule::new(50), + "AWS::AmazonMQ::Configuration" => NameRule::new(150), + "AWS::OpenSearchService::Domain" | "AWS::Elasticsearch::Domain" => { + NameRule::new(28).lower() + } + "AWS::Pipes::Pipe" => NameRule::new(64), + "AWS::ServiceDiscovery::Instance" => NameRule::new(64), + "AWS::SES::ConfigurationSet" + | "AWS::SES::ConfigurationSetEventDestination" + | "AWS::SES::Template" + | "AWS::SES::ContactList" + | "AWS::SES::DedicatedIpPool" + | "AWS::SES::ReceiptRuleSet" + | "AWS::SES::ReceiptRule" + | "AWS::SES::ReceiptFilter" => NameRule::new(64), + "AWS::StepFunctions::StateMachine" => NameRule::new(80).without_stack(), + "AWS::SecretsManager::Secret" => NameRule::new(512).without_stack(), + "AWS::Organizations::OrganizationalUnit" | "AWS::Organizations::Policy" => { + NameRule::new(128) + } + "AWS::Timestream::Database" | "AWS::Timestream::Table" => NameRule::new(256), + "AWS::Amplify::App" => NameRule::new(255), + "AWS::EMR::Cluster" => NameRule::new(256), + t if t.starts_with("AWS::SageMaker::") => NameRule::new(63), + _ => NameRule::new(255), + } +} + +/// The stack name inside a stack id ARN +/// (`arn:aws:cloudformation:{region}:{account}:stack/{name}/{uuid}`). Stack ids +/// that are not stack ARNs (Cloud Control's one-shot provisioners) have none. +fn stack_name(stack_id: &str) -> Option<&str> { + let resource = stack_id.splitn(6, ':').nth(5)?; + let mut parts = resource.split('/'); + (parts.next()? == "stack").then_some(())?; + parts.next().filter(|name| !name.is_empty()) +} + +fn random_suffix() -> String { + let bytes = [ + uuid::Uuid::new_v4().into_bytes(), + uuid::Uuid::new_v4().into_bytes(), + ] + .concat(); + bytes + .iter() + .take(SUFFIX_LEN) + .map(|b| ALPHABET[usize::from(*b) % ALPHABET.len()] as char) + .collect() +} + +/// Truncate `stack` and `logical` so both fit in `budget` characters, sharing +/// the space evenly and giving either one's unused share to the other. +fn fit(stack: &str, logical: &str, budget: usize) -> (String, String) { + let take = |s: &str, n: usize| s.chars().take(n).collect::(); + let (s_len, l_len) = (stack.chars().count(), logical.chars().count()); + if s_len + l_len <= budget { + return (stack.to_string(), logical.to_string()); + } + let half = budget / 2; + if s_len <= half { + (stack.to_string(), take(logical, budget - s_len)) + } else if l_len <= budget - half { + (take(stack, budget - l_len), logical.to_string()) + } else { + (take(stack, half), take(logical, budget - half)) + } +} + +/// Generate the physical name for `logical_id` in the stack `stack_id`. +pub(crate) fn generate(stack_id: &str, logical_id: &str, rule: NameRule) -> String { + generate_with_suffix(stack_id, logical_id, rule, &random_suffix()) +} + +fn generate_with_suffix(stack_id: &str, logical_id: &str, rule: NameRule, suffix: &str) -> String { + let sep = rule.separator; + let stack = stack_name(stack_id).filter(|_| rule.include_stack); + // Room left for the stack name and logical id once the suffix and the + // separators are accounted for. + let separators = if stack.is_some() { 2 } else { 1 }; + let budget = rule.max_len.saturating_sub(SUFFIX_LEN + separators); + let name = match stack { + Some(stack) => { + // A stack name may carry `-`, which a type with another separator + // does not accept either. + let stack = stack.replace('-', &sep.to_string()); + let (stack, logical) = fit(&stack, logical_id, budget); + // A cut right after a `-` in the stack name would double it. + let stack = stack.trim_end_matches(sep); + format!("{stack}{sep}{logical}{sep}{suffix}") + } + None => { + let logical: String = logical_id.chars().take(budget).collect(); + format!("{logical}{sep}{suffix}") + } + }; + let name: String = name.chars().take(rule.max_len).collect(); + if rule.lowercase { + name.to_lowercase() + } else { + name + } +} + +/// Whether `name` is one an older build gave an unnamed resource: the bare +/// logical id, `{LogicalId}-{8 hex}`, `cfn-{kind}-{LogicalId}`, or +/// `cfn-[{kind}-]{logicalid}-{8 alphanumerics}`. +fn is_legacy_name(name: &str, logical_id: &str) -> bool { + if name.eq_ignore_ascii_case(logical_id) { + return true; + } + let is_id8 = |s: &str, hex: bool| { + s.len() == 8 + && s.bytes().all(|b| { + if hex { + b.is_ascii_hexdigit() + } else { + b.is_ascii_alphanumeric() + } + }) + }; + if let Some((head, tail)) = name.rsplit_once('-') { + if head == logical_id && is_id8(tail, true) { + return true; + } + } + let tokens: Vec<&str> = name.split('-').collect(); + match tokens.as_slice() { + ["cfn", logical, id] | ["cfn", _, logical, id] + if *logical == logical_id.to_lowercase() && is_id8(id, false) => + { + true + } + ["cfn", _kind, logical] => *logical == logical_id, + _ => false, + } +} + +impl ResourceProvisioner { + /// The name CloudFormation gives `resource` when its template leaves the + /// name property out. + pub(crate) fn physical_name(&self, resource: &ResourceDefinition) -> String { + if let Some(name) = self.reused_names.lock().get(&resource.logical_id) { + return name.clone(); + } + generate( + &self.stack_id, + &resource.logical_id, + name_rule(&resource.resource_type), + ) + } + + /// Run `create` with `existing`'s name reserved for a resource it + /// generates a name for, so a resource re-created in place of `existing` + /// (an update fakecloud applies by re-provisioning) keeps its name. + pub(crate) fn with_existing_name( + &self, + existing: &StackResource, + create: impl FnOnce() -> T, + ) -> T { + let Some(name) = self.existing_name(existing) else { + return create(); + }; + self.reused_names + .lock() + .insert(existing.logical_id.clone(), name); + let out = create(); + self.reused_names.lock().remove(&existing.logical_id); + out + } + + /// The name `existing` was given when its template left the name out, + /// recovered from its physical id (a name, an ARN, a URL, `name:revision`). + /// Resources created before names were generated were named after their + /// logical id, which is recognized too. + pub(crate) fn existing_name(&self, existing: &StackResource) -> Option { + let rule = name_rule(&existing.resource_type); + let physical = &existing.physical_id; + let is_suffix_char = |c: char| { + if rule.lowercase { + c.is_ascii_lowercase() || c.is_ascii_digit() + } else { + c.is_ascii_uppercase() || c.is_ascii_digit() + } + }; + // A FIFO queue or topic was generated with room left for `.fifo`. + let mut rules = vec![rule]; + if matches!( + existing.resource_type.as_str(), + "AWS::SQS::Queue" | "AWS::SNS::Topic" + ) { + rules.push(NameRule { + max_len: rule.max_len - ".fifo".len(), + ..rule + }); + } + for rule in rules { + let prefix = generate_with_suffix(&self.stack_id, &existing.logical_id, rule, ""); + for (start, _) in physical.match_indices(&prefix) { + let rest = &physical[start + prefix.len()..]; + let suffix: String = rest.chars().take_while(|c| is_suffix_char(*c)).collect(); + if suffix.len() == SUFFIX_LEN { + return Some(format!("{prefix}{suffix}")); + } + } + } + // Only the part of the physical id that holds the name: the last path + // segment, without a `:revision` or `|detail`. + let last = physical.rsplit('/').next().unwrap_or(physical); + let last = last.split('|').next().unwrap_or(last); + let name = match last.rsplit_once(':') { + Some((name, revision)) if revision.bytes().all(|b| b.is_ascii_digit()) => name, + _ => last, + }; + let name = name.rsplit(':').next().unwrap_or(name); + is_legacy_name(name, &existing.logical_id).then(|| name.to_string()) + } + + /// `physical_name` for a resource type whose name must fit `max_len` + /// with a trailing `ending` (an SQS FIFO queue's `.fifo`). + pub(crate) fn physical_name_ending( + &self, + resource: &ResourceDefinition, + ending: &str, + ) -> String { + if let Some(name) = self.reused_names.lock().get(&resource.logical_id) { + // Recovered names stop short of the ending. + return if name.ends_with(ending) { + name.clone() + } else { + format!("{name}{ending}") + }; + } + let mut rule = name_rule(&resource.resource_type); + rule.max_len = rule.max_len.saturating_sub(ending.len()); + format!( + "{}{ending}", + generate(&self.stack_id, &resource.logical_id, rule) + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const STACK: &str = + "arn:aws:cloudformation:us-east-1:123456789012:stack/my-app/1b2c3d4e-0000-4000-8000-000000000000"; + + #[test] + fn names_follow_the_stack_logical_suffix_shape() { + let name = generate(STACK, "OrdersQueue", name_rule("AWS::SQS::Queue")); + let (prefix, suffix) = name.rsplit_once('-').unwrap(); + assert_eq!(prefix, "my-app-OrdersQueue"); + assert_eq!(suffix.len(), SUFFIX_LEN); + assert!(suffix.bytes().all(|b| ALPHABET.contains(&b)), "{name}"); + } + + #[test] + fn every_generated_name_is_distinct() { + let rule = name_rule("AWS::SQS::Queue"); + // Two stacks with the same name (one per region), or a replacement in + // the same stack, never reuse a name. + assert_ne!(generate(STACK, "Q", rule), generate(STACK, "Q", rule)); + let fixed = "ABCDEFGHIJKLM"; + assert_eq!( + generate_with_suffix(STACK, "Q", rule, fixed), + "my-app-Q-ABCDEFGHIJKLM" + ); + } + + #[test] + fn long_parts_are_truncated_to_the_type_limit() { + let stack = STACK.replace("my-app", &"s".repeat(100)); + let name = generate( + &stack, + &"L".repeat(100), + name_rule("AWS::ElasticLoadBalancingV2::LoadBalancer"), + ); + assert_eq!(name.len(), 32, "{name}"); + // Both parts survive the cut. + assert!(name.starts_with("ss"), "{name}"); + assert!(name.contains("-LL"), "{name}"); + + // A short stack name leaves the logical id the rest of the room. + let name = generate( + STACK, + &"L".repeat(100), + name_rule("AWS::ElasticLoadBalancingV2::LoadBalancer"), + ); + assert_eq!(name.len(), 32, "{name}"); + assert!(name.starts_with("my-app-LLLLLLLLL"), "{name}"); + } + + #[test] + fn a_cut_after_a_hyphen_does_not_double_it() { + // 32-char limit: budget 17 split 8/9, and "my-apps-" is cut after "-". + let stack = STACK.replace("my-app", "my-apps-production"); + let name = generate_with_suffix( + &stack, + "LoadBalancerMain", + name_rule("AWS::ElasticLoadBalancingV2::LoadBalancer"), + "ABCDEFGHIJKLM", + ); + assert!(!name.contains("--"), "{name}"); + assert!(name.len() <= 32, "{name}"); + } + + #[test] + fn lowercase_types_are_lowercased() { + let name = generate(STACK, "AssetsBucket", name_rule("AWS::S3::Bucket")); + assert_eq!(name, name.to_lowercase()); + assert!(name.starts_with("my-app-assetsbucket-"), "{name}"); + } + + #[test] + fn some_types_leave_the_stack_name_out() { + let name = generate(STACK, "Flow", name_rule("AWS::StepFunctions::StateMachine")); + assert!(name.starts_with("Flow-"), "{name}"); + assert!(!name.contains("my-app"), "{name}"); + } + + #[test] + fn a_stack_id_that_is_not_an_arn_has_no_stack_part() { + let name = generate( + "cloudcontrol-1234", + "Resource", + name_rule("AWS::SQS::Queue"), + ); + assert!(name.starts_with("Resource-"), "{name}"); + } + + #[test] + fn names_from_older_builds_are_recognized() { + assert!(is_legacy_name("MySet", "MySet")); + assert!(is_legacy_name("cfn-cs-MySet", "MySet")); + assert!(is_legacy_name("cfn-db-4f3a9c1e", "Db")); + assert!(is_legacy_name("cfn-cluster-db-x9k2m4pq", "Db")); + assert!(is_legacy_name("Flow-1a2b3c4d", "Flow")); + assert!(!is_legacy_name("orders", "MySet")); + assert!(!is_legacy_name("Flow-notahexx", "Flow")); + assert!(!is_legacy_name("cfn-x-other-Batch", "Batch")); + } + + #[test] + fn identity_pools_avoid_hyphens() { + let name = generate(STACK, "Pool", name_rule("AWS::Cognito::IdentityPool")); + assert!(name.starts_with("my_app_Pool_"), "{name}"); + } +} diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/opensearch.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/opensearch.rs index 1f1968604..4195219f5 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/opensearch.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/opensearch.rs @@ -40,7 +40,7 @@ impl ResourceProvisioner { .get("DomainName") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let region = &self.region; let account = &self.account_id; let arn = domain_arn(region, account, &name); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/organizations.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/organizations.rs index 31f6c1f67..ac7f7502e 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/organizations.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/organizations.rs @@ -46,10 +46,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let parent_id = props .get("ParentId") @@ -226,10 +227,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let description = props .get("Description") @@ -368,11 +370,11 @@ impl ResourceProvisioner { ) -> Result { let props = &resource.properties; let id = existing.physical_id.clone(); + // Leaving Name out of an update keeps the OU's name. let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) - .to_string(); + .map(str::to_string); let mut org_lock = self.organizations_state.write(); let org = org_lock @@ -384,7 +386,10 @@ impl ResourceProvisioner { .ok_or_else(|| format!("Organizational unit {id} not yet provisioned"))?; // Name is the only in-place-mutable OU property; id/arn/parent_id and // the OU's policy attachments are preserved. - ou.name = name.clone(); + if let Some(name) = name { + ou.name = name; + } + let name = ou.name.clone(); let arn = ou.arn.clone(); Ok(ProvisionResult::new(id.clone()) @@ -467,11 +472,11 @@ impl ResourceProvisioner { ) -> Result { let props = &resource.properties; let id = existing.physical_id.clone(); + // Leaving Name out of an update keeps the policy's name. let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) - .to_string(); + .map(str::to_string); let description = props .get("Description") .and_then(|v| v.as_str()) @@ -501,16 +506,18 @@ impl ResourceProvisioner { let org = org_lock .as_mut() .ok_or_else(|| "Organization not yet created".to_string())?; - let arn = { + let (arn, name) = { let policy = org .policies .get_mut(&id) .ok_or_else(|| format!("Policy {id} not yet provisioned"))?; // Name/Description/Content update in place; id/arn/type preserved. - policy.name = name.clone(); + if let Some(name) = name { + policy.name = name; + } policy.description = description; policy.content = content; - policy.arn.clone() + (policy.arn.clone(), policy.name.clone()) }; // Reconcile attachments to the desired TargetIds: detach from all // targets, then attach to the requested set (preserves the policy id). diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/pipes.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/pipes.rs index 522e45284..9657c1a2d 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/pipes.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/pipes.rs @@ -52,7 +52,7 @@ impl ResourceProvisioner { .get("Name") .and_then(Value::as_str) .map(String::from) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); // Apply the same validation the direct CreatePipe handler enforces, so a // CFN-created pipe can't slip past name/ARN constraints the API rejects. fakecloud_pipes::validate_pipe_name(&name).map_err(|e| e.message())?; diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/rds.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/rds.rs index 89e83e48b..769dd1ed4 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/rds.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/rds.rs @@ -13,10 +13,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("DBSubnetGroupName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let description = props .get("DBSubnetGroupDescription") @@ -61,10 +62,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("DBParameterGroupName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let family = props .get("Family") @@ -115,11 +117,12 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("DBClusterParameterGroupName") .or_else(|| props.get("Name")) .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let family = props .get("Family") @@ -164,10 +167,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("OptionGroupName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let engine_name = props .get("EngineName") @@ -215,10 +219,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("SubscriptionName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let sns_topic_arn = props .get("SnsTopicArn") @@ -251,10 +256,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("DBSecurityGroupName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let description = props .get("GroupDescription") @@ -286,10 +292,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("DBProxyName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let engine_family = props .get("EngineFamily") @@ -334,13 +341,7 @@ impl ResourceProvisioner { .get("DBInstanceIdentifier") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| { - format!( - "cfn-{}-{}", - resource.logical_id.to_lowercase(), - fakecloud_core::ids::short_id(8).to_lowercase() - ) - }); + .unwrap_or_else(|| self.physical_name(resource)); let class = props .get("DBInstanceClass") .and_then(|v| v.as_str()) @@ -804,13 +805,7 @@ impl ResourceProvisioner { .get("DBClusterIdentifier") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| { - format!( - "cfn-cluster-{}-{}", - resource.logical_id.to_lowercase(), - fakecloud_core::ids::short_id(8).to_lowercase() - ) - }); + .unwrap_or_else(|| self.physical_name(resource)); let engine = props .get("Engine") .and_then(|v| v.as_str()) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/redshiftlike.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/redshiftlike.rs index 6f4ba337b..76c012a6e 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/redshiftlike.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/redshiftlike.rs @@ -76,20 +76,14 @@ fn props_to_query(props: &Value) -> HashMap { params } -/// Derive a stable cluster identifier from the template (or synthesize one when -/// the property is omitted, as CloudFormation does). -fn cluster_identifier(props: &Value, key: &str, logical_id: &str) -> String { +/// The cluster identifier from the template, or the name CloudFormation +/// generates when the property is omitted. +fn cluster_identifier(props: &Value, key: &str, generated: impl FnOnce() -> String) -> String { props .get(key) .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| { - format!( - "cfn-{}-{}", - logical_id.to_lowercase(), - fakecloud_core::ids::short_id(8).to_lowercase() - ) - }) + .unwrap_or_else(generated) } impl ResourceProvisioner { @@ -100,7 +94,7 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; - let id = cluster_identifier(props, "ClusterIdentifier", &resource.logical_id); + let id = cluster_identifier(props, "ClusterIdentifier", || self.physical_name(resource)); let mut params = props_to_query(props); params.insert("ClusterIdentifier".to_string(), id.clone()); @@ -198,7 +192,9 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; - let id = cluster_identifier(props, "DBClusterIdentifier", &resource.logical_id); + let id = cluster_identifier(props, "DBClusterIdentifier", || { + self.physical_name(resource) + }); let mut params = props_to_query(props); params.insert("DBClusterIdentifier".to_string(), id.clone()); // `AWS::DocDB::DBCluster` has no Engine property (always docdb); the @@ -299,7 +295,9 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; - let id = cluster_identifier(props, "DBClusterIdentifier", &resource.logical_id); + let id = cluster_identifier(props, "DBClusterIdentifier", || { + self.physical_name(resource) + }); let mut params = props_to_query(props); params.insert("DBClusterIdentifier".to_string(), id.clone()); // `AWS::Neptune::DBCluster` has no Engine property (always neptune); the diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/route.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/route.rs index b5584ac82..af96d4f07 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/route.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/route.rs @@ -57,7 +57,7 @@ impl ResourceProvisioner { let zone = StoredHostedZone { id: id.clone(), name: normalized_name, - caller_reference: format!("cfn-{}", resource.logical_id), + caller_reference: self.physical_name(resource), comment, private_zone, features: Some(HostedZoneFeatures::default()), @@ -321,7 +321,7 @@ impl ResourceProvisioner { let id = Uuid::new_v4().to_string(); let hc = StoredHealthCheck { id: id.clone(), - caller_reference: format!("cfn-{}", resource.logical_id), + caller_reference: self.physical_name(resource), version: 1, config: cfg, created_time: Utc::now(), diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/s3.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/s3.rs index 19187e933..454a94b50 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/s3.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/s3.rs @@ -35,10 +35,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let bucket_name = props .get("BucketName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id); + .unwrap_or(&generated_name); let mut __s3_mas = self.s3_state.write(); let state = __s3_mas.get_or_create(&self.account_id); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/sagemaker.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/sagemaker.rs index feec25ff4..a5632623b 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/sagemaker.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/sagemaker.rs @@ -66,7 +66,7 @@ impl ResourceProvisioner { .get(key_prop) .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let region = &self.region; let account = &self.account_id; let arn = format!("arn:aws:sagemaker:{region}:{account}:{arn_path}/{name}"); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/secrets.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/secrets.rs index d8801440e..134d28171 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/secrets.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/secrets.rs @@ -29,10 +29,11 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + let generated_name = self.physical_name(resource); let name = props .get("Name") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let description = props .get("Description") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/servicediscovery.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/servicediscovery.rs index 26f992096..35d642e25 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/servicediscovery.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/servicediscovery.rs @@ -276,10 +276,11 @@ impl ResourceProvisioner { .map(|s| resource_id_from(s).to_string()) .ok_or("ServiceId is required")?; // CFN uses `InstanceId`; it defaults to the logical id when omitted. + let generated_name = self.physical_name(resource); let instance_id = props .get("InstanceId") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id) + .unwrap_or(&generated_name) .to_string(); let attributes: BTreeMap = props .get("InstanceAttributes") diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/ses.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/ses.rs index 832e24882..5307c5a36 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/ses.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/ses.rs @@ -101,7 +101,7 @@ impl ResourceProvisioner { .get("Name") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| format!("cfn-cs-{}", resource.logical_id)); + .unwrap_or_else(|| self.physical_name(resource)); let sending_enabled = props .get("SendingOptions") .and_then(|v| v.get("SendingEnabled")) @@ -200,7 +200,7 @@ impl ResourceProvisioner { .get("Name") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| format!("cfn-ed-{}", resource.logical_id)); + .unwrap_or_else(|| self.physical_name(resource)); let enabled = dest_props .get("Enabled") .and_then(|v| v.as_bool()) @@ -353,7 +353,7 @@ impl ResourceProvisioner { .get("TemplateName") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| format!("cfn-tpl-{}", resource.logical_id)); + .unwrap_or_else(|| self.physical_name(resource)); let tpl = SesEmailTemplate { template_name: template_name.clone(), subject: template_block @@ -392,7 +392,7 @@ impl ResourceProvisioner { .get("ContactListName") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| format!("cfn-cl-{}", resource.logical_id)); + .unwrap_or_else(|| self.physical_name(resource)); let description = props .get("Description") .and_then(|v| v.as_str()) @@ -428,7 +428,7 @@ impl ResourceProvisioner { .get("PoolName") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| format!("cfn-pool-{}", resource.logical_id)); + .unwrap_or_else(|| self.physical_name(resource)); let scaling_mode = props .get("ScalingMode") .and_then(|v| v.as_str()) @@ -460,7 +460,7 @@ impl ResourceProvisioner { .get("RuleSetName") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| format!("cfn-rs-{}", resource.logical_id)); + .unwrap_or_else(|| self.physical_name(resource)); let rs = SesReceiptRuleSet { name: name.clone(), rules: Vec::new(), @@ -497,7 +497,7 @@ impl ResourceProvisioner { .get("Name") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| format!("cfn-rule-{}", resource.logical_id)); + .unwrap_or_else(|| self.physical_name(resource)); let enabled = rule_block .get("Enabled") .and_then(|v| v.as_bool()) @@ -581,7 +581,7 @@ impl ResourceProvisioner { .get("Name") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| format!("cfn-filter-{}", resource.logical_id)); + .unwrap_or_else(|| self.physical_name(resource)); let ip_block = filter_block .get("IpFilter") .and_then(|v| v.as_object()) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/sns.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/sns.rs index 1211767c2..bf8626243 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/sns.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/sns.rs @@ -98,10 +98,19 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + // An unnamed FIFO topic still needs the `.fifo` ending SNS requires. + let fifo_requested = props + .get("FifoTopic") + .is_some_and(|v| v.as_bool() == Some(true) || v.as_str() == Some("true")); + let generated_name = if fifo_requested { + self.physical_name_ending(resource, ".fifo") + } else { + self.physical_name(resource) + }; let topic_name = props .get("TopicName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id); + .unwrap_or(&generated_name); let mut __sns_mas = self.sns_state.write(); let state = __sns_mas.get_or_create(&self.account_id); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/sqs.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/sqs.rs index e4d32448f..b9eadd783 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/sqs.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/sqs.rs @@ -75,10 +75,19 @@ impl ResourceProvisioner { resource: &ResourceDefinition, ) -> Result { let props = &resource.properties; + // An unnamed FIFO queue still needs the `.fifo` ending SQS requires. + let fifo_requested = props + .get("FifoQueue") + .is_some_and(|v| v.as_bool() == Some(true) || v.as_str() == Some("true")); + let generated_name = if fifo_requested { + self.physical_name_ending(resource, ".fifo") + } else { + self.physical_name(resource) + }; let queue_name = props .get("QueueName") .and_then(|v| v.as_str()) - .unwrap_or(&resource.logical_id); + .unwrap_or(&generated_name); let mut __sqs_mas = self.sqs_state.write(); let state = __sqs_mas.get_or_create(&self.account_id); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/stepfunctions.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/stepfunctions.rs index fa9e03d67..d164218ba 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/stepfunctions.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/stepfunctions.rs @@ -17,10 +17,7 @@ impl ResourceProvisioner { .get("StateMachineName") .and_then(|v| v.as_str()) .map(String::from) - .unwrap_or_else(|| { - let suffix = Uuid::new_v4().simple().to_string(); - format!("{}-{}", resource.logical_id, &suffix[..8]) - }); + .unwrap_or_else(|| self.physical_name(resource)); let role_arn = props .get("RoleArn") .and_then(|v| v.as_str()) diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/timestream.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/timestream.rs index fc1c142c7..7c9257da1 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/timestream.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/timestream.rs @@ -27,7 +27,7 @@ impl ResourceProvisioner { .get("DatabaseName") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let region = &self.region; let account = &self.account_id; let arn = database_arn(region, account, &name); @@ -151,7 +151,7 @@ impl ResourceProvisioner { .get("TableName") .and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| resource.logical_id.clone()); + .unwrap_or_else(|| self.physical_name(resource)); let region = &self.region; let account = &self.account_id; let arn = table_arn(region, account, &database, &table); diff --git a/crates/fakecloud-cloudformation/src/service.rs b/crates/fakecloud-cloudformation/src/service.rs index 31ab7db30..e6b7c14d9 100644 --- a/crates/fakecloud-cloudformation/src/service.rs +++ b/crates/fakecloud-cloudformation/src/service.rs @@ -1159,6 +1159,7 @@ impl CloudFormationService { // CreateStack + changeset/update/delete accept unmodeled types; only // the Cloud Control bridge flips this on to reject them. strict_unknown_types: false, + reused_names: Default::default(), } } diff --git a/crates/fakecloud-cloudformation/src/stack_sets.rs b/crates/fakecloud-cloudformation/src/stack_sets.rs index d1021189f..fa1d72445 100644 --- a/crates/fakecloud-cloudformation/src/stack_sets.rs +++ b/crates/fakecloud-cloudformation/src/stack_sets.rs @@ -3925,9 +3925,9 @@ mod tests { const ADMIN: &str = "000000000000"; const ACCT_B: &str = "111111111111"; const ACCT_C: &str = "222222222222"; - // Unnamed resources are named after their logical id, so the queue is named - // after its stack to keep instances in one account apart. - const QUEUE_TEMPLATE: &str = "Parameters:\n Env:\n Type: String\n Default: dev\nResources:\n Q:\n Type: AWS::SQS::Queue\n Properties:\n QueueName:\n Fn::Sub: \"${AWS::StackName}-q\"\n"; + // The queue is unnamed: CloudFormation generates a distinct name per stack, + // so instances in one account (one per region) do not collide. + const QUEUE_TEMPLATE: &str = "Parameters:\n Env:\n Type: String\n Default: dev\nResources:\n Q:\n Type: AWS::SQS::Queue\n"; const TOPIC_TEMPLATE: &str = "Parameters:\n Env:\n Type: String\n Default: dev\nResources:\n T:\n Type: AWS::SNS::Topic\n"; fn service_with(deps: CloudFormationDeps) -> CloudFormationService { diff --git a/crates/fakecloud-e2e/tests/cloudformation_autoscaling.rs b/crates/fakecloud-e2e/tests/cloudformation_autoscaling.rs index 122ce5c40..662aeea10 100644 --- a/crates/fakecloud-e2e/tests/cloudformation_autoscaling.rs +++ b/crates/fakecloud-e2e/tests/cloudformation_autoscaling.rs @@ -126,7 +126,11 @@ async fn cfn_asg_honors_launch_template() { let g = groups .auto_scaling_groups() .iter() - .find(|g| g.auto_scaling_group_name() == Some("ASG")) + // No AutoScalingGroupName, so the name is generated from the stack. + .find(|g| { + g.auto_scaling_group_name() + .is_some_and(|n| n.starts_with("asg-lt-stack-ASG-")) + }) .expect("CFN ASG exists"); let lt = g .launch_template() diff --git a/crates/fakecloud-e2e/tests/cloudformation_elasticbeanstalk.rs b/crates/fakecloud-e2e/tests/cloudformation_elasticbeanstalk.rs index ad1edc42a..e89d5dcf9 100644 --- a/crates/fakecloud-e2e/tests/cloudformation_elasticbeanstalk.rs +++ b/crates/fakecloud-e2e/tests/cloudformation_elasticbeanstalk.rs @@ -105,9 +105,18 @@ async fn cfn_provisions_elastic_beanstalk_resources() { // --- Ref resolution (verified against the AWS resource specs) --- assert_eq!(output(stack, "AppRef"), "cfn-eb-app"); - // VersionLabel/TemplateName were omitted, so they default to the logical id. - assert_eq!(output(stack, "VersionRef"), "MyVersion"); - assert_eq!(output(stack, "TemplateRef"), "MyTemplate"); + // VersionLabel/TemplateName were omitted, so CloudFormation generates + // `{StackName}-{LogicalId}-{SUFFIX}` names for them. + let version_label = output(stack, "VersionRef").to_string(); + let template_name = output(stack, "TemplateRef").to_string(); + assert!( + version_label.starts_with("eb-stack-MyVersion-"), + "{version_label}" + ); + assert!( + template_name.starts_with("eb-stack-MyTemplate-"), + "{template_name}" + ); assert_eq!(output(stack, "EnvRef"), "cfn-eb-env"); // --- Environment EndpointURL GetAtt --- @@ -142,15 +151,15 @@ async fn cfn_provisions_elastic_beanstalk_resources() { versions .application_versions() .iter() - .any(|v| v.version_label() == Some("MyVersion")), - "expected version MyVersion" + .any(|v| v.version_label() == Some(version_label.as_str())), + "expected version {version_label}" ); // --- The ConfigurationTemplate exists (and copied the option setting) --- let settings = eb .describe_configuration_settings() .application_name("cfn-eb-app") - .template_name("MyTemplate") + .template_name(&template_name) .send() .await .expect("DescribeConfigurationSettings"); @@ -159,7 +168,7 @@ async fn cfn_provisions_elastic_beanstalk_resources() { .first() .expect("configuration settings present"); assert_eq!(cfg.application_name(), Some("cfn-eb-app")); - assert_eq!(cfg.template_name(), Some("MyTemplate")); + assert_eq!(cfg.template_name(), Some(template_name.as_str())); assert!( cfg.option_settings().iter().any(|o| { o.namespace() == Some("aws:autoscaling:launchconfiguration") diff --git a/crates/fakecloud-e2e/tests/cloudformation_generated_names.rs b/crates/fakecloud-e2e/tests/cloudformation_generated_names.rs new file mode 100644 index 000000000..cbbe7feb5 --- /dev/null +++ b/crates/fakecloud-e2e/tests/cloudformation_generated_names.rs @@ -0,0 +1,168 @@ +//! CloudFormation generates names for resources whose name property the +//! template leaves out: `{StackName}-{LogicalId}-{SUFFIX}`, so two stacks from +//! one template never collide. + +mod helpers; + +use helpers::TestServer; + +const TEMPLATE: &str = r#"{ + "Resources": { + "Jobs": { "Type": "AWS::SQS::Queue" }, + "Ordered": { "Type": "AWS::SQS::Queue", "Properties": { "FifoQueue": true } }, + "Assets": { "Type": "AWS::S3::Bucket" }, + "Events": { "Type": "AWS::SNS::Topic" } + }, + "Outputs": { + "JobsUrl": { "Value": { "Ref": "Jobs" } }, + "OrderedUrl": { "Value": { "Ref": "Ordered" } }, + "Bucket": { "Value": { "Ref": "Assets" } }, + "Topic": { "Value": { "Ref": "Events" } } + } +}"#; + +async fn outputs( + cfn: &aws_sdk_cloudformation::Client, + stack: &str, +) -> std::collections::BTreeMap { + let described = cfn + .describe_stacks() + .stack_name(stack) + .send() + .await + .unwrap(); + let stack = &described.stacks()[0]; + assert_eq!( + stack.stack_status().map(|s| s.as_str()), + Some("CREATE_COMPLETE"), + "{:?}", + stack.stack_status_reason() + ); + stack + .outputs() + .iter() + .map(|o| { + ( + o.output_key().unwrap().to_string(), + o.output_value().unwrap().to_string(), + ) + }) + .collect() +} + +#[tokio::test] +async fn stacks_from_one_template_get_distinct_generated_names() { + let server = TestServer::start().await; + let cfn = server.cloudformation_client().await; + let sqs = server.sqs_client().await; + let s3 = server.s3_client().await; + + for stack in ["dev-app", "prod-app"] { + cfn.create_stack() + .stack_name(stack) + .template_body(TEMPLATE) + .send() + .await + .unwrap_or_else(|e| panic!("create {stack}: {e:?}")); + } + let dev = outputs(&cfn, "dev-app").await; + let prod = outputs(&cfn, "prod-app").await; + + for (key, value) in &dev { + assert_ne!(Some(value), prod.get(key), "{key} collided across stacks"); + } + + let queue_name = |url: &str| url.rsplit('/').next().unwrap().to_string(); + let jobs = queue_name(&dev["JobsUrl"]); + let (prefix, suffix) = jobs.rsplit_once('-').unwrap(); + assert_eq!(prefix, "dev-app-Jobs"); + assert_eq!(suffix.len(), 13, "{jobs}"); + + let ordered = queue_name(&dev["OrderedUrl"]); + assert!(ordered.starts_with("dev-app-Ordered-"), "{ordered}"); + assert!(ordered.ends_with(".fifo"), "{ordered}"); + let attrs = sqs + .get_queue_attributes() + .queue_url(&dev["OrderedUrl"]) + .attribute_names(aws_sdk_sqs::types::QueueAttributeName::FifoQueue) + .send() + .await + .unwrap(); + assert_eq!( + attrs + .attributes() + .and_then(|a| a.get(&aws_sdk_sqs::types::QueueAttributeName::FifoQueue)) + .map(String::as_str), + Some("true") + ); + + // Bucket names are lowercase. + let bucket = &dev["Bucket"]; + assert!(bucket.starts_with("dev-app-assets-"), "{bucket}"); + assert_eq!(bucket, &bucket.to_lowercase()); + s3.head_bucket().bucket(bucket).send().await.unwrap(); + + assert!( + dev["Topic"].contains(":dev-app-Events-"), + "{}", + dev["Topic"] + ); + + let queues = sqs.list_queues().send().await.unwrap(); + assert_eq!(queues.queue_urls().len(), 4, "{:?}", queues.queue_urls()); +} + +#[tokio::test] +async fn an_update_keeps_a_generated_name() { + let server = TestServer::start().await; + let cfn = server.cloudformation_client().await; + let sqs = server.sqs_client().await; + + let v1 = r#"{"Resources":{"Jobs":{"Type":"AWS::SQS::Queue"}},"Outputs":{"Url":{"Value":{"Ref":"Jobs"}}}}"#; + let v2 = r#"{"Resources":{"Jobs":{"Type":"AWS::SQS::Queue","Properties":{"VisibilityTimeout":90}}},"Outputs":{"Url":{"Value":{"Ref":"Jobs"}}}}"#; + cfn.create_stack() + .stack_name("keep") + .template_body(v1) + .send() + .await + .unwrap(); + let before = outputs(&cfn, "keep").await["Url"].clone(); + cfn.update_stack() + .stack_name("keep") + .template_body(v2) + .send() + .await + .unwrap(); + let described = cfn + .describe_stacks() + .stack_name("keep") + .send() + .await + .unwrap(); + let stack = &described.stacks()[0]; + assert_eq!( + stack.stack_status().map(|s| s.as_str()), + Some("UPDATE_COMPLETE") + ); + let after = stack + .outputs() + .iter() + .find(|o| o.output_key() == Some("Url")) + .and_then(|o| o.output_value()) + .unwrap(); + assert_eq!(before, after); + let attrs = sqs + .get_queue_attributes() + .queue_url(after) + .attribute_names(aws_sdk_sqs::types::QueueAttributeName::VisibilityTimeout) + .send() + .await + .unwrap(); + assert_eq!( + attrs + .attributes() + .and_then(|a| a.get(&aws_sdk_sqs::types::QueueAttributeName::VisibilityTimeout)) + .map(String::as_str), + Some("90") + ); +} diff --git a/crates/fakecloud-e2e/tests/cloudformation_sam_events.rs b/crates/fakecloud-e2e/tests/cloudformation_sam_events.rs index 564d015b2..4ac1023ad 100644 --- a/crates/fakecloud-e2e/tests/cloudformation_sam_events.rs +++ b/crates/fakecloud-e2e/tests/cloudformation_sam_events.rs @@ -76,7 +76,8 @@ async fn sam_function_expands_policies_and_events() { let role = roles .roles() .iter() - .find(|r| r.role_name() == "WorkerRole") + // The synthesized role has no RoleName, so it is named after the stack. + .find(|r| r.role_name().starts_with("sam-events-WorkerRole-")) .expect("WorkerRole synthesized from Policies"); let attached = iam .list_attached_role_policies() @@ -110,7 +111,11 @@ async fn sam_function_expands_policies_and_events() { let rule = rules .rules() .iter() - .find(|r| r.name() == Some("WorkerTickRule")) + // Unnamed, so named after the stack like any generated resource. + .find(|r| { + r.name() + .is_some_and(|n| n.starts_with("sam-events-WorkerTickRule-")) + }) .expect("WorkerTickRule synthesized from Schedule event"); let targets = events .list_targets_by_rule() diff --git a/website/content/docs/services/cloudformation.md b/website/content/docs/services/cloudformation.md index fdbae6cca..20cb65f7f 100644 --- a/website/content/docs/services/cloudformation.md +++ b/website/content/docs/services/cloudformation.md @@ -27,6 +27,7 @@ Query protocol. Form-encoded body, `Action` parameter, XML responses. Templates ## Stack lifecycle - **`CreateStack` / `UpdateStack` / `DeleteStack`** — drive real provisioning against the other fakecloud services. Resources are created in topological order based on `Ref` / `Fn::GetAtt` / `DependsOn` edges; updates compute a diff and call the per-type updater; deletes walk in reverse order and respect `DeletionPolicy: Retain` / `Snapshot` / `RetainExceptOnCreate` (the physical resource is left in place instead of being destroyed). A resource replaced by an update honors its `UpdateReplacePolicy` the same way, so the old physical resource is preserved when the policy is `Retain` / `Snapshot`. (`Snapshot` is treated as retain — the resource is preserved rather than snapshot-then-deleted.) +- **Generated names** — a resource whose name property the template leaves out is named the way CloudFormation names it: `{StackName}-{LogicalId}-{SUFFIX}`, with a random 13-character suffix, truncated to the type's name limit and lowercased where the type requires it (S3 buckets, RDS and ElastiCache identifiers, ECR repositories, ...). State machines and secrets are named `{LogicalId}-{SUFFIX}`, and a nested stack `{ParentStack}-{LogicalId}-{SUFFIX}`. Stacks built from the same template therefore never collide. An update that still leaves the name out keeps the resource's name; a replacement gets a new one. An unnamed FIFO queue (`FifoQueue: true`) ends in `.fifo`. - **Stack events** — each stage transition (`CREATE_IN_PROGRESS`, `CREATE_COMPLETE`, `UPDATE_ROLLBACK_*`, `DELETE_*`, etc.) emits a real `StackEvent` with timestamp, logical/physical IDs, and resource type. A failing transition also carries `ResourceStatusReason`. `DescribeStackEvents` returns them in reverse-chronological order, matching AWS. - **Failure reporting** — a template that is a CloudFormation document but cannot be parsed (a syntax error, a resource with no `Type`, an unresolvable condition, malformed `Fn::ForEach`) is rejected up front with a `ValidationError` naming the problem, and no stack record is created — so fixing the template and redeploying under the same name just works. The same applies to a `TemplateURL` that is unusable or resolves to nothing. `CREATE_FAILED` is reserved for failures that happen once provisioning has begun, and carries the reason in `StackStatusReason`; `DescribeStacks` and `ListStacks` surface it whenever a stack has one, and `DescribeStackEvents` carries `ResourceStatusReason` on the failing event. - **`DescribeStacks` / `DescribeStackResource` / `DescribeStackResources` / `ListStackResources`** — read from persisted state, including the resolved physical ID for every provisioned resource. @@ -183,7 +184,7 @@ aws --endpoint-url http://localhost:4566 cloudformation list-exports - **Not every resource type provisions something.** Types in the provisioner list above create real backing state. Anything else (the remaining `AWS::EC2::*` types such as `NatGateway` / `Route`, etc.) is recorded but has no underlying resource, so a follow-up call against that service will 404. - **Drift only detects deleted resources.** A resource removed outside CloudFormation reports `DELETED`; property changes made outside CloudFormation are not compared. -- **Unnamed resources are named after their logical ID.** Two stacks from one template in the same account (such as stack instances of one stack set in two regions) collide on resources that take a name, unless the template names them per stack, for example `QueueName: !Sub "${AWS::StackName}-queue"`. +- **Explicit names are per account, not per region.** A resource the template names (`QueueName: orders`) is one resource per account in fakecloud, so deploying that template to two regions of the same account (a stack set, say) collides where AWS would keep the regions apart. Leave the name out, or include `${AWS::Region}` in it. - **SAM expansion runs at create time.** A re-uploaded template still requires `Capabilities=[CAPABILITY_AUTO_EXPAND]` on operations that touch transforms. ## Source