Skip to main content

fakecloud_cloudformation/resource_provisioner/
mod.rs

1use chrono::Utc;
2use parking_lot::RwLock;
3use std::collections::BTreeMap;
4use std::sync::Arc;
5use uuid::Uuid;
6
7use crate::state::SharedCloudFormationState;
8use fakecloud_acm::{
9    CertificateOptions as AcmCertificateOptions, DomainValidation as AcmDomainValidation,
10    RenewalSummary as AcmRenewalSummary, SharedAcmState, StoredCertificate as AcmStoredCertificate,
11};
12use fakecloud_acmpca::SharedAcmPcaState;
13use fakecloud_apigateway::{
14    make_id as apigw_make_id, ApiKey as ApiGwApiKey, Authorizer as ApiGwAuthorizer,
15    Deployment as ApiGwDeployment, Integration as ApiGwIntegration, Method as ApiGwMethod,
16    Model as ApiGwModel, Resource as ApiGwResource, RestApi as ApiGwRestApi, SharedApiGatewayState,
17    Stage as ApiGwStage, UsagePlan as ApiGwUsagePlan,
18};
19use fakecloud_apigatewayv2::{
20    Authorizer as ApiGwV2Authorizer, CorsConfiguration as ApiGwV2CorsConfiguration,
21    Deployment as ApiGwV2Deployment, HttpApi as ApiGwV2HttpApi, Integration as ApiGwV2Integration,
22    JwtConfiguration as ApiGwV2JwtConfiguration, Route as ApiGwV2Route, SharedApiGatewayV2State,
23    Stage as ApiGwV2Stage,
24};
25use fakecloud_application_autoscaling::{
26    ScalableTarget as AppasScalableTarget, ScalingPolicy as AppasScalingPolicy,
27    SharedApplicationAutoScalingState as AppasState, SuspendedState as AppasSuspendedState,
28};
29use fakecloud_athena::{DataCatalog, NamedQuery, PreparedStatement, SharedAthenaState, WorkGroup};
30use fakecloud_aws::arn::Arn;
31use fakecloud_cloudfront::{
32    functions::{
33        CloudFrontOriginAccessIdentityConfig, FunctionConfig, KeyGroupConfig, KeyGroupItems,
34        PublicKeyConfig, StoredFunction, StoredKeyGroup, StoredOriginAccessIdentity,
35        StoredPublicKey,
36    },
37    model::{
38        DefaultCacheBehavior, DistributionConfig, Origin, OriginItems, Origins, ViewerCertificate,
39    },
40    policies::{
41        CachePolicyConfig, OriginAccessControlConfig, OriginRequestPolicyConfig,
42        OriginRequestPolicyCookiesConfig, OriginRequestPolicyHeadersConfig,
43        OriginRequestPolicyQueryStringsConfig, ResponseHeadersPolicyConfig, StoredCachePolicy,
44        StoredOriginAccessControl, StoredOriginRequestPolicy, StoredResponseHeadersPolicy,
45    },
46    SharedCloudFrontState, StoredDistribution,
47};
48use fakecloud_cloudwatch::{
49    AlarmMetricQuery, AlarmMetricStat, AlarmState, Dashboard, MetricAlarm, SharedCloudWatchState,
50};
51use fakecloud_cognito::{
52    default_schema_attributes, AccountRecoverySetting, AdminCreateUserConfig,
53    CognitoIdentityProvider, CustomDomainConfig, EmailConfiguration, IdentityPool,
54    IdentityPoolRoleAttachment, PasswordPolicy, PoolPolicies, RecoveryOption, SchemaAttribute,
55    SharedCognitoState, SignInPolicy, SmsConfiguration, UserPool, UserPoolClient, UserPoolDomain,
56};
57use fakecloud_core::delivery::DeliveryBus;
58use fakecloud_dynamodb::{
59    AttributeDefinition, DynamoTable, KeySchemaElement, OnDemandThroughput, ProvisionedThroughput,
60    SharedDynamoDbState,
61};
62use fakecloud_ecr::{Repository, SharedEcrState};
63use fakecloud_ecs::{
64    CapacityProvider as EcsCapacityProvider, Cluster as EcsCluster, Service as EcsService,
65    SharedEcsState, TagEntry as EcsTagEntry, TaskDefinition as EcsTaskDefinition,
66};
67use fakecloud_eks::SharedEksState;
68use fakecloud_elasticache::{
69    CacheCluster as EcCacheCluster, CacheParameterGroup, CacheSecurityGroup, CacheSubnetGroup,
70    ElastiCacheUser as EcUser, ElastiCacheUserGroup as EcUserGroup,
71    ReplicationGroup as EcReplicationGroup, SharedElastiCacheState,
72};
73use fakecloud_elbv2::{
74    Action as ElbAction, Listener, LoadBalancer, Rule as ElbRule, RuleCondition, SharedElbv2State,
75    Tag as ElbTag, TargetGroup, TargetGroupTuple,
76};
77use fakecloud_eventbridge::{
78    ApiDestination, Archive, Connection, Endpoint, EventBus, EventRule, SharedEventBridgeState,
79};
80use fakecloud_firehose::{DeliveryStream, S3Destination};
81use fakecloud_iam::{
82    IamAccessKey, IamGroup, IamInstanceProfile, IamPolicy, IamRole, IamUser, OidcProvider,
83    PolicyVersion, SamlProvider, SharedIamState, Tag, VirtualMfaDevice,
84};
85use fakecloud_kinesis::{build_stream_shards, KinesisConsumer, KinesisStream, SharedKinesisState};
86use fakecloud_kms::provisioner as kms_provisioner;
87use fakecloud_kms::SharedKmsState;
88use fakecloud_lambda::{
89    AttachedLayer, EventSourceMapping, FunctionAlias, FunctionUrlConfig, Layer, LayerVersion,
90    SharedLambdaState,
91};
92use fakecloud_logs::{
93    Delivery, DeliveryDestination, DeliverySource, Destination, LogStream, MetricFilter,
94    MetricTransformation, QueryDefinition, ResourcePolicy, SharedLogsState, SubscriptionFilter,
95};
96use fakecloud_organizations::{
97    OrganizationState, OrganizationalUnit, Policy as OrgPolicy, SharedOrganizationsState,
98    POLICY_TYPE_SCP,
99};
100use fakecloud_persistence::{BucketSubresource, S3Store};
101use fakecloud_rds::{DbInstance, DbParameterGroup, DbSubnetGroup, RdsTag, SharedRdsState};
102use fakecloud_route53::{
103    model::{HealthCheckConfig, HostedZoneFeatures, ResourceRecordSet},
104    SharedRoute53State, StoredHealthCheck, StoredHostedZone,
105};
106use fakecloud_s3::persistence::bucket_meta_snapshot;
107use fakecloud_s3::{S3Bucket, SharedS3State};
108use fakecloud_secretsmanager::{RotationRules, Secret, SecretVersion, SharedSecretsManagerState};
109use fakecloud_servicediscovery::SharedServiceDiscoveryState;
110use fakecloud_ses::{
111    ConfigurationSet as SesConfigurationSet, ContactList as SesContactList,
112    DedicatedIpPool as SesDedicatedIpPool, EmailIdentity as SesEmailIdentity,
113    EmailTemplate as SesEmailTemplate, EventDestination as SesEventDestination,
114    IpFilter as SesIpFilter, ReceiptAction as SesReceiptAction, ReceiptFilter as SesReceiptFilter,
115    ReceiptRule as SesReceiptRule, ReceiptRuleSet as SesReceiptRuleSet, SharedSesState,
116};
117use fakecloud_sns::{SharedSnsState, SnsSubscription, SnsTopic};
118use fakecloud_sqs::{SharedSqsState, SqsQueue};
119use fakecloud_ssm::{SharedSsmState, SsmParameter};
120use fakecloud_stepfunctions::{
121    Activity as SfnActivity, AliasRoute, SharedStepFunctionsState, StateMachine, StateMachineAlias,
122    StateMachineStatus, StateMachineType, StateMachineVersion,
123};
124use fakecloud_wafv2::{IpSet, RegexPatternSet, RuleGroup, SharedWafv2State, WebAcl};
125
126use crate::state::StackResource;
127use crate::template::ResourceDefinition;
128
129/// Convert a CFN `Tags` property (`[{Key, Value}, ...]`) into the IAM
130/// crate's `Tag` Vec form. Silently skips malformed entries — the same
131/// tolerant behaviour the existing IAM service uses for runtime input.
132fn parse_iam_tags(value: Option<&serde_json::Value>) -> Vec<Tag> {
133    let Some(arr) = value.and_then(|v| v.as_array()) else {
134        return Vec::new();
135    };
136    arr.iter()
137        .filter_map(|t| {
138            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
139            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
140            Some(Tag { key, value })
141        })
142        .collect()
143}
144
145/// Mirror of `parse_iam_tags` but for the ELBv2 crate's separate `Tag`
146/// type. Same `[{Key, Value}, ...]` JSON shape, ignored on malformed entries.
147fn parse_elb_tags(value: Option<&serde_json::Value>) -> Vec<ElbTag> {
148    let Some(arr) = value.and_then(|v| v.as_array()) else {
149        return Vec::new();
150    };
151    arr.iter()
152        .filter_map(|t| {
153            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
154            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
155            Some(ElbTag { key, value })
156        })
157        .collect()
158}
159
160/// Translate CFN-shape Listener/ListenerRule actions into ELBv2 internal
161/// `Action`s. Only the action-type knobs CFN exposes are wired; anything
162/// not recognised becomes a bare action with no target.
163fn parse_elb_actions(value: Option<&serde_json::Value>) -> Vec<ElbAction> {
164    let Some(arr) = value.and_then(|v| v.as_array()) else {
165        return Vec::new();
166    };
167    arr.iter()
168        .map(|a| {
169            let action_type = a
170                .get("Type")
171                .and_then(|v| v.as_str())
172                .unwrap_or("forward")
173                .to_string();
174            let target_group_arn = a
175                .get("TargetGroupArn")
176                .and_then(|v| v.as_str())
177                .map(|s| s.to_string());
178            let order = a.get("Order").and_then(|v| v.as_i64()).map(|n| n as i32);
179            let redirect = a
180                .get("RedirectConfig")
181                .map(|r| fakecloud_elbv2::RedirectConfig {
182                    protocol: r
183                        .get("Protocol")
184                        .and_then(|v| v.as_str())
185                        .map(|s| s.to_string()),
186                    port: r
187                        .get("Port")
188                        .and_then(|v| v.as_str())
189                        .map(|s| s.to_string()),
190                    host: r
191                        .get("Host")
192                        .and_then(|v| v.as_str())
193                        .map(|s| s.to_string()),
194                    path: r
195                        .get("Path")
196                        .and_then(|v| v.as_str())
197                        .map(|s| s.to_string()),
198                    query: r
199                        .get("Query")
200                        .and_then(|v| v.as_str())
201                        .map(|s| s.to_string()),
202                    status_code: r
203                        .get("StatusCode")
204                        .and_then(|v| v.as_str())
205                        .unwrap_or("HTTP_302")
206                        .to_string(),
207                });
208            let fixed_response =
209                a.get("FixedResponseConfig")
210                    .map(|f| fakecloud_elbv2::FixedResponseConfig {
211                        message_body: f
212                            .get("MessageBody")
213                            .and_then(|v| v.as_str())
214                            .map(|s| s.to_string()),
215                        status_code: f
216                            .get("StatusCode")
217                            .and_then(|v| v.as_str())
218                            .unwrap_or("200")
219                            .to_string(),
220                        content_type: f
221                            .get("ContentType")
222                            .and_then(|v| v.as_str())
223                            .map(|s| s.to_string()),
224                    });
225            let forward = a.get("ForwardConfig").map(|f| {
226                let target_groups: Vec<TargetGroupTuple> = f
227                    .get("TargetGroups")
228                    .and_then(|v| v.as_array())
229                    .map(|arr| {
230                        arr.iter()
231                            .filter_map(|t| {
232                                let target_group_arn = t
233                                    .get("TargetGroupArn")
234                                    .and_then(|v| v.as_str())?
235                                    .to_string();
236                                let weight =
237                                    t.get("Weight").and_then(|v| v.as_i64()).map(|n| n as i32);
238                                Some(TargetGroupTuple {
239                                    target_group_arn,
240                                    weight,
241                                })
242                            })
243                            .collect()
244                    })
245                    .unwrap_or_default();
246                fakecloud_elbv2::ForwardConfig {
247                    target_groups,
248                    stickiness: None,
249                }
250            });
251            ElbAction {
252                action_type,
253                target_group_arn,
254                order,
255                redirect,
256                fixed_response,
257                forward,
258                authenticate_cognito: None,
259                authenticate_oidc: None,
260            }
261        })
262        .collect()
263}
264
265fn parse_elb_rule_conditions(value: Option<&serde_json::Value>) -> Vec<RuleCondition> {
266    let Some(arr) = value.and_then(|v| v.as_array()) else {
267        return Vec::new();
268    };
269    arr.iter()
270        .map(|c| {
271            let field = c
272                .get("Field")
273                .and_then(|v| v.as_str())
274                .unwrap_or("")
275                .to_string();
276            let values: Vec<String> = c
277                .get("Values")
278                .and_then(|v| v.as_array())
279                .map(|arr| {
280                    arr.iter()
281                        .filter_map(|s| s.as_str().map(|s| s.to_string()))
282                        .collect()
283                })
284                .unwrap_or_default();
285            let host_header_values: Vec<String> = c
286                .get("HostHeaderConfig")
287                .and_then(|v| v.get("Values"))
288                .and_then(|v| v.as_array())
289                .map(|arr| {
290                    arr.iter()
291                        .filter_map(|s| s.as_str().map(|s| s.to_string()))
292                        .collect()
293                })
294                .unwrap_or_default();
295            RuleCondition {
296                field,
297                values,
298                host_header_values,
299                path_pattern_values: Vec::new(),
300                http_header_name: None,
301                http_header_values: Vec::new(),
302                query_string_values: Vec::new(),
303                http_request_method_values: Vec::new(),
304                source_ip_values: Vec::new(),
305            }
306        })
307        .collect()
308}
309
310/// Parse the `KeyPolicy` field of an `AWS::KMS::Key` /
311/// `AWS::KMS::ReplicaKey` resource. CFN allows either a JSON string or
312/// an inline JSON object; we serialize the object form back to a string
313/// to match the [`KmsKey.policy`](fakecloud_kms::KmsKey) shape.
314fn parse_key_policy(props: &serde_json::Value) -> Option<String> {
315    match props.get("KeyPolicy") {
316        Some(v) if v.is_string() => Some(v.as_str().unwrap_or("").to_string()),
317        Some(v) => Some(serde_json::to_string(v).unwrap_or_default()),
318        None => None,
319    }
320}
321
322/// Parse the `Tags` field common to KMS CFN resources: an array of
323/// `{Key, Value}` pairs into a sorted map.
324fn parse_tag_list(props: &serde_json::Value) -> BTreeMap<String, String> {
325    let mut tags: BTreeMap<String, String> = BTreeMap::new();
326    if let Some(arr) = props.get("Tags").and_then(|v| v.as_array()) {
327        for t in arr {
328            if let (Some(k), Some(v)) = (
329                t.get("Key").and_then(|x| x.as_str()),
330                t.get("Value").and_then(|x| x.as_str()),
331            ) {
332                tags.insert(k.to_string(), v.to_string());
333            }
334        }
335    }
336    tags
337}
338
339/// Parse the `Properties` of an `AWS::KMS::Key` resource into the
340/// shared [`fakecloud_kms::provisioner::KeyCreationInput`]. Defaults
341/// match AWS: `ENCRYPT_DECRYPT` / `SYMMETRIC_DEFAULT` / `AWS_KMS` /
342/// `Enabled=true`. `RotationPeriodInDays`, `PendingWindowInDays`, and
343/// `BypassPolicyLockoutSafetyCheck` are accepted by the parser for
344/// CFN compatibility but not persisted on the key — the underlying
345/// KMS state doesn't model per-key rotation periods, and the deletion
346/// window only matters at scheduled-deletion time which CFN delete
347/// short-circuits.
348fn parse_kms_key_input(props: &serde_json::Value) -> kms_provisioner::KeyCreationInput {
349    kms_provisioner::KeyCreationInput {
350        description: props
351            .get("Description")
352            .and_then(|v| v.as_str())
353            .unwrap_or("")
354            .to_string(),
355        key_usage: props
356            .get("KeyUsage")
357            .and_then(|v| v.as_str())
358            .unwrap_or("ENCRYPT_DECRYPT")
359            .to_string(),
360        key_spec: props
361            .get("KeySpec")
362            .and_then(|v| v.as_str())
363            .unwrap_or("SYMMETRIC_DEFAULT")
364            .to_string(),
365        origin: props
366            .get("Origin")
367            .and_then(|v| v.as_str())
368            .unwrap_or("AWS_KMS")
369            .to_string(),
370        enabled: props
371            .get("Enabled")
372            .and_then(|v| v.as_bool())
373            .unwrap_or(true),
374        multi_region: props
375            .get("MultiRegion")
376            .and_then(|v| v.as_bool())
377            .unwrap_or(false),
378        key_rotation_enabled: props
379            .get("EnableKeyRotation")
380            .and_then(|v| v.as_bool())
381            .unwrap_or(false),
382        policy: parse_key_policy(props),
383        tags: parse_tag_list(props),
384    }
385}
386
387/// `LogGroupName` properties on Logs CFN resources may carry either a
388/// log-group ARN (when they come from `{Ref: SomeLogGroup}` in the same
389/// template) or a plain name. Extract the name in either case.
390fn parse_log_group_name(input: &str) -> String {
391    if let Some(rest) = input.strip_prefix("arn:aws:logs:") {
392        if let Some(after) = rest.split(":log-group:").nth(1) {
393            // ARN ends with `:*`; trim it if present.
394            return after.trim_end_matches(":*").to_string();
395        }
396    }
397    input.to_string()
398}
399
400/// Pull the bare function name out of any shape AWS accepts for a Lambda
401/// `FunctionName`: a name, a partial ARN, or a full ARN — each optionally
402/// `:qualified` with an alias or version. CFN feeds this `{Ref: SomeFunction}`
403/// (resolves to a name, or — for an alias — the alias ARN), `{Fn::GetAtt:
404/// [F, Arn]}` (full ARN), or a literal; all shapes must land at the same map
405/// key. Function names cannot contain `:`, so any trailing `:segment` on a
406/// non-ARN input is always a qualifier.
407fn parse_lambda_function_name(input: &str) -> String {
408    // Full ARN: arn:aws:lambda:region:account:function:name[:qualifier]
409    if let Some(rest) = input.strip_prefix("arn:aws:lambda:") {
410        if let Some(after) = rest.split(":function:").nth(1) {
411            return after.split(':').next().unwrap_or(after).to_string();
412        }
413    }
414    // Partial ARN: account:function:name[:qualifier]
415    if let Some(after) = input.split(":function:").nth(1) {
416        return after.split(':').next().unwrap_or(after).to_string();
417    }
418    // Bare name, optionally qualified: name[:qualifier]
419    input.split(':').next().unwrap_or(input).to_string()
420}
421
422/// Recover the internal `{function}:{alias}` key used by the lambda
423/// state's `aliases` / `provisioned_concurrency` maps from an alias
424/// resource's physical id. The physical id is the alias ARN
425/// (`arn:aws:lambda:region:account:function:name:alias`); a legacy bare
426/// `name:alias` value is returned unchanged.
427fn alias_state_key(physical_id: &str) -> String {
428    if let Some(rest) = physical_id.strip_prefix("arn:aws:lambda:") {
429        if let Some(after) = rest.split(":function:").nth(1) {
430            return after.to_string();
431        }
432    }
433    physical_id.to_string()
434}
435
436/// All AWS::Lambda::Function CFN properties parsed and pre-defaulted into
437/// their lambda-state shapes. Mirrors the lambda service's
438/// `CreateFunctionInput` so create + update share one parse path.
439struct LambdaFunctionProps {
440    runtime: String,
441    role: String,
442    handler: String,
443    description: String,
444    timeout: i64,
445    memory_size: i64,
446    package_type: String,
447    tags: BTreeMap<String, String>,
448    environment: BTreeMap<String, String>,
449    architectures: Vec<String>,
450    /// Decoded `Code.ZipFile` bytes (base64 → raw). `None` when the
451    /// caller specified `Code.S3Bucket`/`Code.S3Key` or `Code.ImageUri`
452    /// instead — the create/update path resolves S3 separately.
453    code_zip: Option<Vec<u8>>,
454    s3_bucket: Option<String>,
455    s3_key: Option<String>,
456    image_uri: Option<String>,
457    layers: Vec<String>,
458    tracing_mode: Option<String>,
459    kms_key_arn: Option<String>,
460    ephemeral_storage_size: Option<i64>,
461    vpc_config: Option<serde_json::Value>,
462    snap_start: Option<serde_json::Value>,
463    dead_letter_config_arn: Option<String>,
464    file_system_configs: Vec<serde_json::Value>,
465    logging_config: Option<serde_json::Value>,
466}
467
468/// Parse the `Properties` value of an `AWS::Lambda::Function` resource
469/// into a `LambdaFunctionProps`. Defaults match the AWS Lambda
470/// CreateFunction API: `python3.12` runtime, `index.handler` handler,
471/// `Zip` package type, `x86_64` architecture, 3s timeout, 128MB memory.
472fn parse_lambda_function_props(props: &serde_json::Value) -> Result<LambdaFunctionProps, String> {
473    let runtime = props
474        .get("Runtime")
475        .and_then(|v| v.as_str())
476        .unwrap_or("python3.12")
477        .to_string();
478    let role = props
479        .get("Role")
480        .and_then(|v| v.as_str())
481        .unwrap_or_default()
482        .to_string();
483    let handler = props
484        .get("Handler")
485        .and_then(|v| v.as_str())
486        .unwrap_or("index.handler")
487        .to_string();
488    let description = props
489        .get("Description")
490        .and_then(|v| v.as_str())
491        .unwrap_or_default()
492        .to_string();
493    let timeout = props.get("Timeout").and_then(|v| v.as_i64()).unwrap_or(3);
494    let memory_size = props
495        .get("MemorySize")
496        .and_then(|v| v.as_i64())
497        .unwrap_or(128);
498    let architectures = props
499        .get("Architectures")
500        .and_then(|v| v.as_array())
501        .map(|a| {
502            a.iter()
503                .filter_map(|v| v.as_str().map(|s| s.to_string()))
504                .collect::<Vec<_>>()
505        })
506        .unwrap_or_else(|| vec!["x86_64".to_string()]);
507    let package_type = props
508        .get("PackageType")
509        .and_then(|v| v.as_str())
510        .unwrap_or("Zip")
511        .to_string();
512    let environment = props
513        .get("Environment")
514        .and_then(|v| v.get("Variables"))
515        .and_then(|v| v.as_object())
516        .map(|o| {
517            o.iter()
518                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
519                .collect::<BTreeMap<String, String>>()
520        })
521        .unwrap_or_default();
522
523    // CFN tags ride as `[{Key, Value}, ...]`; flatten to the map shape
524    // the lambda crate stores tags in.
525    let tags: BTreeMap<String, String> = props
526        .get("Tags")
527        .and_then(|v| v.as_array())
528        .map(|arr| {
529            arr.iter()
530                .filter_map(|t| {
531                    let k = t.get("Key").and_then(|v| v.as_str())?.to_string();
532                    let v = t.get("Value").and_then(|v| v.as_str())?.to_string();
533                    Some((k, v))
534                })
535                .collect()
536        })
537        .unwrap_or_default();
538
539    let code = props.get("Code");
540    // CFN's `Code.ZipFile` is the raw source code inline (per the
541    // CloudFormation user guide), not base64 like the Lambda
542    // CreateFunction API. We store it as the raw bytes — fakecloud's
543    // Lambda runtime is content-agnostic and just needs *some* deployable
544    // payload to execute.
545    let code_zip = code
546        .and_then(|c| c.get("ZipFile"))
547        .and_then(|v| v.as_str())
548        .map(|s| s.as_bytes().to_vec());
549    let s3_bucket = code
550        .and_then(|c| c.get("S3Bucket"))
551        .and_then(|v| v.as_str())
552        .map(|s| s.to_string());
553    let s3_key = code
554        .and_then(|c| c.get("S3Key"))
555        .and_then(|v| v.as_str())
556        .map(|s| s.to_string());
557    // ImageUri is only meaningful for `PackageType=Image`. Mirroring the
558    // lambda service path, drop it on Zip functions so GetFunction
559    // doesn't return ECR metadata for a ZIP-based function.
560    let image_uri = if package_type == "Image" {
561        code.and_then(|c| c.get("ImageUri"))
562            .and_then(|v| v.as_str())
563            .map(|s| s.to_string())
564    } else {
565        None
566    };
567    if package_type == "Image" && image_uri.is_none() {
568        return Err("Code.ImageUri is required when PackageType is Image".to_string());
569    }
570
571    let layers: Vec<String> = props
572        .get("Layers")
573        .and_then(|v| v.as_array())
574        .map(|arr| {
575            arr.iter()
576                .filter_map(|v| v.as_str().map(String::from))
577                .collect()
578        })
579        .unwrap_or_default();
580
581    let tracing_mode = props
582        .get("TracingConfig")
583        .and_then(|v| v.get("Mode"))
584        .and_then(|v| v.as_str())
585        .map(String::from);
586    let kms_key_arn = props
587        .get("KmsKeyArn")
588        .and_then(|v| v.as_str())
589        .map(String::from);
590    let ephemeral_storage_size = props
591        .get("EphemeralStorage")
592        .and_then(|v| v.get("Size"))
593        .and_then(|v| v.as_i64());
594    let vpc_config = props.get("VpcConfig").filter(|v| v.is_object()).cloned();
595    let snap_start = props.get("SnapStart").filter(|v| v.is_object()).cloned();
596    let dead_letter_config_arn = props
597        .get("DeadLetterConfig")
598        .and_then(|v| v.get("TargetArn"))
599        .and_then(|v| v.as_str())
600        .map(String::from);
601    let file_system_configs = props
602        .get("FileSystemConfigs")
603        .and_then(|v| v.as_array())
604        .cloned()
605        .unwrap_or_default();
606    let logging_config = props
607        .get("LoggingConfig")
608        .filter(|v| v.is_object())
609        .cloned();
610
611    Ok(LambdaFunctionProps {
612        runtime,
613        role,
614        handler,
615        description,
616        timeout,
617        memory_size,
618        package_type,
619        tags,
620        environment,
621        architectures,
622        code_zip,
623        s3_bucket,
624        s3_key,
625        image_uri,
626        layers,
627        tracing_mode,
628        kms_key_arn,
629        ephemeral_storage_size,
630        vpc_config,
631        snap_start,
632        dead_letter_config_arn,
633        file_system_configs,
634        logging_config,
635    })
636}
637
638/// Properties for `AWS::Lambda::EventSourceMapping` shared between the
639/// create and update provisioners. Mirrors the lambda crate
640/// `EventSourceMapping` shape one-for-one — every CFN-mutable field
641/// lands here so update can rewrite without re-parsing.
642struct LambdaEventSourceMappingProps {
643    event_source_arn: String,
644    batch_size: i64,
645    enabled: bool,
646    starting_position: Option<String>,
647    starting_position_timestamp: Option<f64>,
648    parallelization_factor: Option<i64>,
649    maximum_batching_window_in_seconds: Option<i64>,
650    function_response_types: Vec<String>,
651    filter_patterns: Vec<String>,
652    kms_key_arn: Option<String>,
653    metrics_config: Option<serde_json::Value>,
654    destination_config: Option<serde_json::Value>,
655    maximum_retry_attempts: Option<i64>,
656    maximum_record_age_in_seconds: Option<i64>,
657    bisect_batch_on_function_error: Option<bool>,
658    tumbling_window_in_seconds: Option<i64>,
659    topics: Vec<String>,
660    queues: Vec<String>,
661}
662
663/// Parse the `Properties` value of an `AWS::Lambda::EventSourceMapping`
664/// resource. `EventSourceArn` is required on create; updates re-parse but
665/// the value is ignored since the field is immutable.
666fn parse_lambda_event_source_mapping_props(
667    props: &serde_json::Value,
668) -> Result<LambdaEventSourceMappingProps, String> {
669    let event_source_arn = props
670        .get("EventSourceArn")
671        .and_then(|v| v.as_str())
672        .unwrap_or_default()
673        .to_string();
674    let batch_size = props
675        .get("BatchSize")
676        .and_then(|v| v.as_i64())
677        .unwrap_or(10);
678    let enabled = props
679        .get("Enabled")
680        .and_then(|v| v.as_bool())
681        .unwrap_or(true);
682    let starting_position = props
683        .get("StartingPosition")
684        .and_then(|v| v.as_str())
685        .map(|s| s.to_string());
686    let starting_position_timestamp = props
687        .get("StartingPositionTimestamp")
688        .and_then(|v| v.as_f64());
689    let parallelization_factor = props.get("ParallelizationFactor").and_then(|v| v.as_i64());
690    let maximum_batching_window_in_seconds = props
691        .get("MaximumBatchingWindowInSeconds")
692        .and_then(|v| v.as_i64());
693    let function_response_types: Vec<String> = props
694        .get("FunctionResponseTypes")
695        .and_then(|v| v.as_array())
696        .map(|arr| {
697            arr.iter()
698                .filter_map(|v| v.as_str().map(|s| s.to_string()))
699                .collect()
700        })
701        .unwrap_or_default();
702    let filter_patterns: Vec<String> = props
703        .get("FilterCriteria")
704        .and_then(|v| v.get("Filters"))
705        .and_then(|v| v.as_array())
706        .map(|arr| {
707            arr.iter()
708                .filter_map(|f| {
709                    f.get("Pattern")
710                        .and_then(|p| p.as_str())
711                        .map(|s| s.to_string())
712                })
713                .collect()
714        })
715        .unwrap_or_default();
716    let kms_key_arn = props
717        .get("KmsKeyArn")
718        .and_then(|v| v.as_str())
719        .map(|s| s.to_string());
720    let metrics_config = props
721        .get("MetricsConfig")
722        .filter(|v| v.is_object())
723        .cloned();
724    let destination_config = props
725        .get("DestinationConfig")
726        .filter(|v| v.is_object())
727        .cloned();
728    let maximum_retry_attempts = props.get("MaximumRetryAttempts").and_then(|v| v.as_i64());
729    let maximum_record_age_in_seconds = props
730        .get("MaximumRecordAgeInSeconds")
731        .and_then(|v| v.as_i64());
732    let bisect_batch_on_function_error = props
733        .get("BisectBatchOnFunctionError")
734        .and_then(|v| v.as_bool());
735    let tumbling_window_in_seconds = props
736        .get("TumblingWindowInSeconds")
737        .and_then(|v| v.as_i64());
738    let topics: Vec<String> = props
739        .get("Topics")
740        .and_then(|v| v.as_array())
741        .map(|arr| {
742            arr.iter()
743                .filter_map(|v| v.as_str().map(|s| s.to_string()))
744                .collect()
745        })
746        .unwrap_or_default();
747    let queues: Vec<String> = props
748        .get("Queues")
749        .and_then(|v| v.as_array())
750        .map(|arr| {
751            arr.iter()
752                .filter_map(|v| v.as_str().map(|s| s.to_string()))
753                .collect()
754        })
755        .unwrap_or_default();
756
757    Ok(LambdaEventSourceMappingProps {
758        event_source_arn,
759        batch_size,
760        enabled,
761        starting_position,
762        starting_position_timestamp,
763        parallelization_factor,
764        maximum_batching_window_in_seconds,
765        function_response_types,
766        filter_patterns,
767        kms_key_arn,
768        metrics_config,
769        destination_config,
770        maximum_retry_attempts,
771        maximum_record_age_in_seconds,
772        bisect_batch_on_function_error,
773        tumbling_window_in_seconds,
774        topics,
775        queues,
776    })
777}
778
779/// Compute base64-encoded SHA-256 of code bytes — matches what the
780/// lambda service stores in `LambdaFunction.code_sha256`.
781fn sha256_b64(bytes: &[u8]) -> String {
782    use sha2::Digest;
783    let hash = sha2::Sha256::digest(bytes);
784    base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash)
785}
786
787/// Look up the `code_size` for an attached layer-version ARN by walking
788/// the in-process lambda state. Falls back to 0 when the ARN is
789/// unparseable or the layer/version is unknown — same fallback as the
790/// lambda service helper.
791fn layer_code_size(
792    accounts: &fakecloud_core::multi_account::MultiAccountState<fakecloud_lambda::LambdaState>,
793    arn: &str,
794) -> i64 {
795    // arn:aws:lambda:<region>:<account>:layer:<name>:<version>
796    let Some(rest) = arn.strip_prefix("arn:aws:lambda:") else {
797        return 0;
798    };
799    let mut parts = rest.split(':');
800    let _region = parts.next();
801    let Some(account) = parts.next() else {
802        return 0;
803    };
804    if parts.next() != Some("layer") {
805        return 0;
806    }
807    let Some(name) = parts.next() else {
808        return 0;
809    };
810    let Some(ver_str) = parts.next() else {
811        return 0;
812    };
813    let Ok(ver) = ver_str.parse::<i64>() else {
814        return 0;
815    };
816    accounts
817        .get(account)
818        .and_then(|s| s.layers.get(name))
819        .and_then(|l| l.versions.iter().find(|v| v.version == ver))
820        .map(|v| v.code_size)
821        .unwrap_or(0)
822}
823
824/// What a resource provisioner returns. The physical id is what `Ref` resolves
825/// to; `attributes` is what `Fn::GetAtt` resolves to (per-resource-type).
826pub struct ProvisionResult {
827    pub physical_id: String,
828    pub attributes: BTreeMap<String, String>,
829}
830
831impl ProvisionResult {
832    pub fn new(physical_id: impl Into<String>) -> Self {
833        Self {
834            physical_id: physical_id.into(),
835            attributes: BTreeMap::new(),
836        }
837    }
838
839    pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
840        self.attributes.insert(key.to_string(), value.into());
841        self
842    }
843
844    /// Merge another attribute map into this result. Used by update
845    /// handlers that delegate to a create handler but need to keep the
846    /// existing physical id while inheriting the freshly computed
847    /// attributes.
848    pub fn merge_attributes(mut self, other: BTreeMap<String, String>) -> Self {
849        for (k, v) in other {
850            self.attributes.insert(k, v);
851        }
852        self
853    }
854}
855
856/// Extract a resource policy's `PolicyDocument` property as a JSON string,
857/// accepting either an inline object or an already-serialized string. Shared by
858/// the SQS/SNS/S3 resource-policy provisioners.
859fn policy_document_string(props: &serde_json::Value) -> Result<String, String> {
860    match props.get("PolicyDocument") {
861        Some(serde_json::Value::String(s)) => Ok(s.clone()),
862        Some(other) => Ok(other.to_string()),
863        None => Err("PolicyDocument is required".to_string()),
864    }
865}
866
867/// Holds references to all service states so CloudFormation can provision resources.
868pub struct ResourceProvisioner {
869    pub sqs_state: SharedSqsState,
870    pub sns_state: SharedSnsState,
871    pub ssm_state: SharedSsmState,
872    pub iam_state: SharedIamState,
873    pub s3_state: SharedS3State,
874    pub eventbridge_state: SharedEventBridgeState,
875    pub dynamodb_state: SharedDynamoDbState,
876    pub logs_state: SharedLogsState,
877    pub lambda_state: SharedLambdaState,
878    pub secretsmanager_state: SharedSecretsManagerState,
879    pub kinesis_state: SharedKinesisState,
880    pub kms_state: SharedKmsState,
881    pub ecr_state: SharedEcrState,
882    pub cloudwatch_state: SharedCloudWatchState,
883    pub elbv2_state: SharedElbv2State,
884    pub organizations_state: SharedOrganizationsState,
885    pub cognito_state: SharedCognitoState,
886    pub rds_state: SharedRdsState,
887    pub ec2_state: fakecloud_ec2::SharedEc2State,
888    pub autoscaling_state: fakecloud_autoscaling::SharedAutoScalingState,
889    pub batch_state: fakecloud_batch::SharedBatchState,
890    pub pipes_state: fakecloud_pipes::SharedPipesState,
891    pub ecs_state: SharedEcsState,
892    pub acm_state: SharedAcmState,
893    pub acmpca_state: SharedAcmPcaState,
894    pub config_state: fakecloud_config::SharedConfigState,
895    pub route53resolver_state: fakecloud_route53resolver::SharedRoute53ResolverState,
896    pub elasticache_state: SharedElastiCacheState,
897    pub route53_state: SharedRoute53State,
898    pub cloudfront_state: SharedCloudFrontState,
899    pub stepfunctions_state: SharedStepFunctionsState,
900    pub wafv2_state: SharedWafv2State,
901    pub apigateway_state: SharedApiGatewayState,
902    pub apigatewayv2_state: SharedApiGatewayV2State,
903    pub ses_state: SharedSesState,
904    pub app_autoscaling_state: AppasState,
905    pub athena_state: SharedAthenaState,
906    pub firehose_state: fakecloud_firehose::SharedFirehoseState,
907    pub glue_state: fakecloud_glue::SharedGlueState,
908    pub eks_state: SharedEksState,
909    pub servicediscovery_state: SharedServiceDiscoveryState,
910    pub codeartifact_state: fakecloud_codeartifact::SharedCodeArtifactState,
911    pub codecommit_state: fakecloud_codecommit::SharedCodeCommitState,
912    pub efs_state: fakecloud_efs::SharedEfsState,
913    pub elasticbeanstalk_state: fakecloud_elasticbeanstalk::SharedEbState,
914    pub mq_state: fakecloud_mq::SharedMqState,
915    pub kafka_state: fakecloud_kafka::SharedKafkaState,
916    pub ka2_state: fakecloud_kinesisanalyticsv2::SharedKa2State,
917    pub cloudformation_state: SharedCloudFormationState,
918    pub delivery: Arc<DeliveryBus>,
919    /// Lambda container runtime for pre-pulling CFN-provisioned function
920    /// images (see `CloudFormationDeps::lambda_runtime`). `None` outside a
921    /// configured runtime (e.g. unit tests).
922    pub lambda_runtime: Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
923    /// Container runtimes for stateful services whose CFN-provisioned resources
924    /// must be backed by REAL containers. See `CloudFormationDeps`. `None`
925    /// (no Docker/Podman, e.g. CI/unit tests) keeps metadata-only provisioning.
926    pub rds_runtime: Option<Arc<fakecloud_rds::runtime::RdsRuntime>>,
927    pub ec2_runtime: Option<Arc<fakecloud_ec2::runtime::Ec2Runtime>>,
928    pub ecs_runtime: Option<Arc<fakecloud_ecs::runtime::EcsRuntime>>,
929    pub elasticache_runtime: Option<Arc<fakecloud_elasticache::runtime::ElastiCacheRuntime>>,
930    pub mq_runtime: Option<Arc<fakecloud_mq::MqRuntime>>,
931    pub kafka_runtime: Option<Arc<fakecloud_kafka::KafkaRuntime>>,
932    /// Intents queued by container-backed provisioners during the synchronous
933    /// provisioning pass. After provisioning, `CreateStack` drains these and
934    /// backs each freshly-inserted record with a real container in the
935    /// background (so `CreateStack` returns without blocking on a container
936    /// boot — the #1539/#1730 timeout lesson). Shared via `Arc` so the drain
937    /// can read it after the provisioner is moved into `spawn_blocking`.
938    pub pending_container_spawns: Arc<parking_lot::Mutex<Vec<ContainerSpawnIntent>>>,
939    /// Teardown intents queued by container-backed delete provisioners during a
940    /// synchronous delete pass (stack delete, or a stack update that removes a
941    /// resource). The in-memory record is removed synchronously (so
942    /// `DescribeStacks` reflects the deletion at once); the REAL backing
943    /// container is reaped in the background by the CloudFormation delete drain,
944    /// mirroring `pending_container_spawns` for teardown. Without this drain a
945    /// stack delete would leak the running RDS / ElastiCache / ECS / EC2
946    /// containers (the create-side #2031-#2034 hardening never reached delete).
947    pub pending_container_teardowns: Arc<parking_lot::Mutex<Vec<ContainerTeardownIntent>>>,
948    /// Custom-resource (`Custom::*`) Lambda invoke intents queued during a
949    /// changeset/update provision when `defer_custom_invokes` is set. Invoking
950    /// the Lambda synchronously (`invoke_lambda_sync`) can cold-pull a container
951    /// image for minutes -- far past the client's 60s read timeout -- and, on
952    /// the changeset/update path, it ran while holding the CloudFormation state
953    /// write lock, stalling every other CFN op behind it. Queueing here lets the
954    /// caller drain + `tokio::spawn` the invokes off the request path after the
955    /// lock is dropped, mirroring how `CreateStack` provisions custom resources
956    /// off the request path.
957    pub pending_custom_invokes: Arc<parking_lot::Mutex<Vec<CustomInvokeIntent>>>,
958    /// When `true`, `create_custom_resource` / `delete_custom_resource` queue
959    /// their Lambda invoke onto `pending_custom_invokes` instead of running it
960    /// synchronously. Set on the changeset/update/delete provisioners; left
961    /// `false` for `CreateStack` (which already provisions off the request path
962    /// in a detached task, so its synchronous invoke never blocks the client or
963    /// the state lock).
964    pub defer_custom_invokes: bool,
965    /// Fine-grained S3 disk store. Bucket create/delete (and bucket-policy
966    /// updates) write through this so a CFN-provisioned bucket lands on disk,
967    /// matching the real `CreateBucket`/`DeleteBucket` handlers. A
968    /// `MemoryS3Store` (memory mode) makes the writes no-ops.
969    pub s3_store: Arc<dyn S3Store>,
970    pub account_id: String,
971    pub region: String,
972    pub stack_id: String,
973    /// When `true`, `create_resource`'s fallback arm for unmodeled resource
974    /// types returns an error instead of recording a phantom resource with no
975    /// backing state. Cloud Control API sets this so `CreateResource` rejects a
976    /// `TypeName` fakecloud has no provisioner for, rather than reporting
977    /// success for a resource `Get`/`List` would then surface with no owning
978    /// service state. `CreateStack` leaves it `false` to keep accepting full
979    /// templates (SAM/CDK output routinely includes types fakecloud does not
980    /// model).
981    pub strict_unknown_types: bool,
982}
983
984/// A container-backed resource the synchronous provisioning pass inserted as a
985/// "creating" record that now needs a real backing container. Drained by
986/// `CreateStack` after provisioning and backgrounded so the stack create call
987/// never blocks on a container boot/pull.
988#[derive(Debug, Clone)]
989pub enum ContainerSpawnIntent {
990    /// `AWS::RDS::DBInstance` — back the inserted DbInstance with a real
991    /// Postgres/MySQL container via the RDS runtime.
992    RdsInstance { identifier: String },
993    /// `AWS::AutoScaling::AutoScalingGroup` — reconcile the inserted group to
994    /// its desired capacity by launching real container-backed EC2 instances
995    /// via the EC2 runtime, matching the direct `CreateAutoScalingGroup` path.
996    AsgInstances { group_name: String },
997    /// `AWS::EC2::Instance` — back the inserted (control-plane `pending`)
998    /// instance with a real container via the EC2 runtime, matching the direct
999    /// `RunInstances` path.
1000    Ec2Instance { instance_id: String },
1001    /// `AWS::ElastiCache::CacheCluster` — back the inserted CacheCluster with a
1002    /// real Redis/Memcached container via the ElastiCache runtime, matching the
1003    /// direct `CreateCacheCluster` path.
1004    ElastiCacheCluster { cache_cluster_id: String },
1005    /// `AWS::ElastiCache::ReplicationGroup` — back the inserted ReplicationGroup
1006    /// with a real Redis container via the ElastiCache runtime, matching the
1007    /// direct `CreateReplicationGroup` path.
1008    ElastiCacheReplicationGroup { replication_group_id: String },
1009    /// `AWS::ECS::Service` — launch the REAL tasks (containers) the inserted
1010    /// service needs to reach its `desiredCount` via the ECS runtime, matching
1011    /// the direct `CreateService` path. The cluster + service name locate the
1012    /// inserted record (and its desired count) when the drain runs.
1013    EcsServiceTasks {
1014        cluster_name: String,
1015        service_name: String,
1016    },
1017    /// `AWS::AmazonMQ::Broker` — back the inserted broker with a real
1018    /// ActiveMQ/RabbitMQ container via the MQ runtime, matching the direct
1019    /// `CreateBroker` path.
1020    MqBroker { broker_id: String },
1021    /// `AWS::MSK::Cluster` — back the inserted PROVISIONED cluster with a real
1022    /// Apache Kafka container via the Kafka runtime, matching the direct
1023    /// `CreateCluster` path.
1024    MskCluster { cluster_arn: String },
1025}
1026
1027/// A container-backed resource the synchronous delete pass removed from memory
1028/// that still has a REAL backing container to reap. Drained by the
1029/// CloudFormation delete path (stack delete / update-removed) and backgrounded
1030/// so the stack op never blocks on a container stop. Mirrors
1031/// [`ContainerSpawnIntent`] for teardown.
1032#[derive(Debug, Clone)]
1033pub enum ContainerTeardownIntent {
1034    /// `AWS::RDS::DBInstance` -- stop + remove the Postgres/MySQL container and
1035    /// its persisted data volume.
1036    RdsInstance { identifier: String },
1037    /// `AWS::ElastiCache::CacheCluster` -- stop + remove the Redis/Memcached
1038    /// container and its data volume.
1039    ElastiCacheCluster { cache_cluster_id: String },
1040    /// `AWS::ElastiCache::ReplicationGroup` -- stop + remove the Redis container
1041    /// and its data volume.
1042    ElastiCacheReplicationGroup { replication_group_id: String },
1043    /// `AWS::ECS::Service` -- stop the REAL tasks (containers) the service was
1044    /// running. The cluster + service name locate the orphaned task records.
1045    EcsService {
1046        cluster_name: String,
1047        service_name: String,
1048    },
1049    /// `AWS::AutoScaling::AutoScalingGroup` -- terminate the REAL EC2 instances
1050    /// the group launched (captured before the group record was removed).
1051    AsgInstances { instance_ids: Vec<String> },
1052    /// `AWS::EC2::Instance` -- terminate the REAL EC2 instance + reap its
1053    /// backing container when the stack is deleted.
1054    Ec2Instance { instance_id: String },
1055    /// `AWS::AmazonMQ::Broker` -- stop + remove the ActiveMQ/RabbitMQ container.
1056    MqBroker { broker_id: String },
1057    /// `AWS::MSK::Cluster` -- stop + remove the backing Apache Kafka container.
1058    MskCluster { cluster_arn: String },
1059}
1060
1061/// A queued custom-resource (`Custom::*`) Lambda invocation. Built by
1062/// `create_custom_resource` / `delete_custom_resource` when
1063/// `defer_custom_invokes` is set, drained and `tokio::spawn`ed off the request
1064/// path so a cold image pull never blocks the client or the CFN state lock.
1065#[derive(Debug, Clone)]
1066pub struct CustomInvokeIntent {
1067    pub service_token: String,
1068    pub payload: String,
1069}
1070
1071mod acm;
1072mod acmpca;
1073mod apigw;
1074mod apigwv2;
1075mod appautoscaling;
1076mod athena;
1077mod autoscaling;
1078mod batch;
1079mod cloudformation;
1080mod cloudfront;
1081mod cloudwatch;
1082mod codeartifact;
1083mod codecommit;
1084mod cognito;
1085mod config;
1086mod cwlogs;
1087mod dynamodb;
1088mod ec2;
1089mod ecr;
1090mod ecs;
1091mod efs;
1092mod eks;
1093mod elasticache;
1094mod elasticbeanstalk;
1095mod elbv2;
1096mod eventbridge;
1097mod firehose;
1098mod glue;
1099mod iam;
1100mod kafka;
1101mod kinesis;
1102mod kinesisanalyticsv2;
1103mod kms;
1104mod lambda;
1105mod logs;
1106mod mq;
1107mod organizations;
1108mod pipes;
1109mod rds;
1110mod route;
1111mod route53resolver;
1112mod s3;
1113mod secrets;
1114mod servicediscovery;
1115mod ses;
1116mod sns;
1117mod sqs;
1118mod ssm;
1119mod stepfunctions;
1120mod wafv2;
1121
1122impl ResourceProvisioner {
1123    /// Create a resource and return the StackResource with physical ID.
1124    pub fn create_resource(&self, resource: &ResourceDefinition) -> Result<StackResource, String> {
1125        let result = match resource.resource_type.as_str() {
1126            "AWS::SQS::Queue" => self.create_sqs_queue(resource),
1127            "AWS::SQS::QueuePolicy" => self.create_sqs_queue_policy(resource),
1128            "AWS::SNS::Topic" => self.create_sns_topic(resource),
1129            "AWS::SNS::TopicPolicy" => self.create_sns_topic_policy(resource),
1130            "AWS::SNS::Subscription" => self.create_sns_subscription(resource),
1131            "AWS::SSM::Parameter" => self.create_ssm_parameter(resource),
1132            "AWS::IAM::Role" => self.create_iam_role(resource),
1133            "AWS::IAM::Policy" => self.create_iam_policy(resource),
1134            "AWS::IAM::User" => self.create_iam_user(resource),
1135            "AWS::IAM::Group" => self.create_iam_group(resource),
1136            "AWS::IAM::ManagedPolicy" => self.create_iam_managed_policy(resource),
1137            "AWS::IAM::UserToGroupAddition" => self.create_iam_user_to_group_addition(resource),
1138            "AWS::IAM::AccessKey" => self.create_iam_access_key(resource),
1139            "AWS::IAM::InstanceProfile" => self.create_iam_instance_profile(resource),
1140            "AWS::IAM::OIDCProvider" => self.create_iam_oidc_provider(resource),
1141            "AWS::IAM::SAMLProvider" => self.create_iam_saml_provider(resource),
1142            "AWS::IAM::ServiceLinkedRole" => self.create_iam_service_linked_role(resource),
1143            "AWS::IAM::VirtualMFADevice" => self.create_iam_virtual_mfa_device(resource),
1144            "AWS::S3::Bucket" => self.create_s3_bucket(resource),
1145            "AWS::S3::BucketPolicy" => self.create_s3_bucket_policy(resource),
1146            "AWS::Events::Rule" => self.create_eventbridge_rule(resource),
1147            "AWS::Events::Connection" => self.create_eventbridge_connection(resource),
1148            "AWS::Events::ApiDestination" => self.create_eventbridge_api_destination(resource),
1149            "AWS::Events::Archive" => self.create_eventbridge_archive(resource),
1150            "AWS::Events::EventBus" => self.create_eventbridge_event_bus(resource),
1151            "AWS::Events::EventBusPolicy" => self.create_eventbridge_event_bus_policy(resource),
1152            "AWS::Events::Endpoint" => self.create_eventbridge_endpoint(resource),
1153            "AWS::DynamoDB::Table" => self.create_dynamodb_table(resource),
1154            "AWS::Logs::LogGroup" => self.create_log_group(resource),
1155            "AWS::Logs::LogStream" => self.create_log_stream(resource),
1156            "AWS::Logs::MetricFilter" => self.create_metric_filter(resource),
1157            "AWS::Logs::SubscriptionFilter" => self.create_subscription_filter(resource),
1158            "AWS::Logs::Destination" => self.create_logs_destination(resource),
1159            "AWS::Logs::ResourcePolicy" => self.create_logs_resource_policy(resource),
1160            "AWS::Logs::QueryDefinition" => self.create_logs_query_definition(resource),
1161            "AWS::Logs::Delivery" => self.create_logs_delivery(resource),
1162            "AWS::Logs::DeliveryDestination" => self.create_logs_delivery_destination(resource),
1163            "AWS::Logs::DeliverySource" => self.create_logs_delivery_source(resource),
1164            "AWS::Lambda::Function" => self.create_lambda_function(resource),
1165            "AWS::Lambda::Permission" => self.create_lambda_permission(resource),
1166            "AWS::Lambda::EventSourceMapping" => self.create_lambda_event_source_mapping(resource),
1167            "AWS::Lambda::LayerVersion" => self.create_lambda_layer_version(resource),
1168            "AWS::Lambda::Url" => self.create_lambda_url(resource),
1169            "AWS::Lambda::Alias" => self.create_lambda_alias(resource),
1170            "AWS::Lambda::Version" => self.create_lambda_version(resource),
1171            "AWS::SecretsManager::Secret" => self.create_secrets_manager_secret(resource),
1172            "AWS::Kinesis::Stream" => self.create_kinesis_stream(resource),
1173            "AWS::Kinesis::StreamConsumer" => self.create_kinesis_stream_consumer(resource),
1174            "AWS::KMS::Key" => self.create_kms_key(resource),
1175            "AWS::KMS::Alias" => self.create_kms_alias(resource),
1176            "AWS::KMS::ReplicaKey" => self.create_kms_replica_key(resource),
1177            "AWS::ECR::Repository" => self.create_ecr_repository(resource),
1178            "AWS::ECR::RepositoryPolicy" => self.create_ecr_repository_policy(resource),
1179            "AWS::ECR::LifecyclePolicy" => self.create_ecr_lifecycle_policy(resource),
1180            "AWS::ECR::RegistryPolicy" => self.create_ecr_registry_policy(resource),
1181            "AWS::ECR::ReplicationConfiguration" => {
1182                self.create_ecr_replication_configuration(resource)
1183            }
1184            "AWS::ECR::RegistryScanningConfiguration" => {
1185                self.create_ecr_registry_scanning_configuration(resource)
1186            }
1187            "AWS::ECR::PullThroughCacheRule" => self.create_ecr_pull_through_cache_rule(resource),
1188            "AWS::CloudWatch::Alarm" => self.create_cloudwatch_alarm(resource),
1189            "AWS::CloudWatch::Dashboard" => self.create_cloudwatch_dashboard(resource),
1190            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
1191                self.create_elbv2_load_balancer(resource)
1192            }
1193            "AWS::ElasticLoadBalancingV2::TargetGroup" => self.create_elbv2_target_group(resource),
1194            "AWS::ElasticLoadBalancingV2::Listener" => self.create_elbv2_listener(resource),
1195            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
1196                self.create_elbv2_listener_rule(resource)
1197            }
1198            "AWS::ElasticLoadBalancingV2::ListenerCertificate" => {
1199                self.create_elbv2_listener_certificate(resource)
1200            }
1201            "AWS::ElasticLoadBalancingV2::TrustStore" => self.create_elbv2_trust_store(resource),
1202            "AWS::Organizations::Organization" => self.create_organization(resource),
1203            "AWS::Organizations::OrganizationalUnit" => self.create_organization_unit(resource),
1204            "AWS::Organizations::Account" => self.create_organization_account(resource),
1205            "AWS::Organizations::Policy" => self.create_organization_policy(resource),
1206            "AWS::Organizations::ResourcePolicy" => {
1207                self.create_organization_resource_policy(resource)
1208            }
1209            "AWS::Cognito::UserPool" => self.create_cognito_user_pool(resource),
1210            "AWS::Cognito::UserPoolClient" => self.create_cognito_user_pool_client(resource),
1211            "AWS::Cognito::UserPoolDomain" => self.create_cognito_user_pool_domain(resource),
1212            "AWS::Cognito::IdentityPool" => self.create_cognito_identity_pool(resource),
1213            "AWS::Cognito::IdentityPoolRoleAttachment" => {
1214                self.create_cognito_identity_pool_role_attachment(resource)
1215            }
1216            "AWS::RDS::DBSubnetGroup" => self.create_rds_subnet_group(resource),
1217            "AWS::RDS::DBParameterGroup" => self.create_rds_parameter_group(resource),
1218            "AWS::RDS::DBClusterParameterGroup" => {
1219                self.create_rds_cluster_parameter_group(resource)
1220            }
1221            "AWS::RDS::OptionGroup" => self.create_rds_option_group(resource),
1222            "AWS::RDS::EventSubscription" => self.create_rds_event_subscription(resource),
1223            "AWS::RDS::DBSecurityGroup" => self.create_rds_security_group(resource),
1224            "AWS::RDS::DBProxy" => self.create_rds_db_proxy(resource),
1225            "AWS::RDS::DBInstance" => self.create_rds_db_instance(resource),
1226            "AWS::RDS::DBCluster" => self.create_rds_db_cluster(resource),
1227            "AWS::AutoScaling::LaunchConfiguration" => {
1228                self.create_autoscaling_launch_configuration(resource)
1229            }
1230            "AWS::AutoScaling::AutoScalingGroup" => self.create_autoscaling_group(resource),
1231            "AWS::Batch::ComputeEnvironment" => self.create_batch_compute_environment(resource),
1232            "AWS::Batch::JobQueue" => self.create_batch_job_queue(resource),
1233            "AWS::Batch::JobDefinition" => self.create_batch_job_definition(resource),
1234            "AWS::Batch::SchedulingPolicy" => self.create_batch_scheduling_policy(resource),
1235            "AWS::Pipes::Pipe" => self.create_pipes_pipe(resource),
1236            "AWS::CodeArtifact::Domain" => self.create_codeartifact_domain(resource),
1237            "AWS::CodeArtifact::Repository" => self.create_codeartifact_repository(resource),
1238            "AWS::AmazonMQ::Broker" => self.create_mq_broker(resource),
1239            "AWS::AmazonMQ::Configuration" => self.create_mq_configuration(resource),
1240            "AWS::AmazonMQ::ConfigurationAssociation" => {
1241                self.create_mq_configuration_association(resource)
1242            }
1243            "AWS::MSK::Cluster" => self.create_msk_cluster(resource),
1244            "AWS::MSK::ServerlessCluster" => self.create_msk_serverless_cluster(resource),
1245            "AWS::MSK::Configuration" => self.create_msk_configuration(resource),
1246            "AWS::MSK::ClusterPolicy" => self.create_msk_cluster_policy(resource),
1247            "AWS::MSK::BatchScramSecret" => self.create_msk_batch_scram_secret(resource),
1248            "AWS::MSK::VpcConnection" => self.create_msk_vpc_connection(resource),
1249            "AWS::MSK::Replicator" => self.create_msk_replicator(resource),
1250            "AWS::KinesisAnalyticsV2::Application" => self.create_ka2_application(resource),
1251            "AWS::KinesisAnalyticsV2::ApplicationOutput" => {
1252                self.create_ka2_application_output(resource)
1253            }
1254            "AWS::KinesisAnalyticsV2::ApplicationReferenceDataSource" => {
1255                self.create_ka2_reference_data_source(resource)
1256            }
1257            "AWS::KinesisAnalyticsV2::ApplicationCloudWatchLoggingOption" => {
1258                self.create_ka2_cloudwatch_logging_option(resource)
1259            }
1260            "AWS::CodeCommit::Repository" => self.create_codecommit_repository(resource),
1261            "AWS::EFS::FileSystem" => self.create_efs_file_system(resource),
1262            "AWS::EFS::MountTarget" => self.create_efs_mount_target(resource),
1263            "AWS::EFS::AccessPoint" => self.create_efs_access_point(resource),
1264            "AWS::ElasticBeanstalk::Application" => self.create_eb_application(resource),
1265            "AWS::ElasticBeanstalk::ApplicationVersion" => {
1266                self.create_eb_application_version(resource)
1267            }
1268            "AWS::ElasticBeanstalk::Environment" => self.create_eb_environment(resource),
1269            "AWS::ElasticBeanstalk::ConfigurationTemplate" => {
1270                self.create_eb_configuration_template(resource)
1271            }
1272            "AWS::EC2::VPC" => self.create_ec2_vpc(resource),
1273            "AWS::EC2::Instance" => self.create_ec2_instance(resource),
1274            "AWS::EC2::Subnet" => self.create_ec2_subnet(resource),
1275            "AWS::EC2::SecurityGroup" => self.create_ec2_security_group(resource),
1276            "AWS::EC2::InternetGateway" => self.create_ec2_internet_gateway(resource),
1277            "AWS::EC2::RouteTable" => self.create_ec2_route_table(resource),
1278            "AWS::ECS::Cluster" => self.create_ecs_cluster(resource),
1279            "AWS::ECS::TaskDefinition" => self.create_ecs_task_definition(resource),
1280            "AWS::ECS::Service" => self.create_ecs_service(resource),
1281            "AWS::ECS::CapacityProvider" => self.create_ecs_capacity_provider(resource),
1282            "AWS::CertificateManager::Certificate" => self.create_acm_certificate(resource),
1283            "AWS::CertificateManager::Account" => self.create_acm_account(resource),
1284            "AWS::ACMPCA::CertificateAuthority" => {
1285                self.create_acmpca_certificate_authority(resource)
1286            }
1287            "AWS::ACMPCA::Certificate" => self.create_acmpca_certificate(resource),
1288            "AWS::ACMPCA::CertificateAuthorityActivation" => {
1289                self.create_acmpca_certificate_authority_activation(resource)
1290            }
1291            "AWS::ACMPCA::Permission" => self.create_acmpca_permission(resource),
1292            "AWS::Route53Resolver::ResolverEndpoint" => {
1293                self.create_r53r_resolver_endpoint(resource)
1294            }
1295            "AWS::Route53Resolver::ResolverRule" => self.create_r53r_resolver_rule(resource),
1296            "AWS::Route53Resolver::ResolverRuleAssociation" => {
1297                self.create_r53r_rule_association(resource)
1298            }
1299            "AWS::Route53Resolver::ResolverQueryLoggingConfig" => {
1300                self.create_r53r_query_log_config(resource)
1301            }
1302            "AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation" => {
1303                self.create_r53r_query_log_association(resource)
1304            }
1305            "AWS::Route53Resolver::FirewallDomainList" => {
1306                self.create_r53r_firewall_domain_list(resource)
1307            }
1308            "AWS::Route53Resolver::FirewallRuleGroup" => {
1309                self.create_r53r_firewall_rule_group(resource)
1310            }
1311            "AWS::Route53Resolver::FirewallRuleGroupAssociation" => {
1312                self.create_r53r_firewall_rule_group_association(resource)
1313            }
1314            "AWS::Route53Resolver::FirewallConfig" => self.create_r53r_firewall_config(resource),
1315            "AWS::Route53Resolver::ResolverConfig" => self.create_r53r_resolver_config(resource),
1316            "AWS::Route53Resolver::ResolverDNSSECConfig" => {
1317                self.create_r53r_dnssec_config(resource)
1318            }
1319            "AWS::Config::ConfigurationRecorder"
1320            | "AWS::Config::DeliveryChannel"
1321            | "AWS::Config::ConfigRule"
1322            | "AWS::Config::ConfigurationAggregator"
1323            | "AWS::Config::AggregationAuthorization"
1324            | "AWS::Config::ConformancePack"
1325            | "AWS::Config::OrganizationConfigRule" => self.create_config_resource(resource),
1326            "AWS::ElastiCache::ParameterGroup" => self.create_ec_parameter_group(resource),
1327            "AWS::ElastiCache::SubnetGroup" => self.create_ec_subnet_group(resource),
1328            "AWS::ElastiCache::SecurityGroup" => self.create_ec_security_group(resource),
1329            "AWS::ElastiCache::User" => self.create_ec_user(resource),
1330            "AWS::ElastiCache::UserGroup" => self.create_ec_user_group(resource),
1331            "AWS::ElastiCache::CacheCluster" => self.create_ec_cache_cluster(resource),
1332            "AWS::ElastiCache::ReplicationGroup" => self.create_ec_replication_group(resource),
1333            "AWS::Route53::HostedZone" => self.create_route53_hosted_zone(resource),
1334            "AWS::Route53::RecordSet" => self.create_route53_record_set(resource),
1335            "AWS::Route53::HealthCheck" => self.create_route53_health_check(resource),
1336            "AWS::Route53::DNSSEC" => self.create_route53_dnssec(resource),
1337            "AWS::Route53::KeySigningKey" => self.create_route53_key_signing_key(resource),
1338            "AWS::CloudFront::CloudFrontOriginAccessIdentity" => {
1339                self.create_cf_origin_access_identity(resource)
1340            }
1341            "AWS::CloudFront::Distribution" => self.create_cf_distribution(resource),
1342            "AWS::CloudFront::OriginAccessControl" => {
1343                self.create_cf_origin_access_control(resource)
1344            }
1345            "AWS::CloudFront::PublicKey" => self.create_cf_public_key(resource),
1346            "AWS::CloudFront::KeyGroup" => self.create_cf_key_group(resource),
1347            "AWS::CloudFront::Function" => self.create_cf_function(resource),
1348            "AWS::CloudFront::CachePolicy" => self.create_cf_cache_policy(resource),
1349            "AWS::CloudFront::OriginRequestPolicy" => {
1350                self.create_cf_origin_request_policy(resource)
1351            }
1352            "AWS::CloudFront::ResponseHeadersPolicy" => {
1353                self.create_cf_response_headers_policy(resource)
1354            }
1355            "AWS::StepFunctions::StateMachine" => self.create_sfn_state_machine(resource),
1356            "AWS::StepFunctions::Activity" => self.create_sfn_activity(resource),
1357            "AWS::StepFunctions::StateMachineVersion" => self.create_sfn_version(resource),
1358            "AWS::StepFunctions::StateMachineAlias" => self.create_sfn_alias(resource),
1359            "AWS::WAFv2::WebACL" => self.create_wafv2_web_acl(resource),
1360            "AWS::WAFv2::IPSet" => self.create_wafv2_ip_set(resource),
1361            "AWS::WAFv2::RegexPatternSet" => self.create_wafv2_regex_pattern_set(resource),
1362            "AWS::WAFv2::RuleGroup" => self.create_wafv2_rule_group(resource),
1363            "AWS::WAFv2::LoggingConfiguration" => self.create_wafv2_logging_configuration(resource),
1364            "AWS::WAFv2::WebACLAssociation" => self.create_wafv2_web_acl_association(resource),
1365            "AWS::ApiGateway::RestApi" => self.create_apigw_rest_api(resource),
1366            "AWS::ApiGateway::Resource" => self.create_apigw_resource(resource),
1367            "AWS::ApiGateway::Method" => self.create_apigw_method(resource),
1368            "AWS::ApiGateway::Deployment" => self.create_apigw_deployment(resource),
1369            "AWS::ApiGateway::Stage" => self.create_apigw_stage(resource),
1370            "AWS::ApiGateway::Authorizer" => self.create_apigw_authorizer(resource),
1371            "AWS::ApiGateway::RequestValidator" => self.create_apigw_request_validator(resource),
1372            "AWS::ApiGateway::Model" => self.create_apigw_model(resource),
1373            "AWS::ApiGateway::GatewayResponse" => self.create_apigw_gateway_response(resource),
1374            "AWS::ApiGateway::UsagePlan" => self.create_apigw_usage_plan(resource),
1375            "AWS::ApiGateway::ApiKey" => self.create_apigw_api_key(resource),
1376            "AWS::ApiGateway::UsagePlanKey" => self.create_apigw_usage_plan_key(resource),
1377            "AWS::ApiGateway::DomainName" => self.create_apigw_domain_name(resource),
1378            "AWS::ApiGateway::BasePathMapping" => self.create_apigw_base_path_mapping(resource),
1379            "AWS::ApiGatewayV2::Api" => self.create_apigwv2_api(resource),
1380            "AWS::ApiGatewayV2::Route" => self.create_apigwv2_route(resource),
1381            "AWS::ApiGatewayV2::Integration" => self.create_apigwv2_integration(resource),
1382            "AWS::ApiGatewayV2::IntegrationResponse" => {
1383                self.create_apigwv2_integration_response(resource)
1384            }
1385            "AWS::ApiGatewayV2::RouteResponse" => self.create_apigwv2_route_response(resource),
1386            "AWS::ApiGatewayV2::Stage" => self.create_apigwv2_stage(resource),
1387            "AWS::ApiGatewayV2::Deployment" => self.create_apigwv2_deployment(resource),
1388            "AWS::ApiGatewayV2::Authorizer" => self.create_apigwv2_authorizer(resource),
1389            "AWS::ApiGatewayV2::DomainName" => self.create_apigwv2_domain_name(resource),
1390            "AWS::ApiGatewayV2::ApiMapping" => self.create_apigwv2_api_mapping(resource),
1391            "AWS::ApiGatewayV2::VpcLink" => self.create_apigwv2_vpc_link(resource),
1392            "AWS::ApiGatewayV2::Model" => self.create_apigwv2_model(resource),
1393            "AWS::SES::ConfigurationSet" => self.create_ses_configuration_set(resource),
1394            "AWS::SES::ConfigurationSetEventDestination" => {
1395                self.create_ses_event_destination(resource)
1396            }
1397            "AWS::SES::EmailIdentity" => self.create_ses_email_identity(resource),
1398            "AWS::SES::Template" => self.create_ses_template(resource),
1399            "AWS::SES::ContactList" => self.create_ses_contact_list(resource),
1400            "AWS::SES::DedicatedIpPool" => self.create_ses_dedicated_ip_pool(resource),
1401            "AWS::SES::ReceiptRule" => self.create_ses_receipt_rule(resource),
1402            "AWS::SES::ReceiptRuleSet" => self.create_ses_receipt_rule_set(resource),
1403            "AWS::SES::ReceiptFilter" => self.create_ses_receipt_filter(resource),
1404            "AWS::SES::VdmAttributes" => self.create_ses_vdm_attributes(resource),
1405            "AWS::SecretsManager::RotationSchedule" => {
1406                self.create_secrets_manager_rotation_schedule(resource)
1407            }
1408            "AWS::SecretsManager::ResourcePolicy" => {
1409                self.create_secrets_manager_resource_policy(resource)
1410            }
1411            "AWS::SecretsManager::SecretTargetAttachment" => {
1412                self.create_secrets_manager_target_attachment(resource)
1413            }
1414            "AWS::ApplicationAutoScaling::ScalableTarget" => {
1415                self.create_application_autoscaling_scalable_target(resource)
1416            }
1417            "AWS::ApplicationAutoScaling::ScalingPolicy" => {
1418                self.create_application_autoscaling_scaling_policy(resource)
1419            }
1420            "AWS::Athena::DataCatalog" => self.create_athena_data_catalog(resource),
1421            "AWS::Athena::NamedQuery" => self.create_athena_named_query(resource),
1422            "AWS::Athena::WorkGroup" => self.create_athena_work_group(resource),
1423            "AWS::Athena::PreparedStatement" => self.create_athena_prepared_statement(resource),
1424            "AWS::KinesisFirehose::DeliveryStream" => {
1425                self.create_firehose_delivery_stream(resource)
1426            }
1427            "AWS::Glue::Database" => self.create_glue_database(resource),
1428            "AWS::EKS::Cluster" => self.create_eks_cluster(resource),
1429            "AWS::EKS::Nodegroup" => self.create_eks_nodegroup(resource),
1430            "AWS::EKS::FargateProfile" => self.create_eks_fargate_profile(resource),
1431            "AWS::EKS::Addon" => self.create_eks_addon(resource),
1432            "AWS::EKS::AccessEntry" => self.create_eks_access_entry(resource),
1433            "AWS::EKS::IdentityProviderConfig" => {
1434                self.create_eks_identity_provider_config(resource)
1435            }
1436            "AWS::EKS::PodIdentityAssociation" => {
1437                self.create_eks_pod_identity_association(resource)
1438            }
1439            "AWS::ServiceDiscovery::HttpNamespace" => self.create_sd_http_namespace(resource),
1440            "AWS::ServiceDiscovery::PublicDnsNamespace" => {
1441                self.create_sd_public_dns_namespace(resource)
1442            }
1443            "AWS::ServiceDiscovery::PrivateDnsNamespace" => {
1444                self.create_sd_private_dns_namespace(resource)
1445            }
1446            "AWS::ServiceDiscovery::Service" => self.create_sd_service(resource),
1447            "AWS::ServiceDiscovery::Instance" => self.create_sd_instance(resource),
1448            "AWS::CloudFormation::Stack" => self.create_cloudformation_stack(resource),
1449            "AWS::Glue::Table" => self.create_glue_table(resource),
1450            "AWS::Glue::Partition" => self.create_glue_partition(resource),
1451            t if t.starts_with("Custom::") || t == "AWS::CloudFormation::CustomResource" => self
1452                .create_custom_resource(resource)
1453                .map(ProvisionResult::new),
1454            other if self.strict_unknown_types => {
1455                // Cloud Control API path: reject a resource type fakecloud has
1456                // no provisioner for instead of recording a phantom resource.
1457                // Otherwise `GetResource`/`ListResources` would report a
1458                // resource whose owning service never created any backing state.
1459                return Err(format!(
1460                    "Resource type '{other}' is not supported by Cloud Control API on fakecloud."
1461                ));
1462            }
1463            other => {
1464                // No provisioner for this type. Real CloudFormation provisions
1465                // many resource types fakecloud doesn't model, and SAM/CDK
1466                // output routinely includes ones like
1467                // `AWS::CloudFormation::WaitConditionHandle`; failing the whole
1468                // stack on each would block otherwise-valid templates. Match the
1469                // documented contract (docs/services/cloudformation.md): accept
1470                // and record the resource as provisioned with no backing state.
1471                // Dependent operations that need real state may still fail —
1472                // that is the honest outcome. The physical id is the logical id
1473                // so `Ref` on the resource resolves to a stable value.
1474                tracing::warn!(
1475                    resource_type = %other,
1476                    logical_id = %resource.logical_id,
1477                    "CloudFormation: no provisioner for resource type; recording it as provisioned with no backing state"
1478                );
1479                Ok(ProvisionResult::new(resource.logical_id.clone()))
1480            }
1481        };
1482
1483        let is_custom = resource.resource_type.starts_with("Custom::")
1484            || resource.resource_type == "AWS::CloudFormation::CustomResource";
1485        let service_token = if is_custom {
1486            resource
1487                .properties
1488                .get("ServiceToken")
1489                .and_then(|v| v.as_str())
1490                .map(|s| s.to_string())
1491        } else {
1492            None
1493        };
1494
1495        result.map(|res| StackResource {
1496            logical_id: resource.logical_id.clone(),
1497            physical_id: res.physical_id,
1498            resource_type: resource.resource_type.clone(),
1499            status: "CREATE_COMPLETE".to_string(),
1500            service_token,
1501            attributes: res.attributes,
1502        })
1503    }
1504
1505    /// Apply a property update to an existing stack resource. Returns
1506    /// `Ok(Some(updated))` when the resource type supports in-place updates
1507    /// (the caller swaps the resulting `StackResource` for the old one) or
1508    /// `Ok(None)` when the type has no update path defined (the caller
1509    /// leaves the existing resource alone). `Err` propagates a
1510    /// resource-level failure up to the stack-level UPDATE_FAILED status.
1511    pub fn update_resource(
1512        &self,
1513        existing: &StackResource,
1514        new_def: &ResourceDefinition,
1515    ) -> Result<Option<StackResource>, String> {
1516        let result = match new_def.resource_type.as_str() {
1517            "AWS::Lambda::Function" => Some(self.update_lambda_function(existing, new_def)?),
1518            "AWS::Lambda::Permission" => Some(self.update_lambda_permission(existing, new_def)?),
1519            "AWS::Lambda::EventSourceMapping" => {
1520                Some(self.update_lambda_event_source_mapping(existing, new_def)?)
1521            }
1522            "AWS::Lambda::LayerVersion" => {
1523                Some(self.update_lambda_layer_version(existing, new_def)?)
1524            }
1525            "AWS::Lambda::Url" => Some(self.update_lambda_url(existing, new_def)?),
1526            "AWS::Lambda::Alias" => Some(self.update_lambda_alias(existing, new_def)?),
1527            "AWS::Lambda::Version" => Some(self.update_lambda_version(existing, new_def)?),
1528            "AWS::IAM::Role" => Some(self.update_iam_role(existing, new_def)?),
1529            "AWS::IAM::Policy" => Some(self.update_iam_policy(existing, new_def)?),
1530            "AWS::IAM::ManagedPolicy" => Some(self.update_iam_policy(existing, new_def)?),
1531            "AWS::ApiGateway::RestApi" => Some(self.update_apigw_rest_api(existing, new_def)?),
1532            "AWS::ApiGateway::Resource" => Some(self.update_apigw_resource(existing, new_def)?),
1533            "AWS::ApiGateway::Method" => Some(self.update_apigw_method(existing, new_def)?),
1534            "AWS::ApiGateway::Deployment" => Some(self.update_apigw_deployment(existing, new_def)?),
1535            "AWS::ApiGateway::Stage" => Some(self.update_apigw_stage(existing, new_def)?),
1536            "AWS::ApiGateway::Authorizer" => Some(self.update_apigw_authorizer(existing, new_def)?),
1537            "AWS::ApiGateway::RequestValidator" => {
1538                Some(self.update_apigw_request_validator(existing, new_def)?)
1539            }
1540            "AWS::ApiGateway::Model" => Some(self.update_apigw_model(existing, new_def)?),
1541            "AWS::ApiGateway::GatewayResponse" => {
1542                Some(self.update_apigw_gateway_response(existing, new_def)?)
1543            }
1544            "AWS::ApiGateway::UsagePlan" => Some(self.update_apigw_usage_plan(existing, new_def)?),
1545            "AWS::ApiGateway::ApiKey" => Some(self.update_apigw_api_key(existing, new_def)?),
1546            "AWS::ApiGateway::UsagePlanKey" => {
1547                Some(self.update_apigw_usage_plan_key(existing, new_def)?)
1548            }
1549            "AWS::ApiGateway::DomainName" => {
1550                Some(self.update_apigw_domain_name(existing, new_def)?)
1551            }
1552            "AWS::ApiGateway::BasePathMapping" => {
1553                Some(self.update_apigw_base_path_mapping(existing, new_def)?)
1554            }
1555            "AWS::ApiGatewayV2::Api" => Some(self.update_apigwv2_api(existing, new_def)?),
1556            "AWS::ApiGatewayV2::Route" => Some(self.update_apigwv2_route(existing, new_def)?),
1557            "AWS::ApiGatewayV2::Integration" => {
1558                Some(self.update_apigwv2_integration(existing, new_def)?)
1559            }
1560            "AWS::ApiGatewayV2::IntegrationResponse" => {
1561                Some(self.update_apigwv2_integration_response(existing, new_def)?)
1562            }
1563            "AWS::ApiGatewayV2::RouteResponse" => {
1564                Some(self.update_apigwv2_route_response(existing, new_def)?)
1565            }
1566            "AWS::ApiGatewayV2::Stage" => Some(self.update_apigwv2_stage(existing, new_def)?),
1567            "AWS::ApiGatewayV2::Deployment" => {
1568                Some(self.update_apigwv2_deployment(existing, new_def)?)
1569            }
1570            "AWS::ApiGatewayV2::Authorizer" => {
1571                Some(self.update_apigwv2_authorizer(existing, new_def)?)
1572            }
1573            "AWS::ApiGatewayV2::DomainName" => {
1574                Some(self.update_apigwv2_domain_name(existing, new_def)?)
1575            }
1576            "AWS::ApiGatewayV2::ApiMapping" => {
1577                Some(self.update_apigwv2_api_mapping(existing, new_def)?)
1578            }
1579            "AWS::ApiGatewayV2::VpcLink" => Some(self.update_apigwv2_vpc_link(existing, new_def)?),
1580            "AWS::ApiGatewayV2::Model" => Some(self.update_apigwv2_model(existing, new_def)?),
1581            "AWS::ECS::Cluster" => Some(self.update_ecs_cluster(existing, new_def)?),
1582            "AWS::ECS::Service" => Some(self.update_ecs_service(existing, new_def)?),
1583            "AWS::ECS::TaskDefinition" => Some(self.update_ecs_task_definition(existing, new_def)?),
1584            "AWS::ECS::CapacityProvider" => {
1585                Some(self.update_ecs_capacity_provider(existing, new_def)?)
1586            }
1587            "AWS::ECR::Repository" => Some(self.update_ecr_repository(existing, new_def)?),
1588            "AWS::ECR::RepositoryPolicy" => {
1589                Some(self.update_ecr_repository_policy(existing, new_def)?)
1590            }
1591            "AWS::ECR::LifecyclePolicy" => {
1592                Some(self.update_ecr_lifecycle_policy(existing, new_def)?)
1593            }
1594            "AWS::ECR::RegistryPolicy" => Some(self.update_ecr_registry_policy(existing, new_def)?),
1595            "AWS::ECR::ReplicationConfiguration" => {
1596                Some(self.update_ecr_replication_configuration(existing, new_def)?)
1597            }
1598            "AWS::ECR::RegistryScanningConfiguration" => {
1599                Some(self.update_ecr_registry_scanning_configuration(existing, new_def)?)
1600            }
1601            "AWS::ECR::PullThroughCacheRule" => {
1602                Some(self.update_ecr_pull_through_cache_rule(existing, new_def)?)
1603            }
1604            "AWS::KMS::Key" => Some(self.update_kms_key(existing, new_def)?),
1605            "AWS::KMS::ReplicaKey" => Some(self.update_kms_replica_key(existing, new_def)?),
1606            "AWS::KMS::Alias" => Some(self.update_kms_alias(existing, new_def)?),
1607            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
1608                Some(self.update_elbv2_load_balancer(existing, new_def)?)
1609            }
1610            "AWS::ElasticLoadBalancingV2::TargetGroup" => {
1611                Some(self.update_elbv2_target_group(existing, new_def)?)
1612            }
1613            "AWS::ElasticLoadBalancingV2::Listener" => {
1614                Some(self.update_elbv2_listener(existing, new_def)?)
1615            }
1616            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
1617                Some(self.update_elbv2_listener_rule(existing, new_def)?)
1618            }
1619            "AWS::ElasticLoadBalancingV2::ListenerCertificate" => {
1620                Some(self.update_elbv2_listener_certificate(existing, new_def)?)
1621            }
1622            "AWS::ElasticLoadBalancingV2::TrustStore" => {
1623                Some(self.update_elbv2_trust_store(existing, new_def)?)
1624            }
1625            "AWS::CloudWatch::Alarm" => Some(self.update_cloudwatch_alarm(existing, new_def)?),
1626            "AWS::CloudWatch::Dashboard" => {
1627                Some(self.update_cloudwatch_dashboard(existing, new_def)?)
1628            }
1629            "AWS::StepFunctions::StateMachine" => {
1630                Some(self.update_sfn_state_machine(existing, new_def)?)
1631            }
1632            "AWS::SQS::Queue" => Some(self.update_sqs_queue(existing, new_def)?),
1633            "AWS::SQS::QueuePolicy" => Some(self.update_sqs_queue_policy(existing, new_def)?),
1634            "AWS::SNS::Topic" => Some(self.update_sns_topic(existing, new_def)?),
1635            "AWS::SNS::TopicPolicy" => Some(self.update_sns_topic_policy(existing, new_def)?),
1636            "AWS::S3::Bucket" => Some(self.update_s3_bucket(existing, new_def)?),
1637            "AWS::S3::BucketPolicy" => Some(self.update_s3_bucket_policy(existing, new_def)?),
1638            "AWS::Pipes::Pipe" => Some(self.update_pipes_pipe(existing, new_def)?),
1639            "AWS::CodeArtifact::Domain" => {
1640                Some(self.update_codeartifact_domain(existing, new_def)?)
1641            }
1642            "AWS::CodeArtifact::Repository" => {
1643                Some(self.update_codeartifact_repository(existing, new_def)?)
1644            }
1645            "AWS::AmazonMQ::Broker" => Some(self.update_mq_broker(existing, new_def)?),
1646            "AWS::AmazonMQ::Configuration" => {
1647                Some(self.update_mq_configuration(existing, new_def)?)
1648            }
1649            "AWS::AmazonMQ::ConfigurationAssociation" => {
1650                Some(self.create_mq_configuration_association(new_def)?)
1651            }
1652            "AWS::MSK::Cluster" => Some(self.update_msk_cluster(existing, new_def)?),
1653            "AWS::MSK::ServerlessCluster" => Some(self.update_msk_cluster(existing, new_def)?),
1654            "AWS::MSK::Configuration" => Some(self.update_msk_configuration(existing, new_def)?),
1655            "AWS::MSK::ClusterPolicy" => Some(self.update_msk_cluster_policy(existing, new_def)?),
1656            "AWS::MSK::BatchScramSecret" => {
1657                Some(self.update_msk_batch_scram_secret(existing, new_def)?)
1658            }
1659            "AWS::MSK::VpcConnection" => Some(self.update_msk_vpc_connection(existing, new_def)?),
1660            "AWS::MSK::Replicator" => Some(self.update_msk_replicator(existing, new_def)?),
1661            "AWS::KinesisAnalyticsV2::Application" => {
1662                Some(self.update_ka2_application(existing, new_def)?)
1663            }
1664            "AWS::KinesisAnalyticsV2::ApplicationOutput" => {
1665                Some(self.update_ka2_application_output(existing, new_def)?)
1666            }
1667            "AWS::KinesisAnalyticsV2::ApplicationReferenceDataSource" => {
1668                Some(self.update_ka2_reference_data_source(existing, new_def)?)
1669            }
1670            "AWS::KinesisAnalyticsV2::ApplicationCloudWatchLoggingOption" => {
1671                Some(self.update_ka2_cloudwatch_logging_option(existing, new_def)?)
1672            }
1673            "AWS::CodeCommit::Repository" => {
1674                Some(self.update_codecommit_repository(existing, new_def)?)
1675            }
1676            "AWS::EFS::FileSystem" => Some(self.update_efs_file_system(existing, new_def)?),
1677            "AWS::EFS::MountTarget" => Some(self.update_efs_mount_target(existing, new_def)?),
1678            "AWS::EFS::AccessPoint" => Some(self.update_efs_access_point(existing, new_def)?),
1679            "AWS::ElasticBeanstalk::Application" => {
1680                Some(self.update_eb_application(existing, new_def)?)
1681            }
1682            "AWS::ElasticBeanstalk::ApplicationVersion" => {
1683                Some(self.update_eb_application_version(existing, new_def)?)
1684            }
1685            "AWS::ElasticBeanstalk::Environment" => {
1686                Some(self.update_eb_environment(existing, new_def)?)
1687            }
1688            "AWS::ElasticBeanstalk::ConfigurationTemplate" => {
1689                Some(self.update_eb_configuration_template(existing, new_def)?)
1690            }
1691            _ => None,
1692        };
1693
1694        Ok(result.map(|res| StackResource {
1695            logical_id: existing.logical_id.clone(),
1696            physical_id: res.physical_id,
1697            resource_type: existing.resource_type.clone(),
1698            status: "UPDATE_COMPLETE".to_string(),
1699            service_token: existing.service_token.clone(),
1700            attributes: res.attributes,
1701        }))
1702    }
1703
1704    /// Resolve a `Fn::GetAtt` against a previously provisioned resource.
1705    /// Returns the attribute value as a string, or `None` if the resource
1706    /// type doesn't expose that attribute (caller falls back to a placeholder
1707    /// so multi-pass provisioning can retry).
1708    ///
1709    /// The lookup first checks attributes captured at create time on the
1710    /// `StackResource`, then falls back to live service-state queries for
1711    /// the well-known attribute names of each resource type. This means
1712    /// attributes that change after creation (e.g. Lambda `FunctionUrl`)
1713    /// resolve correctly even when the URL was added in a separate pass.
1714    pub fn get_att(&self, resource: &StackResource, attribute: &str) -> Option<String> {
1715        // Amazon MQ broker endpoint / IP attributes are the ONE captured set that
1716        // legitimately goes stale: at create time the backing container is not up
1717        // yet, so they are the cosmetic `*.amazonaws.com` synthesis, but they
1718        // become the REAL bound `tcp://127.0.0.1:<port>` once the container
1719        // settles. Resolve them LIVE so CFN GetAtt / outputs match what the direct
1720        // DescribeBroker returns rather than serving the frozen cosmetic values.
1721        const MQ_BROKER_LIVE_ATTRS: &[&str] = &[
1722            "IpAddresses",
1723            "OpenWireEndpoints",
1724            "AmqpEndpoints",
1725            "StompEndpoints",
1726            "MqttEndpoints",
1727            "WssEndpoints",
1728        ];
1729        if resource.resource_type == "AWS::AmazonMQ::Broker"
1730            && MQ_BROKER_LIVE_ATTRS.contains(&attribute)
1731        {
1732            if let Some(v) = self.get_att_mq_broker(&resource.physical_id, attribute) {
1733                return Some(v);
1734            }
1735        }
1736        // Captured attributes are the source of truth — they were computed
1737        // at create time and never go stale for the resources we ship today.
1738        if let Some(v) = resource.attributes.get(attribute) {
1739            return Some(v.clone());
1740        }
1741        // Live-state fallback for attributes that aren't pre-captured. This
1742        // is the extension point for future provisioners.
1743        match resource.resource_type.as_str() {
1744            "AWS::S3::Bucket" => self.get_att_s3_bucket(&resource.physical_id, attribute),
1745            "AWS::Lambda::Function" => {
1746                self.get_att_lambda_function(&resource.physical_id, attribute)
1747            }
1748            "AWS::IAM::Role" => self.get_att_iam_role(&resource.physical_id, attribute),
1749            "AWS::SQS::Queue" => self.get_att_sqs_queue(&resource.physical_id, attribute),
1750            "AWS::SNS::Topic" => self.get_att_sns_topic(&resource.physical_id, attribute),
1751            "AWS::DynamoDB::Table" => self.get_att_dynamodb_table(&resource.physical_id, attribute),
1752            "AWS::KMS::Key" => self.get_att_kms_key(&resource.physical_id, attribute),
1753            "AWS::SecretsManager::Secret" => {
1754                self.get_att_secrets_manager_secret(&resource.physical_id, attribute)
1755            }
1756            "AWS::CloudFront::Distribution" => {
1757                self.get_att_cf_distribution(&resource.physical_id, attribute)
1758            }
1759            "AWS::ECS::Cluster" => self.get_att_ecs_cluster(&resource.physical_id, attribute),
1760            "AWS::ECS::Service" => self.get_att_ecs_service(&resource.physical_id, attribute),
1761            "AWS::EC2::VPC"
1762            | "AWS::EC2::Subnet"
1763            | "AWS::EC2::SecurityGroup"
1764            | "AWS::EC2::InternetGateway"
1765            | "AWS::EC2::Instance"
1766            | "AWS::EC2::RouteTable" => self.get_att_ec2(resource, attribute),
1767            "AWS::ECS::CapacityProvider" => {
1768                self.get_att_ecs_capacity_provider(&resource.physical_id, attribute)
1769            }
1770            "AWS::ECR::Repository" => self.get_att_ecr_repository(&resource.physical_id, attribute),
1771            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
1772                self.get_att_elbv2_load_balancer(&resource.physical_id, attribute)
1773            }
1774            "AWS::ElasticLoadBalancingV2::TargetGroup" => {
1775                self.get_att_elbv2_target_group(&resource.physical_id, attribute)
1776            }
1777            "AWS::ElasticLoadBalancingV2::Listener" => {
1778                self.get_att_elbv2_listener(&resource.physical_id, attribute)
1779            }
1780            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
1781                self.get_att_elbv2_listener_rule(&resource.physical_id, attribute)
1782            }
1783            "AWS::ElasticLoadBalancingV2::TrustStore" => {
1784                self.get_att_elbv2_trust_store(&resource.physical_id, attribute)
1785            }
1786            "AWS::WAFv2::WebACL" => self.get_att_wafv2_web_acl(&resource.physical_id, attribute),
1787            "AWS::WAFv2::IPSet" => self.get_att_wafv2_ip_set(&resource.physical_id, attribute),
1788            "AWS::WAFv2::RegexPatternSet" => {
1789                self.get_att_wafv2_regex_pattern_set(&resource.physical_id, attribute)
1790            }
1791            "AWS::WAFv2::RuleGroup" => {
1792                self.get_att_wafv2_rule_group(&resource.physical_id, attribute)
1793            }
1794            "AWS::SES::ConfigurationSet" => {
1795                self.get_att_ses_configuration_set(&resource.physical_id, attribute)
1796            }
1797            "AWS::SES::EmailIdentity" => {
1798                self.get_att_ses_email_identity(&resource.physical_id, attribute)
1799            }
1800            "AWS::SES::Template" => self.get_att_ses_template(&resource.physical_id, attribute),
1801            "AWS::SES::ContactList" => {
1802                self.get_att_ses_contact_list(&resource.physical_id, attribute)
1803            }
1804            "AWS::SES::DedicatedIpPool" => {
1805                self.get_att_ses_dedicated_ip_pool(&resource.physical_id, attribute)
1806            }
1807            "AWS::SES::ReceiptRuleSet" => {
1808                self.get_att_ses_receipt_rule_set(&resource.physical_id, attribute)
1809            }
1810            "AWS::Athena::DataCatalog" => {
1811                self.get_att_athena_data_catalog(&resource.physical_id, attribute)
1812            }
1813            "AWS::Athena::NamedQuery" => {
1814                self.get_att_athena_named_query(&resource.physical_id, attribute)
1815            }
1816            "AWS::Athena::WorkGroup" => {
1817                self.get_att_athena_work_group(&resource.physical_id, attribute)
1818            }
1819            "AWS::Athena::PreparedStatement" => {
1820                self.get_att_athena_prepared_statement(&resource.physical_id, attribute)
1821            }
1822            "AWS::CloudFormation::Stack" => {
1823                self.get_att_cloudformation_stack(&resource.physical_id, attribute)
1824            }
1825            "AWS::Pipes::Pipe" => self.get_att_pipes_pipe(&resource.physical_id, attribute),
1826            "AWS::CodeArtifact::Domain" => {
1827                self.get_att_codeartifact_domain(&resource.physical_id, attribute)
1828            }
1829            "AWS::CodeArtifact::Repository" => {
1830                self.get_att_codeartifact_repository(&resource.physical_id, attribute)
1831            }
1832            "AWS::AmazonMQ::Broker" => self.get_att_mq_broker(&resource.physical_id, attribute),
1833            "AWS::AmazonMQ::Configuration" => {
1834                self.get_att_mq_configuration(&resource.physical_id, attribute)
1835            }
1836            "AWS::MSK::Cluster" | "AWS::MSK::ServerlessCluster" => {
1837                self.get_att_msk_cluster(&resource.physical_id, attribute)
1838            }
1839            "AWS::MSK::Configuration" => {
1840                self.get_att_msk_configuration(&resource.physical_id, attribute)
1841            }
1842            "AWS::MSK::ClusterPolicy" => {
1843                self.get_att_msk_cluster_policy(&resource.physical_id, attribute)
1844            }
1845            "AWS::MSK::VpcConnection" => {
1846                self.get_att_msk_vpc_connection(&resource.physical_id, attribute)
1847            }
1848            "AWS::MSK::Replicator" => self.get_att_msk_replicator(&resource.physical_id, attribute),
1849            "AWS::CodeCommit::Repository" => {
1850                self.get_att_codecommit_repository(&resource.physical_id, attribute)
1851            }
1852            "AWS::EFS::FileSystem" => {
1853                self.get_att_efs_file_system(&resource.physical_id, attribute)
1854            }
1855            "AWS::EFS::MountTarget" => {
1856                self.get_att_efs_mount_target(&resource.physical_id, attribute)
1857            }
1858            "AWS::EFS::AccessPoint" => {
1859                self.get_att_efs_access_point(&resource.physical_id, attribute)
1860            }
1861            "AWS::ElasticBeanstalk::Environment" => {
1862                self.get_att_eb_environment(&resource.physical_id, attribute)
1863            }
1864            "AWS::EKS::Cluster" => self.get_att_eks_cluster(&resource.physical_id, attribute),
1865            "AWS::EKS::Nodegroup" => self.get_att_eks_nodegroup(&resource.physical_id, attribute),
1866            "AWS::EKS::FargateProfile" => {
1867                self.get_att_eks_fargate_profile(&resource.physical_id, attribute)
1868            }
1869            "AWS::EKS::Addon" => self.get_att_eks_addon(&resource.physical_id, attribute),
1870            "AWS::EKS::AccessEntry" => {
1871                self.get_att_eks_access_entry(&resource.physical_id, attribute)
1872            }
1873            "AWS::EKS::IdentityProviderConfig" => {
1874                self.get_att_eks_identity_provider_config(&resource.physical_id, attribute)
1875            }
1876            "AWS::EKS::PodIdentityAssociation" => {
1877                self.get_att_eks_pod_identity_association(&resource.physical_id, attribute)
1878            }
1879            "AWS::ServiceDiscovery::HttpNamespace"
1880            | "AWS::ServiceDiscovery::PublicDnsNamespace"
1881            | "AWS::ServiceDiscovery::PrivateDnsNamespace" => {
1882                self.get_att_sd_namespace(&resource.physical_id, attribute)
1883            }
1884            "AWS::ServiceDiscovery::Service" => {
1885                self.get_att_sd_service(&resource.physical_id, attribute)
1886            }
1887            t if t.starts_with("AWS::Route53Resolver::") => {
1888                self.get_att_route53resolver(resource, attribute)
1889            }
1890            _ => None,
1891        }
1892    }
1893
1894    fn get_att_cf_distribution(&self, physical_id: &str, attribute: &str) -> Option<String> {
1895        // CloudFront state is keyed under a fixed "000000000000" account in the
1896        // fakecloud_cloudfront crate; matching create_cf_distribution above.
1897        let accounts = self.cloudfront_state.read();
1898        let state = accounts.get("000000000000")?;
1899        let dist = state.distributions.get(physical_id)?;
1900        match attribute {
1901            "DomainName" => Some(dist.domain_name.clone()),
1902            "Id" => Some(dist.id.clone()),
1903            _ => None,
1904        }
1905    }
1906
1907    /// Delete a previously created resource.
1908    pub fn delete_resource(&self, resource: &StackResource) -> Result<(), String> {
1909        match resource.resource_type.as_str() {
1910            "AWS::SQS::Queue" => self.delete_sqs_queue(&resource.physical_id),
1911            "AWS::SQS::QueuePolicy" => self.delete_sqs_queue_policy(&resource.physical_id),
1912            "AWS::SNS::Topic" => self.delete_sns_topic(&resource.physical_id),
1913            "AWS::SNS::TopicPolicy" => self.delete_sns_topic_policy(&resource.physical_id),
1914            "AWS::SNS::Subscription" => self.delete_sns_subscription(&resource.physical_id),
1915            "AWS::SSM::Parameter" => self.delete_ssm_parameter(&resource.physical_id),
1916            "AWS::IAM::Role" => self.delete_iam_role(&resource.physical_id),
1917            "AWS::IAM::Policy" => self.delete_iam_policy(&resource.physical_id),
1918            "AWS::IAM::User" => self.delete_iam_user(&resource.physical_id),
1919            "AWS::IAM::Group" => self.delete_iam_group(&resource.physical_id),
1920            "AWS::IAM::ManagedPolicy" => self.delete_iam_managed_policy(&resource.physical_id),
1921            "AWS::IAM::UserToGroupAddition" => {
1922                self.delete_iam_user_to_group_addition(&resource.physical_id)
1923            }
1924            "AWS::IAM::AccessKey" => self.delete_iam_access_key(&resource.physical_id),
1925            "AWS::IAM::InstanceProfile" => self.delete_iam_instance_profile(&resource.physical_id),
1926            "AWS::IAM::OIDCProvider" => self.delete_iam_oidc_provider(&resource.physical_id),
1927            "AWS::IAM::SAMLProvider" => self.delete_iam_saml_provider(&resource.physical_id),
1928            "AWS::IAM::ServiceLinkedRole" => {
1929                self.delete_iam_service_linked_role(&resource.physical_id)
1930            }
1931            "AWS::IAM::VirtualMFADevice" => {
1932                self.delete_iam_virtual_mfa_device(&resource.physical_id)
1933            }
1934            "AWS::S3::Bucket" => self.delete_s3_bucket(&resource.physical_id),
1935            "AWS::S3::BucketPolicy" => self.delete_s3_bucket_policy(&resource.physical_id),
1936            "AWS::Events::Rule" => self.delete_eventbridge_rule(&resource.physical_id),
1937            "AWS::Events::Connection" => self.delete_eventbridge_connection(&resource.physical_id),
1938            "AWS::Events::EventBus" => self.delete_eventbridge_event_bus(&resource.physical_id),
1939            "AWS::Events::EventBusPolicy" => {
1940                self.delete_eventbridge_event_bus_policy(&resource.physical_id)
1941            }
1942            "AWS::Events::Endpoint" => self.delete_eventbridge_endpoint(&resource.physical_id),
1943            "AWS::Events::ApiDestination" => {
1944                self.delete_eventbridge_api_destination(&resource.physical_id)
1945            }
1946            "AWS::Events::Archive" => self.delete_eventbridge_archive(&resource.physical_id),
1947            "AWS::DynamoDB::Table" => self.delete_dynamodb_table(&resource.physical_id),
1948            "AWS::Logs::LogGroup" => self.delete_log_group(&resource.physical_id),
1949            "AWS::Logs::LogStream" => self.delete_log_stream(&resource.physical_id),
1950            "AWS::Logs::MetricFilter" => self.delete_metric_filter(&resource.physical_id),
1951            "AWS::Logs::SubscriptionFilter" => {
1952                self.delete_subscription_filter(&resource.physical_id)
1953            }
1954            "AWS::Logs::Destination" => self.delete_logs_destination(&resource.physical_id),
1955            "AWS::Logs::ResourcePolicy" => self.delete_logs_resource_policy(&resource.physical_id),
1956            "AWS::Logs::QueryDefinition" => {
1957                self.delete_logs_query_definition(&resource.physical_id)
1958            }
1959            "AWS::Logs::Delivery" => self.delete_logs_delivery(&resource.physical_id),
1960            "AWS::Logs::DeliveryDestination" => {
1961                self.delete_logs_delivery_destination(&resource.physical_id)
1962            }
1963            "AWS::Logs::DeliverySource" => self.delete_logs_delivery_source(&resource.physical_id),
1964            "AWS::Lambda::Function" => self.delete_lambda_function(&resource.physical_id),
1965            "AWS::Lambda::Permission" => self.delete_lambda_permission(&resource.physical_id),
1966            "AWS::Lambda::EventSourceMapping" => {
1967                self.delete_lambda_event_source_mapping(&resource.physical_id)
1968            }
1969            "AWS::Lambda::LayerVersion" => self.delete_lambda_layer_version(&resource.physical_id),
1970            "AWS::Lambda::Url" => self.delete_lambda_url(&resource.physical_id),
1971            "AWS::Lambda::Alias" => self.delete_lambda_alias(&resource.physical_id),
1972            "AWS::Lambda::Version" => self.delete_lambda_version(&resource.physical_id),
1973            "AWS::SecretsManager::Secret" => {
1974                self.delete_secrets_manager_secret(&resource.physical_id)
1975            }
1976            "AWS::Kinesis::Stream" => self.delete_kinesis_stream(&resource.physical_id),
1977            "AWS::Kinesis::StreamConsumer" => {
1978                self.delete_kinesis_stream_consumer(&resource.physical_id)
1979            }
1980            "AWS::KMS::Key" => self.delete_kms_key(&resource.physical_id),
1981            "AWS::KMS::ReplicaKey" => self.delete_kms_replica_key(&resource.physical_id),
1982            "AWS::KMS::Alias" => self.delete_kms_alias(&resource.physical_id),
1983            "AWS::ECR::Repository" => self.delete_ecr_repository(&resource.physical_id),
1984            "AWS::ECR::RepositoryPolicy" => {
1985                self.delete_ecr_repository_policy(&resource.physical_id)
1986            }
1987            "AWS::ECR::LifecyclePolicy" => self.delete_ecr_lifecycle_policy(&resource.physical_id),
1988            "AWS::ECR::RegistryPolicy" => self.delete_ecr_registry_policy(),
1989            "AWS::ECR::ReplicationConfiguration" => self.delete_ecr_replication_configuration(),
1990            "AWS::ECR::RegistryScanningConfiguration" => {
1991                self.delete_ecr_registry_scanning_configuration()
1992            }
1993            "AWS::ECR::PullThroughCacheRule" => {
1994                self.delete_ecr_pull_through_cache_rule(&resource.physical_id)
1995            }
1996            "AWS::CloudWatch::Alarm" => self.delete_cloudwatch_alarm(&resource.physical_id),
1997            "AWS::CloudWatch::Dashboard" => self.delete_cloudwatch_dashboard(&resource.physical_id),
1998            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
1999                self.delete_elbv2_load_balancer(&resource.physical_id)
2000            }
2001            "AWS::ElasticLoadBalancingV2::TargetGroup" => {
2002                self.delete_elbv2_target_group(&resource.physical_id)
2003            }
2004            "AWS::ElasticLoadBalancingV2::Listener" => {
2005                self.delete_elbv2_listener(&resource.physical_id)
2006            }
2007            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
2008                self.delete_elbv2_listener_rule(&resource.physical_id)
2009            }
2010            "AWS::ElasticLoadBalancingV2::ListenerCertificate" => {
2011                self.delete_elbv2_listener_certificate(&resource.physical_id)
2012            }
2013            "AWS::ElasticLoadBalancingV2::TrustStore" => {
2014                self.delete_elbv2_trust_store(&resource.physical_id)
2015            }
2016            "AWS::Organizations::Organization" => self.delete_organization(&resource.physical_id),
2017            "AWS::Organizations::OrganizationalUnit" => {
2018                self.delete_organization_unit(&resource.physical_id)
2019            }
2020            "AWS::Organizations::Account" => {
2021                self.delete_organization_account(&resource.physical_id)
2022            }
2023            "AWS::Organizations::Policy" => self.delete_organization_policy(&resource.physical_id),
2024            "AWS::Organizations::ResourcePolicy" => {
2025                self.delete_organization_resource_policy(&resource.physical_id)
2026            }
2027            "AWS::Cognito::UserPool" => self.delete_cognito_user_pool(&resource.physical_id),
2028            "AWS::Cognito::UserPoolClient" => {
2029                self.delete_cognito_user_pool_client(&resource.physical_id)
2030            }
2031            "AWS::Cognito::UserPoolDomain" => {
2032                self.delete_cognito_user_pool_domain(&resource.physical_id)
2033            }
2034            "AWS::Cognito::IdentityPool" => {
2035                self.delete_cognito_identity_pool(&resource.physical_id)
2036            }
2037            "AWS::Cognito::IdentityPoolRoleAttachment" => {
2038                self.delete_cognito_identity_pool_role_attachment(&resource.physical_id)
2039            }
2040            "AWS::RDS::DBSubnetGroup" => self.delete_rds_subnet_group(&resource.physical_id),
2041            "AWS::RDS::DBParameterGroup" => self.delete_rds_parameter_group(&resource.physical_id),
2042            "AWS::RDS::DBClusterParameterGroup" => {
2043                self.delete_rds_cluster_parameter_group(&resource.physical_id)
2044            }
2045            "AWS::RDS::OptionGroup" => self.delete_rds_option_group(&resource.physical_id),
2046            "AWS::RDS::EventSubscription" => {
2047                self.delete_rds_event_subscription(&resource.physical_id)
2048            }
2049            "AWS::RDS::DBSecurityGroup" => self.delete_rds_security_group(&resource.physical_id),
2050            "AWS::RDS::DBProxy" => self.delete_rds_db_proxy(&resource.physical_id),
2051            "AWS::RDS::DBInstance" => self.delete_rds_db_instance(&resource.physical_id),
2052            "AWS::RDS::DBCluster" => self.delete_rds_db_cluster(&resource.physical_id),
2053            "AWS::EC2::Instance" => {
2054                // Queue terminating the REAL instance + reaping its backing
2055                // container so the stack delete drain doesn't leak an EC2
2056                // container.
2057                self.pending_container_teardowns.lock().push(
2058                    ContainerTeardownIntent::Ec2Instance {
2059                        instance_id: resource.physical_id.clone(),
2060                    },
2061                );
2062                Ok(())
2063            }
2064            "AWS::EC2::VPC"
2065            | "AWS::EC2::Subnet"
2066            | "AWS::EC2::SecurityGroup"
2067            | "AWS::EC2::InternetGateway"
2068            | "AWS::EC2::RouteTable" => {
2069                self.delete_ec2_resource(&resource.resource_type, &resource.physical_id)
2070            }
2071            "AWS::AutoScaling::LaunchConfiguration" | "AWS::AutoScaling::AutoScalingGroup" => {
2072                self.delete_autoscaling(&resource.resource_type, &resource.physical_id);
2073                Ok(())
2074            }
2075            "AWS::Batch::ComputeEnvironment"
2076            | "AWS::Batch::JobQueue"
2077            | "AWS::Batch::JobDefinition"
2078            | "AWS::Batch::SchedulingPolicy" => {
2079                self.delete_batch(&resource.resource_type, &resource.physical_id);
2080                Ok(())
2081            }
2082            "AWS::Pipes::Pipe" => {
2083                self.delete_pipes_pipe(&resource.physical_id);
2084                Ok(())
2085            }
2086            "AWS::CodeArtifact::Domain" => {
2087                self.delete_codeartifact_domain(&resource.physical_id);
2088                Ok(())
2089            }
2090            "AWS::CodeArtifact::Repository" => {
2091                self.delete_codeartifact_repository(&resource.physical_id);
2092                Ok(())
2093            }
2094            "AWS::AmazonMQ::Broker" => {
2095                self.delete_mq_broker(&resource.physical_id);
2096                Ok(())
2097            }
2098            "AWS::AmazonMQ::Configuration" => {
2099                self.delete_mq_configuration(&resource.physical_id);
2100                Ok(())
2101            }
2102            "AWS::AmazonMQ::ConfigurationAssociation" => Ok(()),
2103            "AWS::MSK::Cluster" | "AWS::MSK::ServerlessCluster" => {
2104                self.delete_msk_cluster(&resource.physical_id);
2105                Ok(())
2106            }
2107            "AWS::MSK::Configuration" => {
2108                self.delete_msk_configuration(&resource.physical_id);
2109                Ok(())
2110            }
2111            "AWS::MSK::ClusterPolicy" => {
2112                self.delete_msk_cluster_policy(&resource.physical_id);
2113                Ok(())
2114            }
2115            "AWS::MSK::BatchScramSecret" => {
2116                self.delete_msk_batch_scram_secret(&resource.physical_id);
2117                Ok(())
2118            }
2119            "AWS::MSK::VpcConnection" => {
2120                self.delete_msk_vpc_connection(&resource.physical_id);
2121                Ok(())
2122            }
2123            "AWS::MSK::Replicator" => {
2124                self.delete_msk_replicator(&resource.physical_id);
2125                Ok(())
2126            }
2127            "AWS::KinesisAnalyticsV2::Application" => {
2128                self.delete_ka2_application(&resource.physical_id);
2129                Ok(())
2130            }
2131            "AWS::KinesisAnalyticsV2::ApplicationOutput" => {
2132                self.delete_ka2_application_output(resource);
2133                Ok(())
2134            }
2135            "AWS::KinesisAnalyticsV2::ApplicationReferenceDataSource" => {
2136                self.delete_ka2_reference_data_source(resource);
2137                Ok(())
2138            }
2139            "AWS::KinesisAnalyticsV2::ApplicationCloudWatchLoggingOption" => {
2140                self.delete_ka2_cloudwatch_logging_option(resource);
2141                Ok(())
2142            }
2143            "AWS::CodeCommit::Repository" => {
2144                self.delete_codecommit_repository(&resource.physical_id);
2145                Ok(())
2146            }
2147            "AWS::EFS::FileSystem" => {
2148                self.delete_efs_file_system(&resource.physical_id);
2149                Ok(())
2150            }
2151            "AWS::EFS::MountTarget" => {
2152                self.delete_efs_mount_target(&resource.physical_id);
2153                Ok(())
2154            }
2155            "AWS::EFS::AccessPoint" => {
2156                self.delete_efs_access_point(&resource.physical_id);
2157                Ok(())
2158            }
2159            "AWS::ElasticBeanstalk::Application" => {
2160                self.delete_eb_application(&resource.physical_id);
2161                Ok(())
2162            }
2163            "AWS::ElasticBeanstalk::ApplicationVersion" => {
2164                self.delete_eb_application_version(resource);
2165                Ok(())
2166            }
2167            "AWS::ElasticBeanstalk::Environment" => {
2168                self.delete_eb_environment(resource);
2169                Ok(())
2170            }
2171            "AWS::ElasticBeanstalk::ConfigurationTemplate" => {
2172                self.delete_eb_configuration_template(resource);
2173                Ok(())
2174            }
2175            "AWS::ECS::Cluster" => self.delete_ecs_cluster(&resource.physical_id),
2176            "AWS::ECS::TaskDefinition" => self.delete_ecs_task_definition(&resource.physical_id),
2177            "AWS::ECS::Service" => self.delete_ecs_service(&resource.physical_id),
2178            "AWS::ECS::CapacityProvider" => {
2179                self.delete_ecs_capacity_provider(&resource.physical_id)
2180            }
2181            "AWS::CertificateManager::Certificate" => {
2182                self.delete_acm_certificate(&resource.physical_id)
2183            }
2184            "AWS::CertificateManager::Account" => self.delete_acm_account(),
2185            "AWS::ACMPCA::CertificateAuthority" => {
2186                self.delete_acmpca_certificate_authority(&resource.physical_id)
2187            }
2188            "AWS::ACMPCA::Certificate" => Ok(()),
2189            "AWS::ACMPCA::CertificateAuthorityActivation" => Ok(()),
2190            "AWS::ACMPCA::Permission" => self.delete_acmpca_permission(&resource.physical_id),
2191            "AWS::Route53Resolver::ResolverEndpoint" => {
2192                self.delete_r53r_resolver_endpoint(&resource.physical_id)
2193            }
2194            "AWS::Route53Resolver::ResolverRule" => {
2195                self.delete_r53r_resolver_rule(&resource.physical_id)
2196            }
2197            "AWS::Route53Resolver::ResolverRuleAssociation" => {
2198                self.delete_r53r_rule_association(&resource.physical_id)
2199            }
2200            "AWS::Route53Resolver::ResolverQueryLoggingConfig" => {
2201                self.delete_r53r_query_log_config(&resource.physical_id)
2202            }
2203            "AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation" => {
2204                self.delete_r53r_query_log_association(&resource.physical_id)
2205            }
2206            "AWS::Route53Resolver::FirewallDomainList" => {
2207                self.delete_r53r_firewall_domain_list(&resource.physical_id)
2208            }
2209            "AWS::Route53Resolver::FirewallRuleGroup" => {
2210                self.delete_r53r_firewall_rule_group(&resource.physical_id)
2211            }
2212            "AWS::Route53Resolver::FirewallRuleGroupAssociation" => {
2213                self.delete_r53r_firewall_rule_group_association(&resource.physical_id)
2214            }
2215            "AWS::Route53Resolver::FirewallConfig" => {
2216                self.delete_r53r_firewall_config(&resource.physical_id)
2217            }
2218            "AWS::Route53Resolver::ResolverConfig" => {
2219                self.delete_r53r_resolver_config(&resource.physical_id)
2220            }
2221            "AWS::Route53Resolver::ResolverDNSSECConfig" => {
2222                self.delete_r53r_dnssec_config(&resource.physical_id)
2223            }
2224            "AWS::Config::ConfigurationRecorder"
2225            | "AWS::Config::DeliveryChannel"
2226            | "AWS::Config::ConfigRule"
2227            | "AWS::Config::ConfigurationAggregator"
2228            | "AWS::Config::AggregationAuthorization"
2229            | "AWS::Config::ConformancePack"
2230            | "AWS::Config::OrganizationConfigRule" => {
2231                self.delete_config_resource(&resource.resource_type, &resource.physical_id)
2232            }
2233            "AWS::ElastiCache::ParameterGroup" => {
2234                self.delete_ec_parameter_group(&resource.physical_id)
2235            }
2236            "AWS::ElastiCache::SubnetGroup" => self.delete_ec_subnet_group(&resource.physical_id),
2237            "AWS::ElastiCache::SecurityGroup" => {
2238                self.delete_ec_security_group(&resource.physical_id)
2239            }
2240            "AWS::ElastiCache::User" => self.delete_ec_user(&resource.physical_id),
2241            "AWS::ElastiCache::UserGroup" => self.delete_ec_user_group(&resource.physical_id),
2242            "AWS::ElastiCache::CacheCluster" => self.delete_ec_cache_cluster(&resource.physical_id),
2243            "AWS::ElastiCache::ReplicationGroup" => {
2244                self.delete_ec_replication_group(&resource.physical_id)
2245            }
2246            "AWS::Route53::HostedZone" => self.delete_route53_hosted_zone(&resource.physical_id),
2247            "AWS::Route53::RecordSet" => {
2248                self.delete_route53_record_set(&resource.physical_id, &resource.attributes)
2249            }
2250            "AWS::Route53::HealthCheck" => self.delete_route53_health_check(&resource.physical_id),
2251            "AWS::Route53::DNSSEC" => self.delete_route53_dnssec(&resource.physical_id),
2252            "AWS::Route53::KeySigningKey" => {
2253                self.delete_route53_key_signing_key(&resource.physical_id)
2254            }
2255            "AWS::CloudFront::CloudFrontOriginAccessIdentity" => {
2256                self.delete_cf_origin_access_identity(&resource.physical_id)
2257            }
2258            "AWS::CloudFront::Distribution" => self.delete_cf_distribution(&resource.physical_id),
2259            "AWS::CloudFront::OriginAccessControl" => {
2260                self.delete_cf_origin_access_control(&resource.physical_id)
2261            }
2262            "AWS::CloudFront::PublicKey" => self.delete_cf_public_key(&resource.physical_id),
2263            "AWS::CloudFront::KeyGroup" => self.delete_cf_key_group(&resource.physical_id),
2264            "AWS::CloudFront::Function" => self.delete_cf_function(&resource.physical_id),
2265            "AWS::CloudFront::CachePolicy" => self.delete_cf_cache_policy(&resource.physical_id),
2266            "AWS::CloudFront::OriginRequestPolicy" => {
2267                self.delete_cf_origin_request_policy(&resource.physical_id)
2268            }
2269            "AWS::CloudFront::ResponseHeadersPolicy" => {
2270                self.delete_cf_response_headers_policy(&resource.physical_id)
2271            }
2272            "AWS::StepFunctions::StateMachine" => {
2273                self.delete_sfn_state_machine(&resource.physical_id)
2274            }
2275            "AWS::StepFunctions::Activity" => self.delete_sfn_activity(&resource.physical_id),
2276            "AWS::StepFunctions::StateMachineVersion" => {
2277                self.delete_sfn_version(&resource.physical_id)
2278            }
2279            "AWS::StepFunctions::StateMachineAlias" => self.delete_sfn_alias(&resource.physical_id),
2280            "AWS::WAFv2::WebACL" => self.delete_wafv2_web_acl(&resource.physical_id),
2281            "AWS::WAFv2::IPSet" => self.delete_wafv2_ip_set(&resource.physical_id),
2282            "AWS::WAFv2::RegexPatternSet" => {
2283                self.delete_wafv2_regex_pattern_set(&resource.physical_id)
2284            }
2285            "AWS::WAFv2::RuleGroup" => self.delete_wafv2_rule_group(&resource.physical_id),
2286            "AWS::WAFv2::LoggingConfiguration" => {
2287                self.delete_wafv2_logging_configuration(&resource.physical_id)
2288            }
2289            "AWS::WAFv2::WebACLAssociation" => {
2290                self.delete_wafv2_web_acl_association(&resource.physical_id)
2291            }
2292            "AWS::ApiGateway::RestApi" => self.delete_apigw_rest_api(&resource.physical_id),
2293            "AWS::ApiGateway::Resource" => {
2294                self.delete_apigw_resource(&resource.physical_id, &resource.attributes)
2295            }
2296            "AWS::ApiGateway::Method" => self.delete_apigw_method(&resource.physical_id),
2297            "AWS::ApiGateway::Deployment" => {
2298                self.delete_apigw_deployment(&resource.physical_id, &resource.attributes)
2299            }
2300            "AWS::ApiGateway::Stage" => {
2301                self.delete_apigw_stage(&resource.physical_id, &resource.attributes)
2302            }
2303            "AWS::ApiGateway::Authorizer" => {
2304                self.delete_apigw_authorizer(&resource.physical_id, &resource.attributes)
2305            }
2306            "AWS::ApiGateway::RequestValidator" => {
2307                self.delete_apigw_request_validator(&resource.physical_id, &resource.attributes)
2308            }
2309            "AWS::ApiGateway::Model" => {
2310                self.delete_apigw_model(&resource.physical_id, &resource.attributes)
2311            }
2312            "AWS::ApiGateway::GatewayResponse" => {
2313                self.delete_apigw_gateway_response(&resource.physical_id, &resource.attributes)
2314            }
2315            "AWS::ApiGateway::UsagePlan" => self.delete_apigw_usage_plan(&resource.physical_id),
2316            "AWS::ApiGateway::ApiKey" => self.delete_apigw_api_key(&resource.physical_id),
2317            "AWS::ApiGateway::UsagePlanKey" => {
2318                self.delete_apigw_usage_plan_key(&resource.physical_id, &resource.attributes)
2319            }
2320            "AWS::ApiGateway::DomainName" => self.delete_apigw_domain_name(&resource.physical_id),
2321            "AWS::ApiGateway::BasePathMapping" => {
2322                self.delete_apigw_base_path_mapping(&resource.physical_id, &resource.attributes)
2323            }
2324            "AWS::ApiGatewayV2::Api" => self.delete_apigwv2_api(&resource.physical_id),
2325            "AWS::ApiGatewayV2::Route" => {
2326                self.delete_apigwv2_route(&resource.physical_id, &resource.attributes)
2327            }
2328            "AWS::ApiGatewayV2::Integration" => {
2329                self.delete_apigwv2_integration(&resource.physical_id, &resource.attributes)
2330            }
2331            "AWS::ApiGatewayV2::IntegrationResponse" => self
2332                .delete_apigwv2_integration_response(&resource.physical_id, &resource.attributes),
2333            "AWS::ApiGatewayV2::RouteResponse" => {
2334                self.delete_apigwv2_route_response(&resource.physical_id, &resource.attributes)
2335            }
2336            "AWS::ApiGatewayV2::Stage" => {
2337                self.delete_apigwv2_stage(&resource.physical_id, &resource.attributes)
2338            }
2339            "AWS::ApiGatewayV2::Deployment" => {
2340                self.delete_apigwv2_deployment(&resource.physical_id, &resource.attributes)
2341            }
2342            "AWS::ApiGatewayV2::Authorizer" => {
2343                self.delete_apigwv2_authorizer(&resource.physical_id, &resource.attributes)
2344            }
2345            "AWS::ApiGatewayV2::DomainName" => {
2346                self.delete_apigwv2_domain_name(&resource.physical_id)
2347            }
2348            "AWS::ApiGatewayV2::ApiMapping" => {
2349                self.delete_apigwv2_api_mapping(&resource.physical_id, &resource.attributes)
2350            }
2351            "AWS::ApiGatewayV2::VpcLink" => self.delete_apigwv2_vpc_link(&resource.physical_id),
2352            "AWS::ApiGatewayV2::Model" => {
2353                self.delete_apigwv2_model(&resource.physical_id, &resource.attributes)
2354            }
2355            "AWS::SES::ConfigurationSet" => {
2356                self.delete_ses_configuration_set(&resource.physical_id)
2357            }
2358            "AWS::SES::ConfigurationSetEventDestination" => {
2359                self.delete_ses_event_destination(&resource.physical_id, &resource.attributes)
2360            }
2361            "AWS::SES::EmailIdentity" => self.delete_ses_email_identity(&resource.physical_id),
2362            "AWS::SES::Template" => self.delete_ses_template(&resource.physical_id),
2363            "AWS::SES::ContactList" => self.delete_ses_contact_list(&resource.physical_id),
2364            "AWS::SES::DedicatedIpPool" => self.delete_ses_dedicated_ip_pool(&resource.physical_id),
2365            "AWS::SES::ReceiptRule" => {
2366                self.delete_ses_receipt_rule(&resource.physical_id, &resource.attributes)
2367            }
2368            "AWS::SES::ReceiptRuleSet" => self.delete_ses_receipt_rule_set(&resource.physical_id),
2369            "AWS::SES::ReceiptFilter" => self.delete_ses_receipt_filter(&resource.physical_id),
2370            "AWS::SES::VdmAttributes" => Ok(()),
2371            "AWS::SecretsManager::RotationSchedule" => {
2372                self.delete_secrets_manager_rotation_schedule(&resource.physical_id)
2373            }
2374            "AWS::SecretsManager::ResourcePolicy" => {
2375                self.delete_secrets_manager_resource_policy(&resource.physical_id)
2376            }
2377            "AWS::SecretsManager::SecretTargetAttachment" => Ok(()),
2378            "AWS::ApplicationAutoScaling::ScalableTarget" => self
2379                .delete_application_autoscaling_scalable_target(
2380                    &resource.physical_id,
2381                    &resource.attributes,
2382                ),
2383            "AWS::ApplicationAutoScaling::ScalingPolicy" => self
2384                .delete_application_autoscaling_scaling_policy(
2385                    &resource.physical_id,
2386                    &resource.attributes,
2387                ),
2388            "AWS::Athena::DataCatalog" => self.delete_athena_data_catalog(&resource.physical_id),
2389            "AWS::Athena::NamedQuery" => self.delete_athena_named_query(&resource.physical_id),
2390            "AWS::Athena::WorkGroup" => self.delete_athena_work_group(&resource.physical_id),
2391            "AWS::Athena::PreparedStatement" => {
2392                self.delete_athena_prepared_statement(&resource.physical_id, &resource.attributes)
2393            }
2394            "AWS::KinesisFirehose::DeliveryStream" => {
2395                self.delete_firehose_delivery_stream(&resource.physical_id)
2396            }
2397            "AWS::Glue::Database" => self.delete_glue_database(&resource.physical_id),
2398            "AWS::CloudFormation::Stack" => self.delete_cloudformation_stack(&resource.physical_id),
2399            "AWS::Glue::Table" => self.delete_glue_table(&resource.physical_id),
2400            "AWS::Glue::Partition" => {
2401                self.delete_glue_partition(&resource.physical_id, &resource.attributes)
2402            }
2403            "AWS::EKS::Cluster" => self.delete_eks_cluster(&resource.physical_id),
2404            "AWS::EKS::Nodegroup" => self.delete_eks_nodegroup(&resource.physical_id),
2405            "AWS::EKS::FargateProfile" => self.delete_eks_fargate_profile(&resource.physical_id),
2406            "AWS::EKS::Addon" => self.delete_eks_addon(&resource.physical_id),
2407            "AWS::EKS::AccessEntry" => self.delete_eks_access_entry(&resource.physical_id),
2408            "AWS::EKS::IdentityProviderConfig" => {
2409                self.delete_eks_identity_provider_config(&resource.physical_id)
2410            }
2411            "AWS::EKS::PodIdentityAssociation" => {
2412                self.delete_eks_pod_identity_association(&resource.physical_id)
2413            }
2414            "AWS::ServiceDiscovery::HttpNamespace"
2415            | "AWS::ServiceDiscovery::PublicDnsNamespace"
2416            | "AWS::ServiceDiscovery::PrivateDnsNamespace" => {
2417                self.delete_sd_namespace(&resource.physical_id)
2418            }
2419            "AWS::ServiceDiscovery::Service" => self.delete_sd_service(&resource.physical_id),
2420            "AWS::ServiceDiscovery::Instance" => {
2421                self.delete_sd_instance(&resource.physical_id, &resource.attributes)
2422            }
2423            t if t.starts_with("Custom::") || t == "AWS::CloudFormation::CustomResource" => {
2424                self.delete_custom_resource(resource)
2425            }
2426            // No provisioner for this type — it was recorded with no backing
2427            // state at create time (see `create_resource`), so there is nothing
2428            // to delete. Succeed so stack teardown isn't blocked.
2429            _ => Ok(()),
2430        }
2431    }
2432
2433    // --- CloudWatch Logs ---
2434
2435    fn read_s3_object_bytes(&self, bucket: &str, key: &str) -> Result<Vec<u8>, String> {
2436        let mut accounts = self.s3_state.write();
2437        let state = accounts.get_or_create(&self.account_id);
2438        let body_ref = {
2439            let b = state
2440                .buckets
2441                .get(bucket)
2442                .ok_or_else(|| format!("S3 bucket {bucket} does not exist"))?;
2443            let object = b
2444                .objects
2445                .get(key)
2446                .ok_or_else(|| format!("S3 object s3://{bucket}/{key} does not exist"))?;
2447            object.body.clone()
2448        };
2449        // `read_body` consults the body cache (which is owned by the state),
2450        // so re-borrow `state` after dropping the bucket borrow above.
2451        state
2452            .read_body(&body_ref)
2453            .map(|b| b.to_vec())
2454            .map_err(|e| format!("S3 read failed: {e}"))
2455    }
2456
2457    /// Read a specific object version's bytes. Used when a CFN property
2458    /// pins an S3 object `Version` (e.g.
2459    /// `DefinitionS3Location.Version`), so the exact referenced body is
2460    /// loaded rather than whatever is current. Falls through the current
2461    /// object and the version history.
2462    fn read_s3_object_version_bytes(
2463        &self,
2464        bucket: &str,
2465        key: &str,
2466        version_id: &str,
2467    ) -> Result<Vec<u8>, String> {
2468        let mut accounts = self.s3_state.write();
2469        let state = accounts.get_or_create(&self.account_id);
2470        let body_ref = {
2471            let b = state
2472                .buckets
2473                .get(bucket)
2474                .ok_or_else(|| format!("S3 bucket {bucket} does not exist"))?;
2475            let from_current = b
2476                .objects
2477                .get(key)
2478                .filter(|o| o.version_id.as_deref() == Some(version_id))
2479                .map(|o| o.body.clone());
2480            from_current
2481                .or_else(|| {
2482                    b.object_versions.get(key).and_then(|versions| {
2483                        versions
2484                            .iter()
2485                            .find(|o| o.version_id.as_deref() == Some(version_id))
2486                            .map(|o| o.body.clone())
2487                    })
2488                })
2489                .ok_or_else(|| {
2490                    format!("S3 object s3://{bucket}/{key} version {version_id} does not exist")
2491                })?
2492        };
2493        state
2494            .read_body(&body_ref)
2495            .map(|b| b.to_vec())
2496            .map_err(|e| format!("S3 read failed: {e}"))
2497    }
2498
2499    /// Build a canonical Lambda resource-policy statement from a CFN
2500    /// `AWS::Lambda::Permission` `Properties` map and append it to the
2501    /// function's stored policy. Shared by create + update so the same
2502    /// property→condition mapping applies on both paths. Returns the
2503    /// function ARN of the target so callers can echo it back if needed.
2504    fn append_lambda_permission_statement(
2505        &self,
2506        function_name: &str,
2507        statement_id: &str,
2508        props: &serde_json::Value,
2509    ) -> Result<String, String> {
2510        let action = props
2511            .get("Action")
2512            .and_then(|v| v.as_str())
2513            .ok_or_else(|| "Action is required".to_string())?
2514            .to_string();
2515        let principal = props
2516            .get("Principal")
2517            .and_then(|v| v.as_str())
2518            .ok_or_else(|| "Principal is required".to_string())?
2519            .to_string();
2520        let source_arn = props
2521            .get("SourceArn")
2522            .and_then(|v| v.as_str())
2523            .map(|s| s.to_string());
2524        let source_account = props
2525            .get("SourceAccount")
2526            .and_then(|v| v.as_str())
2527            .map(|s| s.to_string());
2528        let event_source_token = props
2529            .get("EventSourceToken")
2530            .and_then(|v| v.as_str())
2531            .map(|s| s.to_string());
2532        let function_url_auth_type = props
2533            .get("FunctionUrlAuthType")
2534            .and_then(|v| v.as_str())
2535            .map(|s| s.to_string());
2536        let principal_org_id = props
2537            .get("PrincipalOrgID")
2538            .and_then(|v| v.as_str())
2539            .map(|s| s.to_string());
2540
2541        let mut accounts = self.lambda_state.write();
2542        let state = accounts.get_or_create(&self.account_id);
2543        let func = state.functions.get_mut(function_name).ok_or_else(|| {
2544            format!(
2545                "Function {function_name} does not exist yet — retry once it has been provisioned"
2546            )
2547        })?;
2548
2549        let mut doc: serde_json::Value = func
2550            .policy
2551            .as_deref()
2552            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
2553            .filter(|v| v.is_object())
2554            .unwrap_or_else(|| serde_json::json!({"Version": "2012-10-17", "Statement": []}));
2555        if !doc.get("Statement").map(|s| s.is_array()).unwrap_or(false) {
2556            doc["Statement"] = serde_json::json!([]);
2557        }
2558        let principal_value =
2559            if principal.ends_with(".amazonaws.com") || principal.contains(".amazon") {
2560                serde_json::json!({ "Service": principal })
2561            } else {
2562                serde_json::json!({ "AWS": principal })
2563            };
2564        let mut arn_like = serde_json::Map::new();
2565        let mut string_equals = serde_json::Map::new();
2566        if let Some(src) = source_arn {
2567            arn_like.insert("AWS:SourceArn".to_string(), serde_json::Value::String(src));
2568        }
2569        if let Some(acct) = source_account {
2570            string_equals.insert(
2571                "AWS:SourceAccount".to_string(),
2572                serde_json::Value::String(acct),
2573            );
2574        }
2575        if let Some(token) = event_source_token {
2576            string_equals.insert(
2577                "lambda:EventSourceToken".to_string(),
2578                serde_json::Value::String(token),
2579            );
2580        }
2581        if let Some(auth) = function_url_auth_type {
2582            string_equals.insert(
2583                "lambda:FunctionUrlAuthType".to_string(),
2584                serde_json::Value::String(auth),
2585            );
2586        }
2587        if let Some(org) = principal_org_id {
2588            string_equals.insert(
2589                "aws:PrincipalOrgID".to_string(),
2590                serde_json::Value::String(org),
2591            );
2592        }
2593        let mut conditions = serde_json::Map::new();
2594        if !arn_like.is_empty() {
2595            conditions.insert("ArnLike".to_string(), serde_json::Value::Object(arn_like));
2596        }
2597        if !string_equals.is_empty() {
2598            conditions.insert(
2599                "StringEquals".to_string(),
2600                serde_json::Value::Object(string_equals),
2601            );
2602        }
2603
2604        let mut statement = serde_json::Map::new();
2605        statement.insert(
2606            "Sid".to_string(),
2607            serde_json::Value::String(statement_id.to_string()),
2608        );
2609        statement.insert(
2610            "Effect".to_string(),
2611            serde_json::Value::String("Allow".to_string()),
2612        );
2613        statement.insert("Principal".to_string(), principal_value);
2614        statement.insert("Action".to_string(), serde_json::Value::String(action));
2615        statement.insert(
2616            "Resource".to_string(),
2617            serde_json::Value::String(func.function_arn.clone()),
2618        );
2619        if !conditions.is_empty() {
2620            statement.insert(
2621                "Condition".to_string(),
2622                serde_json::Value::Object(conditions),
2623            );
2624        }
2625        doc["Statement"]
2626            .as_array_mut()
2627            .unwrap()
2628            .push(serde_json::Value::Object(statement));
2629        func.policy = Some(doc.to_string());
2630        Ok(func.function_arn.clone())
2631    }
2632
2633    // --- ELBv2 ---
2634
2635    fn get_att_elbv2_load_balancer(&self, physical_id: &str, attribute: &str) -> Option<String> {
2636        let mut accounts = self.elbv2_state.write();
2637        let state = accounts.get_or_create(&self.account_id);
2638        let lb = state.load_balancers.get(physical_id)?;
2639        let lb_full = lb
2640            .arn
2641            .rsplit("loadbalancer/")
2642            .next()
2643            .unwrap_or("")
2644            .to_string();
2645        match attribute {
2646            "Arn" | "LoadBalancerArn" => Some(lb.arn.clone()),
2647            "DNSName" => Some(lb.dns_name.clone()),
2648            "CanonicalHostedZoneID" => Some(lb.canonical_hosted_zone_id.clone()),
2649            "LoadBalancerFullName" => Some(lb_full),
2650            "LoadBalancerName" => Some(lb.name.clone()),
2651            "SecurityGroups" => Some(lb.security_groups.join(",")),
2652            _ => None,
2653        }
2654    }
2655
2656    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::TargetGroup.
2657    fn get_att_elbv2_target_group(&self, physical_id: &str, attribute: &str) -> Option<String> {
2658        let mut accounts = self.elbv2_state.write();
2659        let state = accounts.get_or_create(&self.account_id);
2660        let tg = state.target_groups.get(physical_id)?;
2661        let tg_full = tg
2662            .arn
2663            .rsplit("targetgroup/")
2664            .next()
2665            .map(|s| format!("targetgroup/{s}"))
2666            .unwrap_or_default();
2667        match attribute {
2668            "TargetGroupArn" => Some(tg.arn.clone()),
2669            "TargetGroupName" => Some(tg.name.clone()),
2670            "TargetGroupFullName" => Some(tg_full),
2671            "LoadBalancerArns" => Some(tg.load_balancer_arns.join(",")),
2672            _ => None,
2673        }
2674    }
2675
2676    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::Listener.
2677    fn get_att_elbv2_listener(&self, physical_id: &str, attribute: &str) -> Option<String> {
2678        let mut accounts = self.elbv2_state.write();
2679        let state = accounts.get_or_create(&self.account_id);
2680        let listener = state.listeners.get(physical_id)?;
2681        match attribute {
2682            "Arn" | "ListenerArn" => Some(listener.arn.clone()),
2683            _ => None,
2684        }
2685    }
2686
2687    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::ListenerRule.
2688    fn get_att_elbv2_listener_rule(&self, physical_id: &str, attribute: &str) -> Option<String> {
2689        let mut accounts = self.elbv2_state.write();
2690        let state = accounts.get_or_create(&self.account_id);
2691        let rule = state.rules.get(physical_id)?;
2692        match attribute {
2693            "RuleArn" => Some(rule.arn.clone()),
2694            "IsDefault" => Some(rule.is_default.to_string()),
2695            _ => None,
2696        }
2697    }
2698
2699    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::TrustStore.
2700    fn get_att_elbv2_trust_store(&self, physical_id: &str, attribute: &str) -> Option<String> {
2701        let mut accounts = self.elbv2_state.write();
2702        let state = accounts.get_or_create(&self.account_id);
2703        let ts = state.trust_stores.get(physical_id)?;
2704        match attribute {
2705            "TrustStoreArn" => Some(ts.arn.clone()),
2706            "Name" => Some(ts.name.clone()),
2707            "Status" => Some(ts.status.clone()),
2708            "NumberOfCaCertificates" => Some(ts.number_of_ca_certificates.to_string()),
2709            "TotalRevokedEntries" => Some(ts.total_revoked_entries.to_string()),
2710            _ => None,
2711        }
2712    }
2713
2714    // --- Organizations ---
2715
2716    fn invoke_lambda_sync(&self, function_arn: &str, payload: &str) -> Result<(), String> {
2717        let delivery = self.delivery.clone();
2718        let function_arn = function_arn.to_string();
2719        let payload = payload.to_string();
2720        std::thread::scope(|s| {
2721            s.spawn(|| {
2722                let rt = tokio::runtime::Builder::new_current_thread()
2723                    .enable_all()
2724                    .build()
2725                    .map_err(|e| format!("Failed to create runtime: {e}"))?;
2726                rt.block_on(async {
2727                    match delivery.invoke_lambda(&function_arn, &payload).await {
2728                        Some(Ok(_)) => {
2729                            tracing::info!(
2730                                "Custom resource Lambda {} invoked successfully",
2731                                function_arn
2732                            );
2733                            Ok(())
2734                        }
2735                        Some(Err(e)) => {
2736                            tracing::warn!(
2737                                "Custom resource Lambda {} invocation failed: {e}",
2738                                function_arn
2739                            );
2740                            Err(format!("Lambda invocation failed: {e}"))
2741                        }
2742                        None => {
2743                            tracing::warn!(
2744                                "No Lambda delivery configured; skipping custom resource invocation for {}",
2745                                function_arn
2746                            );
2747                            Ok(())
2748                        }
2749                    }
2750                })
2751            })
2752            .join()
2753            .map_err(|_| "Lambda invocation thread panicked".to_string())?
2754        })
2755    }
2756
2757    fn create_custom_resource(&self, resource: &ResourceDefinition) -> Result<String, String> {
2758        let props = &resource.properties;
2759        let service_token = props
2760            .get("ServiceToken")
2761            .and_then(|v| v.as_str())
2762            .ok_or("Custom resource requires ServiceToken property")?;
2763
2764        let request_id = Uuid::new_v4().to_string();
2765
2766        // Build the CloudFormation custom resource event
2767        let event = serde_json::json!({
2768            "RequestType": "Create",
2769            "ServiceToken": service_token,
2770            "StackId": self.stack_id,
2771            "RequestId": request_id,
2772            "ResourceType": resource.resource_type,
2773            "LogicalResourceId": resource.logical_id,
2774            "ResourceProperties": props,
2775        });
2776
2777        let payload = serde_json::to_string(&event).map_err(|e| e.to_string())?;
2778        if self.defer_custom_invokes {
2779            // Changeset/update path: queue the invoke so the caller runs it off
2780            // the request path after the state write lock is dropped. The Lambda
2781            // response is not consumed for `GetAtt` (the physical id is generated
2782            // below regardless), so deferring loses nothing.
2783            self.pending_custom_invokes.lock().push(CustomInvokeIntent {
2784                service_token: service_token.to_string(),
2785                payload,
2786            });
2787        } else {
2788            self.invoke_lambda_sync(service_token, &payload)?;
2789        }
2790
2791        // Physical resource ID: use a generated ID (the Lambda could return one,
2792        // but for simplicity we generate one here).
2793        let physical_id = format!("{}-{}", resource.logical_id, &request_id[..8]);
2794        Ok(physical_id)
2795    }
2796
2797    fn delete_custom_resource(&self, resource: &StackResource) -> Result<(), String> {
2798        let service_token = match &resource.service_token {
2799            Some(token) => token.clone(),
2800            None => {
2801                // No ServiceToken stored — nothing to invoke
2802                return Ok(());
2803            }
2804        };
2805
2806        let request_id = Uuid::new_v4().to_string();
2807
2808        let event = serde_json::json!({
2809            "RequestType": "Delete",
2810            "ServiceToken": service_token,
2811            "StackId": self.stack_id,
2812            "RequestId": request_id,
2813            "ResourceType": resource.resource_type,
2814            "LogicalResourceId": resource.logical_id,
2815            "PhysicalResourceId": resource.physical_id,
2816        });
2817
2818        let payload = serde_json::to_string(&event).map_err(|e| e.to_string())?;
2819
2820        if self.defer_custom_invokes {
2821            // Stack-delete / update-removed path: queue the Delete invoke so the
2822            // caller runs it off the request path after the lock is dropped.
2823            self.pending_custom_invokes.lock().push(CustomInvokeIntent {
2824                service_token,
2825                payload,
2826            });
2827        } else if let Err(e) = self.invoke_lambda_sync(&service_token, &payload) {
2828            // Best-effort: don't fail stack deletion if Lambda invocation fails
2829            tracing::warn!(
2830                "Custom resource delete Lambda invocation failed for {}: {e}",
2831                resource.logical_id
2832            );
2833        }
2834        Ok(())
2835    }
2836
2837    // --- Application Auto Scaling ---
2838
2839    fn parse_athena_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
2840        let mut out = BTreeMap::new();
2841        let Some(arr) = value.and_then(|v| v.as_array()) else {
2842            return out;
2843        };
2844        for tag in arr {
2845            if let (Some(k), Some(v)) = (
2846                tag.get("Key").and_then(|v| v.as_str()),
2847                tag.get("Value").and_then(|v| v.as_str()),
2848            ) {
2849                out.insert(k.to_string(), v.to_string());
2850            }
2851        }
2852        out
2853    }
2854
2855    fn fetch_template_from_url(&self, url: &str) -> Result<String, String> {
2856        if let Some(rest) = url.strip_prefix("s3://") {
2857            let parts: Vec<&str> = rest.splitn(2, '/').collect();
2858            if parts.len() != 2 {
2859                return Err("Invalid s3:// URL".to_string());
2860            }
2861            return self.fetch_s3_template(parts[0], parts[1]);
2862        }
2863
2864        if let Some(rest) = url.strip_prefix("https://s3.amazonaws.com/") {
2865            let parts: Vec<&str> = rest.splitn(2, '/').collect();
2866            if parts.len() != 2 {
2867                return Err("Invalid S3 HTTPS URL".to_string());
2868            }
2869            return self.fetch_s3_template(parts[0], parts[1]);
2870        }
2871
2872        if let Some(host_rest) = url.strip_prefix("https://") {
2873            if let Some(slash_pos) = host_rest.find('/') {
2874                let host = &host_rest[..slash_pos];
2875                let key = &host_rest[slash_pos + 1..];
2876                if let Some(bucket) = host.strip_suffix(".s3.amazonaws.com") {
2877                    return self.fetch_s3_template(bucket, key);
2878                }
2879                if host.contains(".s3.") && host.ends_with(".amazonaws.com") {
2880                    let bucket = host.split(".s3.").next().unwrap_or("");
2881                    if !bucket.is_empty() {
2882                        return self.fetch_s3_template(bucket, key);
2883                    }
2884                }
2885            }
2886        }
2887
2888        Err(format!("Unsupported TemplateURL: {url}"))
2889    }
2890
2891    fn fetch_s3_template(&self, bucket: &str, key: &str) -> Result<String, String> {
2892        let mut s3_accounts = self.s3_state.write();
2893        let s3_state = s3_accounts.get_or_create(&self.account_id);
2894        let bucket_obj = s3_state
2895            .buckets
2896            .get(bucket)
2897            .ok_or_else(|| format!("S3 bucket not found: {bucket}"))?;
2898        let obj = bucket_obj
2899            .objects
2900            .get(key)
2901            .ok_or_else(|| format!("S3 object not found: {bucket}/{key}"))?;
2902        let bytes = s3_state
2903            .read_body(&obj.body)
2904            .map_err(|e| format!("Failed to read S3 object body: {e}"))?;
2905        String::from_utf8(bytes.to_vec()).map_err(|e| format!("S3 object is not valid UTF-8: {e}"))
2906    }
2907}
2908
2909/// Implements the CloudFormation `GenerateSecretString` shape on
2910/// `AWS::SecretsManager::Secret`. Produces the plaintext payload that
2911/// will become the AWSCURRENT version of the secret.
2912///
2913/// When `SecretStringTemplate` is set together with `GenerateStringKey`,
2914/// the generated password is inserted under `GenerateStringKey` in the
2915/// JSON template (this is the standard "DB credential" pattern).
2916/// Without those two, the bare generated password is returned.
2917fn generate_secret_string_payload(gen: &serde_json::Value) -> Result<String, String> {
2918    let length = gen
2919        .get("PasswordLength")
2920        .and_then(|v| v.as_i64())
2921        .unwrap_or(32) as usize;
2922    let exclude_lowercase = gen
2923        .get("ExcludeLowercase")
2924        .and_then(|v| v.as_bool())
2925        .unwrap_or(false);
2926    let exclude_uppercase = gen
2927        .get("ExcludeUppercase")
2928        .and_then(|v| v.as_bool())
2929        .unwrap_or(false);
2930    let exclude_numbers = gen
2931        .get("ExcludeNumbers")
2932        .and_then(|v| v.as_bool())
2933        .unwrap_or(false);
2934    let exclude_punctuation = gen
2935        .get("ExcludePunctuation")
2936        .and_then(|v| v.as_bool())
2937        .unwrap_or(false);
2938    let include_space = gen
2939        .get("IncludeSpace")
2940        .and_then(|v| v.as_bool())
2941        .unwrap_or(false);
2942    let exclude_chars = gen
2943        .get("ExcludeCharacters")
2944        .and_then(|v| v.as_str())
2945        .unwrap_or("")
2946        .to_string();
2947
2948    let lowercase = "abcdefghijklmnopqrstuvwxyz";
2949    let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
2950    let digits = "0123456789";
2951    let punctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
2952
2953    let mut pool = String::new();
2954    if !exclude_lowercase {
2955        pool.extend(lowercase.chars().filter(|c| !exclude_chars.contains(*c)));
2956    }
2957    if !exclude_uppercase {
2958        pool.extend(uppercase.chars().filter(|c| !exclude_chars.contains(*c)));
2959    }
2960    if !exclude_numbers {
2961        pool.extend(digits.chars().filter(|c| !exclude_chars.contains(*c)));
2962    }
2963    if !exclude_punctuation {
2964        pool.extend(punctuation.chars().filter(|c| !exclude_chars.contains(*c)));
2965    }
2966    if include_space && !exclude_chars.contains(' ') {
2967        pool.push(' ');
2968    }
2969    if pool.is_empty() {
2970        return Err("GenerateSecretString character pool is empty".to_string());
2971    }
2972
2973    let pool_chars: Vec<char> = pool.chars().collect();
2974    let mut password = String::with_capacity(length);
2975    let mut counter: u64 = std::time::SystemTime::now()
2976        .duration_since(std::time::UNIX_EPOCH)
2977        .map(|d| d.as_nanos() as u64)
2978        .unwrap_or(0);
2979    while password.len() < length {
2980        // Splitmix64 — deterministic, no_std-friendly, good enough for
2981        // fake-cloud password generation. Real entropy isn't a goal here.
2982        counter = counter.wrapping_add(0x9E3779B97F4A7C15);
2983        let mut z = counter;
2984        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
2985        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
2986        z ^= z >> 31;
2987        let idx = (z as usize) % pool_chars.len();
2988        password.push(pool_chars[idx]);
2989    }
2990
2991    let template = gen.get("SecretStringTemplate").and_then(|v| v.as_str());
2992    let key = gen.get("GenerateStringKey").and_then(|v| v.as_str());
2993    match (template, key) {
2994        (Some(tmpl), Some(k)) => {
2995            let mut value: serde_json::Value = serde_json::from_str(tmpl)
2996                .map_err(|e| format!("SecretStringTemplate is not valid JSON: {e}"))?;
2997            if let Some(obj) = value.as_object_mut() {
2998                obj.insert(k.to_string(), serde_json::Value::String(password));
2999                Ok(value.to_string())
3000            } else {
3001                Err("SecretStringTemplate must be a JSON object".to_string())
3002            }
3003        }
3004        _ => Ok(password),
3005    }
3006}
3007
3008fn parse_ses_receipt_action(value: &serde_json::Value) -> Option<SesReceiptAction> {
3009    let obj = value.as_object()?;
3010    if let Some(s3) = obj.get("S3Action").and_then(|v| v.as_object()) {
3011        let bucket_name = s3.get("BucketName").and_then(|v| v.as_str())?.to_string();
3012        return Some(SesReceiptAction::S3 {
3013            bucket_name,
3014            object_key_prefix: s3
3015                .get("ObjectKeyPrefix")
3016                .and_then(|v| v.as_str())
3017                .map(String::from),
3018            topic_arn: s3
3019                .get("TopicArn")
3020                .and_then(|v| v.as_str())
3021                .map(String::from),
3022            kms_key_arn: s3
3023                .get("KmsKeyArn")
3024                .and_then(|v| v.as_str())
3025                .map(String::from),
3026        });
3027    }
3028    if let Some(sns) = obj.get("SNSAction").and_then(|v| v.as_object()) {
3029        return Some(SesReceiptAction::Sns {
3030            topic_arn: sns.get("TopicArn").and_then(|v| v.as_str())?.to_string(),
3031            encoding: sns
3032                .get("Encoding")
3033                .and_then(|v| v.as_str())
3034                .map(String::from),
3035        });
3036    }
3037    if let Some(la) = obj.get("LambdaAction").and_then(|v| v.as_object()) {
3038        return Some(SesReceiptAction::Lambda {
3039            function_arn: la.get("FunctionArn").and_then(|v| v.as_str())?.to_string(),
3040            invocation_type: la
3041                .get("InvocationType")
3042                .and_then(|v| v.as_str())
3043                .map(String::from),
3044            topic_arn: la
3045                .get("TopicArn")
3046                .and_then(|v| v.as_str())
3047                .map(String::from),
3048        });
3049    }
3050    if let Some(b) = obj.get("BounceAction").and_then(|v| v.as_object()) {
3051        return Some(SesReceiptAction::Bounce {
3052            smtp_reply_code: b
3053                .get("SmtpReplyCode")
3054                .and_then(|v| v.as_str())
3055                .unwrap_or("550")
3056                .to_string(),
3057            message: b
3058                .get("Message")
3059                .and_then(|v| v.as_str())
3060                .unwrap_or("")
3061                .to_string(),
3062            sender: b
3063                .get("Sender")
3064                .and_then(|v| v.as_str())
3065                .unwrap_or("")
3066                .to_string(),
3067            status_code: b
3068                .get("StatusCode")
3069                .and_then(|v| v.as_str())
3070                .map(String::from),
3071            topic_arn: b.get("TopicArn").and_then(|v| v.as_str()).map(String::from),
3072        });
3073    }
3074    if let Some(ah) = obj.get("AddHeaderAction").and_then(|v| v.as_object()) {
3075        return Some(SesReceiptAction::AddHeader {
3076            header_name: ah.get("HeaderName").and_then(|v| v.as_str())?.to_string(),
3077            header_value: ah.get("HeaderValue").and_then(|v| v.as_str())?.to_string(),
3078        });
3079    }
3080    if let Some(s) = obj.get("StopAction").and_then(|v| v.as_object()) {
3081        return Some(SesReceiptAction::Stop {
3082            scope: s
3083                .get("Scope")
3084                .and_then(|v| v.as_str())
3085                .unwrap_or("RuleSet")
3086                .to_string(),
3087            topic_arn: s.get("TopicArn").and_then(|v| v.as_str()).map(String::from),
3088        });
3089    }
3090    None
3091}
3092
3093/// Generate an N-character alphanumeric id for API Gateway v2 resources.
3094/// AWS HTTP API ids are 10 chars; routes/integrations/etc are similarly
3095/// short. This mirrors the runtime crate's id shape.
3096fn make_apigwv2_id(n: usize) -> String {
3097    let s = uuid::Uuid::new_v4().simple().to_string();
3098    s[..n.min(s.len())].to_string()
3099}
3100
3101/// Lowercase the first letter of each key in a JSON object, recursively.
3102/// CloudFormation property names are PascalCase (`BurstLimit`,
3103/// `RateLimit`); the runtime API Gateway service stores values keyed in
3104/// camelCase (`burstLimit`, `rateLimit`). Used at the CFN/service
3105/// boundary so JSON pulled from the template can flow into the runtime
3106/// state without renaming each leaf by hand.
3107/// Coerce a CFN-resolved Number/String parameter into i64. CFN
3108/// parameters surface here as `Value::String` after `Ref` substitution,
3109/// even when the parameter `Type` is `Number`, so we need both paths.
3110fn cfn_as_i64(v: &serde_json::Value) -> Option<i64> {
3111    if let Some(n) = v.as_i64() {
3112        return Some(n);
3113    }
3114    v.as_str().and_then(|s| s.parse::<i64>().ok())
3115}
3116
3117fn lowercase_first_keys(value: serde_json::Value) -> serde_json::Value {
3118    match value {
3119        serde_json::Value::Object(map) => {
3120            let mut out = serde_json::Map::new();
3121            for (k, v) in map {
3122                let new_key = if let Some(first) = k.chars().next() {
3123                    let mut s = String::with_capacity(k.len());
3124                    s.extend(first.to_lowercase());
3125                    s.push_str(&k[first.len_utf8()..]);
3126                    s
3127                } else {
3128                    k
3129                };
3130                out.insert(new_key, lowercase_first_keys(v));
3131            }
3132            serde_json::Value::Object(out)
3133        }
3134        serde_json::Value::Array(arr) => {
3135            serde_json::Value::Array(arr.into_iter().map(lowercase_first_keys).collect())
3136        }
3137        other => other,
3138    }
3139}
3140
3141/// Synthesize the per-domain DNS validation record list for a
3142/// CFN-provisioned cert. Mirrors the runtime ACM path: each name gets
3143/// a SUCCESS record (since CFN-issued certs are auto-validated above)
3144/// and an `_amzn-validations.<domain>.` resource record so callers
3145/// that read DescribeCertificate see the same shape they'd expect
3146/// from a real ACM-issued cert.
3147fn synth_acm_domain_validation(
3148    domain_name: &str,
3149    sans: &[String],
3150    validation_method: &str,
3151) -> Vec<AcmDomainValidation> {
3152    let mut all = vec![domain_name.to_string()];
3153    for s in sans {
3154        if !all.contains(s) {
3155            all.push(s.clone());
3156        }
3157    }
3158    all.into_iter()
3159        .map(|name| AcmDomainValidation {
3160            domain_name: name.clone(),
3161            validation_status: "SUCCESS".to_string(),
3162            validation_method: validation_method.to_string(),
3163            resource_record_name: Some(format!("_amzn-validations.{name}.")),
3164            resource_record_type: Some("CNAME".to_string()),
3165            resource_record_value: Some(format!("{}.acm-validations.aws.", Uuid::new_v4())),
3166        })
3167        .collect()
3168}
3169
3170/// Convert CFN `Tags` array into the ACM crate's tag map form.
3171fn parse_acm_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
3172    let mut out = BTreeMap::new();
3173    if let Some(arr) = value.and_then(|v| v.as_array()) {
3174        for t in arr {
3175            if let (Some(k), Some(v)) = (
3176                t.get("Key").and_then(|v| v.as_str()),
3177                t.get("Value").and_then(|v| v.as_str()),
3178            ) {
3179                out.insert(k.to_string(), v.to_string());
3180            }
3181        }
3182    }
3183    out
3184}
3185
3186/// Convert CFN `Tags` array into the ECS `TagEntry` form.
3187fn parse_ecs_tags(value: Option<&serde_json::Value>) -> Vec<EcsTagEntry> {
3188    let Some(arr) = value.and_then(|v| v.as_array()) else {
3189        return Vec::new();
3190    };
3191    arr.iter()
3192        .filter_map(|t| {
3193            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
3194            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
3195            Some(EcsTagEntry { key, value })
3196        })
3197        .collect()
3198}
3199
3200/// Strip the cluster ARN prefix when CFN passes a Ref-resolved name or
3201/// a GetAtt-resolved ARN; ECS internal state keys clusters by name.
3202fn parse_ecs_cluster_name(input: &str) -> String {
3203    if let Some(after) = input.split(":cluster/").nth(1) {
3204        return after.to_string();
3205    }
3206    input.to_string()
3207}
3208
3209/// Pull `(family, revision)` out of a task definition ARN tail like
3210/// `task-definition/web:3`. Returns `(family, revision)` or
3211/// `(input, 1)` for unrecognised shapes.
3212fn parse_td_arn(input: &str) -> (String, i32) {
3213    let suffix = input.rsplit('/').next().unwrap_or(input);
3214    if let Some((family, rev)) = suffix.split_once(':') {
3215        if let Ok(revision) = rev.parse::<i32>() {
3216            return (family.to_string(), revision);
3217        }
3218    }
3219    (input.to_string(), 1)
3220}
3221
3222/// Pull `(cluster, service)` out of a service ARN like
3223/// `arn:aws:ecs:us-east-1:000000000000:service/<cluster>/<service>`.
3224fn parse_service_arn(input: &str) -> Option<(String, String)> {
3225    let after = input.split(":service/").nth(1)?;
3226    let mut parts = after.splitn(2, '/');
3227    let cluster = parts.next()?.to_string();
3228    let service = parts.next()?.to_string();
3229    Some((cluster, service))
3230}
3231
3232/// Parse CFN-shape Tags array into the RDS crate's tag form.
3233fn parse_rds_tags(value: Option<&serde_json::Value>) -> Vec<RdsTag> {
3234    let Some(arr) = value.and_then(|v| v.as_array()) else {
3235        return Vec::new();
3236    };
3237    arr.iter()
3238        .filter_map(|t| {
3239            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
3240            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
3241            Some(RdsTag { key, value })
3242        })
3243        .collect()
3244}
3245
3246/// Lazy-create an entry in the RDS `extras` bucket so the provisioner
3247/// doesn't have to re-implement the `BTreeMap::entry` boilerplate per
3248/// resource type.
3249fn rds_extras_mut<'a>(
3250    state: &'a mut fakecloud_rds::RdsState,
3251    category: &str,
3252) -> &'a mut BTreeMap<String, serde_json::Value> {
3253    state.extras.entry(category.to_string()).or_default()
3254}
3255
3256/// Parse a JSON array-of-strings property. Returns empty Vec when the
3257/// value is missing or shaped wrong; matches the tolerant input handling
3258/// used by the runtime Cognito service.
3259fn parse_cognito_string_array(value: Option<&serde_json::Value>) -> Vec<String> {
3260    value
3261        .and_then(|v| v.as_array())
3262        .map(|arr| {
3263            arr.iter()
3264                .filter_map(|v| v.as_str().map(|s| s.to_string()))
3265                .collect()
3266        })
3267        .unwrap_or_default()
3268}
3269
3270fn parse_cognito_password_policy(value: Option<&serde_json::Value>) -> PasswordPolicy {
3271    let Some(inner) = value
3272        .and_then(|v| v.get("PasswordPolicy"))
3273        .and_then(|v| v.as_object())
3274    else {
3275        return PasswordPolicy::default();
3276    };
3277    let mut p = PasswordPolicy::default();
3278    if let Some(n) = inner.get("MinimumLength").and_then(|v| v.as_i64()) {
3279        p.minimum_length = n;
3280    }
3281    if let Some(b) = inner.get("RequireUppercase").and_then(|v| v.as_bool()) {
3282        p.require_uppercase = b;
3283    }
3284    if let Some(b) = inner.get("RequireLowercase").and_then(|v| v.as_bool()) {
3285        p.require_lowercase = b;
3286    }
3287    if let Some(b) = inner.get("RequireNumbers").and_then(|v| v.as_bool()) {
3288        p.require_numbers = b;
3289    }
3290    if let Some(b) = inner.get("RequireSymbols").and_then(|v| v.as_bool()) {
3291        p.require_symbols = b;
3292    }
3293    if let Some(n) = inner
3294        .get("TemporaryPasswordValidityDays")
3295        .and_then(|v| v.as_i64())
3296    {
3297        p.temporary_password_validity_days = n;
3298    }
3299    p
3300}
3301
3302fn parse_cognito_schema_attribute(value: &serde_json::Value) -> Option<SchemaAttribute> {
3303    let name = value.get("Name").and_then(|v| v.as_str())?.to_string();
3304    Some(SchemaAttribute {
3305        name,
3306        attribute_data_type: value
3307            .get("AttributeDataType")
3308            .and_then(|v| v.as_str())
3309            .unwrap_or("String")
3310            .to_string(),
3311        developer_only_attribute: value
3312            .get("DeveloperOnlyAttribute")
3313            .and_then(|v| v.as_bool())
3314            .unwrap_or(false),
3315        mutable: value
3316            .get("Mutable")
3317            .and_then(|v| v.as_bool())
3318            .unwrap_or(true),
3319        required: value
3320            .get("Required")
3321            .and_then(|v| v.as_bool())
3322            .unwrap_or(false),
3323        string_attribute_constraints: None,
3324        number_attribute_constraints: None,
3325    })
3326}
3327
3328fn parse_cognito_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
3329    let mut out = BTreeMap::new();
3330    if let Some(obj) = value.and_then(|v| v.as_object()) {
3331        for (k, v) in obj {
3332            if let Some(s) = v.as_str() {
3333                out.insert(k.clone(), s.to_string());
3334            }
3335        }
3336    }
3337    out
3338}
3339
3340fn parse_cognito_email_configuration(
3341    value: Option<&serde_json::Value>,
3342) -> Option<EmailConfiguration> {
3343    let inner = value?.as_object()?;
3344    Some(EmailConfiguration {
3345        source_arn: inner
3346            .get("SourceArn")
3347            .and_then(|v| v.as_str())
3348            .map(|s| s.to_string()),
3349        reply_to_email_address: inner
3350            .get("ReplyToEmailAddress")
3351            .and_then(|v| v.as_str())
3352            .map(|s| s.to_string()),
3353        email_sending_account: inner
3354            .get("EmailSendingAccount")
3355            .and_then(|v| v.as_str())
3356            .map(|s| s.to_string()),
3357        from_email_address: inner
3358            .get("From")
3359            .and_then(|v| v.as_str())
3360            .map(|s| s.to_string()),
3361        configuration_set: inner
3362            .get("ConfigurationSet")
3363            .and_then(|v| v.as_str())
3364            .map(|s| s.to_string()),
3365    })
3366}
3367
3368fn parse_cognito_sms_configuration(value: Option<&serde_json::Value>) -> Option<SmsConfiguration> {
3369    let inner = value?.as_object()?;
3370    Some(SmsConfiguration {
3371        sns_caller_arn: inner
3372            .get("SnsCallerArn")
3373            .and_then(|v| v.as_str())
3374            .map(|s| s.to_string()),
3375        external_id: inner
3376            .get("ExternalId")
3377            .and_then(|v| v.as_str())
3378            .map(|s| s.to_string()),
3379        sns_region: inner
3380            .get("SnsRegion")
3381            .and_then(|v| v.as_str())
3382            .map(|s| s.to_string()),
3383    })
3384}
3385
3386fn parse_cognito_admin_create_user_config(
3387    value: Option<&serde_json::Value>,
3388) -> Option<AdminCreateUserConfig> {
3389    let inner = value?.as_object()?;
3390    Some(AdminCreateUserConfig {
3391        allow_admin_create_user_only: inner
3392            .get("AllowAdminCreateUserOnly")
3393            .and_then(|v| v.as_bool()),
3394        invite_message_template: None,
3395        unused_account_validity_days: inner
3396            .get("UnusedAccountValidityDays")
3397            .and_then(|v| v.as_i64()),
3398    })
3399}
3400
3401fn parse_cognito_account_recovery(
3402    value: Option<&serde_json::Value>,
3403) -> Option<AccountRecoverySetting> {
3404    let arr = value?.get("RecoveryMechanisms")?.as_array()?;
3405    Some(AccountRecoverySetting {
3406        recovery_mechanisms: arr
3407            .iter()
3408            .filter_map(|m| {
3409                let name = m.get("Name").and_then(|v| v.as_str())?.to_string();
3410                let priority = m.get("Priority").and_then(|v| v.as_i64()).unwrap_or(1);
3411                Some(RecoveryOption { name, priority })
3412            })
3413            .collect(),
3414    })
3415}
3416
3417fn parse_firehose_s3_destination(value: &serde_json::Value) -> Result<S3Destination, String> {
3418    let role_arn = value
3419        .get("RoleARN")
3420        .and_then(|v| v.as_str())
3421        .ok_or("S3 destination requires RoleARN")?
3422        .to_string();
3423    let bucket_arn = value
3424        .get("BucketARN")
3425        .and_then(|v| v.as_str())
3426        .ok_or("S3 destination requires BucketARN")?
3427        .to_string();
3428    let prefix = value
3429        .get("Prefix")
3430        .and_then(|v| v.as_str())
3431        .map(|s| s.to_string());
3432    let error_output_prefix = value
3433        .get("ErrorOutputPrefix")
3434        .and_then(|v| v.as_str())
3435        .map(|s| s.to_string());
3436    let mut buffering_size_mb = None;
3437    let mut buffering_interval_seconds = None;
3438    if let Some(hints) = value.get("BufferingHints") {
3439        buffering_size_mb = hints.get("SizeInMBs").and_then(|v| v.as_i64());
3440        buffering_interval_seconds = hints.get("IntervalInSeconds").and_then(|v| v.as_i64());
3441    }
3442    let compression_format = value
3443        .get("CompressionFormat")
3444        .and_then(|v| v.as_str())
3445        .map(|s| s.to_string());
3446
3447    Ok(S3Destination {
3448        destination_id: "destination-1".to_string(),
3449        role_arn,
3450        bucket_arn,
3451        prefix,
3452        error_output_prefix,
3453        buffering_size_mb,
3454        buffering_interval_seconds,
3455        compression_format,
3456        processing_configuration: None,
3457        data_format_conversion_configuration: None,
3458        cloudwatch_logging_options: None,
3459        custom_time_zone: None,
3460        s3_backup_mode: None,
3461        file_extension: None,
3462    })
3463}
3464
3465#[cfg(test)]
3466mod tests {
3467    use super::*;
3468    use parking_lot::RwLock;
3469
3470    fn make_provisioner() -> ResourceProvisioner {
3471        ResourceProvisioner {
3472            sqs_state: Arc::new(RwLock::new(
3473                fakecloud_core::multi_account::MultiAccountState::new(
3474                    "123456789012",
3475                    "us-east-1",
3476                    "http://localhost:4566",
3477                ),
3478            )),
3479            sns_state: Arc::new(RwLock::new(
3480                fakecloud_core::multi_account::MultiAccountState::new(
3481                    "123456789012",
3482                    "us-east-1",
3483                    "http://localhost:4566",
3484                ),
3485            )),
3486            ssm_state: Arc::new(RwLock::new(
3487                fakecloud_core::multi_account::MultiAccountState::new(
3488                    "123456789012",
3489                    "us-east-1",
3490                    "http://localhost:4566",
3491                ),
3492            )),
3493            iam_state: Arc::new(RwLock::new(
3494                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", "http://localhost:4566"),
3495            )),
3496            s3_state: Arc::new(RwLock::new(fakecloud_core::multi_account::MultiAccountState::new(
3497                "123456789012",
3498                "us-east-1", "",
3499            ))),
3500            eventbridge_state: Arc::new(RwLock::new(
3501                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3502            )),
3503            dynamodb_state: Arc::new(RwLock::new(fakecloud_core::multi_account::MultiAccountState::new(
3504                "123456789012",
3505                "us-east-1", "",
3506            ))),
3507            logs_state: Arc::new(RwLock::new(
3508                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3509            )),
3510            lambda_state: Arc::new(RwLock::new(
3511                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3512            )),
3513            secretsmanager_state: Arc::new(RwLock::new(
3514                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3515            )),
3516            kinesis_state: Arc::new(RwLock::new(
3517                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3518            )),
3519            kms_state: Arc::new(RwLock::new(
3520                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3521            )),
3522            ecr_state: Arc::new(RwLock::new(
3523                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3524            )),
3525            cloudwatch_state: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
3526            elbv2_state: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
3527            organizations_state: Arc::new(RwLock::new(None)),
3528            cognito_state: Arc::new(RwLock::new(
3529                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3530            )),
3531            rds_state: Arc::new(RwLock::new(
3532                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3533            )),
3534            ec2_state: Arc::new(RwLock::new(
3535                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3536            )),
3537            autoscaling_state: Arc::new(RwLock::new(
3538                fakecloud_autoscaling::AutoScalingAccounts::new(),
3539            )),
3540            batch_state: Arc::new(RwLock::new(fakecloud_batch::BatchAccounts::new())),
3541            pipes_state: Arc::new(RwLock::new(fakecloud_pipes::PipesAccounts::new())),
3542            ecs_state: Arc::new(RwLock::new(
3543                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3544            )),
3545            acm_state: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
3546            acmpca_state: Arc::new(RwLock::new(fakecloud_acmpca::AcmPcaAccounts::new())),
3547            config_state: Arc::new(RwLock::new(fakecloud_config::ConfigAccounts::new())),
3548            route53resolver_state: Arc::new(RwLock::new(
3549                fakecloud_route53resolver::Route53ResolverAccounts::new(),
3550            )),
3551            elasticache_state: Arc::new(RwLock::new(
3552                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3553            )),
3554            route53_state: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
3555            cloudfront_state: Arc::new(RwLock::new(
3556                fakecloud_cloudfront::CloudFrontAccounts::new(),
3557            )),
3558            cloudformation_state: Arc::new(RwLock::new(
3559                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3560            )),
3561            stepfunctions_state: Arc::new(RwLock::new(
3562                fakecloud_core::multi_account::MultiAccountState::new(
3563                    "123456789012",
3564                    "us-east-1",
3565                    "",
3566                ),
3567            )),
3568            wafv2_state: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
3569            apigateway_state: Arc::new(RwLock::new(
3570                fakecloud_core::multi_account::MultiAccountState::new(
3571                    "123456789012",
3572                    "us-east-1",
3573                    "",
3574                ),
3575            )),
3576            apigatewayv2_state: Arc::new(RwLock::new(
3577                fakecloud_core::multi_account::MultiAccountState::new(
3578                    "123456789012",
3579                    "us-east-1",
3580                    "",
3581                ),
3582            )),
3583            ses_state: Arc::new(RwLock::new(
3584                fakecloud_core::multi_account::MultiAccountState::new(
3585                    "123456789012",
3586                    "us-east-1",
3587                    "",
3588                ),
3589            )),
3590            app_autoscaling_state: Arc::new(parking_lot::RwLock::new(
3591                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
3592            )),
3593            athena_state: Arc::new(parking_lot::RwLock::new(
3594                fakecloud_athena::AthenaAccounts::new(),
3595            )),
3596            firehose_state: Arc::new(parking_lot::RwLock::new(
3597                fakecloud_firehose::FirehoseAccounts::new(),
3598            )),
3599            glue_state: Arc::new(parking_lot::RwLock::new(
3600                fakecloud_glue::GlueAccounts::new(),
3601            )),
3602            eks_state: Arc::new(parking_lot::RwLock::new(
3603                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3604            )),
3605            servicediscovery_state: Arc::new(parking_lot::RwLock::new(
3606                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3607            )),
3608            codeartifact_state: Arc::new(parking_lot::RwLock::new(
3609                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3610            )),
3611            codecommit_state: Arc::new(parking_lot::RwLock::new(
3612                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3613            )),
3614            efs_state: Arc::new(parking_lot::RwLock::new(
3615                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3616            )),
3617            elasticbeanstalk_state: Arc::new(parking_lot::RwLock::new(
3618                fakecloud_elasticbeanstalk::EbAccounts::new(),
3619            )),
3620            mq_state: Arc::new(parking_lot::RwLock::new(
3621                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3622            )),
3623            kafka_state: Arc::new(parking_lot::RwLock::new(
3624                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3625            )),
3626            ka2_state: Arc::new(parking_lot::RwLock::new(
3627                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
3628            )),
3629            delivery: Arc::new(DeliveryBus::new()),
3630            lambda_runtime: None,
3631            rds_runtime: None,
3632            ec2_runtime: None,
3633            ecs_runtime: None,
3634            elasticache_runtime: None,
3635            mq_runtime: None,
3636            kafka_runtime: None,
3637            pending_container_spawns: Arc::new(parking_lot::Mutex::new(Vec::new())),
3638            pending_container_teardowns: Arc::new(parking_lot::Mutex::new(Vec::new())),
3639            pending_custom_invokes: Arc::new(parking_lot::Mutex::new(Vec::new())),
3640            defer_custom_invokes: false,
3641            s3_store: Arc::new(fakecloud_persistence::s3::MemoryS3Store::new()),
3642            account_id: "123456789012".to_string(),
3643            region: "us-east-1".to_string(),
3644            stack_id: "arn:aws:cloudformation:us-east-1:123456789012:stack/test/00000000-0000-0000-0000-000000000000".to_string(),
3645            strict_unknown_types: false,
3646        }
3647    }
3648
3649    fn make_resource(
3650        resource_type: &str,
3651        logical_id: &str,
3652        props: serde_json::Value,
3653    ) -> ResourceDefinition {
3654        ResourceDefinition {
3655            logical_id: logical_id.to_string(),
3656            resource_type: resource_type.to_string(),
3657            properties: props,
3658        }
3659    }
3660
3661    #[test]
3662    fn cloudwatch_alarm_provisions_and_updates_metrics() {
3663        let prov = make_provisioner();
3664        let created = prov
3665            .create_resource(&make_resource(
3666                "AWS::CloudWatch::Alarm",
3667                "A",
3668                serde_json::json!({
3669                    "AlarmName": "math-alarm",
3670                    "ComparisonOperator": "GreaterThanUpperThreshold",
3671                    "EvaluationPeriods": 1,
3672                    "ThresholdMetricId": "ad1",
3673                    "Metrics": [
3674                        {"Id": "e1", "Expression": "m1", "Label": "expr", "ReturnData": true},
3675                        {"Id": "m1", "ReturnData": false, "MetricStat": {
3676                            "Metric": {
3677                                "Namespace": "AWS/EC2",
3678                                "MetricName": "CPUUtilization",
3679                                "Dimensions": [{"Name": "InstanceId", "Value": "i-123"}]
3680                            },
3681                            "Period": 300,
3682                            "Stat": "Average"
3683                        }}
3684                    ]
3685                }),
3686            ))
3687            .expect("alarm provisions");
3688
3689        {
3690            let cw = prov.cloudwatch_state.read();
3691            let acct = cw.get("123456789012").unwrap();
3692            let alarm = acct
3693                .alarms_in("us-east-1")
3694                .unwrap()
3695                .get("math-alarm")
3696                .unwrap();
3697            assert_eq!(alarm.metrics.len(), 2, "Metrics parsed on create");
3698            assert_eq!(alarm.metrics[0].id, "e1");
3699            assert_eq!(alarm.metrics[0].expression.as_deref(), Some("m1"));
3700            assert_eq!(alarm.metrics[0].return_data, Some(true));
3701            assert_eq!(alarm.threshold_metric_id.as_deref(), Some("ad1"));
3702            let stat = alarm.metrics[1].metric_stat.as_ref().unwrap();
3703            assert_eq!(stat.metric_name.as_deref(), Some("CPUUtilization"));
3704            assert_eq!(
3705                stat.dimensions.get("InstanceId").map(String::as_str),
3706                Some("i-123")
3707            );
3708            assert_eq!(stat.stat.as_deref(), Some("Average"));
3709            assert_eq!(stat.period, Some(300));
3710        }
3711
3712        // Update replaces the Metrics list AND refreshes ThresholdMetricId.
3713        prov.update_resource(
3714            &created,
3715            &make_resource(
3716                "AWS::CloudWatch::Alarm",
3717                "A",
3718                serde_json::json!({
3719                    "AlarmName": "math-alarm",
3720                    "ComparisonOperator": "GreaterThanUpperThreshold",
3721                    "EvaluationPeriods": 1,
3722                    "ThresholdMetricId": "ad2",
3723                    "Metrics": [{"Id": "e2", "Expression": "m1*2", "ReturnData": true}]
3724                }),
3725            ),
3726        )
3727        .expect("update succeeds")
3728        .expect("AWS::CloudWatch::Alarm is updatable");
3729
3730        let cw = prov.cloudwatch_state.read();
3731        let acct = cw.get("123456789012").unwrap();
3732        let alarm = acct
3733            .alarms_in("us-east-1")
3734            .unwrap()
3735            .get("math-alarm")
3736            .unwrap();
3737        assert_eq!(alarm.metrics.len(), 1, "Metrics re-parsed on update");
3738        assert_eq!(alarm.metrics[0].id, "e2");
3739        assert_eq!(alarm.metrics[0].expression.as_deref(), Some("m1*2"));
3740        // ThresholdMetricId reflects the updated definition, not the stale one.
3741        assert_eq!(
3742            alarm.threshold_metric_id.as_deref(),
3743            Some("ad2"),
3744            "ThresholdMetricId refreshed on update"
3745        );
3746    }
3747
3748    #[test]
3749    fn update_stack_reconciles_iam_role_policies() {
3750        let prov = make_provisioner();
3751        let created = prov
3752            .create_resource(&make_resource(
3753                "AWS::IAM::Role",
3754                "R",
3755                serde_json::json!({
3756                    "RoleName": "r1",
3757                    "AssumeRolePolicyDocument": {"Version": "2012-10-17"},
3758                    "ManagedPolicyArns": ["arn:aws:iam::aws:policy/ReadOnlyAccess"],
3759                }),
3760            ))
3761            .expect("role provisions");
3762        prov.update_resource(
3763            &created,
3764            &make_resource(
3765                "AWS::IAM::Role",
3766                "R",
3767                serde_json::json!({
3768                    "RoleName": "r1",
3769                    "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []},
3770                    "Description": "updated",
3771                    "ManagedPolicyArns": ["arn:aws:iam::aws:policy/AdministratorAccess"],
3772                    "Policies": [{"PolicyName": "inline1", "PolicyDocument": {"k": "v"}}],
3773                }),
3774            ),
3775        )
3776        .expect("update succeeds")
3777        .expect("IAM::Role is updatable");
3778        let iam = prov.iam_state.read();
3779        let acct = iam.get("123456789012").unwrap();
3780        let role = acct.roles.get("r1").unwrap();
3781        assert_eq!(role.description.as_deref(), Some("updated"));
3782        assert!(role.assume_role_policy_document.contains("Statement"));
3783        // Attached managed policies replaced, not appended.
3784        let attached = acct.role_policies.get("r1").unwrap();
3785        assert_eq!(
3786            attached,
3787            &vec!["arn:aws:iam::aws:policy/AdministratorAccess".to_string()]
3788        );
3789        assert!(acct
3790            .role_inline_policies
3791            .get("r1")
3792            .unwrap()
3793            .contains_key("inline1"));
3794    }
3795
3796    #[test]
3797    fn update_stack_bumps_managed_policy_version() {
3798        let prov = make_provisioner();
3799        let created = prov
3800            .create_resource(&make_resource(
3801                "AWS::IAM::ManagedPolicy",
3802                "P",
3803                serde_json::json!({
3804                    "ManagedPolicyName": "p1",
3805                    "PolicyDocument": {"Version": "2012-10-17", "Statement": [{"Effect": "Allow"}]},
3806                }),
3807            ))
3808            .expect("managed policy provisions");
3809        prov.update_resource(
3810            &created,
3811            &make_resource(
3812                "AWS::IAM::ManagedPolicy",
3813                "P",
3814                serde_json::json!({
3815                    "ManagedPolicyName": "p1",
3816                    "PolicyDocument": {"Version": "2012-10-17", "Statement": [{"Effect": "Deny"}]},
3817                }),
3818            ),
3819        )
3820        .expect("update succeeds")
3821        .expect("IAM::ManagedPolicy is updatable");
3822        let iam = prov.iam_state.read();
3823        let acct = iam.get("123456789012").unwrap();
3824        let policy = acct.policies.get(&created.physical_id).unwrap();
3825        assert_eq!(policy.versions.len(), 2);
3826        assert_eq!(policy.default_version_id, "v2");
3827        let default = policy.versions.iter().find(|v| v.is_default).unwrap();
3828        assert!(default.document.contains("Deny"));
3829    }
3830
3831    fn pipe_props(name: &str, target: &str, description: Option<&str>) -> serde_json::Value {
3832        let mut m = serde_json::json!({
3833            "Name": name,
3834            "Source": "arn:aws:sqs:us-east-1:123456789012:src",
3835            "Target": target,
3836            "RoleArn": "arn:aws:iam::123456789012:role/pipe",
3837        });
3838        if let Some(d) = description {
3839            m["Description"] = serde_json::json!(d);
3840        }
3841        m
3842    }
3843
3844    #[test]
3845    fn update_stack_applies_pipes_pipe_change() {
3846        let prov = make_provisioner();
3847        let created = prov
3848            .create_resource(&make_resource(
3849                "AWS::Pipes::Pipe",
3850                "P",
3851                pipe_props(
3852                    "my-pipe",
3853                    "arn:aws:sqs:us-east-1:123456789012:dst",
3854                    Some("v1"),
3855                ),
3856            ))
3857            .expect("pipe provisions");
3858
3859        // UpdateStack on a Pipes resource previously fell through to `_ => None`,
3860        // so CFN reported UPDATE_COMPLETE while discarding the change.
3861        let updated = prov
3862            .update_resource(
3863                &created,
3864                &make_resource(
3865                    "AWS::Pipes::Pipe",
3866                    "P",
3867                    pipe_props(
3868                        "my-pipe",
3869                        "arn:aws:sqs:us-east-1:123456789012:dst2",
3870                        Some("v2"),
3871                    ),
3872                ),
3873            )
3874            .expect("update succeeds")
3875            .expect("Pipes::Pipe is updatable (not a silent no-op)");
3876        assert_eq!(updated.status, "UPDATE_COMPLETE");
3877
3878        // The change is actually applied to the pipes service state.
3879        let pipes = prov.pipes_state.read();
3880        let pipe = pipes
3881            .get("123456789012")
3882            .unwrap()
3883            .pipes
3884            .get("my-pipe")
3885            .unwrap();
3886        assert_eq!(pipe["Description"], "v2");
3887        assert_eq!(pipe["Target"], "arn:aws:sqs:us-east-1:123456789012:dst2");
3888        // CFN provisioning is synchronous, so the pipe stays settled.
3889        assert_eq!(pipe["CurrentState"], "RUNNING");
3890    }
3891
3892    #[test]
3893    fn update_stack_replaces_pipe_on_source_change() {
3894        // Source is replacement-required on AWS::Pipes::Pipe: a changed Source
3895        // must actually take effect (via delete+recreate), not be silently
3896        // dropped by an in-place update that only touches the mutable fields.
3897        let prov = make_provisioner();
3898        let created = prov
3899            .create_resource(&make_resource(
3900                "AWS::Pipes::Pipe",
3901                "P",
3902                serde_json::json!({
3903                    "Name": "src-pipe",
3904                    "Source": "arn:aws:sqs:us-east-1:123456789012:src-old",
3905                    "Target": "arn:aws:sqs:us-east-1:123456789012:dst",
3906                    "RoleArn": "arn:aws:iam::123456789012:role/pipe",
3907                }),
3908            ))
3909            .expect("pipe provisions");
3910        prov.update_resource(
3911            &created,
3912            &make_resource(
3913                "AWS::Pipes::Pipe",
3914                "P",
3915                serde_json::json!({
3916                    "Name": "src-pipe",
3917                    "Source": "arn:aws:sqs:us-east-1:123456789012:src-new",
3918                    "Target": "arn:aws:sqs:us-east-1:123456789012:dst",
3919                    "RoleArn": "arn:aws:iam::123456789012:role/pipe",
3920                }),
3921            ),
3922        )
3923        .expect("update succeeds")
3924        .expect("Pipes::Pipe is updatable");
3925        let pipes = prov.pipes_state.read();
3926        let acct = pipes.get("123456789012").unwrap();
3927        // Exactly one pipe (the recreate reused the same Name after deleting).
3928        assert_eq!(acct.pipes.len(), 1);
3929        let pipe = acct.pipes.get("src-pipe").unwrap();
3930        assert_eq!(
3931            pipe["Source"], "arn:aws:sqs:us-east-1:123456789012:src-new",
3932            "changed Source is applied via replacement, not silently dropped"
3933        );
3934    }
3935
3936    #[test]
3937    fn update_stack_clears_omitted_pipe_field() {
3938        let prov = make_provisioner();
3939        let created = prov
3940            .create_resource(&make_resource(
3941                "AWS::Pipes::Pipe",
3942                "P",
3943                pipe_props(
3944                    "p2",
3945                    "arn:aws:sqs:us-east-1:123456789012:dst",
3946                    Some("drop-me"),
3947                ),
3948            ))
3949            .expect("pipe provisions");
3950        prov.update_resource(
3951            &created,
3952            &make_resource(
3953                "AWS::Pipes::Pipe",
3954                "P",
3955                pipe_props("p2", "arn:aws:sqs:us-east-1:123456789012:dst", None),
3956            ),
3957        )
3958        .expect("update succeeds")
3959        .expect("updatable");
3960        let pipes = prov.pipes_state.read();
3961        let pipe = pipes.get("123456789012").unwrap().pipes.get("p2").unwrap();
3962        assert!(
3963            pipe.get("Description").is_none(),
3964            "an omitted updatable field is cleared (full-replace semantics)"
3965        );
3966    }
3967
3968    #[test]
3969    fn create_pipe_rejects_empty_required_field() {
3970        let prov = make_provisioner();
3971        let err = prov
3972            .create_resource(&make_resource(
3973                "AWS::Pipes::Pipe",
3974                "P",
3975                serde_json::json!({
3976                    "Name": "p3",
3977                    "Source": "",
3978                    "Target": "arn:aws:sqs:us-east-1:123456789012:dst",
3979                    "RoleArn": "arn:aws:iam::123456789012:role/pipe",
3980                }),
3981            ))
3982            .unwrap_err();
3983        assert!(err.contains("Source"), "empty Source rejected: {err}");
3984    }
3985
3986    #[test]
3987    fn create_pipe_rejects_duplicate_name() {
3988        let prov = make_provisioner();
3989        let props = pipe_props("dup", "arn:aws:sqs:us-east-1:123456789012:dst", None);
3990        prov.create_resource(&make_resource("AWS::Pipes::Pipe", "P", props.clone()))
3991            .expect("first create ok");
3992        // A second resource with the same Name must conflict, not overwrite.
3993        let err = prov
3994            .create_resource(&make_resource("AWS::Pipes::Pipe", "P2", props))
3995            .unwrap_err();
3996        assert!(err.contains("already exists"), "duplicate rejected: {err}");
3997    }
3998
3999    #[test]
4000    fn update_stack_applies_sqs_queue_property_change() {
4001        let prov = make_provisioner();
4002        let created = prov
4003            .create_resource(&make_resource(
4004                "AWS::SQS::Queue",
4005                "Q",
4006                serde_json::json!({ "QueueName": "q1", "VisibilityTimeout": "30" }),
4007            ))
4008            .expect("queue provisions");
4009        // UpdateStack changes a property; previously a no-op (`_ => None`).
4010        let updated = prov
4011            .update_resource(
4012                &created,
4013                &make_resource(
4014                    "AWS::SQS::Queue",
4015                    "Q",
4016                    serde_json::json!({ "QueueName": "q1", "VisibilityTimeout": "120" }),
4017                ),
4018            )
4019            .expect("update succeeds")
4020            .expect("SQS::Queue is an updatable type");
4021        assert_eq!(updated.physical_id, created.physical_id);
4022        let sqs = prov.sqs_state.read();
4023        let acct = sqs.get("123456789012").unwrap();
4024        let queue = acct.queues.get(&created.physical_id).unwrap();
4025        assert_eq!(
4026            queue
4027                .attributes
4028                .get("VisibilityTimeout")
4029                .map(String::as_str),
4030            Some("120")
4031        );
4032    }
4033
4034    #[test]
4035    fn update_stack_applies_sns_topic_property_change() {
4036        let prov = make_provisioner();
4037        let created = prov
4038            .create_resource(&make_resource(
4039                "AWS::SNS::Topic",
4040                "T",
4041                serde_json::json!({ "TopicName": "t1", "DisplayName": "before" }),
4042            ))
4043            .expect("topic provisions");
4044        let updated = prov
4045            .update_resource(
4046                &created,
4047                &make_resource(
4048                    "AWS::SNS::Topic",
4049                    "T",
4050                    serde_json::json!({ "TopicName": "t1", "DisplayName": "after" }),
4051                ),
4052            )
4053            .expect("update succeeds")
4054            .expect("SNS::Topic is an updatable type");
4055        assert_eq!(updated.physical_id, created.physical_id);
4056        let sns = prov.sns_state.read();
4057        let acct = sns.get("123456789012").unwrap();
4058        let topic = acct.topics.get(&created.physical_id).unwrap();
4059        assert_eq!(
4060            topic.attributes.get("DisplayName").map(String::as_str),
4061            Some("after")
4062        );
4063    }
4064
4065    #[test]
4066    fn ec2_vpc_subnet_provision_through_real_handlers() {
4067        let prov = make_provisioner();
4068        let vpc = prov
4069            .create_resource(&make_resource(
4070                "AWS::EC2::VPC",
4071                "Vpc",
4072                serde_json::json!({ "CidrBlock": "10.1.0.0/16" }),
4073            ))
4074            .expect("VPC provisions");
4075        assert!(
4076            vpc.physical_id.starts_with("vpc-"),
4077            "got {}",
4078            vpc.physical_id
4079        );
4080        // The real VPC handler ran: a VPC exists in EC2 state (with the default
4081        // SG/route-table/NACL the handler creates), not a recorded no-op.
4082        {
4083            let ec2 = prov.ec2_state.read();
4084            let acct = ec2.get("123456789012").unwrap();
4085            assert!(acct.vpcs.contains_key(&vpc.physical_id));
4086        }
4087        // GetAtt VpcId/CidrBlock resolve; Ref is the vpc id.
4088        assert_eq!(
4089            prov.get_att(&vpc, "VpcId").as_deref(),
4090            Some(vpc.physical_id.as_str())
4091        );
4092        assert_eq!(
4093            prov.get_att(&vpc, "CidrBlock").as_deref(),
4094            Some("10.1.0.0/16")
4095        );
4096
4097        // A subnet referencing the VPC (Ref already resolved to the physical id).
4098        let subnet = prov
4099            .create_resource(&make_resource(
4100                "AWS::EC2::Subnet",
4101                "Subnet",
4102                serde_json::json!({ "VpcId": vpc.physical_id, "CidrBlock": "10.1.1.0/24" }),
4103            ))
4104            .expect("subnet provisions");
4105        assert!(subnet.physical_id.starts_with("subnet-"));
4106        {
4107            let ec2 = prov.ec2_state.read();
4108            let acct = ec2.get("123456789012").unwrap();
4109            assert!(acct.subnets.contains_key(&subnet.physical_id));
4110        }
4111
4112        // Delete routes through the real handler.
4113        prov.delete_resource(&subnet).expect("subnet deletes");
4114        prov.delete_resource(&vpc).expect("vpc deletes");
4115    }
4116
4117    #[test]
4118    fn unknown_resource_type_records_instead_of_failing() {
4119        let prov = make_provisioner();
4120        // A real AWS type fakecloud has no provisioner for. It must NOT fail
4121        // the stack — it's recorded as provisioned with no backing state, and
4122        // `Ref` (its physical id) resolves to a stable value (the logical id).
4123        let sr = prov
4124            .create_resource(&make_resource(
4125                "AWS::CloudFormation::WaitConditionHandle",
4126                "Handle",
4127                serde_json::json!({}),
4128            ))
4129            .expect("unknown resource type should record, not fail");
4130        assert_eq!(sr.physical_id, "Handle");
4131        assert_eq!(sr.status, "CREATE_COMPLETE");
4132        // Teardown of a recorded no-op resource also succeeds.
4133        prov.delete_resource(&sr)
4134            .expect("delete no-op should succeed");
4135    }
4136
4137    #[test]
4138    fn ecr_repository_uri_uses_bound_endpoint_not_public_dns() {
4139        // A CFN-provisioned RepositoryUri must point at fakecloud's own registry
4140        // endpoint, not the public AWS DNS, so `docker push` against the GetAtt
4141        // RepositoryUri reaches it (bug-audit 2026-06-20, 1.4).
4142        let prov = make_provisioner();
4143        let sr = prov
4144            .create_resource(&make_resource(
4145                "AWS::ECR::Repository",
4146                "Repo",
4147                serde_json::json!({ "RepositoryName": "my-repo" }),
4148            ))
4149            .expect("ECR repo provisions");
4150        let uri = sr
4151            .attributes
4152            .get("RepositoryUri")
4153            .expect("RepositoryUri attribute");
4154        assert!(
4155            !uri.contains("amazonaws.com"),
4156            "RepositoryUri must not use the public ECR DNS, got {uri}"
4157        );
4158        assert!(uri.contains("my-repo"), "uri should name the repo: {uri}");
4159    }
4160
4161    #[test]
4162    fn sns_subscription_rejects_nonexistent_topic() {
4163        let prov = make_provisioner();
4164        let resource = make_resource(
4165            "AWS::SNS::Subscription",
4166            "MySub",
4167            serde_json::json!({
4168                "TopicArn": "arn:aws:sns:us-east-1:123456789012:NonExistent",
4169                "Protocol": "sqs",
4170                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
4171            }),
4172        );
4173        let result = prov.create_resource(&resource);
4174        assert!(result.is_err());
4175        assert!(result.unwrap_err().contains("does not exist"));
4176    }
4177
4178    #[test]
4179    fn sns_subscription_succeeds_when_topic_exists() {
4180        let prov = make_provisioner();
4181        // First create the topic
4182        let topic = make_resource(
4183            "AWS::SNS::Topic",
4184            "MyTopic",
4185            serde_json::json!({ "TopicName": "my-topic" }),
4186        );
4187        let topic_result = prov.create_resource(&topic);
4188        assert!(topic_result.is_ok());
4189        let topic_arn = topic_result.unwrap().physical_id;
4190
4191        // Now create subscription referencing that topic
4192        let sub = make_resource(
4193            "AWS::SNS::Subscription",
4194            "MySub",
4195            serde_json::json!({
4196                "TopicArn": topic_arn,
4197                "Protocol": "sqs",
4198                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
4199            }),
4200        );
4201        let result = prov.create_resource(&sub);
4202        assert!(result.is_ok());
4203    }
4204
4205    #[test]
4206    fn eventbridge_rule_arn_default_bus_omits_bus_name() {
4207        let prov = make_provisioner();
4208        let resource = make_resource(
4209            "AWS::Events::Rule",
4210            "MyRule",
4211            serde_json::json!({
4212                "Name": "my-rule",
4213                "ScheduleExpression": "rate(1 hour)"
4214            }),
4215        );
4216        let result = prov.create_resource(&resource).unwrap();
4217        // For default bus, ARN should be rule/<name> without /default/
4218        assert_eq!(
4219            result.physical_id,
4220            "arn:aws:events:us-east-1:123456789012:rule/my-rule"
4221        );
4222        assert!(!result.physical_id.contains("rule/default/"));
4223    }
4224
4225    #[test]
4226    fn eventbridge_rule_arn_custom_bus_includes_bus_name() {
4227        let prov = make_provisioner();
4228        // Create a custom bus first
4229        {
4230            let mut eb_accounts = prov.eventbridge_state.write();
4231            let state = eb_accounts.default_mut();
4232            state.buses.insert(
4233                "custom-bus".to_string(),
4234                fakecloud_eventbridge::EventBus {
4235                    name: "custom-bus".to_string(),
4236                    arn: "arn:aws:events:us-east-1:123456789012:event-bus/custom-bus".to_string(),
4237                    policy: None,
4238                    creation_time: Utc::now(),
4239                    last_modified_time: Utc::now(),
4240                    description: None,
4241                    kms_key_identifier: None,
4242                    dead_letter_config: None,
4243                    tags: std::collections::BTreeMap::new(),
4244                },
4245            );
4246        }
4247        let resource = make_resource(
4248            "AWS::Events::Rule",
4249            "MyRule",
4250            serde_json::json!({
4251                "Name": "my-rule",
4252                "EventBusName": "custom-bus",
4253                "ScheduleExpression": "rate(1 hour)"
4254            }),
4255        );
4256        let result = prov.create_resource(&resource).unwrap();
4257        assert_eq!(
4258            result.physical_id,
4259            "arn:aws:events:us-east-1:123456789012:rule/custom-bus/my-rule"
4260        );
4261    }
4262
4263    #[test]
4264    fn eventbridge_rule_rejects_nonexistent_bus() {
4265        let prov = make_provisioner();
4266        let resource = make_resource(
4267            "AWS::Events::Rule",
4268            "MyRule",
4269            serde_json::json!({
4270                "Name": "my-rule",
4271                "EventBusName": "nonexistent-bus",
4272                "ScheduleExpression": "rate(1 hour)"
4273            }),
4274        );
4275        let result = prov.create_resource(&resource);
4276        assert!(result.is_err());
4277        assert!(result.unwrap_err().contains("does not exist"));
4278    }
4279
4280    #[test]
4281    fn custom_resource_requires_service_token() {
4282        let prov = make_provisioner();
4283        let resource = make_resource(
4284            "Custom::MyResource",
4285            "MyCustom",
4286            serde_json::json!({
4287                "Foo": "bar"
4288            }),
4289        );
4290        let result = prov.create_resource(&resource);
4291        assert!(result.is_err());
4292        assert!(
4293            result.unwrap_err().contains("ServiceToken"),
4294            "Should require ServiceToken property"
4295        );
4296    }
4297
4298    #[test]
4299    fn custom_resource_succeeds_without_lambda_delivery() {
4300        // When no Lambda delivery is configured, custom resource creation
4301        // should still succeed (the invocation is silently skipped).
4302        let prov = make_provisioner();
4303        let resource = make_resource(
4304            "Custom::MyResource",
4305            "MyCustom",
4306            serde_json::json!({
4307                "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:my-func",
4308                "Foo": "bar"
4309            }),
4310        );
4311        let result = prov.create_resource(&resource);
4312        assert!(result.is_ok());
4313        let sr = result.unwrap();
4314        assert_eq!(sr.logical_id, "MyCustom");
4315        assert_eq!(sr.resource_type, "Custom::MyResource");
4316        assert!(sr.physical_id.starts_with("MyCustom-"));
4317    }
4318
4319    #[test]
4320    fn cloudformation_custom_resource_type_succeeds() {
4321        let prov = make_provisioner();
4322        let resource = make_resource(
4323            "AWS::CloudFormation::CustomResource",
4324            "MyCustom2",
4325            serde_json::json!({
4326                "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:my-func",
4327                "Key": "value"
4328            }),
4329        );
4330        let result = prov.create_resource(&resource);
4331        assert!(result.is_ok());
4332        let sr = result.unwrap();
4333        assert_eq!(sr.resource_type, "AWS::CloudFormation::CustomResource");
4334    }
4335
4336    // ── Resource create/delete lifecycle tests ──
4337
4338    #[test]
4339    fn sqs_queue_create_and_delete() {
4340        let prov = make_provisioner();
4341        let res = make_resource(
4342            "AWS::SQS::Queue",
4343            "MyQ",
4344            serde_json::json!({"QueueName": "my-q"}),
4345        );
4346        let sr = prov.create_resource(&res).unwrap();
4347        assert!(sr.physical_id.contains("my-q"));
4348        assert_eq!(sr.resource_type, "AWS::SQS::Queue");
4349        prov.delete_resource(&sr).unwrap();
4350    }
4351
4352    #[test]
4353    fn sqs_queue_fifo_with_suffix() {
4354        let prov = make_provisioner();
4355        let res = make_resource(
4356            "AWS::SQS::Queue",
4357            "FifoQ",
4358            serde_json::json!({"QueueName": "my-fifo.fifo", "FifoQueue": true}),
4359        );
4360        let sr = prov.create_resource(&res).unwrap();
4361        assert!(sr.physical_id.contains(".fifo"));
4362    }
4363
4364    #[test]
4365    fn sns_topic_create_and_delete() {
4366        let prov = make_provisioner();
4367        let res = make_resource(
4368            "AWS::SNS::Topic",
4369            "MyTopic",
4370            serde_json::json!({"TopicName": "t1"}),
4371        );
4372        let sr = prov.create_resource(&res).unwrap();
4373        assert!(sr.physical_id.contains("t1"));
4374        prov.delete_resource(&sr).unwrap();
4375    }
4376
4377    #[test]
4378    fn ssm_parameter_create_and_delete() {
4379        let prov = make_provisioner();
4380        let res = make_resource(
4381            "AWS::SSM::Parameter",
4382            "MyParam",
4383            serde_json::json!({
4384                "Name": "/my/param",
4385                "Type": "String",
4386                "Value": "v1"
4387            }),
4388        );
4389        let sr = prov.create_resource(&res).unwrap();
4390        assert_eq!(sr.physical_id, "/my/param");
4391        prov.delete_resource(&sr).unwrap();
4392    }
4393
4394    #[test]
4395    fn iam_role_create_and_delete() {
4396        let prov = make_provisioner();
4397        let res = make_resource(
4398            "AWS::IAM::Role",
4399            "MyRole",
4400            serde_json::json!({
4401                "RoleName": "my-role",
4402                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
4403            }),
4404        );
4405        let sr = prov.create_resource(&res).unwrap();
4406        assert!(sr.physical_id.contains("my-role"));
4407        prov.delete_resource(&sr).unwrap();
4408    }
4409
4410    #[test]
4411    fn iam_policy_create_and_delete() {
4412        let prov = make_provisioner();
4413        let res = make_resource(
4414            "AWS::IAM::Policy",
4415            "MyPolicy",
4416            serde_json::json!({
4417                "PolicyName": "my-policy",
4418                "PolicyDocument": {"Version": "2012-10-17", "Statement": []}
4419            }),
4420        );
4421        let sr = prov.create_resource(&res).unwrap();
4422        assert!(sr.physical_id.contains("my-policy"));
4423        prov.delete_resource(&sr).unwrap();
4424    }
4425
4426    #[test]
4427    fn s3_bucket_create_and_delete() {
4428        let prov = make_provisioner();
4429        let res = make_resource(
4430            "AWS::S3::Bucket",
4431            "MyBucket",
4432            serde_json::json!({"BucketName": "my-bucket"}),
4433        );
4434        let sr = prov.create_resource(&res).unwrap();
4435        assert_eq!(sr.physical_id, "my-bucket");
4436        prov.delete_resource(&sr).unwrap();
4437    }
4438
4439    #[test]
4440    fn sqs_queue_policy_stored_on_queue_and_cleared_on_delete() {
4441        let prov = make_provisioner();
4442        let queue = prov
4443            .create_resource(&make_resource(
4444                "AWS::SQS::Queue",
4445                "Q",
4446                serde_json::json!({"QueueName": "q1"}),
4447            ))
4448            .unwrap();
4449        // `Queues` carries the queue URL — what `Ref` resolves to.
4450        let policy = make_resource(
4451            "AWS::SQS::QueuePolicy",
4452            "QP",
4453            serde_json::json!({
4454                "Queues": [queue.physical_id.clone()],
4455                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
4456                    "Effect": "Allow",
4457                    "Principal": {"Service": "sns.amazonaws.com"},
4458                    "Action": "sqs:SendMessage",
4459                    "Resource": "*"
4460                }]}
4461            }),
4462        );
4463        let sr = prov.create_resource(&policy).unwrap();
4464
4465        {
4466            let mut accounts = prov.sqs_state.write();
4467            let state = accounts.get_or_create(&prov.account_id);
4468            let stored = state.queues[&queue.physical_id]
4469                .attributes
4470                .get("Policy")
4471                .expect("policy stored on queue");
4472            assert!(stored.contains("sqs:SendMessage"));
4473        }
4474
4475        prov.delete_resource(&sr).unwrap();
4476        {
4477            let mut accounts = prov.sqs_state.write();
4478            let state = accounts.get_or_create(&prov.account_id);
4479            assert!(!state.queues[&queue.physical_id]
4480                .attributes
4481                .contains_key("Policy"));
4482        }
4483    }
4484
4485    #[test]
4486    fn sns_topic_policy_stored_on_topic_and_cleared_on_delete() {
4487        let prov = make_provisioner();
4488        let topic = prov
4489            .create_resource(&make_resource(
4490                "AWS::SNS::Topic",
4491                "T",
4492                serde_json::json!({"TopicName": "t1"}),
4493            ))
4494            .unwrap();
4495        let policy = make_resource(
4496            "AWS::SNS::TopicPolicy",
4497            "TP",
4498            serde_json::json!({
4499                "Topics": [topic.physical_id.clone()],
4500                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
4501                    "Effect": "Allow",
4502                    "Principal": {"Service": "events.amazonaws.com"},
4503                    "Action": "sns:Publish",
4504                    "Resource": "*"
4505                }]}
4506            }),
4507        );
4508        let sr = prov.create_resource(&policy).unwrap();
4509
4510        {
4511            let mut accounts = prov.sns_state.write();
4512            let state = accounts.get_or_create(&prov.account_id);
4513            let stored = state.topics[&topic.physical_id]
4514                .attributes
4515                .get("Policy")
4516                .expect("policy stored on topic");
4517            assert!(stored.contains("sns:Publish"));
4518        }
4519
4520        prov.delete_resource(&sr).unwrap();
4521        {
4522            let mut accounts = prov.sns_state.write();
4523            let state = accounts.get_or_create(&prov.account_id);
4524            assert!(!state.topics[&topic.physical_id]
4525                .attributes
4526                .contains_key("Policy"));
4527        }
4528    }
4529
4530    #[test]
4531    fn s3_bucket_policy_stored_on_bucket_and_cleared_on_delete() {
4532        let prov = make_provisioner();
4533        let bucket = prov
4534            .create_resource(&make_resource(
4535                "AWS::S3::Bucket",
4536                "B",
4537                serde_json::json!({"BucketName": "b1"}),
4538            ))
4539            .unwrap();
4540        let policy = make_resource(
4541            "AWS::S3::BucketPolicy",
4542            "BP",
4543            serde_json::json!({
4544                "Bucket": bucket.physical_id.clone(),
4545                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
4546                    "Effect": "Allow",
4547                    "Principal": "*",
4548                    "Action": "s3:GetObject",
4549                    "Resource": "arn:aws:s3:::b1/*"
4550                }]}
4551            }),
4552        );
4553        let sr = prov.create_resource(&policy).unwrap();
4554        assert_eq!(sr.physical_id, "b1-policy");
4555
4556        {
4557            let mut accounts = prov.s3_state.write();
4558            let state = accounts.get_or_create(&prov.account_id);
4559            let stored = state.buckets[&bucket.physical_id]
4560                .policy
4561                .as_ref()
4562                .expect("policy stored on bucket");
4563            assert!(stored.contains("s3:GetObject"));
4564        }
4565
4566        prov.delete_resource(&sr).unwrap();
4567        {
4568            let mut accounts = prov.s3_state.write();
4569            let state = accounts.get_or_create(&prov.account_id);
4570            assert!(state.buckets[&bucket.physical_id].policy.is_none());
4571        }
4572    }
4573
4574    #[test]
4575    fn dynamodb_table_create_and_delete() {
4576        let prov = make_provisioner();
4577        let res = make_resource(
4578            "AWS::DynamoDB::Table",
4579            "MyTable",
4580            serde_json::json!({
4581                "TableName": "my-table",
4582                "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
4583                "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
4584                "BillingMode": "PAY_PER_REQUEST"
4585            }),
4586        );
4587        let sr = prov.create_resource(&res).unwrap();
4588        assert!(sr.physical_id.contains("my-table"));
4589        prov.delete_resource(&sr).unwrap();
4590    }
4591
4592    #[test]
4593    fn log_group_create_and_delete() {
4594        let prov = make_provisioner();
4595        let res = make_resource(
4596            "AWS::Logs::LogGroup",
4597            "MyLogs",
4598            serde_json::json!({"LogGroupName": "/app/logs"}),
4599        );
4600        let sr = prov.create_resource(&res).unwrap();
4601        assert!(sr.physical_id.contains("/app/logs"));
4602        prov.delete_resource(&sr).unwrap();
4603    }
4604
4605    #[test]
4606    fn lambda_function_create_and_delete() {
4607        let prov = make_provisioner();
4608        let res = make_resource(
4609            "AWS::Lambda::Function",
4610            "MyFn",
4611            serde_json::json!({
4612                "FunctionName": "my-fn",
4613                "Runtime": "nodejs20.x",
4614                "Role": "arn:aws:iam::123456789012:role/lambda-role",
4615                "Handler": "index.handler",
4616                "MemorySize": 256,
4617                "Timeout": 10,
4618                "Environment": {"Variables": {"FOO": "bar"}}
4619            }),
4620        );
4621        let sr = prov.create_resource(&res).unwrap();
4622        assert_eq!(sr.physical_id, "my-fn");
4623        assert_eq!(
4624            sr.attributes.get("Arn").map(String::as_str),
4625            Some("arn:aws:lambda:us-east-1:123456789012:function:my-fn")
4626        );
4627        // Verify it landed in lambda state
4628        {
4629            let lam = prov.lambda_state.read();
4630            let st = lam.get("123456789012").unwrap();
4631            let f = st.functions.get("my-fn").unwrap();
4632            assert_eq!(f.runtime, "nodejs20.x");
4633            assert_eq!(f.memory_size, 256);
4634            assert_eq!(f.environment.get("FOO").unwrap(), "bar");
4635        }
4636        prov.delete_resource(&sr).unwrap();
4637        let lam = prov.lambda_state.read();
4638        let st = lam.get("123456789012").unwrap();
4639        assert!(!st.functions.contains_key("my-fn"));
4640    }
4641
4642    #[test]
4643    fn unsupported_resource_type_is_recorded_not_failed() {
4644        // An unmodelled type is recorded as provisioned (no backing state)
4645        // rather than failing the stack — see
4646        // `unknown_resource_type_records_instead_of_failing`.
4647        let prov = make_provisioner();
4648        let res = make_resource("AWS::NonExistent::Thing", "X", serde_json::json!({}));
4649        let sr = prov.create_resource(&res).unwrap();
4650        assert_eq!(sr.physical_id, "X");
4651    }
4652
4653    #[test]
4654    fn iam_role_with_inline_policies() {
4655        let prov = make_provisioner();
4656        let res = make_resource(
4657            "AWS::IAM::Role",
4658            "MyRole",
4659            serde_json::json!({
4660                "RoleName": "role-inline",
4661                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []},
4662                "Policies": [
4663                    {
4664                        "PolicyName": "inline-1",
4665                        "PolicyDocument": {"Version": "2012-10-17", "Statement": []}
4666                    }
4667                ]
4668            }),
4669        );
4670        let sr = prov.create_resource(&res).unwrap();
4671        assert!(sr.physical_id.contains("role-inline"));
4672    }
4673
4674    #[test]
4675    fn sqs_queue_auto_name() {
4676        let prov = make_provisioner();
4677        let res = make_resource("AWS::SQS::Queue", "AutoQ", serde_json::json!({}));
4678        let sr = prov.create_resource(&res).unwrap();
4679        // Generated queue name should exist
4680        assert!(!sr.physical_id.is_empty());
4681    }
4682
4683    #[test]
4684    fn sns_topic_auto_name() {
4685        let prov = make_provisioner();
4686        let res = make_resource("AWS::SNS::Topic", "AutoT", serde_json::json!({}));
4687        let sr = prov.create_resource(&res).unwrap();
4688        assert!(!sr.physical_id.is_empty());
4689    }
4690
4691    // ── additional resource types ──
4692
4693    #[test]
4694    fn unsupported_resource_type_recorded_with_logical_id() {
4695        // Recorded, not failed; physical id is the logical id so `Ref` resolves.
4696        let prov = make_provisioner();
4697        let res = make_resource("AWS::FooBar::Thing", "X", serde_json::json!({}));
4698        let sr = prov.create_resource(&res).unwrap();
4699        assert_eq!(sr.physical_id, "X");
4700        assert_eq!(sr.status, "CREATE_COMPLETE");
4701    }
4702
4703    #[test]
4704    fn sqs_queue_with_redrive_policy() {
4705        let prov = make_provisioner();
4706        // Create DLQ first
4707        let dlq = make_resource(
4708            "AWS::SQS::Queue",
4709            "DLQ",
4710            serde_json::json!({"QueueName": "dlq1"}),
4711        );
4712        let dlq_resource = prov.create_resource(&dlq).unwrap();
4713        let _ = dlq_resource.physical_id;
4714
4715        // Create source queue with redrive policy
4716        let src = make_resource(
4717            "AWS::SQS::Queue",
4718            "Src",
4719            serde_json::json!({
4720                "QueueName": "src1",
4721                "RedrivePolicy": {
4722                    "deadLetterTargetArn": "arn:aws:sqs:us-east-1:123456789012:dlq1",
4723                    "maxReceiveCount": 3
4724                }
4725            }),
4726        );
4727        let sr = prov.create_resource(&src).unwrap();
4728        assert!(!sr.physical_id.is_empty());
4729    }
4730
4731    #[test]
4732    fn sns_topic_with_display_name() {
4733        let prov = make_provisioner();
4734        let res = make_resource(
4735            "AWS::SNS::Topic",
4736            "WithName",
4737            serde_json::json!({"TopicName": "named-topic", "DisplayName": "Named"}),
4738        );
4739        let sr = prov.create_resource(&res).unwrap();
4740        assert!(sr.physical_id.contains("named-topic"));
4741    }
4742
4743    #[test]
4744    fn ssm_parameter_with_explicit_name() {
4745        let prov = make_provisioner();
4746        let res = make_resource(
4747            "AWS::SSM::Parameter",
4748            "Param",
4749            serde_json::json!({"Name": "/my/param", "Value": "v", "Type": "String"}),
4750        );
4751        let sr = prov.create_resource(&res).unwrap();
4752        assert!(sr.physical_id.contains("/my/param"));
4753    }
4754
4755    #[test]
4756    fn ssm_parameter_missing_name_errors() {
4757        let prov = make_provisioner();
4758        let res = make_resource(
4759            "AWS::SSM::Parameter",
4760            "AutoP",
4761            serde_json::json!({"Value": "v", "Type": "String"}),
4762        );
4763        assert!(prov.create_resource(&res).is_err());
4764    }
4765
4766    #[test]
4767    fn iam_managed_policy_auto_name() {
4768        let prov = make_provisioner();
4769        let res = make_resource(
4770            "AWS::IAM::Policy",
4771            "AutoPol",
4772            serde_json::json!({
4773                "PolicyName": "inline-pol",
4774                "PolicyDocument": {"Version": "2012-10-17", "Statement": []},
4775                "Users": []
4776            }),
4777        );
4778        let sr = prov.create_resource(&res).unwrap();
4779        assert!(!sr.physical_id.is_empty());
4780    }
4781
4782    #[test]
4783    fn delete_resource_works_for_queue() {
4784        let prov = make_provisioner();
4785        let res = make_resource(
4786            "AWS::SQS::Queue",
4787            "ToDel",
4788            serde_json::json!({"QueueName": "todel"}),
4789        );
4790        let sr = prov.create_resource(&res).unwrap();
4791        assert!(prov.delete_resource(&sr).is_ok());
4792    }
4793
4794    #[test]
4795    fn delete_resource_works_for_topic() {
4796        let prov = make_provisioner();
4797        let res = make_resource(
4798            "AWS::SNS::Topic",
4799            "DelT",
4800            serde_json::json!({"TopicName": "delt"}),
4801        );
4802        let sr = prov.create_resource(&res).unwrap();
4803        assert!(prov.delete_resource(&sr).is_ok());
4804    }
4805
4806    #[test]
4807    fn application_autoscaling_scalable_target_round_trip() {
4808        let prov = make_provisioner();
4809        let res = make_resource(
4810            "AWS::ApplicationAutoScaling::ScalableTarget",
4811            "Target",
4812            serde_json::json!({
4813                "ServiceNamespace": "ecs",
4814                "ResourceId": "service/my-cluster/my-service",
4815                "ScalableDimension": "ecs:service:DesiredCount",
4816                "MinCapacity": 1,
4817                "MaxCapacity": 10,
4818                "RoleARN": "arn:aws:iam::123456789012:role/my-role",
4819            }),
4820        );
4821        let sr = prov.create_resource(&res).unwrap();
4822        assert_eq!(sr.physical_id, "service/my-cluster/my-service");
4823        assert!(sr.attributes.contains_key("ScalableTargetARN"));
4824        assert!(prov.delete_resource(&sr).is_ok());
4825    }
4826
4827    #[test]
4828    fn application_autoscaling_scaling_policy_requires_target() {
4829        let prov = make_provisioner();
4830        let res = make_resource(
4831            "AWS::ApplicationAutoScaling::ScalingPolicy",
4832            "Policy",
4833            serde_json::json!({
4834                "PolicyName": "my-policy",
4835                "ServiceNamespace": "ecs",
4836                "ResourceId": "service/my-cluster/my-service",
4837                "ScalableDimension": "ecs:service:DesiredCount",
4838                "PolicyType": "TargetTrackingScaling",
4839                "TargetTrackingScalingPolicyConfiguration": {
4840                    "TargetValue": 50.0,
4841                    "PredefinedMetricSpecification": {
4842                        "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
4843                    }
4844                },
4845            }),
4846        );
4847        // Should fail because scalable target does not exist
4848        assert!(prov.create_resource(&res).is_err());
4849    }
4850
4851    #[test]
4852    fn application_autoscaling_scaling_policy_round_trip() {
4853        let prov = make_provisioner();
4854        let target = make_resource(
4855            "AWS::ApplicationAutoScaling::ScalableTarget",
4856            "Target",
4857            serde_json::json!({
4858                "ServiceNamespace": "ecs",
4859                "ResourceId": "service/my-cluster/my-service",
4860                "ScalableDimension": "ecs:service:DesiredCount",
4861                "MinCapacity": 1,
4862                "MaxCapacity": 10,
4863            }),
4864        );
4865        let sr = prov.create_resource(&target).unwrap();
4866
4867        let policy = make_resource(
4868            "AWS::ApplicationAutoScaling::ScalingPolicy",
4869            "Policy",
4870            serde_json::json!({
4871                "PolicyName": "my-policy",
4872                "ServiceNamespace": "ecs",
4873                "ResourceId": "service/my-cluster/my-service",
4874                "ScalableDimension": "ecs:service:DesiredCount",
4875                "PolicyType": "TargetTrackingScaling",
4876                "TargetTrackingScalingPolicyConfiguration": {
4877                    "TargetValue": 50.0,
4878                    "PredefinedMetricSpecification": {
4879                        "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
4880                    }
4881                },
4882            }),
4883        );
4884        let psr = prov.create_resource(&policy).unwrap();
4885        assert!(psr.physical_id.starts_with("arn:aws:autoscaling:"));
4886        assert!(prov.delete_resource(&psr).is_ok());
4887        assert!(prov.delete_resource(&sr).is_ok());
4888    }
4889
4890    #[test]
4891    fn sqs_queue_with_fifo_suffix() {
4892        let prov = make_provisioner();
4893        let res = make_resource(
4894            "AWS::SQS::Queue",
4895            "Fifo",
4896            serde_json::json!({"QueueName": "fq.fifo", "FifoQueue": true}),
4897        );
4898        let sr = prov.create_resource(&res).unwrap();
4899        assert!(sr.physical_id.ends_with(".fifo"));
4900    }
4901
4902    // ── S3 bucket config-property provisioning (bug-hunt 1.26) ──
4903
4904    #[test]
4905    fn s3_bucket_provisions_all_config_properties() {
4906        let prov = make_provisioner();
4907        let bucket = make_resource(
4908            "AWS::S3::Bucket",
4909            "MyBucket",
4910            serde_json::json!({
4911                "BucketName": "protected-bucket",
4912                "VersioningConfiguration": { "Status": "Enabled" },
4913                "BucketEncryption": {
4914                    "ServerSideEncryptionConfiguration": [{
4915                        "ServerSideEncryptionByDefault": { "SSEAlgorithm": "AES256" }
4916                    }]
4917                },
4918                "PublicAccessBlockConfiguration": {
4919                    "BlockPublicAcls": true,
4920                    "BlockPublicPolicy": true,
4921                    "IgnorePublicAcls": true,
4922                    "RestrictPublicBuckets": true
4923                },
4924                "NotificationConfiguration": {
4925                    "QueueConfigurations": [{
4926                        "Event": "s3:ObjectCreated:*",
4927                        "Queue": "arn:aws:sqs:us-east-1:123456789012:q"
4928                    }],
4929                    "EventBridgeConfiguration": {}
4930                },
4931                "Tags": [{ "Key": "env", "Value": "prod" }]
4932            }),
4933        );
4934        let sr = prov.create_resource(&bucket).unwrap();
4935        assert_eq!(sr.physical_id, "protected-bucket");
4936
4937        let s3 = prov.s3_state.read();
4938        let acct = s3.get("123456789012").unwrap();
4939        let b = acct.buckets.get("protected-bucket").unwrap();
4940        // GetBucketVersioning
4941        assert_eq!(b.versioning.as_deref(), Some("Enabled"));
4942        // GetBucketEncryption
4943        assert!(b
4944            .encryption_config
4945            .as_deref()
4946            .unwrap()
4947            .contains("<SSEAlgorithm>AES256</SSEAlgorithm>"));
4948        // GetPublicAccessBlock
4949        assert!(b
4950            .public_access_block
4951            .as_deref()
4952            .unwrap()
4953            .contains("<BlockPublicAcls>true</BlockPublicAcls>"));
4954        // GetBucketNotification + EventBridge
4955        assert!(b
4956            .notification_config
4957            .as_deref()
4958            .unwrap()
4959            .contains("<Queue>arn:aws:sqs:us-east-1:123456789012:q</Queue>"));
4960        assert!(b.eventbridge_enabled);
4961        // GetBucketTagging
4962        assert_eq!(b.tags.get("env").map(String::as_str), Some("prod"));
4963    }
4964
4965    #[test]
4966    fn s3_bucket_update_enables_versioning() {
4967        let prov = make_provisioner();
4968        let created = prov
4969            .create_resource(&make_resource(
4970                "AWS::S3::Bucket",
4971                "MyBucket",
4972                serde_json::json!({ "BucketName": "later-versioned" }),
4973            ))
4974            .unwrap();
4975        // Freshly created: no versioning.
4976        {
4977            let s3 = prov.s3_state.read();
4978            let b = s3
4979                .get("123456789012")
4980                .unwrap()
4981                .buckets
4982                .get("later-versioned")
4983                .unwrap();
4984            assert!(b.versioning.is_none());
4985        }
4986        // A follow-up update turning on versioning must be applied, not a no-op.
4987        prov.update_resource(
4988            &created,
4989            &make_resource(
4990                "AWS::S3::Bucket",
4991                "MyBucket",
4992                serde_json::json!({
4993                    "BucketName": "later-versioned",
4994                    "VersioningConfiguration": { "Status": "Enabled" }
4995                }),
4996            ),
4997        )
4998        .unwrap();
4999        let s3 = prov.s3_state.read();
5000        let b = s3
5001            .get("123456789012")
5002            .unwrap()
5003            .buckets
5004            .get("later-versioned")
5005            .unwrap();
5006        assert_eq!(b.versioning.as_deref(), Some("Enabled"));
5007    }
5008
5009    #[test]
5010    fn sqs_queue_provisions_tags() {
5011        let prov = make_provisioner();
5012        let created = prov
5013            .create_resource(&make_resource(
5014                "AWS::SQS::Queue",
5015                "TaggedQ",
5016                serde_json::json!({
5017                    "QueueName": "tagged-q",
5018                    "Tags": [
5019                        { "Key": "team", "Value": "core" },
5020                        { "Key": "env", "Value": "prod" }
5021                    ]
5022                }),
5023            ))
5024            .unwrap();
5025        {
5026            let sqs = prov.sqs_state.read();
5027            let q = sqs
5028                .get("123456789012")
5029                .unwrap()
5030                .queues
5031                .get(&created.physical_id)
5032                .unwrap();
5033            assert_eq!(q.tags.get("team").map(String::as_str), Some("core"));
5034            assert_eq!(q.tags.get("env").map(String::as_str), Some("prod"));
5035        }
5036        // Update replaces the tag set.
5037        prov.update_resource(
5038            &created,
5039            &make_resource(
5040                "AWS::SQS::Queue",
5041                "TaggedQ",
5042                serde_json::json!({
5043                    "QueueName": "tagged-q",
5044                    "Tags": [{ "Key": "team", "Value": "platform" }]
5045                }),
5046            ),
5047        )
5048        .unwrap();
5049        let sqs = prov.sqs_state.read();
5050        let q = sqs
5051            .get("123456789012")
5052            .unwrap()
5053            .queues
5054            .get(&created.physical_id)
5055            .unwrap();
5056        assert_eq!(q.tags.get("team").map(String::as_str), Some("platform"));
5057        assert!(!q.tags.contains_key("env"));
5058    }
5059
5060    // ── get_att dispatch ──
5061
5062    #[test]
5063    fn getatt_s3_bucket_arn_returns_arn() {
5064        let prov = make_provisioner();
5065        let bucket = make_resource(
5066            "AWS::S3::Bucket",
5067            "MyBucket",
5068            serde_json::json!({"BucketName": "my-bucket"}),
5069        );
5070        let sr = prov.create_resource(&bucket).unwrap();
5071        assert_eq!(
5072            prov.get_att(&sr, "Arn"),
5073            Some("arn:aws:s3:::my-bucket".to_string())
5074        );
5075    }
5076
5077    #[test]
5078    fn getatt_s3_bucket_domain_name_returns_dns_name() {
5079        let prov = make_provisioner();
5080        let bucket = make_resource(
5081            "AWS::S3::Bucket",
5082            "MyBucket",
5083            serde_json::json!({"BucketName": "my-bucket"}),
5084        );
5085        let sr = prov.create_resource(&bucket).unwrap();
5086        assert_eq!(
5087            prov.get_att(&sr, "DomainName"),
5088            Some("my-bucket.s3.amazonaws.com".to_string())
5089        );
5090    }
5091
5092    #[test]
5093    fn getatt_lambda_function_arn_returns_function_arn() {
5094        let prov = make_provisioner();
5095        // Lambda needs an existing IAM role to validate the function role.
5096        let role = make_resource(
5097            "AWS::IAM::Role",
5098            "MyRole",
5099            serde_json::json!({
5100                "RoleName": "my-role",
5101                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
5102            }),
5103        );
5104        let role_sr = prov.create_resource(&role).unwrap();
5105        let fn_res = make_resource(
5106            "AWS::Lambda::Function",
5107            "MyFn",
5108            serde_json::json!({
5109                "FunctionName": "my-fn",
5110                "Runtime": "python3.11",
5111                "Handler": "index.handler",
5112                "Role": role_sr.physical_id,
5113                "Code": {"ZipFile": "def handler(e,c): return e"}
5114            }),
5115        );
5116        let fn_sr = prov.create_resource(&fn_res).unwrap();
5117        let arn = prov.get_att(&fn_sr, "Arn").expect("Arn should resolve");
5118        assert!(arn.starts_with("arn:aws:lambda:"));
5119        assert!(arn.contains(":function:my-fn"));
5120    }
5121
5122    #[test]
5123    fn getatt_iam_role_arn_returns_role_arn() {
5124        let prov = make_provisioner();
5125        let role = make_resource(
5126            "AWS::IAM::Role",
5127            "MyRole",
5128            serde_json::json!({
5129                "RoleName": "my-role",
5130                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
5131            }),
5132        );
5133        let sr = prov.create_resource(&role).unwrap();
5134        assert_eq!(
5135            prov.get_att(&sr, "Arn"),
5136            Some("arn:aws:iam::123456789012:role/my-role".to_string())
5137        );
5138        // RoleId is a real value generated at create time (FKIA-prefixed).
5139        let role_id = prov.get_att(&sr, "RoleId").expect("RoleId should resolve");
5140        assert!(role_id.starts_with("FKIA"));
5141    }
5142
5143    #[test]
5144    fn getatt_unknown_attribute_returns_none() {
5145        let prov = make_provisioner();
5146        let bucket = make_resource(
5147            "AWS::S3::Bucket",
5148            "MyBucket",
5149            serde_json::json!({"BucketName": "my-bucket"}),
5150        );
5151        let sr = prov.create_resource(&bucket).unwrap();
5152        // Fall-back behaviour: unknown attribute on a known type returns
5153        // None; the resolver in template.rs surfaces a "Logical.Attr"
5154        // placeholder so the existing template still builds.
5155        assert_eq!(prov.get_att(&sr, "NotARealAttr"), None);
5156    }
5157
5158    #[test]
5159    fn getatt_unknown_resource_type_returns_none() {
5160        let prov = make_provisioner();
5161        // Hand-crafted StackResource with a resource_type we don't dispatch
5162        // on at all. The captured attributes map is also empty, so there's
5163        // nothing to return.
5164        let stack_resource = StackResource {
5165            logical_id: "Mystery".to_string(),
5166            physical_id: "mystery-id".to_string(),
5167            resource_type: "AWS::Made::Up".to_string(),
5168            status: "CREATE_COMPLETE".to_string(),
5169            service_token: None,
5170            attributes: BTreeMap::new(),
5171        };
5172        assert_eq!(prov.get_att(&stack_resource, "Arn"), None);
5173    }
5174
5175    #[test]
5176    fn getatt_falls_back_to_captured_attributes() {
5177        let prov = make_provisioner();
5178        // Captured attributes always win — even if live-state dispatch
5179        // would return something different. This keeps `Fn::GetAtt`
5180        // deterministic for resources with cached attrs.
5181        let stack_resource = StackResource {
5182            logical_id: "MyTopic".to_string(),
5183            physical_id: "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
5184            resource_type: "AWS::SNS::Topic".to_string(),
5185            status: "CREATE_COMPLETE".to_string(),
5186            service_token: None,
5187            attributes: {
5188                let mut m = BTreeMap::new();
5189                m.insert("TopicArn".to_string(), "captured-arn".to_string());
5190                m
5191            },
5192        };
5193        assert_eq!(
5194            prov.get_att(&stack_resource, "TopicArn"),
5195            Some("captured-arn".to_string())
5196        );
5197    }
5198
5199    #[test]
5200    fn getatt_mq_broker_endpoints_track_the_live_data_plane() {
5201        // An MQ broker's endpoint attributes are captured cosmetic at create
5202        // time (no container yet), but once a backing container binds they must
5203        // resolve to the REAL `tcp://`/`amqp://<host>:<port>` the direct
5204        // DescribeBroker returns -- CFN GetAtt is NOT frozen to the cosmetic set.
5205        let prov = make_provisioner();
5206        let created = prov
5207            .create_resource(&make_resource(
5208                "AWS::AmazonMQ::Broker",
5209                "MyBroker",
5210                serde_json::json!({
5211                    "BrokerName": "cfn-live-ep",
5212                    "EngineType": "ACTIVEMQ",
5213                    "HostInstanceType": "mq.m5.large",
5214                    "DeploymentMode": "SINGLE_INSTANCE",
5215                    "PubliclyAccessible": false
5216                }),
5217            ))
5218            .expect("create broker");
5219        let broker_id = created.physical_id.clone();
5220        // The captured attribute is the cosmetic amazonaws endpoint.
5221        assert!(
5222            created.attributes["AmqpEndpoints"].contains("amazonaws.com"),
5223            "create-time capture is cosmetic: {}",
5224            created.attributes["AmqpEndpoints"]
5225        );
5226        // Bind a live data plane, as settle_broker_up does once the container is up.
5227        {
5228            let mut g = prov.mq_state.write();
5229            let acct = g.get_or_create("123456789012");
5230            let mut ports = std::collections::BTreeMap::new();
5231            ports.insert("amqp".to_string(), 15672u16);
5232            ports.insert("openwire".to_string(), 15616u16);
5233            acct.data_plane.insert(
5234                broker_id.clone(),
5235                fakecloud_mq::BrokerDataPlane {
5236                    container_id: "c-live".to_string(),
5237                    host: "127.0.0.1".to_string(),
5238                    ports,
5239                },
5240            );
5241        }
5242        // GetAtt now returns the REAL bound endpoints, not the cosmetic capture.
5243        let amqp = prov
5244            .get_att(&created, "AmqpEndpoints")
5245            .expect("AmqpEndpoints");
5246        assert!(
5247            amqp.contains("amqp://127.0.0.1:15672") && !amqp.contains("amazonaws.com"),
5248            "GetAtt must project the live data plane, got: {amqp}"
5249        );
5250        let ips = prov.get_att(&created, "IpAddresses").expect("IpAddresses");
5251        assert!(ips.contains("127.0.0.1"), "IpAddresses live: {ips}");
5252        let openwire = prov
5253            .get_att(&created, "OpenWireEndpoints")
5254            .expect("OpenWireEndpoints");
5255        assert!(
5256            openwire.contains("tcp://127.0.0.1:15616"),
5257            "OpenWireEndpoints live: {openwire}"
5258        );
5259    }
5260
5261    #[test]
5262    fn getatt_secrets_manager_arn_resolves_via_live_state() {
5263        // Secrets create handler captures Id but not Arn; live-state
5264        // fallback fills in Arn.
5265        let prov = make_provisioner();
5266        let res = make_resource(
5267            "AWS::SecretsManager::Secret",
5268            "MySecret",
5269            serde_json::json!({"Name": "my-secret", "SecretString": "hunter2"}),
5270        );
5271        let sr = prov.create_resource(&res).unwrap();
5272        let arn = prov.get_att(&sr, "Arn").expect("Arn should resolve");
5273        assert!(arn.starts_with("arn:aws:secretsmanager:"));
5274        assert!(arn.ends_with(":secret:my-secret"));
5275    }
5276
5277    #[test]
5278    fn wafv2_web_acl_lifecycle() {
5279        let prov = make_provisioner();
5280        let res = make_resource(
5281            "AWS::WAFv2::WebACL",
5282            "MyAcl",
5283            serde_json::json!({
5284                "Name": "my-acl",
5285                "Scope": "REGIONAL",
5286                "DefaultAction": {"Allow": {}},
5287                "Rules": [{"Name": "rule1", "Priority": 1, "Statement": {}, "VisibilityConfig": {}}],
5288                "VisibilityConfig": {"SampledRequestsEnabled": true, "CloudWatchMetricsEnabled": true, "MetricName": "my-acl-metric"},
5289                "Capacity": 100,
5290            }),
5291        );
5292        let sr = prov.create_resource(&res).unwrap();
5293        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
5294        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
5295        assert_eq!(prov.get_att(&sr, "Name"), Some("my-acl".to_string()));
5296        assert!(prov.get_att(&sr, "Id").is_some());
5297        assert_eq!(prov.get_att(&sr, "Capacity"), Some("100".to_string()));
5298
5299        prov.delete_resource(&sr.clone()).unwrap();
5300        // Verify live-state fallback returns None by using a fresh resource
5301        // with empty attributes (captured attrs would still win).
5302        let fresh = StackResource {
5303            logical_id: "MyAcl".to_string(),
5304            physical_id: sr.physical_id.clone(),
5305            resource_type: "AWS::WAFv2::WebACL".to_string(),
5306            status: "CREATE_COMPLETE".to_string(),
5307            service_token: None,
5308            attributes: BTreeMap::new(),
5309        };
5310        assert_eq!(prov.get_att(&fresh, "Arn"), None);
5311    }
5312
5313    #[test]
5314    fn wafv2_ip_set_lifecycle() {
5315        let prov = make_provisioner();
5316        let res = make_resource(
5317            "AWS::WAFv2::IPSet",
5318            "MyIpSet",
5319            serde_json::json!({
5320                "Name": "my-ipset",
5321                "Scope": "REGIONAL",
5322                "IPAddressVersion": "IPV4",
5323                "Addresses": ["10.0.0.0/8"],
5324            }),
5325        );
5326        let sr = prov.create_resource(&res).unwrap();
5327        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
5328        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
5329        assert_eq!(prov.get_att(&sr, "Name"), Some("my-ipset".to_string()));
5330
5331        prov.delete_resource(&sr.clone()).unwrap();
5332        let fresh = StackResource {
5333            logical_id: "MyIpSet".to_string(),
5334            physical_id: sr.physical_id.clone(),
5335            resource_type: "AWS::WAFv2::IPSet".to_string(),
5336            status: "CREATE_COMPLETE".to_string(),
5337            service_token: None,
5338            attributes: BTreeMap::new(),
5339        };
5340        assert_eq!(prov.get_att(&fresh, "Arn"), None);
5341    }
5342
5343    #[test]
5344    fn wafv2_regex_pattern_set_lifecycle() {
5345        let prov = make_provisioner();
5346        let res = make_resource(
5347            "AWS::WAFv2::RegexPatternSet",
5348            "MyRegexSet",
5349            serde_json::json!({
5350                "Name": "my-regex",
5351                "Scope": "REGIONAL",
5352                "RegularExpressions": [{"RegexString": "^test"}],
5353            }),
5354        );
5355        let sr = prov.create_resource(&res).unwrap();
5356        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
5357        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
5358        assert_eq!(prov.get_att(&sr, "Name"), Some("my-regex".to_string()));
5359
5360        prov.delete_resource(&sr.clone()).unwrap();
5361        let fresh = StackResource {
5362            logical_id: "MyRegexSet".to_string(),
5363            physical_id: sr.physical_id.clone(),
5364            resource_type: "AWS::WAFv2::RegexPatternSet".to_string(),
5365            status: "CREATE_COMPLETE".to_string(),
5366            service_token: None,
5367            attributes: BTreeMap::new(),
5368        };
5369        assert_eq!(prov.get_att(&fresh, "Arn"), None);
5370    }
5371
5372    #[test]
5373    fn wafv2_rule_group_lifecycle() {
5374        let prov = make_provisioner();
5375        let res = make_resource(
5376            "AWS::WAFv2::RuleGroup",
5377            "MyRuleGroup",
5378            serde_json::json!({
5379                "Name": "my-rg",
5380                "Scope": "REGIONAL",
5381                "Capacity": 50,
5382                "Rules": [{"Name": "r1", "Priority": 1, "Statement": {}, "VisibilityConfig": {}}],
5383                "VisibilityConfig": {"SampledRequestsEnabled": true, "CloudWatchMetricsEnabled": true, "MetricName": "rg-metric"},
5384            }),
5385        );
5386        let sr = prov.create_resource(&res).unwrap();
5387        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
5388        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
5389        assert_eq!(prov.get_att(&sr, "Name"), Some("my-rg".to_string()));
5390
5391        prov.delete_resource(&sr.clone()).unwrap();
5392        let fresh = StackResource {
5393            logical_id: "MyRuleGroup".to_string(),
5394            physical_id: sr.physical_id.clone(),
5395            resource_type: "AWS::WAFv2::RuleGroup".to_string(),
5396            status: "CREATE_COMPLETE".to_string(),
5397            service_token: None,
5398            attributes: BTreeMap::new(),
5399        };
5400        assert_eq!(prov.get_att(&fresh, "Arn"), None);
5401    }
5402
5403    #[test]
5404    fn wafv2_logging_configuration_lifecycle() {
5405        let prov = make_provisioner();
5406        let res = make_resource(
5407            "AWS::WAFv2::LoggingConfiguration",
5408            "MyLogConfig",
5409            serde_json::json!({
5410                "ResourceArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/abc",
5411                "LogDestinationConfigs": ["arn:aws:logs:us-east-1:123456789012:log-group:/aws/waf"],
5412            }),
5413        );
5414        let sr = prov.create_resource(&res).unwrap();
5415        assert_eq!(
5416            sr.physical_id,
5417            "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/abc"
5418        );
5419
5420        prov.delete_resource(&sr.clone()).unwrap();
5421    }
5422
5423    #[test]
5424    fn wafv2_web_acl_association_lifecycle() {
5425        let prov = make_provisioner();
5426        let res = make_resource(
5427            "AWS::WAFv2::WebACLAssociation",
5428            "MyAssoc",
5429            serde_json::json!({
5430                "ResourceArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188",
5431                "WebACLArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/my-acl/abc",
5432            }),
5433        );
5434        let sr = prov.create_resource(&res).unwrap();
5435        assert_eq!(sr.physical_id, "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188");
5436
5437        prov.delete_resource(&sr.clone()).unwrap();
5438    }
5439
5440    #[test]
5441    fn ses_configuration_set_lifecycle() {
5442        let prov = make_provisioner();
5443        let res = make_resource(
5444            "AWS::SES::ConfigurationSet",
5445            "MyConfigSet",
5446            serde_json::json!({
5447                "Name": "my-cs",
5448                "SendingOptions": {"SendingEnabled": true},
5449                "DeliveryOptions": {"TlsPolicy": "REQUIRE"},
5450            }),
5451        );
5452        let sr = prov.create_resource(&res).unwrap();
5453        assert_eq!(sr.physical_id, "my-cs");
5454        assert_eq!(prov.get_att(&sr, "Name"), Some("my-cs".to_string()));
5455
5456        prov.delete_resource(&sr.clone()).unwrap();
5457        let fresh = StackResource {
5458            logical_id: "MyConfigSet".to_string(),
5459            physical_id: "my-cs".to_string(),
5460            resource_type: "AWS::SES::ConfigurationSet".to_string(),
5461            status: "CREATE_COMPLETE".to_string(),
5462            service_token: None,
5463            attributes: BTreeMap::new(),
5464        };
5465        assert_eq!(prov.get_att(&fresh, "Name"), None);
5466    }
5467
5468    #[test]
5469    fn ses_email_identity_lifecycle() {
5470        let prov = make_provisioner();
5471        let res = make_resource(
5472            "AWS::SES::EmailIdentity",
5473            "MyIdentity",
5474            serde_json::json!({"EmailIdentity": "example.com"}),
5475        );
5476        let sr = prov.create_resource(&res).unwrap();
5477        assert_eq!(sr.physical_id, "example.com");
5478        assert_eq!(
5479            prov.get_att(&sr, "IdentityName"),
5480            Some("example.com".to_string())
5481        );
5482
5483        prov.delete_resource(&sr.clone()).unwrap();
5484        let fresh = StackResource {
5485            logical_id: "MyIdentity".to_string(),
5486            physical_id: "example.com".to_string(),
5487            resource_type: "AWS::SES::EmailIdentity".to_string(),
5488            status: "CREATE_COMPLETE".to_string(),
5489            service_token: None,
5490            attributes: BTreeMap::new(),
5491        };
5492        assert_eq!(prov.get_att(&fresh, "IdentityName"), None);
5493    }
5494
5495    #[test]
5496    fn ses_template_lifecycle() {
5497        let prov = make_provisioner();
5498        let res = make_resource(
5499            "AWS::SES::Template",
5500            "MyTemplate",
5501            serde_json::json!({
5502                "Template": {
5503                    "TemplateName": "my-tpl",
5504                    "SubjectPart": "Hello",
5505                    "HtmlPart": "<h1>Hi</h1>",
5506                    "TextPart": "Hi",
5507                },
5508            }),
5509        );
5510        let sr = prov.create_resource(&res).unwrap();
5511        assert_eq!(sr.physical_id, "my-tpl");
5512        assert_eq!(
5513            prov.get_att(&sr, "TemplateName"),
5514            Some("my-tpl".to_string())
5515        );
5516
5517        prov.delete_resource(&sr.clone()).unwrap();
5518        let fresh = StackResource {
5519            logical_id: "MyTemplate".to_string(),
5520            physical_id: "my-tpl".to_string(),
5521            resource_type: "AWS::SES::Template".to_string(),
5522            status: "CREATE_COMPLETE".to_string(),
5523            service_token: None,
5524            attributes: BTreeMap::new(),
5525        };
5526        assert_eq!(prov.get_att(&fresh, "TemplateName"), None);
5527    }
5528
5529    #[test]
5530    fn ses_contact_list_lifecycle() {
5531        let prov = make_provisioner();
5532        let res = make_resource(
5533            "AWS::SES::ContactList",
5534            "MyContactList",
5535            serde_json::json!({
5536                "ContactListName": "my-cl",
5537                "Description": "Test contacts",
5538                "Topics": [{"TopicName": "news", "DisplayName": "Newsletter", "Description": "Weekly news"}],
5539            }),
5540        );
5541        let sr = prov.create_resource(&res).unwrap();
5542        assert_eq!(sr.physical_id, "my-cl");
5543        assert_eq!(
5544            prov.get_att(&sr, "ContactListName"),
5545            Some("my-cl".to_string())
5546        );
5547
5548        prov.delete_resource(&sr.clone()).unwrap();
5549        let fresh = StackResource {
5550            logical_id: "MyContactList".to_string(),
5551            physical_id: "my-cl".to_string(),
5552            resource_type: "AWS::SES::ContactList".to_string(),
5553            status: "CREATE_COMPLETE".to_string(),
5554            service_token: None,
5555            attributes: BTreeMap::new(),
5556        };
5557        assert_eq!(prov.get_att(&fresh, "ContactListName"), None);
5558    }
5559
5560    #[test]
5561    fn ses_dedicated_ip_pool_lifecycle() {
5562        let prov = make_provisioner();
5563        let res = make_resource(
5564            "AWS::SES::DedicatedIpPool",
5565            "MyPool",
5566            serde_json::json!({"PoolName": "my-pool", "ScalingMode": "STANDARD"}),
5567        );
5568        let sr = prov.create_resource(&res).unwrap();
5569        assert_eq!(sr.physical_id, "my-pool");
5570        assert_eq!(prov.get_att(&sr, "PoolName"), Some("my-pool".to_string()));
5571
5572        prov.delete_resource(&sr.clone()).unwrap();
5573        let fresh = StackResource {
5574            logical_id: "MyPool".to_string(),
5575            physical_id: "my-pool".to_string(),
5576            resource_type: "AWS::SES::DedicatedIpPool".to_string(),
5577            status: "CREATE_COMPLETE".to_string(),
5578            service_token: None,
5579            attributes: BTreeMap::new(),
5580        };
5581        assert_eq!(prov.get_att(&fresh, "PoolName"), None);
5582    }
5583
5584    #[test]
5585    fn ses_receipt_rule_set_lifecycle() {
5586        let prov = make_provisioner();
5587        let res = make_resource(
5588            "AWS::SES::ReceiptRuleSet",
5589            "MyRuleSet",
5590            serde_json::json!({"RuleSetName": "my-rs"}),
5591        );
5592        let sr = prov.create_resource(&res).unwrap();
5593        assert_eq!(sr.physical_id, "my-rs");
5594        assert_eq!(prov.get_att(&sr, "RuleSetName"), Some("my-rs".to_string()));
5595
5596        prov.delete_resource(&sr.clone()).unwrap();
5597        let fresh = StackResource {
5598            logical_id: "MyRuleSet".to_string(),
5599            physical_id: "my-rs".to_string(),
5600            resource_type: "AWS::SES::ReceiptRuleSet".to_string(),
5601            status: "CREATE_COMPLETE".to_string(),
5602            service_token: None,
5603            attributes: BTreeMap::new(),
5604        };
5605        assert_eq!(prov.get_att(&fresh, "RuleSetName"), None);
5606    }
5607
5608    #[test]
5609    fn ses_receipt_rule_lifecycle() {
5610        let prov = make_provisioner();
5611        let rs = make_resource(
5612            "AWS::SES::ReceiptRuleSet",
5613            "MyRuleSet",
5614            serde_json::json!({"RuleSetName": "my-rs2"}),
5615        );
5616        prov.create_resource(&rs).unwrap();
5617
5618        let res = make_resource(
5619            "AWS::SES::ReceiptRule",
5620            "MyRule",
5621            serde_json::json!({
5622                "RuleSetName": "my-rs2",
5623                "Rule": {
5624                    "Name": "rule1",
5625                    "Priority": 1,
5626                    "Enabled": true,
5627                    "Actions": [{"S3Action": {"BucketName": "my-bucket"}}],
5628                },
5629            }),
5630        );
5631        let sr = prov.create_resource(&res).unwrap();
5632        assert_eq!(sr.physical_id, "my-rs2|rule1");
5633
5634        prov.delete_resource(&sr.clone()).unwrap();
5635    }
5636
5637    #[test]
5638    fn ses_receipt_filter_lifecycle() {
5639        let prov = make_provisioner();
5640        let res = make_resource(
5641            "AWS::SES::ReceiptFilter",
5642            "MyFilter",
5643            serde_json::json!({
5644                "Filter": {
5645                    "Name": "my-filter",
5646                    "IpFilter": {"Policy": "Block", "Cidr": "10.0.0.0/8"},
5647                },
5648            }),
5649        );
5650        let sr = prov.create_resource(&res).unwrap();
5651        assert_eq!(sr.physical_id, "my-filter");
5652
5653        prov.delete_resource(&sr.clone()).unwrap();
5654    }
5655
5656    #[test]
5657    fn ses_vdm_attributes_lifecycle() {
5658        let prov = make_provisioner();
5659        let res = make_resource(
5660            "AWS::SES::VdmAttributes",
5661            "MyVdm",
5662            serde_json::json!({
5663                "DashboardAttributes": {"EngagementMetrics": "ENABLED"},
5664                "GuardianAttributes": {"OptimizedSharedDelivery": "ENABLED"},
5665            }),
5666        );
5667        let sr = prov.create_resource(&res).unwrap();
5668        assert_eq!(sr.physical_id, "vdm-MyVdm");
5669
5670        prov.delete_resource(&sr.clone()).unwrap();
5671    }
5672
5673    #[test]
5674    fn athena_work_group_lifecycle() {
5675        let prov = make_provisioner();
5676        let res = make_resource(
5677            "AWS::Athena::WorkGroup",
5678            "MyWg",
5679            serde_json::json!({
5680                "Name": "my-wg",
5681                "Description": "test wg",
5682                "Configuration": {"EnforceWorkGroupConfiguration": true},
5683            }),
5684        );
5685        let sr = prov.create_resource(&res).unwrap();
5686        assert_eq!(sr.physical_id, "my-wg");
5687        assert_eq!(sr.attributes.get("Name"), Some(&"my-wg".to_string()));
5688        assert!(sr
5689            .attributes
5690            .get("Arn")
5691            .unwrap()
5692            .contains("workgroup/my-wg"));
5693
5694        assert_eq!(
5695            prov.get_att(
5696                &StackResource {
5697                    resource_type: "AWS::Athena::WorkGroup".to_string(),
5698                    physical_id: sr.physical_id.clone(),
5699                    logical_id: "MyWg".to_string(),
5700                    status: "CREATE_COMPLETE".to_string(),
5701                    service_token: None,
5702                    attributes: BTreeMap::new(),
5703                },
5704                "Name",
5705            ),
5706            Some("my-wg".to_string()),
5707        );
5708
5709        prov.delete_resource(&sr.clone()).unwrap();
5710    }
5711
5712    #[test]
5713    fn athena_data_catalog_lifecycle() {
5714        let prov = make_provisioner();
5715        let res = make_resource(
5716            "AWS::Athena::DataCatalog",
5717            "MyCatalog",
5718            serde_json::json!({
5719                "Name": "my-catalog",
5720                "Type": "GLUE",
5721                "Description": "test catalog",
5722            }),
5723        );
5724        let sr = prov.create_resource(&res).unwrap();
5725        assert_eq!(sr.physical_id, "my-catalog");
5726        assert_eq!(sr.attributes.get("Name"), Some(&"my-catalog".to_string()));
5727        assert!(sr
5728            .attributes
5729            .get("Arn")
5730            .unwrap()
5731            .contains("datacatalog/my-catalog"));
5732
5733        prov.delete_resource(&sr.clone()).unwrap();
5734    }
5735
5736    #[test]
5737    fn athena_named_query_lifecycle() {
5738        let prov = make_provisioner();
5739        let res = make_resource(
5740            "AWS::Athena::NamedQuery",
5741            "MyQuery",
5742            serde_json::json!({
5743                "Name": "my-query",
5744                "Database": "mydb",
5745                "QueryString": "SELECT 1",
5746                "WorkGroup": "primary",
5747            }),
5748        );
5749        let sr = prov.create_resource(&res).unwrap();
5750        assert!(!sr.physical_id.is_empty());
5751        assert_eq!(sr.attributes.get("NamedQueryId"), Some(&sr.physical_id));
5752
5753        prov.delete_resource(&sr.clone()).unwrap();
5754    }
5755
5756    #[test]
5757    fn athena_prepared_statement_lifecycle() {
5758        let prov = make_provisioner();
5759        let res = make_resource(
5760            "AWS::Athena::PreparedStatement",
5761            "MyPs",
5762            serde_json::json!({
5763                "StatementName": "my-ps",
5764                "WorkGroupName": "primary",
5765                "QueryStatement": "SELECT 1",
5766            }),
5767        );
5768        let sr = prov.create_resource(&res).unwrap();
5769        assert_eq!(sr.physical_id, "primary|my-ps");
5770
5771        prov.delete_resource(&sr.clone()).unwrap();
5772    }
5773
5774    #[test]
5775    fn parse_lambda_function_name_handles_every_shape() {
5776        // Plain name, unqualified — passes through.
5777        assert_eq!(parse_lambda_function_name("my-func"), "my-func");
5778        // Bare name with an alias/version qualifier (what `Ref` on an
5779        // alias used to feed the ESM create path).
5780        assert_eq!(parse_lambda_function_name("my-func:live"), "my-func");
5781        assert_eq!(parse_lambda_function_name("my-func:42"), "my-func");
5782        // Full ARN, unqualified and qualified.
5783        assert_eq!(
5784            parse_lambda_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-func"),
5785            "my-func"
5786        );
5787        assert_eq!(
5788            parse_lambda_function_name(
5789                "arn:aws:lambda:us-east-1:123456789012:function:my-func:live"
5790            ),
5791            "my-func"
5792        );
5793        // Partial ARN, unqualified and qualified.
5794        assert_eq!(
5795            parse_lambda_function_name("123456789012:function:my-func"),
5796            "my-func"
5797        );
5798        assert_eq!(
5799            parse_lambda_function_name("123456789012:function:my-func:live"),
5800            "my-func"
5801        );
5802    }
5803
5804    #[test]
5805    fn alias_state_key_recovers_internal_key_from_arn() {
5806        // Alias ARN -> `{function}:{alias}` internal map key.
5807        assert_eq!(
5808            alias_state_key("arn:aws:lambda:us-east-1:123456789012:function:my-func:live"),
5809            "my-func:live"
5810        );
5811        // Legacy bare `name:alias` physical id is returned unchanged.
5812        assert_eq!(alias_state_key("my-func:live"), "my-func:live");
5813    }
5814
5815    // ----------------------------------------------------------------- MQ
5816
5817    #[test]
5818    fn cfn_mq_broker_creates_declared_users() {
5819        // The required `Users` property must actually create users on the
5820        // broker (previously the CFN path inserted an empty map).
5821        let prov = make_provisioner();
5822        let created = prov
5823            .create_resource(&make_resource(
5824                "AWS::AmazonMQ::Broker",
5825                "B",
5826                serde_json::json!({
5827                    "BrokerName": "cfn-users",
5828                    "EngineType": "ACTIVEMQ",
5829                    "DeploymentMode": "SINGLE_INSTANCE",
5830                    "HostInstanceType": "mq.m5.large",
5831                    "PubliclyAccessible": false,
5832                    "Users": [ { "Username": "admin", "Password": "SuperSecret1234" } ]
5833                }),
5834            ))
5835            .expect("broker provisions");
5836
5837        let guard = prov.mq_state.read();
5838        let acct = guard.get("123456789012").unwrap();
5839        let users = acct
5840            .users
5841            .get(&created.physical_id)
5842            .expect("broker has a users map");
5843        assert!(users.contains_key("admin"), "declared user was created");
5844    }
5845
5846    #[test]
5847    fn cfn_mq_broker_synthesizes_subnets_matching_api_path() {
5848        // Omitting SubnetIds must synthesize the same default-VPC subnet ids the
5849        // direct CreateBroker path produces (no import drift).
5850        let prov = make_provisioner();
5851        let created = prov
5852            .create_resource(&make_resource(
5853                "AWS::AmazonMQ::Broker",
5854                "B",
5855                serde_json::json!({
5856                    "BrokerName": "cfn-subnets",
5857                    "EngineType": "ACTIVEMQ",
5858                    "DeploymentMode": "SINGLE_INSTANCE",
5859                    "HostInstanceType": "mq.m5.large",
5860                    "PubliclyAccessible": false
5861                }),
5862            ))
5863            .expect("broker provisions");
5864
5865        let guard = prov.mq_state.read();
5866        let acct = guard.get("123456789012").unwrap();
5867        let broker = acct.brokers.get(&created.physical_id).unwrap();
5868        let subnets: Vec<String> = broker["subnetIds"]
5869            .as_array()
5870            .unwrap()
5871            .iter()
5872            .map(|v| v.as_str().unwrap().to_string())
5873            .collect();
5874        let expected: Vec<String> =
5875            fakecloud_mq::shared::synthesize_subnets(&created.physical_id, "SINGLE_INSTANCE")
5876                .into_iter()
5877                .map(|v| v.as_str().unwrap().to_string())
5878                .collect();
5879        assert!(!subnets.is_empty(), "subnets are synthesized, not empty");
5880        assert_eq!(subnets, expected, "CFN subnets match the shared API path");
5881    }
5882
5883    #[test]
5884    fn cfn_mq_configuration_association_preserves_history() {
5885        // Associating a new configuration must push the broker's prior current
5886        // configuration onto history rather than wiping it.
5887        let prov = make_provisioner();
5888        let broker = prov
5889            .create_resource(&make_resource(
5890                "AWS::AmazonMQ::Broker",
5891                "B",
5892                serde_json::json!({
5893                    "BrokerName": "cfn-hist",
5894                    "EngineType": "ACTIVEMQ",
5895                    "DeploymentMode": "SINGLE_INSTANCE",
5896                    "HostInstanceType": "mq.m5.large",
5897                    "PubliclyAccessible": false
5898                }),
5899            ))
5900            .expect("broker provisions");
5901
5902        // The auto-generated current configuration id (pushed to history next).
5903        let prior_id = {
5904            let guard = prov.mq_state.read();
5905            let acct = guard.get("123456789012").unwrap();
5906            acct.brokers.get(&broker.physical_id).unwrap()["configurations"]["current"]["id"]
5907                .as_str()
5908                .unwrap()
5909                .to_string()
5910        };
5911
5912        prov.create_resource(&make_resource(
5913            "AWS::AmazonMQ::ConfigurationAssociation",
5914            "A",
5915            serde_json::json!({
5916                "Broker": broker.physical_id,
5917                "Configuration": { "Id": "c-new-config", "Revision": 2 }
5918            }),
5919        ))
5920        .expect("association provisions");
5921
5922        let guard = prov.mq_state.read();
5923        let acct = guard.get("123456789012").unwrap();
5924        let configs = &acct.brokers.get(&broker.physical_id).unwrap()["configurations"];
5925        assert_eq!(configs["current"]["id"].as_str(), Some("c-new-config"));
5926        assert_eq!(configs["current"]["revision"].as_i64(), Some(2));
5927        let history = configs["history"].as_array().unwrap();
5928        assert!(
5929            history
5930                .iter()
5931                .any(|h| h["id"].as_str() == Some(prior_id.as_str())),
5932            "prior current configuration is preserved in history"
5933        );
5934    }
5935
5936    #[test]
5937    fn config_rule_getatt_config_rule_id_resolves() {
5938        let prov = make_provisioner();
5939        let created = prov
5940            .create_resource(&make_resource(
5941                "AWS::Config::ConfigRule",
5942                "MyRule",
5943                serde_json::json!({
5944                    "ConfigRuleName": "s3-versioning",
5945                    "Source": { "Owner": "AWS", "SourceIdentifier": "S3_BUCKET_VERSIONING_ENABLED" },
5946                }),
5947            ))
5948            .expect("provision config rule");
5949        // The real rule id stored in Config state.
5950        let stored_id = prov
5951            .config_state
5952            .read()
5953            .account("123456789012")
5954            .and_then(|a| a.rules.get("s3-versioning").map(|r| r.rule_id.clone()))
5955            .expect("rule stored");
5956        // `!GetAtt MyRule.ConfigRuleId` resolves to that real id, not a
5957        // placeholder, and `.ComplianceType` / `.Arn` are populated too.
5958        let got = created.attributes.get("ConfigRuleId").cloned();
5959        assert_eq!(got.as_deref(), Some(stored_id.as_str()));
5960        assert!(!stored_id.is_empty());
5961        assert_eq!(
5962            created.attributes.get("ComplianceType").map(String::as_str),
5963            Some("INSUFFICIENT_DATA")
5964        );
5965        assert!(created.attributes.contains_key("Arn"));
5966    }
5967
5968    // A CFN-provisioned Route 53 Resolver endpoint resolves its HostVPCId from
5969    // the referenced subnet's real VPC (created via the EC2 provisioner), and a
5970    // ResolverConfig's declared GetAtt attributes (Id/OwnerId/ResourceId) all
5971    // resolve to real values.
5972    #[test]
5973    fn route53resolver_endpoint_host_vpc_and_config_getatt() {
5974        let prov = make_provisioner();
5975        let vpc = prov
5976            .create_resource(&make_resource(
5977                "AWS::EC2::VPC",
5978                "Vpc",
5979                serde_json::json!({ "CidrBlock": "10.0.0.0/16" }),
5980            ))
5981            .expect("vpc provisions");
5982        let vpc_id = vpc.physical_id.clone();
5983        let subnet = prov
5984            .create_resource(&make_resource(
5985                "AWS::EC2::Subnet",
5986                "Subnet",
5987                serde_json::json!({
5988                    "VpcId": vpc_id,
5989                    "CidrBlock": "10.0.1.0/24",
5990                    "AvailabilityZone": "us-east-1a",
5991                }),
5992            ))
5993            .expect("subnet provisions");
5994        let subnet_id = subnet.physical_id.clone();
5995
5996        let endpoint = prov
5997            .create_resource(&make_resource(
5998                "AWS::Route53Resolver::ResolverEndpoint",
5999                "Endpoint",
6000                serde_json::json!({
6001                    "Direction": "OUTBOUND",
6002                    "SecurityGroupIds": ["sg-1"],
6003                    "IpAddresses": [{ "SubnetId": subnet_id }],
6004                }),
6005            ))
6006            .expect("endpoint provisions");
6007        // HostVPCId is the real VPC id, not an empty string.
6008        assert_eq!(
6009            endpoint.attributes.get("HostVPCId").map(String::as_str),
6010            Some(vpc_id.as_str())
6011        );
6012        assert_eq!(
6013            prov.get_att(&endpoint, "HostVPCId").as_deref(),
6014            Some(vpc_id.as_str())
6015        );
6016
6017        // A ResolverConfig's declared GetAtt attributes all resolve.
6018        let cfg = prov
6019            .create_resource(&make_resource(
6020                "AWS::Route53Resolver::ResolverConfig",
6021                "Cfg",
6022                serde_json::json!({ "ResourceId": vpc_id, "AutodefinedReverseFlag": "DISABLE" }),
6023            ))
6024            .expect("resolver config provisions");
6025        assert_eq!(
6026            prov.get_att(&cfg, "ResourceId").as_deref(),
6027            Some(vpc_id.as_str())
6028        );
6029        assert_eq!(
6030            prov.get_att(&cfg, "OwnerId").as_deref(),
6031            Some("123456789012")
6032        );
6033        assert!(prov.get_att(&cfg, "Id").is_some());
6034    }
6035}