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_apigateway::{
13    make_id as apigw_make_id, ApiKey as ApiGwApiKey, Authorizer as ApiGwAuthorizer,
14    Deployment as ApiGwDeployment, Integration as ApiGwIntegration, Method as ApiGwMethod,
15    Model as ApiGwModel, Resource as ApiGwResource, RestApi as ApiGwRestApi, SharedApiGatewayState,
16    Stage as ApiGwStage, UsagePlan as ApiGwUsagePlan,
17};
18use fakecloud_apigatewayv2::{
19    Authorizer as ApiGwV2Authorizer, CorsConfiguration as ApiGwV2CorsConfiguration,
20    Deployment as ApiGwV2Deployment, HttpApi as ApiGwV2HttpApi, Integration as ApiGwV2Integration,
21    JwtConfiguration as ApiGwV2JwtConfiguration, Route as ApiGwV2Route, SharedApiGatewayV2State,
22    Stage as ApiGwV2Stage,
23};
24use fakecloud_application_autoscaling::{
25    ScalableTarget as AppasScalableTarget, ScalingPolicy as AppasScalingPolicy,
26    SharedApplicationAutoScalingState as AppasState, SuspendedState as AppasSuspendedState,
27};
28use fakecloud_athena::{DataCatalog, NamedQuery, PreparedStatement, SharedAthenaState, WorkGroup};
29use fakecloud_aws::arn::Arn;
30use fakecloud_cloudfront::{
31    functions::{
32        CloudFrontOriginAccessIdentityConfig, FunctionConfig, KeyGroupConfig, KeyGroupItems,
33        PublicKeyConfig, StoredFunction, StoredKeyGroup, StoredOriginAccessIdentity,
34        StoredPublicKey,
35    },
36    model::{
37        DefaultCacheBehavior, DistributionConfig, Origin, OriginItems, Origins, ViewerCertificate,
38    },
39    policies::{
40        CachePolicyConfig, OriginAccessControlConfig, OriginRequestPolicyConfig,
41        OriginRequestPolicyCookiesConfig, OriginRequestPolicyHeadersConfig,
42        OriginRequestPolicyQueryStringsConfig, ResponseHeadersPolicyConfig, StoredCachePolicy,
43        StoredOriginAccessControl, StoredOriginRequestPolicy, StoredResponseHeadersPolicy,
44    },
45    SharedCloudFrontState, StoredDistribution,
46};
47use fakecloud_cloudwatch::{AlarmState, Dashboard, MetricAlarm, SharedCloudWatchState};
48use fakecloud_cognito::{
49    default_schema_attributes, AccountRecoverySetting, AdminCreateUserConfig,
50    CognitoIdentityProvider, CustomDomainConfig, EmailConfiguration, IdentityPool,
51    IdentityPoolRoleAttachment, PasswordPolicy, PoolPolicies, RecoveryOption, SchemaAttribute,
52    SharedCognitoState, SignInPolicy, SmsConfiguration, UserPool, UserPoolClient, UserPoolDomain,
53};
54use fakecloud_core::delivery::DeliveryBus;
55use fakecloud_dynamodb::{
56    AttributeDefinition, DynamoTable, KeySchemaElement, OnDemandThroughput, ProvisionedThroughput,
57    SharedDynamoDbState,
58};
59use fakecloud_ecr::{Repository, SharedEcrState};
60use fakecloud_ecs::{
61    CapacityProvider as EcsCapacityProvider, Cluster as EcsCluster, Service as EcsService,
62    SharedEcsState, TagEntry as EcsTagEntry, TaskDefinition as EcsTaskDefinition,
63};
64use fakecloud_elasticache::{
65    CacheCluster as EcCacheCluster, CacheParameterGroup, CacheSecurityGroup, CacheSubnetGroup,
66    ElastiCacheUser as EcUser, ElastiCacheUserGroup as EcUserGroup,
67    ReplicationGroup as EcReplicationGroup, SharedElastiCacheState,
68};
69use fakecloud_elbv2::{
70    Action as ElbAction, Listener, LoadBalancer, Rule as ElbRule, RuleCondition, SharedElbv2State,
71    Tag as ElbTag, TargetGroup, TargetGroupTuple,
72};
73use fakecloud_eventbridge::{
74    ApiDestination, Archive, Connection, Endpoint, EventBus, EventRule, SharedEventBridgeState,
75};
76use fakecloud_firehose::{DeliveryStream, S3Destination};
77use fakecloud_iam::{
78    IamAccessKey, IamGroup, IamInstanceProfile, IamPolicy, IamRole, IamUser, OidcProvider,
79    PolicyVersion, SamlProvider, SharedIamState, Tag, VirtualMfaDevice,
80};
81use fakecloud_kinesis::{build_stream_shards, KinesisConsumer, KinesisStream, SharedKinesisState};
82use fakecloud_kms::provisioner as kms_provisioner;
83use fakecloud_kms::SharedKmsState;
84use fakecloud_lambda::{
85    AttachedLayer, EventSourceMapping, FunctionAlias, FunctionUrlConfig, Layer, LayerVersion,
86    SharedLambdaState,
87};
88use fakecloud_logs::{
89    Delivery, DeliveryDestination, DeliverySource, Destination, LogStream, MetricFilter,
90    MetricTransformation, QueryDefinition, ResourcePolicy, SharedLogsState, SubscriptionFilter,
91};
92use fakecloud_organizations::{
93    OrganizationState, OrganizationalUnit, Policy as OrgPolicy, SharedOrganizationsState,
94    POLICY_TYPE_SCP,
95};
96use fakecloud_persistence::{BucketSubresource, S3Store};
97use fakecloud_rds::{DbInstance, DbParameterGroup, DbSubnetGroup, RdsTag, SharedRdsState};
98use fakecloud_route53::{
99    model::{HealthCheckConfig, HostedZoneFeatures, ResourceRecordSet},
100    SharedRoute53State, StoredHealthCheck, StoredHostedZone,
101};
102use fakecloud_s3::persistence::bucket_meta_snapshot;
103use fakecloud_s3::{S3Bucket, SharedS3State};
104use fakecloud_secretsmanager::{RotationRules, Secret, SecretVersion, SharedSecretsManagerState};
105use fakecloud_ses::{
106    ConfigurationSet as SesConfigurationSet, ContactList as SesContactList,
107    DedicatedIpPool as SesDedicatedIpPool, EmailIdentity as SesEmailIdentity,
108    EmailTemplate as SesEmailTemplate, EventDestination as SesEventDestination,
109    IpFilter as SesIpFilter, ReceiptAction as SesReceiptAction, ReceiptFilter as SesReceiptFilter,
110    ReceiptRule as SesReceiptRule, ReceiptRuleSet as SesReceiptRuleSet, SharedSesState,
111};
112use fakecloud_sns::{SharedSnsState, SnsSubscription, SnsTopic};
113use fakecloud_sqs::{SharedSqsState, SqsQueue};
114use fakecloud_ssm::{SharedSsmState, SsmParameter};
115use fakecloud_stepfunctions::{
116    Activity as SfnActivity, AliasRoute, SharedStepFunctionsState, StateMachine, StateMachineAlias,
117    StateMachineStatus, StateMachineType, StateMachineVersion,
118};
119use fakecloud_wafv2::{IpSet, RegexPatternSet, RuleGroup, SharedWafv2State, WebAcl};
120
121use crate::state::StackResource;
122use crate::template::ResourceDefinition;
123
124/// Convert a CFN `Tags` property (`[{Key, Value}, ...]`) into the IAM
125/// crate's `Tag` Vec form. Silently skips malformed entries — the same
126/// tolerant behaviour the existing IAM service uses for runtime input.
127fn parse_iam_tags(value: Option<&serde_json::Value>) -> Vec<Tag> {
128    let Some(arr) = value.and_then(|v| v.as_array()) else {
129        return Vec::new();
130    };
131    arr.iter()
132        .filter_map(|t| {
133            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
134            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
135            Some(Tag { key, value })
136        })
137        .collect()
138}
139
140/// Mirror of `parse_iam_tags` but for the ELBv2 crate's separate `Tag`
141/// type. Same `[{Key, Value}, ...]` JSON shape, ignored on malformed entries.
142fn parse_elb_tags(value: Option<&serde_json::Value>) -> Vec<ElbTag> {
143    let Some(arr) = value.and_then(|v| v.as_array()) else {
144        return Vec::new();
145    };
146    arr.iter()
147        .filter_map(|t| {
148            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
149            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
150            Some(ElbTag { key, value })
151        })
152        .collect()
153}
154
155/// Translate CFN-shape Listener/ListenerRule actions into ELBv2 internal
156/// `Action`s. Only the action-type knobs CFN exposes are wired; anything
157/// not recognised becomes a bare action with no target.
158fn parse_elb_actions(value: Option<&serde_json::Value>) -> Vec<ElbAction> {
159    let Some(arr) = value.and_then(|v| v.as_array()) else {
160        return Vec::new();
161    };
162    arr.iter()
163        .map(|a| {
164            let action_type = a
165                .get("Type")
166                .and_then(|v| v.as_str())
167                .unwrap_or("forward")
168                .to_string();
169            let target_group_arn = a
170                .get("TargetGroupArn")
171                .and_then(|v| v.as_str())
172                .map(|s| s.to_string());
173            let order = a.get("Order").and_then(|v| v.as_i64()).map(|n| n as i32);
174            let redirect = a
175                .get("RedirectConfig")
176                .map(|r| fakecloud_elbv2::RedirectConfig {
177                    protocol: r
178                        .get("Protocol")
179                        .and_then(|v| v.as_str())
180                        .map(|s| s.to_string()),
181                    port: r
182                        .get("Port")
183                        .and_then(|v| v.as_str())
184                        .map(|s| s.to_string()),
185                    host: r
186                        .get("Host")
187                        .and_then(|v| v.as_str())
188                        .map(|s| s.to_string()),
189                    path: r
190                        .get("Path")
191                        .and_then(|v| v.as_str())
192                        .map(|s| s.to_string()),
193                    query: r
194                        .get("Query")
195                        .and_then(|v| v.as_str())
196                        .map(|s| s.to_string()),
197                    status_code: r
198                        .get("StatusCode")
199                        .and_then(|v| v.as_str())
200                        .unwrap_or("HTTP_302")
201                        .to_string(),
202                });
203            let fixed_response =
204                a.get("FixedResponseConfig")
205                    .map(|f| fakecloud_elbv2::FixedResponseConfig {
206                        message_body: f
207                            .get("MessageBody")
208                            .and_then(|v| v.as_str())
209                            .map(|s| s.to_string()),
210                        status_code: f
211                            .get("StatusCode")
212                            .and_then(|v| v.as_str())
213                            .unwrap_or("200")
214                            .to_string(),
215                        content_type: f
216                            .get("ContentType")
217                            .and_then(|v| v.as_str())
218                            .map(|s| s.to_string()),
219                    });
220            let forward = a.get("ForwardConfig").map(|f| {
221                let target_groups: Vec<TargetGroupTuple> = f
222                    .get("TargetGroups")
223                    .and_then(|v| v.as_array())
224                    .map(|arr| {
225                        arr.iter()
226                            .filter_map(|t| {
227                                let target_group_arn = t
228                                    .get("TargetGroupArn")
229                                    .and_then(|v| v.as_str())?
230                                    .to_string();
231                                let weight =
232                                    t.get("Weight").and_then(|v| v.as_i64()).map(|n| n as i32);
233                                Some(TargetGroupTuple {
234                                    target_group_arn,
235                                    weight,
236                                })
237                            })
238                            .collect()
239                    })
240                    .unwrap_or_default();
241                fakecloud_elbv2::ForwardConfig {
242                    target_groups,
243                    stickiness: None,
244                }
245            });
246            ElbAction {
247                action_type,
248                target_group_arn,
249                order,
250                redirect,
251                fixed_response,
252                forward,
253                authenticate_cognito: None,
254                authenticate_oidc: None,
255            }
256        })
257        .collect()
258}
259
260fn parse_elb_rule_conditions(value: Option<&serde_json::Value>) -> Vec<RuleCondition> {
261    let Some(arr) = value.and_then(|v| v.as_array()) else {
262        return Vec::new();
263    };
264    arr.iter()
265        .map(|c| {
266            let field = c
267                .get("Field")
268                .and_then(|v| v.as_str())
269                .unwrap_or("")
270                .to_string();
271            let values: Vec<String> = c
272                .get("Values")
273                .and_then(|v| v.as_array())
274                .map(|arr| {
275                    arr.iter()
276                        .filter_map(|s| s.as_str().map(|s| s.to_string()))
277                        .collect()
278                })
279                .unwrap_or_default();
280            let host_header_values: Vec<String> = c
281                .get("HostHeaderConfig")
282                .and_then(|v| v.get("Values"))
283                .and_then(|v| v.as_array())
284                .map(|arr| {
285                    arr.iter()
286                        .filter_map(|s| s.as_str().map(|s| s.to_string()))
287                        .collect()
288                })
289                .unwrap_or_default();
290            RuleCondition {
291                field,
292                values,
293                host_header_values,
294                path_pattern_values: Vec::new(),
295                http_header_name: None,
296                http_header_values: Vec::new(),
297                query_string_values: Vec::new(),
298                http_request_method_values: Vec::new(),
299                source_ip_values: Vec::new(),
300            }
301        })
302        .collect()
303}
304
305/// Parse the `KeyPolicy` field of an `AWS::KMS::Key` /
306/// `AWS::KMS::ReplicaKey` resource. CFN allows either a JSON string or
307/// an inline JSON object; we serialize the object form back to a string
308/// to match the [`KmsKey.policy`](fakecloud_kms::KmsKey) shape.
309fn parse_key_policy(props: &serde_json::Value) -> Option<String> {
310    match props.get("KeyPolicy") {
311        Some(v) if v.is_string() => Some(v.as_str().unwrap_or("").to_string()),
312        Some(v) => Some(serde_json::to_string(v).unwrap_or_default()),
313        None => None,
314    }
315}
316
317/// Parse the `Tags` field common to KMS CFN resources: an array of
318/// `{Key, Value}` pairs into a sorted map.
319fn parse_tag_list(props: &serde_json::Value) -> BTreeMap<String, String> {
320    let mut tags: BTreeMap<String, String> = BTreeMap::new();
321    if let Some(arr) = props.get("Tags").and_then(|v| v.as_array()) {
322        for t in arr {
323            if let (Some(k), Some(v)) = (
324                t.get("Key").and_then(|x| x.as_str()),
325                t.get("Value").and_then(|x| x.as_str()),
326            ) {
327                tags.insert(k.to_string(), v.to_string());
328            }
329        }
330    }
331    tags
332}
333
334/// Parse the `Properties` of an `AWS::KMS::Key` resource into the
335/// shared [`fakecloud_kms::provisioner::KeyCreationInput`]. Defaults
336/// match AWS: `ENCRYPT_DECRYPT` / `SYMMETRIC_DEFAULT` / `AWS_KMS` /
337/// `Enabled=true`. `RotationPeriodInDays`, `PendingWindowInDays`, and
338/// `BypassPolicyLockoutSafetyCheck` are accepted by the parser for
339/// CFN compatibility but not persisted on the key — the underlying
340/// KMS state doesn't model per-key rotation periods, and the deletion
341/// window only matters at scheduled-deletion time which CFN delete
342/// short-circuits.
343fn parse_kms_key_input(props: &serde_json::Value) -> kms_provisioner::KeyCreationInput {
344    kms_provisioner::KeyCreationInput {
345        description: props
346            .get("Description")
347            .and_then(|v| v.as_str())
348            .unwrap_or("")
349            .to_string(),
350        key_usage: props
351            .get("KeyUsage")
352            .and_then(|v| v.as_str())
353            .unwrap_or("ENCRYPT_DECRYPT")
354            .to_string(),
355        key_spec: props
356            .get("KeySpec")
357            .and_then(|v| v.as_str())
358            .unwrap_or("SYMMETRIC_DEFAULT")
359            .to_string(),
360        origin: props
361            .get("Origin")
362            .and_then(|v| v.as_str())
363            .unwrap_or("AWS_KMS")
364            .to_string(),
365        enabled: props
366            .get("Enabled")
367            .and_then(|v| v.as_bool())
368            .unwrap_or(true),
369        multi_region: props
370            .get("MultiRegion")
371            .and_then(|v| v.as_bool())
372            .unwrap_or(false),
373        key_rotation_enabled: props
374            .get("EnableKeyRotation")
375            .and_then(|v| v.as_bool())
376            .unwrap_or(false),
377        policy: parse_key_policy(props),
378        tags: parse_tag_list(props),
379    }
380}
381
382/// `LogGroupName` properties on Logs CFN resources may carry either a
383/// log-group ARN (when they come from `{Ref: SomeLogGroup}` in the same
384/// template) or a plain name. Extract the name in either case.
385fn parse_log_group_name(input: &str) -> String {
386    if let Some(rest) = input.strip_prefix("arn:aws:logs:") {
387        if let Some(after) = rest.split(":log-group:").nth(1) {
388            // ARN ends with `:*`; trim it if present.
389            return after.trim_end_matches(":*").to_string();
390        }
391    }
392    input.to_string()
393}
394
395/// Pull the bare function name out of any shape AWS accepts for a Lambda
396/// `FunctionName`: a name, a partial ARN, or a full ARN — each optionally
397/// `:qualified` with an alias or version. CFN feeds this `{Ref: SomeFunction}`
398/// (resolves to a name, or — for an alias — the alias ARN), `{Fn::GetAtt:
399/// [F, Arn]}` (full ARN), or a literal; all shapes must land at the same map
400/// key. Function names cannot contain `:`, so any trailing `:segment` on a
401/// non-ARN input is always a qualifier.
402fn parse_lambda_function_name(input: &str) -> String {
403    // Full ARN: arn:aws:lambda:region:account:function:name[:qualifier]
404    if let Some(rest) = input.strip_prefix("arn:aws:lambda:") {
405        if let Some(after) = rest.split(":function:").nth(1) {
406            return after.split(':').next().unwrap_or(after).to_string();
407        }
408    }
409    // Partial ARN: account:function:name[:qualifier]
410    if let Some(after) = input.split(":function:").nth(1) {
411        return after.split(':').next().unwrap_or(after).to_string();
412    }
413    // Bare name, optionally qualified: name[:qualifier]
414    input.split(':').next().unwrap_or(input).to_string()
415}
416
417/// Recover the internal `{function}:{alias}` key used by the lambda
418/// state's `aliases` / `provisioned_concurrency` maps from an alias
419/// resource's physical id. The physical id is the alias ARN
420/// (`arn:aws:lambda:region:account:function:name:alias`); a legacy bare
421/// `name:alias` value is returned unchanged.
422fn alias_state_key(physical_id: &str) -> String {
423    if let Some(rest) = physical_id.strip_prefix("arn:aws:lambda:") {
424        if let Some(after) = rest.split(":function:").nth(1) {
425            return after.to_string();
426        }
427    }
428    physical_id.to_string()
429}
430
431/// All AWS::Lambda::Function CFN properties parsed and pre-defaulted into
432/// their lambda-state shapes. Mirrors the lambda service's
433/// `CreateFunctionInput` so create + update share one parse path.
434struct LambdaFunctionProps {
435    runtime: String,
436    role: String,
437    handler: String,
438    description: String,
439    timeout: i64,
440    memory_size: i64,
441    package_type: String,
442    tags: BTreeMap<String, String>,
443    environment: BTreeMap<String, String>,
444    architectures: Vec<String>,
445    /// Decoded `Code.ZipFile` bytes (base64 → raw). `None` when the
446    /// caller specified `Code.S3Bucket`/`Code.S3Key` or `Code.ImageUri`
447    /// instead — the create/update path resolves S3 separately.
448    code_zip: Option<Vec<u8>>,
449    s3_bucket: Option<String>,
450    s3_key: Option<String>,
451    image_uri: Option<String>,
452    layers: Vec<String>,
453    tracing_mode: Option<String>,
454    kms_key_arn: Option<String>,
455    ephemeral_storage_size: Option<i64>,
456    vpc_config: Option<serde_json::Value>,
457    snap_start: Option<serde_json::Value>,
458    dead_letter_config_arn: Option<String>,
459    file_system_configs: Vec<serde_json::Value>,
460    logging_config: Option<serde_json::Value>,
461}
462
463/// Parse the `Properties` value of an `AWS::Lambda::Function` resource
464/// into a `LambdaFunctionProps`. Defaults match the AWS Lambda
465/// CreateFunction API: `python3.12` runtime, `index.handler` handler,
466/// `Zip` package type, `x86_64` architecture, 3s timeout, 128MB memory.
467fn parse_lambda_function_props(props: &serde_json::Value) -> Result<LambdaFunctionProps, String> {
468    let runtime = props
469        .get("Runtime")
470        .and_then(|v| v.as_str())
471        .unwrap_or("python3.12")
472        .to_string();
473    let role = props
474        .get("Role")
475        .and_then(|v| v.as_str())
476        .unwrap_or_default()
477        .to_string();
478    let handler = props
479        .get("Handler")
480        .and_then(|v| v.as_str())
481        .unwrap_or("index.handler")
482        .to_string();
483    let description = props
484        .get("Description")
485        .and_then(|v| v.as_str())
486        .unwrap_or_default()
487        .to_string();
488    let timeout = props.get("Timeout").and_then(|v| v.as_i64()).unwrap_or(3);
489    let memory_size = props
490        .get("MemorySize")
491        .and_then(|v| v.as_i64())
492        .unwrap_or(128);
493    let architectures = props
494        .get("Architectures")
495        .and_then(|v| v.as_array())
496        .map(|a| {
497            a.iter()
498                .filter_map(|v| v.as_str().map(|s| s.to_string()))
499                .collect::<Vec<_>>()
500        })
501        .unwrap_or_else(|| vec!["x86_64".to_string()]);
502    let package_type = props
503        .get("PackageType")
504        .and_then(|v| v.as_str())
505        .unwrap_or("Zip")
506        .to_string();
507    let environment = props
508        .get("Environment")
509        .and_then(|v| v.get("Variables"))
510        .and_then(|v| v.as_object())
511        .map(|o| {
512            o.iter()
513                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
514                .collect::<BTreeMap<String, String>>()
515        })
516        .unwrap_or_default();
517
518    // CFN tags ride as `[{Key, Value}, ...]`; flatten to the map shape
519    // the lambda crate stores tags in.
520    let tags: BTreeMap<String, String> = props
521        .get("Tags")
522        .and_then(|v| v.as_array())
523        .map(|arr| {
524            arr.iter()
525                .filter_map(|t| {
526                    let k = t.get("Key").and_then(|v| v.as_str())?.to_string();
527                    let v = t.get("Value").and_then(|v| v.as_str())?.to_string();
528                    Some((k, v))
529                })
530                .collect()
531        })
532        .unwrap_or_default();
533
534    let code = props.get("Code");
535    // CFN's `Code.ZipFile` is the raw source code inline (per the
536    // CloudFormation user guide), not base64 like the Lambda
537    // CreateFunction API. We store it as the raw bytes — fakecloud's
538    // Lambda runtime is content-agnostic and just needs *some* deployable
539    // payload to execute.
540    let code_zip = code
541        .and_then(|c| c.get("ZipFile"))
542        .and_then(|v| v.as_str())
543        .map(|s| s.as_bytes().to_vec());
544    let s3_bucket = code
545        .and_then(|c| c.get("S3Bucket"))
546        .and_then(|v| v.as_str())
547        .map(|s| s.to_string());
548    let s3_key = code
549        .and_then(|c| c.get("S3Key"))
550        .and_then(|v| v.as_str())
551        .map(|s| s.to_string());
552    // ImageUri is only meaningful for `PackageType=Image`. Mirroring the
553    // lambda service path, drop it on Zip functions so GetFunction
554    // doesn't return ECR metadata for a ZIP-based function.
555    let image_uri = if package_type == "Image" {
556        code.and_then(|c| c.get("ImageUri"))
557            .and_then(|v| v.as_str())
558            .map(|s| s.to_string())
559    } else {
560        None
561    };
562    if package_type == "Image" && image_uri.is_none() {
563        return Err("Code.ImageUri is required when PackageType is Image".to_string());
564    }
565
566    let layers: Vec<String> = props
567        .get("Layers")
568        .and_then(|v| v.as_array())
569        .map(|arr| {
570            arr.iter()
571                .filter_map(|v| v.as_str().map(String::from))
572                .collect()
573        })
574        .unwrap_or_default();
575
576    let tracing_mode = props
577        .get("TracingConfig")
578        .and_then(|v| v.get("Mode"))
579        .and_then(|v| v.as_str())
580        .map(String::from);
581    let kms_key_arn = props
582        .get("KmsKeyArn")
583        .and_then(|v| v.as_str())
584        .map(String::from);
585    let ephemeral_storage_size = props
586        .get("EphemeralStorage")
587        .and_then(|v| v.get("Size"))
588        .and_then(|v| v.as_i64());
589    let vpc_config = props.get("VpcConfig").filter(|v| v.is_object()).cloned();
590    let snap_start = props.get("SnapStart").filter(|v| v.is_object()).cloned();
591    let dead_letter_config_arn = props
592        .get("DeadLetterConfig")
593        .and_then(|v| v.get("TargetArn"))
594        .and_then(|v| v.as_str())
595        .map(String::from);
596    let file_system_configs = props
597        .get("FileSystemConfigs")
598        .and_then(|v| v.as_array())
599        .cloned()
600        .unwrap_or_default();
601    let logging_config = props
602        .get("LoggingConfig")
603        .filter(|v| v.is_object())
604        .cloned();
605
606    Ok(LambdaFunctionProps {
607        runtime,
608        role,
609        handler,
610        description,
611        timeout,
612        memory_size,
613        package_type,
614        tags,
615        environment,
616        architectures,
617        code_zip,
618        s3_bucket,
619        s3_key,
620        image_uri,
621        layers,
622        tracing_mode,
623        kms_key_arn,
624        ephemeral_storage_size,
625        vpc_config,
626        snap_start,
627        dead_letter_config_arn,
628        file_system_configs,
629        logging_config,
630    })
631}
632
633/// Properties for `AWS::Lambda::EventSourceMapping` shared between the
634/// create and update provisioners. Mirrors the lambda crate
635/// `EventSourceMapping` shape one-for-one — every CFN-mutable field
636/// lands here so update can rewrite without re-parsing.
637struct LambdaEventSourceMappingProps {
638    event_source_arn: String,
639    batch_size: i64,
640    enabled: bool,
641    starting_position: Option<String>,
642    starting_position_timestamp: Option<f64>,
643    parallelization_factor: Option<i64>,
644    maximum_batching_window_in_seconds: Option<i64>,
645    function_response_types: Vec<String>,
646    filter_patterns: Vec<String>,
647    kms_key_arn: Option<String>,
648    metrics_config: Option<serde_json::Value>,
649    destination_config: Option<serde_json::Value>,
650    maximum_retry_attempts: Option<i64>,
651    maximum_record_age_in_seconds: Option<i64>,
652    bisect_batch_on_function_error: Option<bool>,
653    tumbling_window_in_seconds: Option<i64>,
654    topics: Vec<String>,
655    queues: Vec<String>,
656}
657
658/// Parse the `Properties` value of an `AWS::Lambda::EventSourceMapping`
659/// resource. `EventSourceArn` is required on create; updates re-parse but
660/// the value is ignored since the field is immutable.
661fn parse_lambda_event_source_mapping_props(
662    props: &serde_json::Value,
663) -> Result<LambdaEventSourceMappingProps, String> {
664    let event_source_arn = props
665        .get("EventSourceArn")
666        .and_then(|v| v.as_str())
667        .unwrap_or_default()
668        .to_string();
669    let batch_size = props
670        .get("BatchSize")
671        .and_then(|v| v.as_i64())
672        .unwrap_or(10);
673    let enabled = props
674        .get("Enabled")
675        .and_then(|v| v.as_bool())
676        .unwrap_or(true);
677    let starting_position = props
678        .get("StartingPosition")
679        .and_then(|v| v.as_str())
680        .map(|s| s.to_string());
681    let starting_position_timestamp = props
682        .get("StartingPositionTimestamp")
683        .and_then(|v| v.as_f64());
684    let parallelization_factor = props.get("ParallelizationFactor").and_then(|v| v.as_i64());
685    let maximum_batching_window_in_seconds = props
686        .get("MaximumBatchingWindowInSeconds")
687        .and_then(|v| v.as_i64());
688    let function_response_types: Vec<String> = props
689        .get("FunctionResponseTypes")
690        .and_then(|v| v.as_array())
691        .map(|arr| {
692            arr.iter()
693                .filter_map(|v| v.as_str().map(|s| s.to_string()))
694                .collect()
695        })
696        .unwrap_or_default();
697    let filter_patterns: Vec<String> = props
698        .get("FilterCriteria")
699        .and_then(|v| v.get("Filters"))
700        .and_then(|v| v.as_array())
701        .map(|arr| {
702            arr.iter()
703                .filter_map(|f| {
704                    f.get("Pattern")
705                        .and_then(|p| p.as_str())
706                        .map(|s| s.to_string())
707                })
708                .collect()
709        })
710        .unwrap_or_default();
711    let kms_key_arn = props
712        .get("KmsKeyArn")
713        .and_then(|v| v.as_str())
714        .map(|s| s.to_string());
715    let metrics_config = props
716        .get("MetricsConfig")
717        .filter(|v| v.is_object())
718        .cloned();
719    let destination_config = props
720        .get("DestinationConfig")
721        .filter(|v| v.is_object())
722        .cloned();
723    let maximum_retry_attempts = props.get("MaximumRetryAttempts").and_then(|v| v.as_i64());
724    let maximum_record_age_in_seconds = props
725        .get("MaximumRecordAgeInSeconds")
726        .and_then(|v| v.as_i64());
727    let bisect_batch_on_function_error = props
728        .get("BisectBatchOnFunctionError")
729        .and_then(|v| v.as_bool());
730    let tumbling_window_in_seconds = props
731        .get("TumblingWindowInSeconds")
732        .and_then(|v| v.as_i64());
733    let topics: Vec<String> = props
734        .get("Topics")
735        .and_then(|v| v.as_array())
736        .map(|arr| {
737            arr.iter()
738                .filter_map(|v| v.as_str().map(|s| s.to_string()))
739                .collect()
740        })
741        .unwrap_or_default();
742    let queues: Vec<String> = props
743        .get("Queues")
744        .and_then(|v| v.as_array())
745        .map(|arr| {
746            arr.iter()
747                .filter_map(|v| v.as_str().map(|s| s.to_string()))
748                .collect()
749        })
750        .unwrap_or_default();
751
752    Ok(LambdaEventSourceMappingProps {
753        event_source_arn,
754        batch_size,
755        enabled,
756        starting_position,
757        starting_position_timestamp,
758        parallelization_factor,
759        maximum_batching_window_in_seconds,
760        function_response_types,
761        filter_patterns,
762        kms_key_arn,
763        metrics_config,
764        destination_config,
765        maximum_retry_attempts,
766        maximum_record_age_in_seconds,
767        bisect_batch_on_function_error,
768        tumbling_window_in_seconds,
769        topics,
770        queues,
771    })
772}
773
774/// Compute base64-encoded SHA-256 of code bytes — matches what the
775/// lambda service stores in `LambdaFunction.code_sha256`.
776fn sha256_b64(bytes: &[u8]) -> String {
777    use sha2::Digest;
778    let hash = sha2::Sha256::digest(bytes);
779    base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash)
780}
781
782/// Look up the `code_size` for an attached layer-version ARN by walking
783/// the in-process lambda state. Falls back to 0 when the ARN is
784/// unparseable or the layer/version is unknown — same fallback as the
785/// lambda service helper.
786fn layer_code_size(
787    accounts: &fakecloud_core::multi_account::MultiAccountState<fakecloud_lambda::LambdaState>,
788    arn: &str,
789) -> i64 {
790    // arn:aws:lambda:<region>:<account>:layer:<name>:<version>
791    let Some(rest) = arn.strip_prefix("arn:aws:lambda:") else {
792        return 0;
793    };
794    let mut parts = rest.split(':');
795    let _region = parts.next();
796    let Some(account) = parts.next() else {
797        return 0;
798    };
799    if parts.next() != Some("layer") {
800        return 0;
801    }
802    let Some(name) = parts.next() else {
803        return 0;
804    };
805    let Some(ver_str) = parts.next() else {
806        return 0;
807    };
808    let Ok(ver) = ver_str.parse::<i64>() else {
809        return 0;
810    };
811    accounts
812        .get(account)
813        .and_then(|s| s.layers.get(name))
814        .and_then(|l| l.versions.iter().find(|v| v.version == ver))
815        .map(|v| v.code_size)
816        .unwrap_or(0)
817}
818
819/// What a resource provisioner returns. The physical id is what `Ref` resolves
820/// to; `attributes` is what `Fn::GetAtt` resolves to (per-resource-type).
821pub struct ProvisionResult {
822    pub physical_id: String,
823    pub attributes: BTreeMap<String, String>,
824}
825
826impl ProvisionResult {
827    pub fn new(physical_id: impl Into<String>) -> Self {
828        Self {
829            physical_id: physical_id.into(),
830            attributes: BTreeMap::new(),
831        }
832    }
833
834    pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
835        self.attributes.insert(key.to_string(), value.into());
836        self
837    }
838
839    /// Merge another attribute map into this result. Used by update
840    /// handlers that delegate to a create handler but need to keep the
841    /// existing physical id while inheriting the freshly computed
842    /// attributes.
843    pub fn merge_attributes(mut self, other: BTreeMap<String, String>) -> Self {
844        for (k, v) in other {
845            self.attributes.insert(k, v);
846        }
847        self
848    }
849}
850
851/// Extract a resource policy's `PolicyDocument` property as a JSON string,
852/// accepting either an inline object or an already-serialized string. Shared by
853/// the SQS/SNS/S3 resource-policy provisioners.
854fn policy_document_string(props: &serde_json::Value) -> Result<String, String> {
855    match props.get("PolicyDocument") {
856        Some(serde_json::Value::String(s)) => Ok(s.clone()),
857        Some(other) => Ok(other.to_string()),
858        None => Err("PolicyDocument is required".to_string()),
859    }
860}
861
862/// Holds references to all service states so CloudFormation can provision resources.
863pub struct ResourceProvisioner {
864    pub sqs_state: SharedSqsState,
865    pub sns_state: SharedSnsState,
866    pub ssm_state: SharedSsmState,
867    pub iam_state: SharedIamState,
868    pub s3_state: SharedS3State,
869    pub eventbridge_state: SharedEventBridgeState,
870    pub dynamodb_state: SharedDynamoDbState,
871    pub logs_state: SharedLogsState,
872    pub lambda_state: SharedLambdaState,
873    pub secretsmanager_state: SharedSecretsManagerState,
874    pub kinesis_state: SharedKinesisState,
875    pub kms_state: SharedKmsState,
876    pub ecr_state: SharedEcrState,
877    pub cloudwatch_state: SharedCloudWatchState,
878    pub elbv2_state: SharedElbv2State,
879    pub organizations_state: SharedOrganizationsState,
880    pub cognito_state: SharedCognitoState,
881    pub rds_state: SharedRdsState,
882    pub ec2_state: fakecloud_ec2::SharedEc2State,
883    pub autoscaling_state: fakecloud_autoscaling::SharedAutoScalingState,
884    pub batch_state: fakecloud_batch::SharedBatchState,
885    pub ecs_state: SharedEcsState,
886    pub acm_state: SharedAcmState,
887    pub elasticache_state: SharedElastiCacheState,
888    pub route53_state: SharedRoute53State,
889    pub cloudfront_state: SharedCloudFrontState,
890    pub stepfunctions_state: SharedStepFunctionsState,
891    pub wafv2_state: SharedWafv2State,
892    pub apigateway_state: SharedApiGatewayState,
893    pub apigatewayv2_state: SharedApiGatewayV2State,
894    pub ses_state: SharedSesState,
895    pub app_autoscaling_state: AppasState,
896    pub athena_state: SharedAthenaState,
897    pub firehose_state: fakecloud_firehose::SharedFirehoseState,
898    pub glue_state: fakecloud_glue::SharedGlueState,
899    pub cloudformation_state: SharedCloudFormationState,
900    pub delivery: Arc<DeliveryBus>,
901    /// Lambda container runtime for pre-pulling CFN-provisioned function
902    /// images (see `CloudFormationDeps::lambda_runtime`). `None` outside a
903    /// configured runtime (e.g. unit tests).
904    pub lambda_runtime: Option<Arc<fakecloud_lambda::runtime::ContainerRuntime>>,
905    /// Container runtimes for stateful services whose CFN-provisioned resources
906    /// must be backed by REAL containers. See `CloudFormationDeps`. `None`
907    /// (no Docker/Podman, e.g. CI/unit tests) keeps metadata-only provisioning.
908    pub rds_runtime: Option<Arc<fakecloud_rds::runtime::RdsRuntime>>,
909    pub ec2_runtime: Option<Arc<fakecloud_ec2::runtime::Ec2Runtime>>,
910    pub ecs_runtime: Option<Arc<fakecloud_ecs::runtime::EcsRuntime>>,
911    pub elasticache_runtime: Option<Arc<fakecloud_elasticache::runtime::ElastiCacheRuntime>>,
912    /// Intents queued by container-backed provisioners during the synchronous
913    /// provisioning pass. After provisioning, `CreateStack` drains these and
914    /// backs each freshly-inserted record with a real container in the
915    /// background (so `CreateStack` returns without blocking on a container
916    /// boot — the #1539/#1730 timeout lesson). Shared via `Arc` so the drain
917    /// can read it after the provisioner is moved into `spawn_blocking`.
918    pub pending_container_spawns: Arc<parking_lot::Mutex<Vec<ContainerSpawnIntent>>>,
919    /// Teardown intents queued by container-backed delete provisioners during a
920    /// synchronous delete pass (stack delete, or a stack update that removes a
921    /// resource). The in-memory record is removed synchronously (so
922    /// `DescribeStacks` reflects the deletion at once); the REAL backing
923    /// container is reaped in the background by the CloudFormation delete drain,
924    /// mirroring `pending_container_spawns` for teardown. Without this drain a
925    /// stack delete would leak the running RDS / ElastiCache / ECS / EC2
926    /// containers (the create-side #2031-#2034 hardening never reached delete).
927    pub pending_container_teardowns: Arc<parking_lot::Mutex<Vec<ContainerTeardownIntent>>>,
928    /// Custom-resource (`Custom::*`) Lambda invoke intents queued during a
929    /// changeset/update provision when `defer_custom_invokes` is set. Invoking
930    /// the Lambda synchronously (`invoke_lambda_sync`) can cold-pull a container
931    /// image for minutes -- far past the client's 60s read timeout -- and, on
932    /// the changeset/update path, it ran while holding the CloudFormation state
933    /// write lock, stalling every other CFN op behind it. Queueing here lets the
934    /// caller drain + `tokio::spawn` the invokes off the request path after the
935    /// lock is dropped, mirroring how `CreateStack` provisions custom resources
936    /// off the request path.
937    pub pending_custom_invokes: Arc<parking_lot::Mutex<Vec<CustomInvokeIntent>>>,
938    /// When `true`, `create_custom_resource` / `delete_custom_resource` queue
939    /// their Lambda invoke onto `pending_custom_invokes` instead of running it
940    /// synchronously. Set on the changeset/update/delete provisioners; left
941    /// `false` for `CreateStack` (which already provisions off the request path
942    /// in a detached task, so its synchronous invoke never blocks the client or
943    /// the state lock).
944    pub defer_custom_invokes: bool,
945    /// Fine-grained S3 disk store. Bucket create/delete (and bucket-policy
946    /// updates) write through this so a CFN-provisioned bucket lands on disk,
947    /// matching the real `CreateBucket`/`DeleteBucket` handlers. A
948    /// `MemoryS3Store` (memory mode) makes the writes no-ops.
949    pub s3_store: Arc<dyn S3Store>,
950    pub account_id: String,
951    pub region: String,
952    pub stack_id: String,
953}
954
955/// A container-backed resource the synchronous provisioning pass inserted as a
956/// "creating" record that now needs a real backing container. Drained by
957/// `CreateStack` after provisioning and backgrounded so the stack create call
958/// never blocks on a container boot/pull.
959#[derive(Debug, Clone)]
960pub enum ContainerSpawnIntent {
961    /// `AWS::RDS::DBInstance` — back the inserted DbInstance with a real
962    /// Postgres/MySQL container via the RDS runtime.
963    RdsInstance { identifier: String },
964    /// `AWS::AutoScaling::AutoScalingGroup` — reconcile the inserted group to
965    /// its desired capacity by launching real container-backed EC2 instances
966    /// via the EC2 runtime, matching the direct `CreateAutoScalingGroup` path.
967    AsgInstances { group_name: String },
968    /// `AWS::EC2::Instance` — back the inserted (control-plane `pending`)
969    /// instance with a real container via the EC2 runtime, matching the direct
970    /// `RunInstances` path.
971    Ec2Instance { instance_id: String },
972    /// `AWS::ElastiCache::CacheCluster` — back the inserted CacheCluster with a
973    /// real Redis/Memcached container via the ElastiCache runtime, matching the
974    /// direct `CreateCacheCluster` path.
975    ElastiCacheCluster { cache_cluster_id: String },
976    /// `AWS::ElastiCache::ReplicationGroup` — back the inserted ReplicationGroup
977    /// with a real Redis container via the ElastiCache runtime, matching the
978    /// direct `CreateReplicationGroup` path.
979    ElastiCacheReplicationGroup { replication_group_id: String },
980    /// `AWS::ECS::Service` — launch the REAL tasks (containers) the inserted
981    /// service needs to reach its `desiredCount` via the ECS runtime, matching
982    /// the direct `CreateService` path. The cluster + service name locate the
983    /// inserted record (and its desired count) when the drain runs.
984    EcsServiceTasks {
985        cluster_name: String,
986        service_name: String,
987    },
988}
989
990/// A container-backed resource the synchronous delete pass removed from memory
991/// that still has a REAL backing container to reap. Drained by the
992/// CloudFormation delete path (stack delete / update-removed) and backgrounded
993/// so the stack op never blocks on a container stop. Mirrors
994/// [`ContainerSpawnIntent`] for teardown.
995#[derive(Debug, Clone)]
996pub enum ContainerTeardownIntent {
997    /// `AWS::RDS::DBInstance` -- stop + remove the Postgres/MySQL container and
998    /// its persisted data volume.
999    RdsInstance { identifier: String },
1000    /// `AWS::ElastiCache::CacheCluster` -- stop + remove the Redis/Memcached
1001    /// container and its data volume.
1002    ElastiCacheCluster { cache_cluster_id: String },
1003    /// `AWS::ElastiCache::ReplicationGroup` -- stop + remove the Redis container
1004    /// and its data volume.
1005    ElastiCacheReplicationGroup { replication_group_id: String },
1006    /// `AWS::ECS::Service` -- stop the REAL tasks (containers) the service was
1007    /// running. The cluster + service name locate the orphaned task records.
1008    EcsService {
1009        cluster_name: String,
1010        service_name: String,
1011    },
1012    /// `AWS::AutoScaling::AutoScalingGroup` -- terminate the REAL EC2 instances
1013    /// the group launched (captured before the group record was removed).
1014    AsgInstances { instance_ids: Vec<String> },
1015    /// `AWS::EC2::Instance` -- terminate the REAL EC2 instance + reap its
1016    /// backing container when the stack is deleted.
1017    Ec2Instance { instance_id: String },
1018}
1019
1020/// A queued custom-resource (`Custom::*`) Lambda invocation. Built by
1021/// `create_custom_resource` / `delete_custom_resource` when
1022/// `defer_custom_invokes` is set, drained and `tokio::spawn`ed off the request
1023/// path so a cold image pull never blocks the client or the CFN state lock.
1024#[derive(Debug, Clone)]
1025pub struct CustomInvokeIntent {
1026    pub service_token: String,
1027    pub payload: String,
1028}
1029
1030mod acm;
1031mod apigw;
1032mod apigwv2;
1033mod athena;
1034mod autoscaling;
1035mod batch;
1036mod cloudformation;
1037mod cloudwatch;
1038mod cognito;
1039mod dynamodb;
1040mod ec2;
1041mod ecr;
1042mod ecs;
1043mod eventbridge;
1044mod firehose;
1045mod glue;
1046mod iam;
1047mod kinesis;
1048mod kms;
1049mod lambda;
1050mod logs;
1051mod rds;
1052mod route;
1053mod s3;
1054mod secrets;
1055mod ses;
1056mod sns;
1057mod sqs;
1058mod ssm;
1059mod stepfunctions;
1060mod wafv2;
1061
1062impl ResourceProvisioner {
1063    /// Create a resource and return the StackResource with physical ID.
1064    pub fn create_resource(&self, resource: &ResourceDefinition) -> Result<StackResource, String> {
1065        let result = match resource.resource_type.as_str() {
1066            "AWS::SQS::Queue" => self.create_sqs_queue(resource),
1067            "AWS::SQS::QueuePolicy" => self.create_sqs_queue_policy(resource),
1068            "AWS::SNS::Topic" => self.create_sns_topic(resource),
1069            "AWS::SNS::TopicPolicy" => self.create_sns_topic_policy(resource),
1070            "AWS::SNS::Subscription" => self.create_sns_subscription(resource),
1071            "AWS::SSM::Parameter" => self.create_ssm_parameter(resource),
1072            "AWS::IAM::Role" => self.create_iam_role(resource),
1073            "AWS::IAM::Policy" => self.create_iam_policy(resource),
1074            "AWS::IAM::User" => self.create_iam_user(resource),
1075            "AWS::IAM::Group" => self.create_iam_group(resource),
1076            "AWS::IAM::ManagedPolicy" => self.create_iam_managed_policy(resource),
1077            "AWS::IAM::UserToGroupAddition" => self.create_iam_user_to_group_addition(resource),
1078            "AWS::IAM::AccessKey" => self.create_iam_access_key(resource),
1079            "AWS::IAM::InstanceProfile" => self.create_iam_instance_profile(resource),
1080            "AWS::IAM::OIDCProvider" => self.create_iam_oidc_provider(resource),
1081            "AWS::IAM::SAMLProvider" => self.create_iam_saml_provider(resource),
1082            "AWS::IAM::ServiceLinkedRole" => self.create_iam_service_linked_role(resource),
1083            "AWS::IAM::VirtualMFADevice" => self.create_iam_virtual_mfa_device(resource),
1084            "AWS::S3::Bucket" => self.create_s3_bucket(resource),
1085            "AWS::S3::BucketPolicy" => self.create_s3_bucket_policy(resource),
1086            "AWS::Events::Rule" => self.create_eventbridge_rule(resource),
1087            "AWS::Events::Connection" => self.create_eventbridge_connection(resource),
1088            "AWS::Events::ApiDestination" => self.create_eventbridge_api_destination(resource),
1089            "AWS::Events::Archive" => self.create_eventbridge_archive(resource),
1090            "AWS::Events::EventBus" => self.create_eventbridge_event_bus(resource),
1091            "AWS::Events::EventBusPolicy" => self.create_eventbridge_event_bus_policy(resource),
1092            "AWS::Events::Endpoint" => self.create_eventbridge_endpoint(resource),
1093            "AWS::DynamoDB::Table" => self.create_dynamodb_table(resource),
1094            "AWS::Logs::LogGroup" => self.create_log_group(resource),
1095            "AWS::Logs::LogStream" => self.create_log_stream(resource),
1096            "AWS::Logs::MetricFilter" => self.create_metric_filter(resource),
1097            "AWS::Logs::SubscriptionFilter" => self.create_subscription_filter(resource),
1098            "AWS::Logs::Destination" => self.create_logs_destination(resource),
1099            "AWS::Logs::ResourcePolicy" => self.create_logs_resource_policy(resource),
1100            "AWS::Logs::QueryDefinition" => self.create_logs_query_definition(resource),
1101            "AWS::Logs::Delivery" => self.create_logs_delivery(resource),
1102            "AWS::Logs::DeliveryDestination" => self.create_logs_delivery_destination(resource),
1103            "AWS::Logs::DeliverySource" => self.create_logs_delivery_source(resource),
1104            "AWS::Lambda::Function" => self.create_lambda_function(resource),
1105            "AWS::Lambda::Permission" => self.create_lambda_permission(resource),
1106            "AWS::Lambda::EventSourceMapping" => self.create_lambda_event_source_mapping(resource),
1107            "AWS::Lambda::LayerVersion" => self.create_lambda_layer_version(resource),
1108            "AWS::Lambda::Url" => self.create_lambda_url(resource),
1109            "AWS::Lambda::Alias" => self.create_lambda_alias(resource),
1110            "AWS::Lambda::Version" => self.create_lambda_version(resource),
1111            "AWS::SecretsManager::Secret" => self.create_secrets_manager_secret(resource),
1112            "AWS::Kinesis::Stream" => self.create_kinesis_stream(resource),
1113            "AWS::Kinesis::StreamConsumer" => self.create_kinesis_stream_consumer(resource),
1114            "AWS::KMS::Key" => self.create_kms_key(resource),
1115            "AWS::KMS::Alias" => self.create_kms_alias(resource),
1116            "AWS::KMS::ReplicaKey" => self.create_kms_replica_key(resource),
1117            "AWS::ECR::Repository" => self.create_ecr_repository(resource),
1118            "AWS::ECR::RepositoryPolicy" => self.create_ecr_repository_policy(resource),
1119            "AWS::ECR::LifecyclePolicy" => self.create_ecr_lifecycle_policy(resource),
1120            "AWS::ECR::RegistryPolicy" => self.create_ecr_registry_policy(resource),
1121            "AWS::ECR::ReplicationConfiguration" => {
1122                self.create_ecr_replication_configuration(resource)
1123            }
1124            "AWS::ECR::RegistryScanningConfiguration" => {
1125                self.create_ecr_registry_scanning_configuration(resource)
1126            }
1127            "AWS::ECR::PullThroughCacheRule" => self.create_ecr_pull_through_cache_rule(resource),
1128            "AWS::CloudWatch::Alarm" => self.create_cloudwatch_alarm(resource),
1129            "AWS::CloudWatch::Dashboard" => self.create_cloudwatch_dashboard(resource),
1130            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
1131                self.create_elbv2_load_balancer(resource)
1132            }
1133            "AWS::ElasticLoadBalancingV2::TargetGroup" => self.create_elbv2_target_group(resource),
1134            "AWS::ElasticLoadBalancingV2::Listener" => self.create_elbv2_listener(resource),
1135            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
1136                self.create_elbv2_listener_rule(resource)
1137            }
1138            "AWS::ElasticLoadBalancingV2::ListenerCertificate" => {
1139                self.create_elbv2_listener_certificate(resource)
1140            }
1141            "AWS::ElasticLoadBalancingV2::TrustStore" => self.create_elbv2_trust_store(resource),
1142            "AWS::Organizations::Organization" => self.create_organization(resource),
1143            "AWS::Organizations::OrganizationalUnit" => self.create_organization_unit(resource),
1144            "AWS::Organizations::Account" => self.create_organization_account(resource),
1145            "AWS::Organizations::Policy" => self.create_organization_policy(resource),
1146            "AWS::Organizations::ResourcePolicy" => {
1147                self.create_organization_resource_policy(resource)
1148            }
1149            "AWS::Cognito::UserPool" => self.create_cognito_user_pool(resource),
1150            "AWS::Cognito::UserPoolClient" => self.create_cognito_user_pool_client(resource),
1151            "AWS::Cognito::UserPoolDomain" => self.create_cognito_user_pool_domain(resource),
1152            "AWS::Cognito::IdentityPool" => self.create_cognito_identity_pool(resource),
1153            "AWS::Cognito::IdentityPoolRoleAttachment" => {
1154                self.create_cognito_identity_pool_role_attachment(resource)
1155            }
1156            "AWS::RDS::DBSubnetGroup" => self.create_rds_subnet_group(resource),
1157            "AWS::RDS::DBParameterGroup" => self.create_rds_parameter_group(resource),
1158            "AWS::RDS::DBClusterParameterGroup" => {
1159                self.create_rds_cluster_parameter_group(resource)
1160            }
1161            "AWS::RDS::OptionGroup" => self.create_rds_option_group(resource),
1162            "AWS::RDS::EventSubscription" => self.create_rds_event_subscription(resource),
1163            "AWS::RDS::DBSecurityGroup" => self.create_rds_security_group(resource),
1164            "AWS::RDS::DBProxy" => self.create_rds_db_proxy(resource),
1165            "AWS::RDS::DBInstance" => self.create_rds_db_instance(resource),
1166            "AWS::RDS::DBCluster" => self.create_rds_db_cluster(resource),
1167            "AWS::AutoScaling::LaunchConfiguration" => {
1168                self.create_autoscaling_launch_configuration(resource)
1169            }
1170            "AWS::AutoScaling::AutoScalingGroup" => self.create_autoscaling_group(resource),
1171            "AWS::Batch::ComputeEnvironment" => self.create_batch_compute_environment(resource),
1172            "AWS::Batch::JobQueue" => self.create_batch_job_queue(resource),
1173            "AWS::Batch::JobDefinition" => self.create_batch_job_definition(resource),
1174            "AWS::Batch::SchedulingPolicy" => self.create_batch_scheduling_policy(resource),
1175            "AWS::EC2::VPC" => self.create_ec2_vpc(resource),
1176            "AWS::EC2::Instance" => self.create_ec2_instance(resource),
1177            "AWS::EC2::Subnet" => self.create_ec2_subnet(resource),
1178            "AWS::EC2::SecurityGroup" => self.create_ec2_security_group(resource),
1179            "AWS::EC2::InternetGateway" => self.create_ec2_internet_gateway(resource),
1180            "AWS::EC2::RouteTable" => self.create_ec2_route_table(resource),
1181            "AWS::ECS::Cluster" => self.create_ecs_cluster(resource),
1182            "AWS::ECS::TaskDefinition" => self.create_ecs_task_definition(resource),
1183            "AWS::ECS::Service" => self.create_ecs_service(resource),
1184            "AWS::ECS::CapacityProvider" => self.create_ecs_capacity_provider(resource),
1185            "AWS::CertificateManager::Certificate" => self.create_acm_certificate(resource),
1186            "AWS::CertificateManager::Account" => self.create_acm_account(resource),
1187            "AWS::ElastiCache::ParameterGroup" => self.create_ec_parameter_group(resource),
1188            "AWS::ElastiCache::SubnetGroup" => self.create_ec_subnet_group(resource),
1189            "AWS::ElastiCache::SecurityGroup" => self.create_ec_security_group(resource),
1190            "AWS::ElastiCache::User" => self.create_ec_user(resource),
1191            "AWS::ElastiCache::UserGroup" => self.create_ec_user_group(resource),
1192            "AWS::ElastiCache::CacheCluster" => self.create_ec_cache_cluster(resource),
1193            "AWS::ElastiCache::ReplicationGroup" => self.create_ec_replication_group(resource),
1194            "AWS::Route53::HostedZone" => self.create_route53_hosted_zone(resource),
1195            "AWS::Route53::RecordSet" => self.create_route53_record_set(resource),
1196            "AWS::Route53::HealthCheck" => self.create_route53_health_check(resource),
1197            "AWS::Route53::DNSSEC" => self.create_route53_dnssec(resource),
1198            "AWS::Route53::KeySigningKey" => self.create_route53_key_signing_key(resource),
1199            "AWS::CloudFront::CloudFrontOriginAccessIdentity" => {
1200                self.create_cf_origin_access_identity(resource)
1201            }
1202            "AWS::CloudFront::Distribution" => self.create_cf_distribution(resource),
1203            "AWS::CloudFront::OriginAccessControl" => {
1204                self.create_cf_origin_access_control(resource)
1205            }
1206            "AWS::CloudFront::PublicKey" => self.create_cf_public_key(resource),
1207            "AWS::CloudFront::KeyGroup" => self.create_cf_key_group(resource),
1208            "AWS::CloudFront::Function" => self.create_cf_function(resource),
1209            "AWS::CloudFront::CachePolicy" => self.create_cf_cache_policy(resource),
1210            "AWS::CloudFront::OriginRequestPolicy" => {
1211                self.create_cf_origin_request_policy(resource)
1212            }
1213            "AWS::CloudFront::ResponseHeadersPolicy" => {
1214                self.create_cf_response_headers_policy(resource)
1215            }
1216            "AWS::StepFunctions::StateMachine" => self.create_sfn_state_machine(resource),
1217            "AWS::StepFunctions::Activity" => self.create_sfn_activity(resource),
1218            "AWS::StepFunctions::StateMachineVersion" => self.create_sfn_version(resource),
1219            "AWS::StepFunctions::StateMachineAlias" => self.create_sfn_alias(resource),
1220            "AWS::WAFv2::WebACL" => self.create_wafv2_web_acl(resource),
1221            "AWS::WAFv2::IPSet" => self.create_wafv2_ip_set(resource),
1222            "AWS::WAFv2::RegexPatternSet" => self.create_wafv2_regex_pattern_set(resource),
1223            "AWS::WAFv2::RuleGroup" => self.create_wafv2_rule_group(resource),
1224            "AWS::WAFv2::LoggingConfiguration" => self.create_wafv2_logging_configuration(resource),
1225            "AWS::WAFv2::WebACLAssociation" => self.create_wafv2_web_acl_association(resource),
1226            "AWS::ApiGateway::RestApi" => self.create_apigw_rest_api(resource),
1227            "AWS::ApiGateway::Resource" => self.create_apigw_resource(resource),
1228            "AWS::ApiGateway::Method" => self.create_apigw_method(resource),
1229            "AWS::ApiGateway::Deployment" => self.create_apigw_deployment(resource),
1230            "AWS::ApiGateway::Stage" => self.create_apigw_stage(resource),
1231            "AWS::ApiGateway::Authorizer" => self.create_apigw_authorizer(resource),
1232            "AWS::ApiGateway::RequestValidator" => self.create_apigw_request_validator(resource),
1233            "AWS::ApiGateway::Model" => self.create_apigw_model(resource),
1234            "AWS::ApiGateway::GatewayResponse" => self.create_apigw_gateway_response(resource),
1235            "AWS::ApiGateway::UsagePlan" => self.create_apigw_usage_plan(resource),
1236            "AWS::ApiGateway::ApiKey" => self.create_apigw_api_key(resource),
1237            "AWS::ApiGateway::UsagePlanKey" => self.create_apigw_usage_plan_key(resource),
1238            "AWS::ApiGateway::DomainName" => self.create_apigw_domain_name(resource),
1239            "AWS::ApiGateway::BasePathMapping" => self.create_apigw_base_path_mapping(resource),
1240            "AWS::ApiGatewayV2::Api" => self.create_apigwv2_api(resource),
1241            "AWS::ApiGatewayV2::Route" => self.create_apigwv2_route(resource),
1242            "AWS::ApiGatewayV2::Integration" => self.create_apigwv2_integration(resource),
1243            "AWS::ApiGatewayV2::IntegrationResponse" => {
1244                self.create_apigwv2_integration_response(resource)
1245            }
1246            "AWS::ApiGatewayV2::RouteResponse" => self.create_apigwv2_route_response(resource),
1247            "AWS::ApiGatewayV2::Stage" => self.create_apigwv2_stage(resource),
1248            "AWS::ApiGatewayV2::Deployment" => self.create_apigwv2_deployment(resource),
1249            "AWS::ApiGatewayV2::Authorizer" => self.create_apigwv2_authorizer(resource),
1250            "AWS::ApiGatewayV2::DomainName" => self.create_apigwv2_domain_name(resource),
1251            "AWS::ApiGatewayV2::ApiMapping" => self.create_apigwv2_api_mapping(resource),
1252            "AWS::ApiGatewayV2::VpcLink" => self.create_apigwv2_vpc_link(resource),
1253            "AWS::ApiGatewayV2::Model" => self.create_apigwv2_model(resource),
1254            "AWS::SES::ConfigurationSet" => self.create_ses_configuration_set(resource),
1255            "AWS::SES::ConfigurationSetEventDestination" => {
1256                self.create_ses_event_destination(resource)
1257            }
1258            "AWS::SES::EmailIdentity" => self.create_ses_email_identity(resource),
1259            "AWS::SES::Template" => self.create_ses_template(resource),
1260            "AWS::SES::ContactList" => self.create_ses_contact_list(resource),
1261            "AWS::SES::DedicatedIpPool" => self.create_ses_dedicated_ip_pool(resource),
1262            "AWS::SES::ReceiptRule" => self.create_ses_receipt_rule(resource),
1263            "AWS::SES::ReceiptRuleSet" => self.create_ses_receipt_rule_set(resource),
1264            "AWS::SES::ReceiptFilter" => self.create_ses_receipt_filter(resource),
1265            "AWS::SES::VdmAttributes" => self.create_ses_vdm_attributes(resource),
1266            "AWS::SecretsManager::RotationSchedule" => {
1267                self.create_secrets_manager_rotation_schedule(resource)
1268            }
1269            "AWS::SecretsManager::ResourcePolicy" => {
1270                self.create_secrets_manager_resource_policy(resource)
1271            }
1272            "AWS::SecretsManager::SecretTargetAttachment" => {
1273                self.create_secrets_manager_target_attachment(resource)
1274            }
1275            "AWS::ApplicationAutoScaling::ScalableTarget" => {
1276                self.create_application_autoscaling_scalable_target(resource)
1277            }
1278            "AWS::ApplicationAutoScaling::ScalingPolicy" => {
1279                self.create_application_autoscaling_scaling_policy(resource)
1280            }
1281            "AWS::Athena::DataCatalog" => self.create_athena_data_catalog(resource),
1282            "AWS::Athena::NamedQuery" => self.create_athena_named_query(resource),
1283            "AWS::Athena::WorkGroup" => self.create_athena_work_group(resource),
1284            "AWS::Athena::PreparedStatement" => self.create_athena_prepared_statement(resource),
1285            "AWS::KinesisFirehose::DeliveryStream" => {
1286                self.create_firehose_delivery_stream(resource)
1287            }
1288            "AWS::Glue::Database" => self.create_glue_database(resource),
1289            "AWS::CloudFormation::Stack" => self.create_cloudformation_stack(resource),
1290            "AWS::Glue::Table" => self.create_glue_table(resource),
1291            "AWS::Glue::Partition" => self.create_glue_partition(resource),
1292            t if t.starts_with("Custom::") || t == "AWS::CloudFormation::CustomResource" => self
1293                .create_custom_resource(resource)
1294                .map(ProvisionResult::new),
1295            other => {
1296                // No provisioner for this type. Real CloudFormation provisions
1297                // many resource types fakecloud doesn't model, and SAM/CDK
1298                // output routinely includes ones like
1299                // `AWS::CloudFormation::WaitConditionHandle`; failing the whole
1300                // stack on each would block otherwise-valid templates. Match the
1301                // documented contract (docs/services/cloudformation.md): accept
1302                // and record the resource as provisioned with no backing state.
1303                // Dependent operations that need real state may still fail —
1304                // that is the honest outcome. The physical id is the logical id
1305                // so `Ref` on the resource resolves to a stable value.
1306                tracing::warn!(
1307                    resource_type = %other,
1308                    logical_id = %resource.logical_id,
1309                    "CloudFormation: no provisioner for resource type; recording it as provisioned with no backing state"
1310                );
1311                Ok(ProvisionResult::new(resource.logical_id.clone()))
1312            }
1313        };
1314
1315        let is_custom = resource.resource_type.starts_with("Custom::")
1316            || resource.resource_type == "AWS::CloudFormation::CustomResource";
1317        let service_token = if is_custom {
1318            resource
1319                .properties
1320                .get("ServiceToken")
1321                .and_then(|v| v.as_str())
1322                .map(|s| s.to_string())
1323        } else {
1324            None
1325        };
1326
1327        result.map(|res| StackResource {
1328            logical_id: resource.logical_id.clone(),
1329            physical_id: res.physical_id,
1330            resource_type: resource.resource_type.clone(),
1331            status: "CREATE_COMPLETE".to_string(),
1332            service_token,
1333            attributes: res.attributes,
1334        })
1335    }
1336
1337    /// Apply a property update to an existing stack resource. Returns
1338    /// `Ok(Some(updated))` when the resource type supports in-place updates
1339    /// (the caller swaps the resulting `StackResource` for the old one) or
1340    /// `Ok(None)` when the type has no update path defined (the caller
1341    /// leaves the existing resource alone). `Err` propagates a
1342    /// resource-level failure up to the stack-level UPDATE_FAILED status.
1343    pub fn update_resource(
1344        &self,
1345        existing: &StackResource,
1346        new_def: &ResourceDefinition,
1347    ) -> Result<Option<StackResource>, String> {
1348        let result = match new_def.resource_type.as_str() {
1349            "AWS::Lambda::Function" => Some(self.update_lambda_function(existing, new_def)?),
1350            "AWS::Lambda::Permission" => Some(self.update_lambda_permission(existing, new_def)?),
1351            "AWS::Lambda::EventSourceMapping" => {
1352                Some(self.update_lambda_event_source_mapping(existing, new_def)?)
1353            }
1354            "AWS::Lambda::LayerVersion" => {
1355                Some(self.update_lambda_layer_version(existing, new_def)?)
1356            }
1357            "AWS::Lambda::Url" => Some(self.update_lambda_url(existing, new_def)?),
1358            "AWS::Lambda::Alias" => Some(self.update_lambda_alias(existing, new_def)?),
1359            "AWS::Lambda::Version" => Some(self.update_lambda_version(existing, new_def)?),
1360            "AWS::IAM::Role" => Some(self.update_iam_role(existing, new_def)?),
1361            "AWS::IAM::Policy" => Some(self.update_iam_policy(existing, new_def)?),
1362            "AWS::IAM::ManagedPolicy" => Some(self.update_iam_policy(existing, new_def)?),
1363            "AWS::ApiGateway::RestApi" => Some(self.update_apigw_rest_api(existing, new_def)?),
1364            "AWS::ApiGateway::Resource" => Some(self.update_apigw_resource(existing, new_def)?),
1365            "AWS::ApiGateway::Method" => Some(self.update_apigw_method(existing, new_def)?),
1366            "AWS::ApiGateway::Deployment" => Some(self.update_apigw_deployment(existing, new_def)?),
1367            "AWS::ApiGateway::Stage" => Some(self.update_apigw_stage(existing, new_def)?),
1368            "AWS::ApiGateway::Authorizer" => Some(self.update_apigw_authorizer(existing, new_def)?),
1369            "AWS::ApiGateway::RequestValidator" => {
1370                Some(self.update_apigw_request_validator(existing, new_def)?)
1371            }
1372            "AWS::ApiGateway::Model" => Some(self.update_apigw_model(existing, new_def)?),
1373            "AWS::ApiGateway::GatewayResponse" => {
1374                Some(self.update_apigw_gateway_response(existing, new_def)?)
1375            }
1376            "AWS::ApiGateway::UsagePlan" => Some(self.update_apigw_usage_plan(existing, new_def)?),
1377            "AWS::ApiGateway::ApiKey" => Some(self.update_apigw_api_key(existing, new_def)?),
1378            "AWS::ApiGateway::UsagePlanKey" => {
1379                Some(self.update_apigw_usage_plan_key(existing, new_def)?)
1380            }
1381            "AWS::ApiGateway::DomainName" => {
1382                Some(self.update_apigw_domain_name(existing, new_def)?)
1383            }
1384            "AWS::ApiGateway::BasePathMapping" => {
1385                Some(self.update_apigw_base_path_mapping(existing, new_def)?)
1386            }
1387            "AWS::ApiGatewayV2::Api" => Some(self.update_apigwv2_api(existing, new_def)?),
1388            "AWS::ApiGatewayV2::Route" => Some(self.update_apigwv2_route(existing, new_def)?),
1389            "AWS::ApiGatewayV2::Integration" => {
1390                Some(self.update_apigwv2_integration(existing, new_def)?)
1391            }
1392            "AWS::ApiGatewayV2::IntegrationResponse" => {
1393                Some(self.update_apigwv2_integration_response(existing, new_def)?)
1394            }
1395            "AWS::ApiGatewayV2::RouteResponse" => {
1396                Some(self.update_apigwv2_route_response(existing, new_def)?)
1397            }
1398            "AWS::ApiGatewayV2::Stage" => Some(self.update_apigwv2_stage(existing, new_def)?),
1399            "AWS::ApiGatewayV2::Deployment" => {
1400                Some(self.update_apigwv2_deployment(existing, new_def)?)
1401            }
1402            "AWS::ApiGatewayV2::Authorizer" => {
1403                Some(self.update_apigwv2_authorizer(existing, new_def)?)
1404            }
1405            "AWS::ApiGatewayV2::DomainName" => {
1406                Some(self.update_apigwv2_domain_name(existing, new_def)?)
1407            }
1408            "AWS::ApiGatewayV2::ApiMapping" => {
1409                Some(self.update_apigwv2_api_mapping(existing, new_def)?)
1410            }
1411            "AWS::ApiGatewayV2::VpcLink" => Some(self.update_apigwv2_vpc_link(existing, new_def)?),
1412            "AWS::ApiGatewayV2::Model" => Some(self.update_apigwv2_model(existing, new_def)?),
1413            "AWS::ECS::Cluster" => Some(self.update_ecs_cluster(existing, new_def)?),
1414            "AWS::ECS::Service" => Some(self.update_ecs_service(existing, new_def)?),
1415            "AWS::ECS::TaskDefinition" => Some(self.update_ecs_task_definition(existing, new_def)?),
1416            "AWS::ECS::CapacityProvider" => {
1417                Some(self.update_ecs_capacity_provider(existing, new_def)?)
1418            }
1419            "AWS::ECR::Repository" => Some(self.update_ecr_repository(existing, new_def)?),
1420            "AWS::ECR::RepositoryPolicy" => {
1421                Some(self.update_ecr_repository_policy(existing, new_def)?)
1422            }
1423            "AWS::ECR::LifecyclePolicy" => {
1424                Some(self.update_ecr_lifecycle_policy(existing, new_def)?)
1425            }
1426            "AWS::ECR::RegistryPolicy" => Some(self.update_ecr_registry_policy(existing, new_def)?),
1427            "AWS::ECR::ReplicationConfiguration" => {
1428                Some(self.update_ecr_replication_configuration(existing, new_def)?)
1429            }
1430            "AWS::ECR::RegistryScanningConfiguration" => {
1431                Some(self.update_ecr_registry_scanning_configuration(existing, new_def)?)
1432            }
1433            "AWS::ECR::PullThroughCacheRule" => {
1434                Some(self.update_ecr_pull_through_cache_rule(existing, new_def)?)
1435            }
1436            "AWS::KMS::Key" => Some(self.update_kms_key(existing, new_def)?),
1437            "AWS::KMS::ReplicaKey" => Some(self.update_kms_replica_key(existing, new_def)?),
1438            "AWS::KMS::Alias" => Some(self.update_kms_alias(existing, new_def)?),
1439            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
1440                Some(self.update_elbv2_load_balancer(existing, new_def)?)
1441            }
1442            "AWS::ElasticLoadBalancingV2::TargetGroup" => {
1443                Some(self.update_elbv2_target_group(existing, new_def)?)
1444            }
1445            "AWS::ElasticLoadBalancingV2::Listener" => {
1446                Some(self.update_elbv2_listener(existing, new_def)?)
1447            }
1448            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
1449                Some(self.update_elbv2_listener_rule(existing, new_def)?)
1450            }
1451            "AWS::ElasticLoadBalancingV2::ListenerCertificate" => {
1452                Some(self.update_elbv2_listener_certificate(existing, new_def)?)
1453            }
1454            "AWS::ElasticLoadBalancingV2::TrustStore" => {
1455                Some(self.update_elbv2_trust_store(existing, new_def)?)
1456            }
1457            "AWS::CloudWatch::Alarm" => Some(self.update_cloudwatch_alarm(existing, new_def)?),
1458            "AWS::CloudWatch::Dashboard" => {
1459                Some(self.update_cloudwatch_dashboard(existing, new_def)?)
1460            }
1461            "AWS::StepFunctions::StateMachine" => {
1462                Some(self.update_sfn_state_machine(existing, new_def)?)
1463            }
1464            "AWS::SQS::Queue" => Some(self.update_sqs_queue(existing, new_def)?),
1465            "AWS::SQS::QueuePolicy" => Some(self.update_sqs_queue_policy(existing, new_def)?),
1466            "AWS::SNS::Topic" => Some(self.update_sns_topic(existing, new_def)?),
1467            "AWS::SNS::TopicPolicy" => Some(self.update_sns_topic_policy(existing, new_def)?),
1468            "AWS::S3::BucketPolicy" => Some(self.update_s3_bucket_policy(existing, new_def)?),
1469            _ => None,
1470        };
1471
1472        Ok(result.map(|res| StackResource {
1473            logical_id: existing.logical_id.clone(),
1474            physical_id: res.physical_id,
1475            resource_type: existing.resource_type.clone(),
1476            status: "UPDATE_COMPLETE".to_string(),
1477            service_token: existing.service_token.clone(),
1478            attributes: res.attributes,
1479        }))
1480    }
1481
1482    /// Resolve a `Fn::GetAtt` against a previously provisioned resource.
1483    /// Returns the attribute value as a string, or `None` if the resource
1484    /// type doesn't expose that attribute (caller falls back to a placeholder
1485    /// so multi-pass provisioning can retry).
1486    ///
1487    /// The lookup first checks attributes captured at create time on the
1488    /// `StackResource`, then falls back to live service-state queries for
1489    /// the well-known attribute names of each resource type. This means
1490    /// attributes that change after creation (e.g. Lambda `FunctionUrl`)
1491    /// resolve correctly even when the URL was added in a separate pass.
1492    pub fn get_att(&self, resource: &StackResource, attribute: &str) -> Option<String> {
1493        // Captured attributes are the source of truth — they were computed
1494        // at create time and never go stale for the resources we ship today.
1495        if let Some(v) = resource.attributes.get(attribute) {
1496            return Some(v.clone());
1497        }
1498        // Live-state fallback for attributes that aren't pre-captured. This
1499        // is the extension point for future provisioners.
1500        match resource.resource_type.as_str() {
1501            "AWS::S3::Bucket" => self.get_att_s3_bucket(&resource.physical_id, attribute),
1502            "AWS::Lambda::Function" => {
1503                self.get_att_lambda_function(&resource.physical_id, attribute)
1504            }
1505            "AWS::IAM::Role" => self.get_att_iam_role(&resource.physical_id, attribute),
1506            "AWS::SQS::Queue" => self.get_att_sqs_queue(&resource.physical_id, attribute),
1507            "AWS::SNS::Topic" => self.get_att_sns_topic(&resource.physical_id, attribute),
1508            "AWS::DynamoDB::Table" => self.get_att_dynamodb_table(&resource.physical_id, attribute),
1509            "AWS::KMS::Key" => self.get_att_kms_key(&resource.physical_id, attribute),
1510            "AWS::SecretsManager::Secret" => {
1511                self.get_att_secrets_manager_secret(&resource.physical_id, attribute)
1512            }
1513            "AWS::CloudFront::Distribution" => {
1514                self.get_att_cf_distribution(&resource.physical_id, attribute)
1515            }
1516            "AWS::ECS::Cluster" => self.get_att_ecs_cluster(&resource.physical_id, attribute),
1517            "AWS::ECS::Service" => self.get_att_ecs_service(&resource.physical_id, attribute),
1518            "AWS::EC2::VPC"
1519            | "AWS::EC2::Subnet"
1520            | "AWS::EC2::SecurityGroup"
1521            | "AWS::EC2::InternetGateway"
1522            | "AWS::EC2::Instance"
1523            | "AWS::EC2::RouteTable" => self.get_att_ec2(resource, attribute),
1524            "AWS::ECS::CapacityProvider" => {
1525                self.get_att_ecs_capacity_provider(&resource.physical_id, attribute)
1526            }
1527            "AWS::ECR::Repository" => self.get_att_ecr_repository(&resource.physical_id, attribute),
1528            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
1529                self.get_att_elbv2_load_balancer(&resource.physical_id, attribute)
1530            }
1531            "AWS::ElasticLoadBalancingV2::TargetGroup" => {
1532                self.get_att_elbv2_target_group(&resource.physical_id, attribute)
1533            }
1534            "AWS::ElasticLoadBalancingV2::Listener" => {
1535                self.get_att_elbv2_listener(&resource.physical_id, attribute)
1536            }
1537            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
1538                self.get_att_elbv2_listener_rule(&resource.physical_id, attribute)
1539            }
1540            "AWS::ElasticLoadBalancingV2::TrustStore" => {
1541                self.get_att_elbv2_trust_store(&resource.physical_id, attribute)
1542            }
1543            "AWS::WAFv2::WebACL" => self.get_att_wafv2_web_acl(&resource.physical_id, attribute),
1544            "AWS::WAFv2::IPSet" => self.get_att_wafv2_ip_set(&resource.physical_id, attribute),
1545            "AWS::WAFv2::RegexPatternSet" => {
1546                self.get_att_wafv2_regex_pattern_set(&resource.physical_id, attribute)
1547            }
1548            "AWS::WAFv2::RuleGroup" => {
1549                self.get_att_wafv2_rule_group(&resource.physical_id, attribute)
1550            }
1551            "AWS::SES::ConfigurationSet" => {
1552                self.get_att_ses_configuration_set(&resource.physical_id, attribute)
1553            }
1554            "AWS::SES::EmailIdentity" => {
1555                self.get_att_ses_email_identity(&resource.physical_id, attribute)
1556            }
1557            "AWS::SES::Template" => self.get_att_ses_template(&resource.physical_id, attribute),
1558            "AWS::SES::ContactList" => {
1559                self.get_att_ses_contact_list(&resource.physical_id, attribute)
1560            }
1561            "AWS::SES::DedicatedIpPool" => {
1562                self.get_att_ses_dedicated_ip_pool(&resource.physical_id, attribute)
1563            }
1564            "AWS::SES::ReceiptRuleSet" => {
1565                self.get_att_ses_receipt_rule_set(&resource.physical_id, attribute)
1566            }
1567            "AWS::Athena::DataCatalog" => {
1568                self.get_att_athena_data_catalog(&resource.physical_id, attribute)
1569            }
1570            "AWS::Athena::NamedQuery" => {
1571                self.get_att_athena_named_query(&resource.physical_id, attribute)
1572            }
1573            "AWS::Athena::WorkGroup" => {
1574                self.get_att_athena_work_group(&resource.physical_id, attribute)
1575            }
1576            "AWS::Athena::PreparedStatement" => {
1577                self.get_att_athena_prepared_statement(&resource.physical_id, attribute)
1578            }
1579            "AWS::CloudFormation::Stack" => {
1580                self.get_att_cloudformation_stack(&resource.physical_id, attribute)
1581            }
1582            _ => None,
1583        }
1584    }
1585
1586    fn get_att_cf_distribution(&self, physical_id: &str, attribute: &str) -> Option<String> {
1587        // CloudFront state is keyed under a fixed "000000000000" account in the
1588        // fakecloud_cloudfront crate; matching create_cf_distribution above.
1589        let accounts = self.cloudfront_state.read();
1590        let state = accounts.get("000000000000")?;
1591        let dist = state.distributions.get(physical_id)?;
1592        match attribute {
1593            "DomainName" => Some(dist.domain_name.clone()),
1594            "Id" => Some(dist.id.clone()),
1595            _ => None,
1596        }
1597    }
1598
1599    /// Delete a previously created resource.
1600    pub fn delete_resource(&self, resource: &StackResource) -> Result<(), String> {
1601        match resource.resource_type.as_str() {
1602            "AWS::SQS::Queue" => self.delete_sqs_queue(&resource.physical_id),
1603            "AWS::SQS::QueuePolicy" => self.delete_sqs_queue_policy(&resource.physical_id),
1604            "AWS::SNS::Topic" => self.delete_sns_topic(&resource.physical_id),
1605            "AWS::SNS::TopicPolicy" => self.delete_sns_topic_policy(&resource.physical_id),
1606            "AWS::SNS::Subscription" => self.delete_sns_subscription(&resource.physical_id),
1607            "AWS::SSM::Parameter" => self.delete_ssm_parameter(&resource.physical_id),
1608            "AWS::IAM::Role" => self.delete_iam_role(&resource.physical_id),
1609            "AWS::IAM::Policy" => self.delete_iam_policy(&resource.physical_id),
1610            "AWS::IAM::User" => self.delete_iam_user(&resource.physical_id),
1611            "AWS::IAM::Group" => self.delete_iam_group(&resource.physical_id),
1612            "AWS::IAM::ManagedPolicy" => self.delete_iam_managed_policy(&resource.physical_id),
1613            "AWS::IAM::UserToGroupAddition" => {
1614                self.delete_iam_user_to_group_addition(&resource.physical_id)
1615            }
1616            "AWS::IAM::AccessKey" => self.delete_iam_access_key(&resource.physical_id),
1617            "AWS::IAM::InstanceProfile" => self.delete_iam_instance_profile(&resource.physical_id),
1618            "AWS::IAM::OIDCProvider" => self.delete_iam_oidc_provider(&resource.physical_id),
1619            "AWS::IAM::SAMLProvider" => self.delete_iam_saml_provider(&resource.physical_id),
1620            "AWS::IAM::ServiceLinkedRole" => {
1621                self.delete_iam_service_linked_role(&resource.physical_id)
1622            }
1623            "AWS::IAM::VirtualMFADevice" => {
1624                self.delete_iam_virtual_mfa_device(&resource.physical_id)
1625            }
1626            "AWS::S3::Bucket" => self.delete_s3_bucket(&resource.physical_id),
1627            "AWS::S3::BucketPolicy" => self.delete_s3_bucket_policy(&resource.physical_id),
1628            "AWS::Events::Rule" => self.delete_eventbridge_rule(&resource.physical_id),
1629            "AWS::Events::Connection" => self.delete_eventbridge_connection(&resource.physical_id),
1630            "AWS::Events::EventBus" => self.delete_eventbridge_event_bus(&resource.physical_id),
1631            "AWS::Events::EventBusPolicy" => {
1632                self.delete_eventbridge_event_bus_policy(&resource.physical_id)
1633            }
1634            "AWS::Events::Endpoint" => self.delete_eventbridge_endpoint(&resource.physical_id),
1635            "AWS::Events::ApiDestination" => {
1636                self.delete_eventbridge_api_destination(&resource.physical_id)
1637            }
1638            "AWS::Events::Archive" => self.delete_eventbridge_archive(&resource.physical_id),
1639            "AWS::DynamoDB::Table" => self.delete_dynamodb_table(&resource.physical_id),
1640            "AWS::Logs::LogGroup" => self.delete_log_group(&resource.physical_id),
1641            "AWS::Logs::LogStream" => self.delete_log_stream(&resource.physical_id),
1642            "AWS::Logs::MetricFilter" => self.delete_metric_filter(&resource.physical_id),
1643            "AWS::Logs::SubscriptionFilter" => {
1644                self.delete_subscription_filter(&resource.physical_id)
1645            }
1646            "AWS::Logs::Destination" => self.delete_logs_destination(&resource.physical_id),
1647            "AWS::Logs::ResourcePolicy" => self.delete_logs_resource_policy(&resource.physical_id),
1648            "AWS::Logs::QueryDefinition" => {
1649                self.delete_logs_query_definition(&resource.physical_id)
1650            }
1651            "AWS::Logs::Delivery" => self.delete_logs_delivery(&resource.physical_id),
1652            "AWS::Logs::DeliveryDestination" => {
1653                self.delete_logs_delivery_destination(&resource.physical_id)
1654            }
1655            "AWS::Logs::DeliverySource" => self.delete_logs_delivery_source(&resource.physical_id),
1656            "AWS::Lambda::Function" => self.delete_lambda_function(&resource.physical_id),
1657            "AWS::Lambda::Permission" => self.delete_lambda_permission(&resource.physical_id),
1658            "AWS::Lambda::EventSourceMapping" => {
1659                self.delete_lambda_event_source_mapping(&resource.physical_id)
1660            }
1661            "AWS::Lambda::LayerVersion" => self.delete_lambda_layer_version(&resource.physical_id),
1662            "AWS::Lambda::Url" => self.delete_lambda_url(&resource.physical_id),
1663            "AWS::Lambda::Alias" => self.delete_lambda_alias(&resource.physical_id),
1664            "AWS::Lambda::Version" => self.delete_lambda_version(&resource.physical_id),
1665            "AWS::SecretsManager::Secret" => {
1666                self.delete_secrets_manager_secret(&resource.physical_id)
1667            }
1668            "AWS::Kinesis::Stream" => self.delete_kinesis_stream(&resource.physical_id),
1669            "AWS::Kinesis::StreamConsumer" => {
1670                self.delete_kinesis_stream_consumer(&resource.physical_id)
1671            }
1672            "AWS::KMS::Key" => self.delete_kms_key(&resource.physical_id),
1673            "AWS::KMS::ReplicaKey" => self.delete_kms_replica_key(&resource.physical_id),
1674            "AWS::KMS::Alias" => self.delete_kms_alias(&resource.physical_id),
1675            "AWS::ECR::Repository" => self.delete_ecr_repository(&resource.physical_id),
1676            "AWS::ECR::RepositoryPolicy" => {
1677                self.delete_ecr_repository_policy(&resource.physical_id)
1678            }
1679            "AWS::ECR::LifecyclePolicy" => self.delete_ecr_lifecycle_policy(&resource.physical_id),
1680            "AWS::ECR::RegistryPolicy" => self.delete_ecr_registry_policy(),
1681            "AWS::ECR::ReplicationConfiguration" => self.delete_ecr_replication_configuration(),
1682            "AWS::ECR::RegistryScanningConfiguration" => {
1683                self.delete_ecr_registry_scanning_configuration()
1684            }
1685            "AWS::ECR::PullThroughCacheRule" => {
1686                self.delete_ecr_pull_through_cache_rule(&resource.physical_id)
1687            }
1688            "AWS::CloudWatch::Alarm" => self.delete_cloudwatch_alarm(&resource.physical_id),
1689            "AWS::CloudWatch::Dashboard" => self.delete_cloudwatch_dashboard(&resource.physical_id),
1690            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
1691                self.delete_elbv2_load_balancer(&resource.physical_id)
1692            }
1693            "AWS::ElasticLoadBalancingV2::TargetGroup" => {
1694                self.delete_elbv2_target_group(&resource.physical_id)
1695            }
1696            "AWS::ElasticLoadBalancingV2::Listener" => {
1697                self.delete_elbv2_listener(&resource.physical_id)
1698            }
1699            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
1700                self.delete_elbv2_listener_rule(&resource.physical_id)
1701            }
1702            "AWS::ElasticLoadBalancingV2::ListenerCertificate" => {
1703                self.delete_elbv2_listener_certificate(&resource.physical_id)
1704            }
1705            "AWS::ElasticLoadBalancingV2::TrustStore" => {
1706                self.delete_elbv2_trust_store(&resource.physical_id)
1707            }
1708            "AWS::Organizations::Organization" => self.delete_organization(&resource.physical_id),
1709            "AWS::Organizations::OrganizationalUnit" => {
1710                self.delete_organization_unit(&resource.physical_id)
1711            }
1712            "AWS::Organizations::Account" => {
1713                self.delete_organization_account(&resource.physical_id)
1714            }
1715            "AWS::Organizations::Policy" => self.delete_organization_policy(&resource.physical_id),
1716            "AWS::Organizations::ResourcePolicy" => {
1717                self.delete_organization_resource_policy(&resource.physical_id)
1718            }
1719            "AWS::Cognito::UserPool" => self.delete_cognito_user_pool(&resource.physical_id),
1720            "AWS::Cognito::UserPoolClient" => {
1721                self.delete_cognito_user_pool_client(&resource.physical_id)
1722            }
1723            "AWS::Cognito::UserPoolDomain" => {
1724                self.delete_cognito_user_pool_domain(&resource.physical_id)
1725            }
1726            "AWS::Cognito::IdentityPool" => {
1727                self.delete_cognito_identity_pool(&resource.physical_id)
1728            }
1729            "AWS::Cognito::IdentityPoolRoleAttachment" => {
1730                self.delete_cognito_identity_pool_role_attachment(&resource.physical_id)
1731            }
1732            "AWS::RDS::DBSubnetGroup" => self.delete_rds_subnet_group(&resource.physical_id),
1733            "AWS::RDS::DBParameterGroup" => self.delete_rds_parameter_group(&resource.physical_id),
1734            "AWS::RDS::DBClusterParameterGroup" => {
1735                self.delete_rds_cluster_parameter_group(&resource.physical_id)
1736            }
1737            "AWS::RDS::OptionGroup" => self.delete_rds_option_group(&resource.physical_id),
1738            "AWS::RDS::EventSubscription" => {
1739                self.delete_rds_event_subscription(&resource.physical_id)
1740            }
1741            "AWS::RDS::DBSecurityGroup" => self.delete_rds_security_group(&resource.physical_id),
1742            "AWS::RDS::DBProxy" => self.delete_rds_db_proxy(&resource.physical_id),
1743            "AWS::RDS::DBInstance" => self.delete_rds_db_instance(&resource.physical_id),
1744            "AWS::RDS::DBCluster" => self.delete_rds_db_cluster(&resource.physical_id),
1745            "AWS::EC2::Instance" => {
1746                // Queue terminating the REAL instance + reaping its backing
1747                // container so the stack delete drain doesn't leak an EC2
1748                // container.
1749                self.pending_container_teardowns.lock().push(
1750                    ContainerTeardownIntent::Ec2Instance {
1751                        instance_id: resource.physical_id.clone(),
1752                    },
1753                );
1754                Ok(())
1755            }
1756            "AWS::EC2::VPC"
1757            | "AWS::EC2::Subnet"
1758            | "AWS::EC2::SecurityGroup"
1759            | "AWS::EC2::InternetGateway"
1760            | "AWS::EC2::RouteTable" => {
1761                self.delete_ec2_resource(&resource.resource_type, &resource.physical_id)
1762            }
1763            "AWS::AutoScaling::LaunchConfiguration" | "AWS::AutoScaling::AutoScalingGroup" => {
1764                self.delete_autoscaling(&resource.resource_type, &resource.physical_id);
1765                Ok(())
1766            }
1767            "AWS::Batch::ComputeEnvironment"
1768            | "AWS::Batch::JobQueue"
1769            | "AWS::Batch::JobDefinition"
1770            | "AWS::Batch::SchedulingPolicy" => {
1771                self.delete_batch(&resource.resource_type, &resource.physical_id);
1772                Ok(())
1773            }
1774            "AWS::ECS::Cluster" => self.delete_ecs_cluster(&resource.physical_id),
1775            "AWS::ECS::TaskDefinition" => self.delete_ecs_task_definition(&resource.physical_id),
1776            "AWS::ECS::Service" => self.delete_ecs_service(&resource.physical_id),
1777            "AWS::ECS::CapacityProvider" => {
1778                self.delete_ecs_capacity_provider(&resource.physical_id)
1779            }
1780            "AWS::CertificateManager::Certificate" => {
1781                self.delete_acm_certificate(&resource.physical_id)
1782            }
1783            "AWS::CertificateManager::Account" => self.delete_acm_account(),
1784            "AWS::ElastiCache::ParameterGroup" => {
1785                self.delete_ec_parameter_group(&resource.physical_id)
1786            }
1787            "AWS::ElastiCache::SubnetGroup" => self.delete_ec_subnet_group(&resource.physical_id),
1788            "AWS::ElastiCache::SecurityGroup" => {
1789                self.delete_ec_security_group(&resource.physical_id)
1790            }
1791            "AWS::ElastiCache::User" => self.delete_ec_user(&resource.physical_id),
1792            "AWS::ElastiCache::UserGroup" => self.delete_ec_user_group(&resource.physical_id),
1793            "AWS::ElastiCache::CacheCluster" => self.delete_ec_cache_cluster(&resource.physical_id),
1794            "AWS::ElastiCache::ReplicationGroup" => {
1795                self.delete_ec_replication_group(&resource.physical_id)
1796            }
1797            "AWS::Route53::HostedZone" => self.delete_route53_hosted_zone(&resource.physical_id),
1798            "AWS::Route53::RecordSet" => {
1799                self.delete_route53_record_set(&resource.physical_id, &resource.attributes)
1800            }
1801            "AWS::Route53::HealthCheck" => self.delete_route53_health_check(&resource.physical_id),
1802            "AWS::Route53::DNSSEC" => self.delete_route53_dnssec(&resource.physical_id),
1803            "AWS::Route53::KeySigningKey" => {
1804                self.delete_route53_key_signing_key(&resource.physical_id)
1805            }
1806            "AWS::CloudFront::CloudFrontOriginAccessIdentity" => {
1807                self.delete_cf_origin_access_identity(&resource.physical_id)
1808            }
1809            "AWS::CloudFront::Distribution" => self.delete_cf_distribution(&resource.physical_id),
1810            "AWS::CloudFront::OriginAccessControl" => {
1811                self.delete_cf_origin_access_control(&resource.physical_id)
1812            }
1813            "AWS::CloudFront::PublicKey" => self.delete_cf_public_key(&resource.physical_id),
1814            "AWS::CloudFront::KeyGroup" => self.delete_cf_key_group(&resource.physical_id),
1815            "AWS::CloudFront::Function" => self.delete_cf_function(&resource.physical_id),
1816            "AWS::CloudFront::CachePolicy" => self.delete_cf_cache_policy(&resource.physical_id),
1817            "AWS::CloudFront::OriginRequestPolicy" => {
1818                self.delete_cf_origin_request_policy(&resource.physical_id)
1819            }
1820            "AWS::CloudFront::ResponseHeadersPolicy" => {
1821                self.delete_cf_response_headers_policy(&resource.physical_id)
1822            }
1823            "AWS::StepFunctions::StateMachine" => {
1824                self.delete_sfn_state_machine(&resource.physical_id)
1825            }
1826            "AWS::StepFunctions::Activity" => self.delete_sfn_activity(&resource.physical_id),
1827            "AWS::StepFunctions::StateMachineVersion" => {
1828                self.delete_sfn_version(&resource.physical_id)
1829            }
1830            "AWS::StepFunctions::StateMachineAlias" => self.delete_sfn_alias(&resource.physical_id),
1831            "AWS::WAFv2::WebACL" => self.delete_wafv2_web_acl(&resource.physical_id),
1832            "AWS::WAFv2::IPSet" => self.delete_wafv2_ip_set(&resource.physical_id),
1833            "AWS::WAFv2::RegexPatternSet" => {
1834                self.delete_wafv2_regex_pattern_set(&resource.physical_id)
1835            }
1836            "AWS::WAFv2::RuleGroup" => self.delete_wafv2_rule_group(&resource.physical_id),
1837            "AWS::WAFv2::LoggingConfiguration" => {
1838                self.delete_wafv2_logging_configuration(&resource.physical_id)
1839            }
1840            "AWS::WAFv2::WebACLAssociation" => {
1841                self.delete_wafv2_web_acl_association(&resource.physical_id)
1842            }
1843            "AWS::ApiGateway::RestApi" => self.delete_apigw_rest_api(&resource.physical_id),
1844            "AWS::ApiGateway::Resource" => {
1845                self.delete_apigw_resource(&resource.physical_id, &resource.attributes)
1846            }
1847            "AWS::ApiGateway::Method" => self.delete_apigw_method(&resource.physical_id),
1848            "AWS::ApiGateway::Deployment" => {
1849                self.delete_apigw_deployment(&resource.physical_id, &resource.attributes)
1850            }
1851            "AWS::ApiGateway::Stage" => {
1852                self.delete_apigw_stage(&resource.physical_id, &resource.attributes)
1853            }
1854            "AWS::ApiGateway::Authorizer" => {
1855                self.delete_apigw_authorizer(&resource.physical_id, &resource.attributes)
1856            }
1857            "AWS::ApiGateway::RequestValidator" => {
1858                self.delete_apigw_request_validator(&resource.physical_id, &resource.attributes)
1859            }
1860            "AWS::ApiGateway::Model" => {
1861                self.delete_apigw_model(&resource.physical_id, &resource.attributes)
1862            }
1863            "AWS::ApiGateway::GatewayResponse" => {
1864                self.delete_apigw_gateway_response(&resource.physical_id, &resource.attributes)
1865            }
1866            "AWS::ApiGateway::UsagePlan" => self.delete_apigw_usage_plan(&resource.physical_id),
1867            "AWS::ApiGateway::ApiKey" => self.delete_apigw_api_key(&resource.physical_id),
1868            "AWS::ApiGateway::UsagePlanKey" => {
1869                self.delete_apigw_usage_plan_key(&resource.physical_id, &resource.attributes)
1870            }
1871            "AWS::ApiGateway::DomainName" => self.delete_apigw_domain_name(&resource.physical_id),
1872            "AWS::ApiGateway::BasePathMapping" => {
1873                self.delete_apigw_base_path_mapping(&resource.physical_id, &resource.attributes)
1874            }
1875            "AWS::ApiGatewayV2::Api" => self.delete_apigwv2_api(&resource.physical_id),
1876            "AWS::ApiGatewayV2::Route" => {
1877                self.delete_apigwv2_route(&resource.physical_id, &resource.attributes)
1878            }
1879            "AWS::ApiGatewayV2::Integration" => {
1880                self.delete_apigwv2_integration(&resource.physical_id, &resource.attributes)
1881            }
1882            "AWS::ApiGatewayV2::IntegrationResponse" => self
1883                .delete_apigwv2_integration_response(&resource.physical_id, &resource.attributes),
1884            "AWS::ApiGatewayV2::RouteResponse" => {
1885                self.delete_apigwv2_route_response(&resource.physical_id, &resource.attributes)
1886            }
1887            "AWS::ApiGatewayV2::Stage" => {
1888                self.delete_apigwv2_stage(&resource.physical_id, &resource.attributes)
1889            }
1890            "AWS::ApiGatewayV2::Deployment" => {
1891                self.delete_apigwv2_deployment(&resource.physical_id, &resource.attributes)
1892            }
1893            "AWS::ApiGatewayV2::Authorizer" => {
1894                self.delete_apigwv2_authorizer(&resource.physical_id, &resource.attributes)
1895            }
1896            "AWS::ApiGatewayV2::DomainName" => {
1897                self.delete_apigwv2_domain_name(&resource.physical_id)
1898            }
1899            "AWS::ApiGatewayV2::ApiMapping" => {
1900                self.delete_apigwv2_api_mapping(&resource.physical_id, &resource.attributes)
1901            }
1902            "AWS::ApiGatewayV2::VpcLink" => self.delete_apigwv2_vpc_link(&resource.physical_id),
1903            "AWS::ApiGatewayV2::Model" => {
1904                self.delete_apigwv2_model(&resource.physical_id, &resource.attributes)
1905            }
1906            "AWS::SES::ConfigurationSet" => {
1907                self.delete_ses_configuration_set(&resource.physical_id)
1908            }
1909            "AWS::SES::ConfigurationSetEventDestination" => {
1910                self.delete_ses_event_destination(&resource.physical_id, &resource.attributes)
1911            }
1912            "AWS::SES::EmailIdentity" => self.delete_ses_email_identity(&resource.physical_id),
1913            "AWS::SES::Template" => self.delete_ses_template(&resource.physical_id),
1914            "AWS::SES::ContactList" => self.delete_ses_contact_list(&resource.physical_id),
1915            "AWS::SES::DedicatedIpPool" => self.delete_ses_dedicated_ip_pool(&resource.physical_id),
1916            "AWS::SES::ReceiptRule" => {
1917                self.delete_ses_receipt_rule(&resource.physical_id, &resource.attributes)
1918            }
1919            "AWS::SES::ReceiptRuleSet" => self.delete_ses_receipt_rule_set(&resource.physical_id),
1920            "AWS::SES::ReceiptFilter" => self.delete_ses_receipt_filter(&resource.physical_id),
1921            "AWS::SES::VdmAttributes" => Ok(()),
1922            "AWS::SecretsManager::RotationSchedule" => {
1923                self.delete_secrets_manager_rotation_schedule(&resource.physical_id)
1924            }
1925            "AWS::SecretsManager::ResourcePolicy" => {
1926                self.delete_secrets_manager_resource_policy(&resource.physical_id)
1927            }
1928            "AWS::SecretsManager::SecretTargetAttachment" => Ok(()),
1929            "AWS::ApplicationAutoScaling::ScalableTarget" => self
1930                .delete_application_autoscaling_scalable_target(
1931                    &resource.physical_id,
1932                    &resource.attributes,
1933                ),
1934            "AWS::ApplicationAutoScaling::ScalingPolicy" => self
1935                .delete_application_autoscaling_scaling_policy(
1936                    &resource.physical_id,
1937                    &resource.attributes,
1938                ),
1939            "AWS::Athena::DataCatalog" => self.delete_athena_data_catalog(&resource.physical_id),
1940            "AWS::Athena::NamedQuery" => self.delete_athena_named_query(&resource.physical_id),
1941            "AWS::Athena::WorkGroup" => self.delete_athena_work_group(&resource.physical_id),
1942            "AWS::Athena::PreparedStatement" => {
1943                self.delete_athena_prepared_statement(&resource.physical_id, &resource.attributes)
1944            }
1945            "AWS::KinesisFirehose::DeliveryStream" => {
1946                self.delete_firehose_delivery_stream(&resource.physical_id)
1947            }
1948            "AWS::Glue::Database" => self.delete_glue_database(&resource.physical_id),
1949            "AWS::CloudFormation::Stack" => self.delete_cloudformation_stack(&resource.physical_id),
1950            "AWS::Glue::Table" => self.delete_glue_table(&resource.physical_id),
1951            "AWS::Glue::Partition" => {
1952                self.delete_glue_partition(&resource.physical_id, &resource.attributes)
1953            }
1954            t if t.starts_with("Custom::") || t == "AWS::CloudFormation::CustomResource" => {
1955                self.delete_custom_resource(resource)
1956            }
1957            // No provisioner for this type — it was recorded with no backing
1958            // state at create time (see `create_resource`), so there is nothing
1959            // to delete. Succeed so stack teardown isn't blocked.
1960            _ => Ok(()),
1961        }
1962    }
1963
1964    // --- CloudWatch Logs ---
1965
1966    fn create_log_group(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
1967        let props = &resource.properties;
1968        let log_group_name = props
1969            .get("LogGroupName")
1970            .and_then(|v| v.as_str())
1971            .unwrap_or(&resource.logical_id);
1972
1973        let retention_in_days = props
1974            .get("RetentionInDays")
1975            .and_then(|v| v.as_i64())
1976            .map(|v| v as i32);
1977
1978        let mut logs_accounts = self.logs_state.write();
1979        let state = logs_accounts.get_or_create(&self.account_id);
1980        let arn = format!(
1981            "arn:aws:logs:{}:{}:log-group:{}:*",
1982            state.region, state.account_id, log_group_name
1983        );
1984
1985        let log_group = fakecloud_logs::LogGroup {
1986            name: log_group_name.to_string(),
1987            arn: arn.clone(),
1988            creation_time: Utc::now().timestamp_millis(),
1989            retention_in_days,
1990            kms_key_id: None,
1991            stored_bytes: 0,
1992            log_streams: std::collections::BTreeMap::new(),
1993            tags: std::collections::BTreeMap::new(),
1994            subscription_filters: Vec::new(),
1995            data_protection_policy: None,
1996            index_policies: Vec::new(),
1997            transformer: None,
1998            deletion_protection: false,
1999            log_group_class: Some("STANDARD".to_string()),
2000        };
2001
2002        state
2003            .log_groups
2004            .insert(log_group_name.to_string(), log_group);
2005        Ok(ProvisionResult::new(arn.clone()).with("Arn", arn))
2006    }
2007
2008    /// Look up an S3 object's bytes from the in-process S3 state. Used by
2009    /// the Lambda function provisioner to hydrate `Code.S3Bucket` /
2010    /// `Code.S3Key` references into real ZIP content. Returns an error
2011    /// string when the bucket or key is missing so the CFN error
2012    /// surfaces back to the caller.
2013    fn read_s3_object_bytes(&self, bucket: &str, key: &str) -> Result<Vec<u8>, String> {
2014        let mut accounts = self.s3_state.write();
2015        let state = accounts.get_or_create(&self.account_id);
2016        let body_ref = {
2017            let b = state
2018                .buckets
2019                .get(bucket)
2020                .ok_or_else(|| format!("S3 bucket {bucket} does not exist"))?;
2021            let object = b
2022                .objects
2023                .get(key)
2024                .ok_or_else(|| format!("S3 object s3://{bucket}/{key} does not exist"))?;
2025            object.body.clone()
2026        };
2027        // `read_body` consults the body cache (which is owned by the state),
2028        // so re-borrow `state` after dropping the bucket borrow above.
2029        state
2030            .read_body(&body_ref)
2031            .map(|b| b.to_vec())
2032            .map_err(|e| format!("S3 read failed: {e}"))
2033    }
2034
2035    /// Read a specific object version's bytes. Used when a CFN property
2036    /// pins an S3 object `Version` (e.g.
2037    /// `DefinitionS3Location.Version`), so the exact referenced body is
2038    /// loaded rather than whatever is current. Falls through the current
2039    /// object and the version history.
2040    fn read_s3_object_version_bytes(
2041        &self,
2042        bucket: &str,
2043        key: &str,
2044        version_id: &str,
2045    ) -> Result<Vec<u8>, String> {
2046        let mut accounts = self.s3_state.write();
2047        let state = accounts.get_or_create(&self.account_id);
2048        let body_ref = {
2049            let b = state
2050                .buckets
2051                .get(bucket)
2052                .ok_or_else(|| format!("S3 bucket {bucket} does not exist"))?;
2053            let from_current = b
2054                .objects
2055                .get(key)
2056                .filter(|o| o.version_id.as_deref() == Some(version_id))
2057                .map(|o| o.body.clone());
2058            from_current
2059                .or_else(|| {
2060                    b.object_versions.get(key).and_then(|versions| {
2061                        versions
2062                            .iter()
2063                            .find(|o| o.version_id.as_deref() == Some(version_id))
2064                            .map(|o| o.body.clone())
2065                    })
2066                })
2067                .ok_or_else(|| {
2068                    format!("S3 object s3://{bucket}/{key} version {version_id} does not exist")
2069                })?
2070        };
2071        state
2072            .read_body(&body_ref)
2073            .map(|b| b.to_vec())
2074            .map_err(|e| format!("S3 read failed: {e}"))
2075    }
2076
2077    /// Build a canonical Lambda resource-policy statement from a CFN
2078    /// `AWS::Lambda::Permission` `Properties` map and append it to the
2079    /// function's stored policy. Shared by create + update so the same
2080    /// property→condition mapping applies on both paths. Returns the
2081    /// function ARN of the target so callers can echo it back if needed.
2082    fn append_lambda_permission_statement(
2083        &self,
2084        function_name: &str,
2085        statement_id: &str,
2086        props: &serde_json::Value,
2087    ) -> Result<String, String> {
2088        let action = props
2089            .get("Action")
2090            .and_then(|v| v.as_str())
2091            .ok_or_else(|| "Action is required".to_string())?
2092            .to_string();
2093        let principal = props
2094            .get("Principal")
2095            .and_then(|v| v.as_str())
2096            .ok_or_else(|| "Principal is required".to_string())?
2097            .to_string();
2098        let source_arn = props
2099            .get("SourceArn")
2100            .and_then(|v| v.as_str())
2101            .map(|s| s.to_string());
2102        let source_account = props
2103            .get("SourceAccount")
2104            .and_then(|v| v.as_str())
2105            .map(|s| s.to_string());
2106        let event_source_token = props
2107            .get("EventSourceToken")
2108            .and_then(|v| v.as_str())
2109            .map(|s| s.to_string());
2110        let function_url_auth_type = props
2111            .get("FunctionUrlAuthType")
2112            .and_then(|v| v.as_str())
2113            .map(|s| s.to_string());
2114        let principal_org_id = props
2115            .get("PrincipalOrgID")
2116            .and_then(|v| v.as_str())
2117            .map(|s| s.to_string());
2118
2119        let mut accounts = self.lambda_state.write();
2120        let state = accounts.get_or_create(&self.account_id);
2121        let func = state.functions.get_mut(function_name).ok_or_else(|| {
2122            format!(
2123                "Function {function_name} does not exist yet — retry once it has been provisioned"
2124            )
2125        })?;
2126
2127        let mut doc: serde_json::Value = func
2128            .policy
2129            .as_deref()
2130            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
2131            .filter(|v| v.is_object())
2132            .unwrap_or_else(|| serde_json::json!({"Version": "2012-10-17", "Statement": []}));
2133        if !doc.get("Statement").map(|s| s.is_array()).unwrap_or(false) {
2134            doc["Statement"] = serde_json::json!([]);
2135        }
2136        let principal_value =
2137            if principal.ends_with(".amazonaws.com") || principal.contains(".amazon") {
2138                serde_json::json!({ "Service": principal })
2139            } else {
2140                serde_json::json!({ "AWS": principal })
2141            };
2142        let mut arn_like = serde_json::Map::new();
2143        let mut string_equals = serde_json::Map::new();
2144        if let Some(src) = source_arn {
2145            arn_like.insert("AWS:SourceArn".to_string(), serde_json::Value::String(src));
2146        }
2147        if let Some(acct) = source_account {
2148            string_equals.insert(
2149                "AWS:SourceAccount".to_string(),
2150                serde_json::Value::String(acct),
2151            );
2152        }
2153        if let Some(token) = event_source_token {
2154            string_equals.insert(
2155                "lambda:EventSourceToken".to_string(),
2156                serde_json::Value::String(token),
2157            );
2158        }
2159        if let Some(auth) = function_url_auth_type {
2160            string_equals.insert(
2161                "lambda:FunctionUrlAuthType".to_string(),
2162                serde_json::Value::String(auth),
2163            );
2164        }
2165        if let Some(org) = principal_org_id {
2166            string_equals.insert(
2167                "aws:PrincipalOrgID".to_string(),
2168                serde_json::Value::String(org),
2169            );
2170        }
2171        let mut conditions = serde_json::Map::new();
2172        if !arn_like.is_empty() {
2173            conditions.insert("ArnLike".to_string(), serde_json::Value::Object(arn_like));
2174        }
2175        if !string_equals.is_empty() {
2176            conditions.insert(
2177                "StringEquals".to_string(),
2178                serde_json::Value::Object(string_equals),
2179            );
2180        }
2181
2182        let mut statement = serde_json::Map::new();
2183        statement.insert(
2184            "Sid".to_string(),
2185            serde_json::Value::String(statement_id.to_string()),
2186        );
2187        statement.insert(
2188            "Effect".to_string(),
2189            serde_json::Value::String("Allow".to_string()),
2190        );
2191        statement.insert("Principal".to_string(), principal_value);
2192        statement.insert("Action".to_string(), serde_json::Value::String(action));
2193        statement.insert(
2194            "Resource".to_string(),
2195            serde_json::Value::String(func.function_arn.clone()),
2196        );
2197        if !conditions.is_empty() {
2198            statement.insert(
2199                "Condition".to_string(),
2200                serde_json::Value::Object(conditions),
2201            );
2202        }
2203        doc["Statement"]
2204            .as_array_mut()
2205            .unwrap()
2206            .push(serde_json::Value::Object(statement));
2207        func.policy = Some(doc.to_string());
2208        Ok(func.function_arn.clone())
2209    }
2210
2211    // --- ELBv2 ---
2212
2213    fn create_elbv2_load_balancer(
2214        &self,
2215        resource: &ResourceDefinition,
2216    ) -> Result<ProvisionResult, String> {
2217        let props = &resource.properties;
2218        let name = props
2219            .get("Name")
2220            .and_then(|v| v.as_str())
2221            .unwrap_or(&resource.logical_id)
2222            .to_string();
2223        let scheme = props
2224            .get("Scheme")
2225            .and_then(|v| v.as_str())
2226            .unwrap_or("internet-facing")
2227            .to_string();
2228        let lb_type = props
2229            .get("Type")
2230            .and_then(|v| v.as_str())
2231            .unwrap_or("application")
2232            .to_string();
2233        let ip_address_type = props
2234            .get("IpAddressType")
2235            .and_then(|v| v.as_str())
2236            .unwrap_or("ipv4")
2237            .to_string();
2238        let security_groups: Vec<String> = props
2239            .get("SecurityGroups")
2240            .and_then(|v| v.as_array())
2241            .map(|arr| {
2242                arr.iter()
2243                    .filter_map(|s| s.as_str().map(|s| s.to_string()))
2244                    .collect()
2245            })
2246            .unwrap_or_default();
2247        let tags = parse_elb_tags(props.get("Tags"));
2248
2249        let mut accounts = self.elbv2_state.write();
2250        let state = accounts.get_or_create(&self.account_id);
2251        let lb_id = Uuid::new_v4().simple().to_string();
2252        let arn = format!(
2253            "arn:aws:elasticloadbalancing:{}:{}:loadbalancer/{}/{}/{}",
2254            self.region,
2255            self.account_id,
2256            if lb_type == "network" { "net" } else { "app" },
2257            name,
2258            &lb_id[..16]
2259        );
2260        let dns_name = format!(
2261            "{}-{}.{}.elb.{}.amazonaws.com",
2262            name,
2263            &lb_id[..16],
2264            self.region,
2265            self.region
2266        );
2267
2268        let mut availability_zones: Vec<fakecloud_elbv2::AvailabilityZone> = Vec::new();
2269        if let Some(arr) = props.get("Subnets").and_then(|v| v.as_array()) {
2270            for s in arr {
2271                if let Some(subnet_id) = s.as_str() {
2272                    availability_zones.push(fakecloud_elbv2::AvailabilityZone {
2273                        zone_name: format!("{}a", self.region),
2274                        subnet_id: subnet_id.to_string(),
2275                        outpost_id: None,
2276                        load_balancer_addresses: Vec::new(),
2277                        source_nat_ipv6_prefixes: Vec::new(),
2278                    });
2279                }
2280            }
2281        }
2282
2283        state.load_balancers.insert(
2284            arn.clone(),
2285            LoadBalancer {
2286                arn: arn.clone(),
2287                name: name.clone(),
2288                dns_name: dns_name.clone(),
2289                canonical_hosted_zone_id: "Z2P70J7EXAMPLE".to_string(),
2290                created_time: Utc::now(),
2291                scheme,
2292                vpc_id: String::new(),
2293                state_code: "active".to_string(),
2294                state_reason: None,
2295                lb_type,
2296                availability_zones,
2297                security_groups,
2298                ip_address_type,
2299                customer_owned_ipv4_pool: None,
2300                enforce_security_group_inbound_rules_on_private_link_traffic: None,
2301                enable_prefix_for_ipv6_source_nat: None,
2302                ipv4_ipam_pool_id: None,
2303                tags,
2304                attributes: BTreeMap::new(),
2305                minimum_capacity_units: None,
2306                bound_port: None,
2307            },
2308        );
2309
2310        Ok(ProvisionResult::new(arn.clone())
2311            .with("LoadBalancerArn", arn)
2312            .with(
2313                "LoadBalancerFullName",
2314                format!("app/{name}/{}", &lb_id[..16]),
2315            )
2316            .with("LoadBalancerName", name)
2317            .with("DNSName", dns_name)
2318            .with("CanonicalHostedZoneID", "Z2P70J7EXAMPLE"))
2319    }
2320
2321    fn delete_elbv2_load_balancer(&self, physical_id: &str) -> Result<(), String> {
2322        let mut accounts = self.elbv2_state.write();
2323        let state = accounts.get_or_create(&self.account_id);
2324        state.load_balancers.remove(physical_id);
2325        // Cascade-delete listeners and rules attached to this LB.
2326        let listeners: Vec<String> = state
2327            .listeners
2328            .iter()
2329            .filter(|(_, l)| l.load_balancer_arn == physical_id)
2330            .map(|(arn, _)| arn.clone())
2331            .collect();
2332        for arn in &listeners {
2333            state.listeners.remove(arn);
2334            let rules: Vec<String> = state
2335                .rules
2336                .iter()
2337                .filter(|(_, r)| r.listener_arn == *arn)
2338                .map(|(a, _)| a.clone())
2339                .collect();
2340            for r in rules {
2341                state.rules.remove(&r);
2342            }
2343        }
2344        for tg in state.target_groups.values_mut() {
2345            tg.load_balancer_arns.retain(|a| a != physical_id);
2346        }
2347        Ok(())
2348    }
2349
2350    fn create_elbv2_target_group(
2351        &self,
2352        resource: &ResourceDefinition,
2353    ) -> Result<ProvisionResult, String> {
2354        let props = &resource.properties;
2355        let name = props
2356            .get("Name")
2357            .and_then(|v| v.as_str())
2358            .unwrap_or(&resource.logical_id)
2359            .to_string();
2360        let protocol = props
2361            .get("Protocol")
2362            .and_then(|v| v.as_str())
2363            .map(|s| s.to_string());
2364        let port = props.get("Port").and_then(|v| v.as_i64()).map(|n| n as i32);
2365        let vpc_id = props
2366            .get("VpcId")
2367            .and_then(|v| v.as_str())
2368            .map(|s| s.to_string());
2369        let target_type = props
2370            .get("TargetType")
2371            .and_then(|v| v.as_str())
2372            .unwrap_or("instance")
2373            .to_string();
2374        let ip_address_type = props
2375            .get("IpAddressType")
2376            .and_then(|v| v.as_str())
2377            .unwrap_or("ipv4")
2378            .to_string();
2379        let protocol_version = props
2380            .get("ProtocolVersion")
2381            .and_then(|v| v.as_str())
2382            .map(|s| s.to_string());
2383        let tags = parse_elb_tags(props.get("Tags"));
2384
2385        let mut accounts = self.elbv2_state.write();
2386        let state = accounts.get_or_create(&self.account_id);
2387        let id = Uuid::new_v4().simple().to_string();
2388        let arn = format!(
2389            "arn:aws:elasticloadbalancing:{}:{}:targetgroup/{}/{}",
2390            self.region,
2391            self.account_id,
2392            name,
2393            &id[..16]
2394        );
2395
2396        state.target_groups.insert(
2397            arn.clone(),
2398            TargetGroup {
2399                arn: arn.clone(),
2400                name: name.clone(),
2401                protocol,
2402                port,
2403                vpc_id,
2404                target_type,
2405                ip_address_type,
2406                protocol_version,
2407                health_check_protocol: props
2408                    .get("HealthCheckProtocol")
2409                    .and_then(|v| v.as_str())
2410                    .map(|s| s.to_string()),
2411                health_check_port: props
2412                    .get("HealthCheckPort")
2413                    .and_then(|v| v.as_str())
2414                    .map(|s| s.to_string()),
2415                health_check_enabled: props
2416                    .get("HealthCheckEnabled")
2417                    .and_then(|v| v.as_bool())
2418                    .unwrap_or(true),
2419                health_check_path: props
2420                    .get("HealthCheckPath")
2421                    .and_then(|v| v.as_str())
2422                    .map(|s| s.to_string()),
2423                health_check_interval_seconds: props
2424                    .get("HealthCheckIntervalSeconds")
2425                    .and_then(|v| v.as_i64())
2426                    .unwrap_or(30) as i32,
2427                health_check_timeout_seconds: props
2428                    .get("HealthCheckTimeoutSeconds")
2429                    .and_then(|v| v.as_i64())
2430                    .unwrap_or(5) as i32,
2431                healthy_threshold_count: props
2432                    .get("HealthyThresholdCount")
2433                    .and_then(|v| v.as_i64())
2434                    .unwrap_or(5) as i32,
2435                unhealthy_threshold_count: props
2436                    .get("UnhealthyThresholdCount")
2437                    .and_then(|v| v.as_i64())
2438                    .unwrap_or(2) as i32,
2439                matcher_http_code: props
2440                    .get("Matcher")
2441                    .and_then(|v| v.get("HttpCode"))
2442                    .and_then(|v| v.as_str())
2443                    .map(|s| s.to_string()),
2444                matcher_grpc_code: props
2445                    .get("Matcher")
2446                    .and_then(|v| v.get("GrpcCode"))
2447                    .and_then(|v| v.as_str())
2448                    .map(|s| s.to_string()),
2449                load_balancer_arns: Vec::new(),
2450                targets: Vec::new(),
2451                tags,
2452                attributes: BTreeMap::new(),
2453                created_time: Utc::now(),
2454            },
2455        );
2456
2457        Ok(ProvisionResult::new(arn.clone())
2458            .with("TargetGroupArn", arn)
2459            .with("TargetGroupName", name)
2460            .with("TargetGroupFullName", format!("targetgroup/{}", &id[..16])))
2461    }
2462
2463    fn delete_elbv2_target_group(&self, physical_id: &str) -> Result<(), String> {
2464        let mut accounts = self.elbv2_state.write();
2465        let state = accounts.get_or_create(&self.account_id);
2466        state.target_groups.remove(physical_id);
2467        Ok(())
2468    }
2469
2470    fn create_elbv2_listener(
2471        &self,
2472        resource: &ResourceDefinition,
2473    ) -> Result<ProvisionResult, String> {
2474        let props = &resource.properties;
2475        let load_balancer_arn = props
2476            .get("LoadBalancerArn")
2477            .and_then(|v| v.as_str())
2478            .ok_or_else(|| "LoadBalancerArn is required".to_string())?
2479            .to_string();
2480        let port = props.get("Port").and_then(|v| v.as_i64()).map(|n| n as i32);
2481        let protocol = props
2482            .get("Protocol")
2483            .and_then(|v| v.as_str())
2484            .map(|s| s.to_string());
2485        let default_actions = parse_elb_actions(props.get("DefaultActions"));
2486
2487        let mut accounts = self.elbv2_state.write();
2488        let state = accounts.get_or_create(&self.account_id);
2489        if !state.load_balancers.contains_key(&load_balancer_arn) {
2490            return Err(format!(
2491                "LoadBalancer {load_balancer_arn} not yet provisioned"
2492            ));
2493        }
2494
2495        let lb_full = load_balancer_arn
2496            .rsplit("loadbalancer/")
2497            .next()
2498            .unwrap_or("")
2499            .to_string();
2500        let listener_id = Uuid::new_v4().simple().to_string();
2501        let arn = format!(
2502            "arn:aws:elasticloadbalancing:{}:{}:listener/{}/{}",
2503            self.region,
2504            self.account_id,
2505            lb_full,
2506            &listener_id[..16]
2507        );
2508
2509        // Wire forward target groups -> LB association so dataplane probing
2510        // and DescribeTargetGroups round-trip the relationship.
2511        for action in &default_actions {
2512            if let Some(tg_arn) = &action.target_group_arn {
2513                if let Some(tg) = state.target_groups.get_mut(tg_arn) {
2514                    if !tg.load_balancer_arns.contains(&load_balancer_arn) {
2515                        tg.load_balancer_arns.push(load_balancer_arn.clone());
2516                    }
2517                }
2518            }
2519            if let Some(forward) = &action.forward {
2520                for tgt in &forward.target_groups {
2521                    if let Some(tg) = state.target_groups.get_mut(&tgt.target_group_arn) {
2522                        if !tg.load_balancer_arns.contains(&load_balancer_arn) {
2523                            tg.load_balancer_arns.push(load_balancer_arn.clone());
2524                        }
2525                    }
2526                }
2527            }
2528        }
2529
2530        state.listeners.insert(
2531            arn.clone(),
2532            Listener {
2533                arn: arn.clone(),
2534                load_balancer_arn,
2535                port,
2536                protocol,
2537                certificates: Vec::new(),
2538                ssl_policy: props
2539                    .get("SslPolicy")
2540                    .and_then(|v| v.as_str())
2541                    .map(|s| s.to_string()),
2542                default_actions,
2543                alpn_policy: Vec::new(),
2544                mutual_authentication: None,
2545                tags: parse_elb_tags(props.get("Tags")),
2546                attributes: BTreeMap::new(),
2547            },
2548        );
2549
2550        Ok(ProvisionResult::new(arn.clone()).with("ListenerArn", arn))
2551    }
2552
2553    fn delete_elbv2_listener(&self, physical_id: &str) -> Result<(), String> {
2554        let mut accounts = self.elbv2_state.write();
2555        let state = accounts.get_or_create(&self.account_id);
2556        state.listeners.remove(physical_id);
2557        let rules: Vec<String> = state
2558            .rules
2559            .iter()
2560            .filter(|(_, r)| r.listener_arn == physical_id)
2561            .map(|(arn, _)| arn.clone())
2562            .collect();
2563        for r in rules {
2564            state.rules.remove(&r);
2565        }
2566        Ok(())
2567    }
2568
2569    fn create_elbv2_listener_rule(
2570        &self,
2571        resource: &ResourceDefinition,
2572    ) -> Result<ProvisionResult, String> {
2573        let props = &resource.properties;
2574        let listener_arn = props
2575            .get("ListenerArn")
2576            .and_then(|v| v.as_str())
2577            .ok_or_else(|| "ListenerArn is required".to_string())?
2578            .to_string();
2579        let priority = props
2580            .get("Priority")
2581            .map(|v| {
2582                if let Some(s) = v.as_str() {
2583                    s.to_string()
2584                } else if let Some(n) = v.as_i64() {
2585                    n.to_string()
2586                } else {
2587                    "1".to_string()
2588                }
2589            })
2590            .unwrap_or_else(|| "1".to_string());
2591        let actions = parse_elb_actions(props.get("Actions"));
2592        let conditions = parse_elb_rule_conditions(props.get("Conditions"));
2593
2594        let mut accounts = self.elbv2_state.write();
2595        let state = accounts.get_or_create(&self.account_id);
2596        if !state.listeners.contains_key(&listener_arn) {
2597            return Err(format!("Listener {listener_arn} not yet provisioned"));
2598        }
2599        let listener_full = listener_arn
2600            .rsplit("listener/")
2601            .next()
2602            .unwrap_or("")
2603            .to_string();
2604        let rule_id = Uuid::new_v4().simple().to_string();
2605        let arn = format!(
2606            "arn:aws:elasticloadbalancing:{}:{}:listener-rule/{}/{}",
2607            self.region,
2608            self.account_id,
2609            listener_full,
2610            &rule_id[..16]
2611        );
2612
2613        state.rules.insert(
2614            arn.clone(),
2615            ElbRule {
2616                arn: arn.clone(),
2617                listener_arn,
2618                priority,
2619                conditions,
2620                actions,
2621                is_default: false,
2622                tags: parse_elb_tags(props.get("Tags")),
2623            },
2624        );
2625
2626        Ok(ProvisionResult::new(arn.clone()).with("RuleArn", arn))
2627    }
2628
2629    fn delete_elbv2_listener_rule(&self, physical_id: &str) -> Result<(), String> {
2630        let mut accounts = self.elbv2_state.write();
2631        let state = accounts.get_or_create(&self.account_id);
2632        state.rules.remove(physical_id);
2633        Ok(())
2634    }
2635
2636    /// Provision an `AWS::ElasticLoadBalancingV2::ListenerCertificate`.
2637    /// Appends each non-default certificate from `Certificates` to the
2638    /// target listener (the default listener cert is set on Listener
2639    /// creation, so this resource only manages SNI extras).
2640    fn create_elbv2_listener_certificate(
2641        &self,
2642        resource: &ResourceDefinition,
2643    ) -> Result<ProvisionResult, String> {
2644        let props = &resource.properties;
2645        let listener_arn = props
2646            .get("ListenerArn")
2647            .and_then(|v| v.as_str())
2648            .ok_or_else(|| "ListenerArn is required".to_string())?
2649            .to_string();
2650        let certs: Vec<String> = props
2651            .get("Certificates")
2652            .and_then(|v| v.as_array())
2653            .map(|arr| {
2654                arr.iter()
2655                    .filter_map(|c| c.get("CertificateArn").and_then(|v| v.as_str()))
2656                    .map(|s| s.to_string())
2657                    .collect()
2658            })
2659            .unwrap_or_default();
2660        if certs.is_empty() {
2661            return Err("Certificates must contain at least one CertificateArn".to_string());
2662        }
2663        let mut accounts = self.elbv2_state.write();
2664        let state = accounts.get_or_create(&self.account_id);
2665        let listener = state
2666            .listeners
2667            .get_mut(&listener_arn)
2668            .ok_or_else(|| format!("Listener {listener_arn} does not exist"))?;
2669        for arn in &certs {
2670            listener.certificates.retain(|c| &c.certificate_arn != arn);
2671            listener.certificates.push(fakecloud_elbv2::Certificate {
2672                certificate_arn: arn.clone(),
2673                is_default: false,
2674            });
2675        }
2676        Ok(ProvisionResult::new(format!(
2677            "{}#{}",
2678            listener_arn,
2679            certs.join(",")
2680        )))
2681    }
2682
2683    fn delete_elbv2_listener_certificate(&self, physical_id: &str) -> Result<(), String> {
2684        let (listener_arn, cert_list) = match physical_id.split_once('#') {
2685            Some(parts) => parts,
2686            None => return Ok(()),
2687        };
2688        let cert_arns: Vec<&str> = cert_list.split(',').collect();
2689        let mut accounts = self.elbv2_state.write();
2690        let state = accounts.get_or_create(&self.account_id);
2691        if let Some(listener) = state.listeners.get_mut(listener_arn) {
2692            listener
2693                .certificates
2694                .retain(|c| !cert_arns.iter().any(|a| *a == c.certificate_arn));
2695        }
2696        Ok(())
2697    }
2698
2699    /// Provision an `AWS::ElasticLoadBalancingV2::TrustStore`.
2700    fn create_elbv2_trust_store(
2701        &self,
2702        resource: &ResourceDefinition,
2703    ) -> Result<ProvisionResult, String> {
2704        let props = &resource.properties;
2705        let name = props
2706            .get("Name")
2707            .and_then(|v| v.as_str())
2708            .unwrap_or(&resource.logical_id)
2709            .to_string();
2710        let bucket = props
2711            .get("CaCertificatesBundleS3Bucket")
2712            .and_then(|v| v.as_str())
2713            .ok_or_else(|| "CaCertificatesBundleS3Bucket is required".to_string())?;
2714        let key = props
2715            .get("CaCertificatesBundleS3Key")
2716            .and_then(|v| v.as_str())
2717            .ok_or_else(|| "CaCertificatesBundleS3Key is required".to_string())?;
2718        let tags: Vec<fakecloud_elbv2::Tag> = props
2719            .get("Tags")
2720            .and_then(|v| v.as_array())
2721            .map(|arr| {
2722                arr.iter()
2723                    .filter_map(|t| {
2724                        let k = t.get("Key").and_then(|v| v.as_str())?;
2725                        let val = t.get("Value").and_then(|v| v.as_str()).unwrap_or("");
2726                        Some(fakecloud_elbv2::Tag {
2727                            key: k.to_string(),
2728                            value: val.to_string(),
2729                        })
2730                    })
2731                    .collect()
2732            })
2733            .unwrap_or_default();
2734
2735        let mut accounts = self.elbv2_state.write();
2736        let state = accounts.get_or_create(&self.account_id);
2737        if state.trust_stores.values().any(|t| t.name == name) {
2738            return Err(format!("Trust store {name} already exists"));
2739        }
2740        let suffix: String = Uuid::new_v4()
2741            .simple()
2742            .to_string()
2743            .chars()
2744            .take(16)
2745            .collect();
2746        let arn = format!(
2747            "arn:aws:elasticloadbalancing:{}:{}:truststore/{}/{}",
2748            self.region, self.account_id, name, suffix
2749        );
2750        let ts = fakecloud_elbv2::TrustStore {
2751            arn: arn.clone(),
2752            name: name.clone(),
2753            status: "ACTIVE".to_string(),
2754            number_of_ca_certificates: 1,
2755            total_revoked_entries: 0,
2756            created_time: Utc::now(),
2757            ca_certificates_bundle: Some(format!("s3://{bucket}/{key}").into_bytes()),
2758            revocations: BTreeMap::new(),
2759            next_revocation_id: 1,
2760            tags,
2761        };
2762        state.trust_stores.insert(arn.clone(), ts);
2763        Ok(ProvisionResult::new(arn.clone())
2764            .with("TrustStoreArn", arn)
2765            .with("Name", name)
2766            .with("Status", "ACTIVE".to_string()))
2767    }
2768
2769    fn delete_elbv2_trust_store(&self, physical_id: &str) -> Result<(), String> {
2770        let mut accounts = self.elbv2_state.write();
2771        let state = accounts.get_or_create(&self.account_id);
2772        state.trust_stores.remove(physical_id);
2773        Ok(())
2774    }
2775
2776    /// In-place update for AWS::ElasticLoadBalancingV2::LoadBalancer. Name,
2777    /// scheme, type and subnet topology are immutable in real AWS — CFN
2778    /// would replace the resource. We only mutate fields the SetSubnets /
2779    /// SetSecurityGroups / SetIpAddressType APIs would touch.
2780    fn update_elbv2_load_balancer(
2781        &self,
2782        existing: &StackResource,
2783        resource: &ResourceDefinition,
2784    ) -> Result<ProvisionResult, String> {
2785        let props = &resource.properties;
2786        let arn = existing.physical_id.clone();
2787        let mut accounts = self.elbv2_state.write();
2788        let state = accounts.get_or_create(&self.account_id);
2789        let lb = state
2790            .load_balancers
2791            .get_mut(&arn)
2792            .ok_or_else(|| format!("LoadBalancer {arn} no longer exists"))?;
2793        if let Some(arr) = props.get("SecurityGroups").and_then(|v| v.as_array()) {
2794            lb.security_groups = arr
2795                .iter()
2796                .filter_map(|s| s.as_str().map(|s| s.to_string()))
2797                .collect();
2798        }
2799        if let Some(s) = props.get("IpAddressType").and_then(|v| v.as_str()) {
2800            lb.ip_address_type = s.to_string();
2801        }
2802        if let Some(arr) = props.get("Subnets").and_then(|v| v.as_array()) {
2803            let mut zones: Vec<fakecloud_elbv2::AvailabilityZone> = Vec::new();
2804            for s in arr {
2805                if let Some(subnet_id) = s.as_str() {
2806                    zones.push(fakecloud_elbv2::AvailabilityZone {
2807                        zone_name: format!("{}a", self.region),
2808                        subnet_id: subnet_id.to_string(),
2809                        outpost_id: None,
2810                        load_balancer_addresses: Vec::new(),
2811                        source_nat_ipv6_prefixes: Vec::new(),
2812                    });
2813                }
2814            }
2815            lb.availability_zones = zones;
2816        }
2817        if props.get("Tags").is_some() {
2818            lb.tags = parse_elb_tags(props.get("Tags"));
2819        }
2820        let name = lb.name.clone();
2821        let dns_name = lb.dns_name.clone();
2822        let canonical = lb.canonical_hosted_zone_id.clone();
2823        let lb_full = arn.rsplit("loadbalancer/").next().unwrap_or("").to_string();
2824        Ok(ProvisionResult::new(arn.clone())
2825            .with("LoadBalancerArn", arn)
2826            .with("LoadBalancerFullName", lb_full)
2827            .with("LoadBalancerName", name)
2828            .with("DNSName", dns_name)
2829            .with("CanonicalHostedZoneID", canonical))
2830    }
2831
2832    /// In-place update for AWS::ElasticLoadBalancingV2::TargetGroup. Mirrors
2833    /// ModifyTargetGroup: only health-check fields and matcher are mutable.
2834    fn update_elbv2_target_group(
2835        &self,
2836        existing: &StackResource,
2837        resource: &ResourceDefinition,
2838    ) -> Result<ProvisionResult, String> {
2839        let props = &resource.properties;
2840        let arn = existing.physical_id.clone();
2841        let mut accounts = self.elbv2_state.write();
2842        let state = accounts.get_or_create(&self.account_id);
2843        let tg = state
2844            .target_groups
2845            .get_mut(&arn)
2846            .ok_or_else(|| format!("TargetGroup {arn} no longer exists"))?;
2847        if let Some(s) = props.get("HealthCheckProtocol").and_then(|v| v.as_str()) {
2848            tg.health_check_protocol = Some(s.to_string());
2849        }
2850        if let Some(s) = props.get("HealthCheckPort").and_then(|v| v.as_str()) {
2851            tg.health_check_port = Some(s.to_string());
2852        }
2853        if let Some(b) = props.get("HealthCheckEnabled").and_then(|v| v.as_bool()) {
2854            tg.health_check_enabled = b;
2855        }
2856        if let Some(s) = props.get("HealthCheckPath").and_then(|v| v.as_str()) {
2857            tg.health_check_path = Some(s.to_string());
2858        }
2859        if let Some(n) = props.get("HealthCheckIntervalSeconds").and_then(cfn_as_i64) {
2860            tg.health_check_interval_seconds = n as i32;
2861        }
2862        if let Some(n) = props.get("HealthCheckTimeoutSeconds").and_then(cfn_as_i64) {
2863            tg.health_check_timeout_seconds = n as i32;
2864        }
2865        if let Some(n) = props.get("HealthyThresholdCount").and_then(cfn_as_i64) {
2866            tg.healthy_threshold_count = n as i32;
2867        }
2868        if let Some(n) = props.get("UnhealthyThresholdCount").and_then(cfn_as_i64) {
2869            tg.unhealthy_threshold_count = n as i32;
2870        }
2871        if let Some(matcher) = props.get("Matcher") {
2872            tg.matcher_http_code = matcher
2873                .get("HttpCode")
2874                .and_then(|v| v.as_str())
2875                .map(|s| s.to_string());
2876            tg.matcher_grpc_code = matcher
2877                .get("GrpcCode")
2878                .and_then(|v| v.as_str())
2879                .map(|s| s.to_string());
2880        }
2881        if props.get("Tags").is_some() {
2882            tg.tags = parse_elb_tags(props.get("Tags"));
2883        }
2884        let name = tg.name.clone();
2885        let tg_full = arn
2886            .rsplit("targetgroup/")
2887            .next()
2888            .map(|s| format!("targetgroup/{s}"))
2889            .unwrap_or_default();
2890        Ok(ProvisionResult::new(arn.clone())
2891            .with("TargetGroupArn", arn)
2892            .with("TargetGroupName", name)
2893            .with("TargetGroupFullName", tg_full))
2894    }
2895
2896    /// In-place update for AWS::ElasticLoadBalancingV2::Listener. Mirrors
2897    /// ModifyListener: port, protocol, default actions, certs, ssl policy.
2898    fn update_elbv2_listener(
2899        &self,
2900        existing: &StackResource,
2901        resource: &ResourceDefinition,
2902    ) -> Result<ProvisionResult, String> {
2903        let props = &resource.properties;
2904        let arn = existing.physical_id.clone();
2905        let new_default_actions = props
2906            .get("DefaultActions")
2907            .map(|v| parse_elb_actions(Some(v)));
2908        let mut accounts = self.elbv2_state.write();
2909        let state = accounts.get_or_create(&self.account_id);
2910        let listener = state
2911            .listeners
2912            .get_mut(&arn)
2913            .ok_or_else(|| format!("Listener {arn} no longer exists"))?;
2914        if let Some(n) = props.get("Port").and_then(cfn_as_i64) {
2915            listener.port = Some(n as i32);
2916        }
2917        if let Some(s) = props.get("Protocol").and_then(|v| v.as_str()) {
2918            listener.protocol = Some(s.to_string());
2919        }
2920        if let Some(s) = props.get("SslPolicy").and_then(|v| v.as_str()) {
2921            listener.ssl_policy = Some(s.to_string());
2922        }
2923        if let Some(actions) = new_default_actions {
2924            listener.default_actions = actions;
2925        }
2926        if props.get("Tags").is_some() {
2927            listener.tags = parse_elb_tags(props.get("Tags"));
2928        }
2929        Ok(ProvisionResult::new(arn.clone()).with("ListenerArn", arn))
2930    }
2931
2932    /// In-place update for AWS::ElasticLoadBalancingV2::ListenerRule. Mirrors
2933    /// ModifyRule + SetRulePriorities.
2934    fn update_elbv2_listener_rule(
2935        &self,
2936        existing: &StackResource,
2937        resource: &ResourceDefinition,
2938    ) -> Result<ProvisionResult, String> {
2939        let props = &resource.properties;
2940        let arn = existing.physical_id.clone();
2941        let new_actions = props.get("Actions").map(|v| parse_elb_actions(Some(v)));
2942        let new_conditions = props
2943            .get("Conditions")
2944            .map(|v| parse_elb_rule_conditions(Some(v)));
2945        let mut accounts = self.elbv2_state.write();
2946        let state = accounts.get_or_create(&self.account_id);
2947        let rule = state
2948            .rules
2949            .get_mut(&arn)
2950            .ok_or_else(|| format!("ListenerRule {arn} no longer exists"))?;
2951        if let Some(v) = props.get("Priority") {
2952            rule.priority = if let Some(s) = v.as_str() {
2953                s.to_string()
2954            } else if let Some(n) = v.as_i64() {
2955                n.to_string()
2956            } else {
2957                rule.priority.clone()
2958            };
2959        }
2960        if let Some(actions) = new_actions {
2961            rule.actions = actions;
2962        }
2963        if let Some(conditions) = new_conditions {
2964            rule.conditions = conditions;
2965        }
2966        if props.get("Tags").is_some() {
2967            rule.tags = parse_elb_tags(props.get("Tags"));
2968        }
2969        Ok(ProvisionResult::new(arn.clone()).with("RuleArn", arn))
2970    }
2971
2972    /// In-place update for AWS::ElasticLoadBalancingV2::ListenerCertificate.
2973    /// CFN treats this as replace-on-cert-list-change in real AWS, but we
2974    /// can rebuild the SNI cert set against the same physical id without
2975    /// disrupting the listener.
2976    fn update_elbv2_listener_certificate(
2977        &self,
2978        existing: &StackResource,
2979        resource: &ResourceDefinition,
2980    ) -> Result<ProvisionResult, String> {
2981        let props = &resource.properties;
2982        let physical_id = existing.physical_id.clone();
2983        let listener_arn = props
2984            .get("ListenerArn")
2985            .and_then(|v| v.as_str())
2986            .map(|s| s.to_string())
2987            .or_else(|| physical_id.split_once('#').map(|(l, _)| l.to_string()))
2988            .ok_or_else(|| "ListenerArn is required".to_string())?;
2989        let new_certs: Vec<String> = props
2990            .get("Certificates")
2991            .and_then(|v| v.as_array())
2992            .map(|arr| {
2993                arr.iter()
2994                    .filter_map(|c| c.get("CertificateArn").and_then(|v| v.as_str()))
2995                    .map(|s| s.to_string())
2996                    .collect()
2997            })
2998            .unwrap_or_default();
2999        if new_certs.is_empty() {
3000            return Err("Certificates must contain at least one CertificateArn".to_string());
3001        }
3002
3003        // Strip the previously-managed certs, then attach the new set.
3004        let prev_certs: Vec<String> = physical_id
3005            .split_once('#')
3006            .map(|(_, list)| list.split(',').map(|s| s.to_string()).collect())
3007            .unwrap_or_default();
3008
3009        let mut accounts = self.elbv2_state.write();
3010        let state = accounts.get_or_create(&self.account_id);
3011        let listener = state
3012            .listeners
3013            .get_mut(&listener_arn)
3014            .ok_or_else(|| format!("Listener {listener_arn} does not exist"))?;
3015        listener
3016            .certificates
3017            .retain(|c| !prev_certs.iter().any(|p| p == &c.certificate_arn));
3018        for arn in &new_certs {
3019            listener.certificates.retain(|c| &c.certificate_arn != arn);
3020            listener.certificates.push(fakecloud_elbv2::Certificate {
3021                certificate_arn: arn.clone(),
3022                is_default: false,
3023            });
3024        }
3025        Ok(ProvisionResult::new(format!(
3026            "{}#{}",
3027            listener_arn,
3028            new_certs.join(",")
3029        )))
3030    }
3031
3032    /// In-place update for AWS::ElasticLoadBalancingV2::TrustStore. Only the
3033    /// CA bundle and tags are mutable; name is immutable in real AWS.
3034    fn update_elbv2_trust_store(
3035        &self,
3036        existing: &StackResource,
3037        resource: &ResourceDefinition,
3038    ) -> Result<ProvisionResult, String> {
3039        let props = &resource.properties;
3040        let arn = existing.physical_id.clone();
3041        let mut accounts = self.elbv2_state.write();
3042        let state = accounts.get_or_create(&self.account_id);
3043        let ts = state
3044            .trust_stores
3045            .get_mut(&arn)
3046            .ok_or_else(|| format!("TrustStore {arn} no longer exists"))?;
3047        let new_bucket = props
3048            .get("CaCertificatesBundleS3Bucket")
3049            .and_then(|v| v.as_str());
3050        let new_key = props
3051            .get("CaCertificatesBundleS3Key")
3052            .and_then(|v| v.as_str());
3053        if let (Some(b), Some(k)) = (new_bucket, new_key) {
3054            ts.ca_certificates_bundle = Some(format!("s3://{b}/{k}").into_bytes());
3055        }
3056        if let Some(arr) = props.get("Tags").and_then(|v| v.as_array()) {
3057            ts.tags = arr
3058                .iter()
3059                .filter_map(|t| {
3060                    let k = t.get("Key").and_then(|v| v.as_str())?;
3061                    let v = t.get("Value").and_then(|v| v.as_str()).unwrap_or("");
3062                    Some(fakecloud_elbv2::Tag {
3063                        key: k.to_string(),
3064                        value: v.to_string(),
3065                    })
3066                })
3067                .collect();
3068        }
3069        let name = ts.name.clone();
3070        let status = ts.status.clone();
3071        Ok(ProvisionResult::new(arn.clone())
3072            .with("TrustStoreArn", arn)
3073            .with("Name", name)
3074            .with("Status", status))
3075    }
3076
3077    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::LoadBalancer.
3078    fn get_att_elbv2_load_balancer(&self, physical_id: &str, attribute: &str) -> Option<String> {
3079        let mut accounts = self.elbv2_state.write();
3080        let state = accounts.get_or_create(&self.account_id);
3081        let lb = state.load_balancers.get(physical_id)?;
3082        let lb_full = lb
3083            .arn
3084            .rsplit("loadbalancer/")
3085            .next()
3086            .unwrap_or("")
3087            .to_string();
3088        match attribute {
3089            "Arn" | "LoadBalancerArn" => Some(lb.arn.clone()),
3090            "DNSName" => Some(lb.dns_name.clone()),
3091            "CanonicalHostedZoneID" => Some(lb.canonical_hosted_zone_id.clone()),
3092            "LoadBalancerFullName" => Some(lb_full),
3093            "LoadBalancerName" => Some(lb.name.clone()),
3094            "SecurityGroups" => Some(lb.security_groups.join(",")),
3095            _ => None,
3096        }
3097    }
3098
3099    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::TargetGroup.
3100    fn get_att_elbv2_target_group(&self, physical_id: &str, attribute: &str) -> Option<String> {
3101        let mut accounts = self.elbv2_state.write();
3102        let state = accounts.get_or_create(&self.account_id);
3103        let tg = state.target_groups.get(physical_id)?;
3104        let tg_full = tg
3105            .arn
3106            .rsplit("targetgroup/")
3107            .next()
3108            .map(|s| format!("targetgroup/{s}"))
3109            .unwrap_or_default();
3110        match attribute {
3111            "TargetGroupArn" => Some(tg.arn.clone()),
3112            "TargetGroupName" => Some(tg.name.clone()),
3113            "TargetGroupFullName" => Some(tg_full),
3114            "LoadBalancerArns" => Some(tg.load_balancer_arns.join(",")),
3115            _ => None,
3116        }
3117    }
3118
3119    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::Listener.
3120    fn get_att_elbv2_listener(&self, physical_id: &str, attribute: &str) -> Option<String> {
3121        let mut accounts = self.elbv2_state.write();
3122        let state = accounts.get_or_create(&self.account_id);
3123        let listener = state.listeners.get(physical_id)?;
3124        match attribute {
3125            "Arn" | "ListenerArn" => Some(listener.arn.clone()),
3126            _ => None,
3127        }
3128    }
3129
3130    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::ListenerRule.
3131    fn get_att_elbv2_listener_rule(&self, physical_id: &str, attribute: &str) -> Option<String> {
3132        let mut accounts = self.elbv2_state.write();
3133        let state = accounts.get_or_create(&self.account_id);
3134        let rule = state.rules.get(physical_id)?;
3135        match attribute {
3136            "RuleArn" => Some(rule.arn.clone()),
3137            "IsDefault" => Some(rule.is_default.to_string()),
3138            _ => None,
3139        }
3140    }
3141
3142    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::TrustStore.
3143    fn get_att_elbv2_trust_store(&self, physical_id: &str, attribute: &str) -> Option<String> {
3144        let mut accounts = self.elbv2_state.write();
3145        let state = accounts.get_or_create(&self.account_id);
3146        let ts = state.trust_stores.get(physical_id)?;
3147        match attribute {
3148            "TrustStoreArn" => Some(ts.arn.clone()),
3149            "Name" => Some(ts.name.clone()),
3150            "Status" => Some(ts.status.clone()),
3151            "NumberOfCaCertificates" => Some(ts.number_of_ca_certificates.to_string()),
3152            "TotalRevokedEntries" => Some(ts.total_revoked_entries.to_string()),
3153            _ => None,
3154        }
3155    }
3156
3157    // --- Organizations ---
3158
3159    fn create_organization(
3160        &self,
3161        resource: &ResourceDefinition,
3162    ) -> Result<ProvisionResult, String> {
3163        let props = &resource.properties;
3164        let feature_set = props
3165            .get("FeatureSet")
3166            .and_then(|v| v.as_str())
3167            .unwrap_or("ALL")
3168            .to_string();
3169
3170        let mut org = self.organizations_state.write();
3171        if org.is_some() {
3172            return Err("Organization already exists; only one per fakecloud process".to_string());
3173        }
3174        let mut state = OrganizationState::bootstrap(&self.account_id);
3175        state.feature_set = feature_set;
3176        let org_id = state.org_id.clone();
3177        let org_arn = state.org_arn.clone();
3178        let mgmt_arn = state.management_account_arn.clone();
3179        let root_id = state.root_id.clone();
3180        *org = Some(state);
3181
3182        Ok(ProvisionResult::new(org_id.clone())
3183            .with("Id", org_id)
3184            .with("Arn", org_arn)
3185            .with("ManagementAccountArn", mgmt_arn)
3186            .with("RootId", root_id))
3187    }
3188
3189    fn delete_organization(&self, _physical_id: &str) -> Result<(), String> {
3190        let mut org = self.organizations_state.write();
3191        *org = None;
3192        Ok(())
3193    }
3194
3195    fn create_organization_unit(
3196        &self,
3197        resource: &ResourceDefinition,
3198    ) -> Result<ProvisionResult, String> {
3199        let props = &resource.properties;
3200        let name = props
3201            .get("Name")
3202            .and_then(|v| v.as_str())
3203            .unwrap_or(&resource.logical_id)
3204            .to_string();
3205        let parent_id = props
3206            .get("ParentId")
3207            .and_then(|v| v.as_str())
3208            .ok_or_else(|| "ParentId is required".to_string())?
3209            .to_string();
3210
3211        let mut org_lock = self.organizations_state.write();
3212        let org = org_lock
3213            .as_mut()
3214            .ok_or_else(|| "Organization not yet created".to_string())?;
3215        // Accept root id, OU id, or `Ref`-resolved logical id (we map to root).
3216        let resolved_parent_id = if parent_id == org.root_id || org.ous.contains_key(&parent_id) {
3217            parent_id
3218        } else {
3219            return Err(format!("Parent {parent_id} does not exist"));
3220        };
3221        let id_suffix: String = Uuid::new_v4()
3222            .simple()
3223            .to_string()
3224            .chars()
3225            .take(8)
3226            .collect();
3227        let id = format!("ou-{}-{}", &org.root_id[2..], id_suffix);
3228        let arn = format!(
3229            "arn:aws:organizations::{}:ou/{}/{}",
3230            org.management_account_id, org.org_id, id
3231        );
3232        org.ous.insert(
3233            id.clone(),
3234            OrganizationalUnit {
3235                id: id.clone(),
3236                arn: arn.clone(),
3237                name: name.clone(),
3238                parent_id: resolved_parent_id,
3239            },
3240        );
3241        Ok(ProvisionResult::new(id.clone())
3242            .with("Id", id)
3243            .with("Arn", arn)
3244            .with("Name", name))
3245    }
3246
3247    fn delete_organization_unit(&self, physical_id: &str) -> Result<(), String> {
3248        let mut org_lock = self.organizations_state.write();
3249        if let Some(org) = org_lock.as_mut() {
3250            org.ous.remove(physical_id);
3251            org.attachments.remove(physical_id);
3252        }
3253        Ok(())
3254    }
3255
3256    /// Provision an `AWS::Organizations::Account`. Mints a new member
3257    /// account synchronously (via Organizations state), optionally moves
3258    /// it under the first ParentId when supplied, and persists tags.
3259    fn create_organization_account(
3260        &self,
3261        resource: &ResourceDefinition,
3262    ) -> Result<ProvisionResult, String> {
3263        let props = &resource.properties;
3264        let email = props
3265            .get("Email")
3266            .and_then(|v| v.as_str())
3267            .ok_or_else(|| "Email is required".to_string())?
3268            .to_string();
3269        let name = props
3270            .get("AccountName")
3271            .and_then(|v| v.as_str())
3272            .ok_or_else(|| "AccountName is required".to_string())?
3273            .to_string();
3274        let parent_ids: Vec<String> = props
3275            .get("ParentIds")
3276            .and_then(|v| v.as_array())
3277            .map(|arr| {
3278                arr.iter()
3279                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
3280                    .collect()
3281            })
3282            .unwrap_or_default();
3283        let tags: Vec<(String, String)> = props
3284            .get("Tags")
3285            .and_then(|v| v.as_array())
3286            .map(|arr| {
3287                arr.iter()
3288                    .filter_map(|t| {
3289                        let k = t.get("Key").and_then(|v| v.as_str())?;
3290                        let val = t.get("Value").and_then(|v| v.as_str()).unwrap_or("");
3291                        Some((k.to_string(), val.to_string()))
3292                    })
3293                    .collect()
3294            })
3295            .unwrap_or_default();
3296
3297        let mut org_lock = self.organizations_state.write();
3298        let org = org_lock
3299            .as_mut()
3300            .ok_or_else(|| "Organization not yet created".to_string())?;
3301        // CFN provisioning is its own asynchronous flow; we don't need
3302        // a second layer of poll-for-completion on top. Begin the
3303        // request and immediately drive it to SUCCEEDED so the rest of
3304        // this provisioner sees a fully enrolled account.
3305        let pending = org.begin_create_account(&email, &name, None);
3306        let status = org.complete_create_account(&pending.id).unwrap_or(pending);
3307        let account_id = status
3308            .account_id
3309            .clone()
3310            .ok_or_else(|| "create_account did not return an account id".to_string())?;
3311        let account_arn = org
3312            .accounts
3313            .get(&account_id)
3314            .map(|a| a.arn.clone())
3315            .unwrap_or_default();
3316        let joined_method = org
3317            .accounts
3318            .get(&account_id)
3319            .map(|a| a.joined_method.clone())
3320            .unwrap_or_else(|| "CREATED".to_string());
3321        let joined_timestamp = org
3322            .accounts
3323            .get(&account_id)
3324            .map(|a| a.joined_timestamp.to_rfc3339())
3325            .unwrap_or_default();
3326        let acct_status = org
3327            .accounts
3328            .get(&account_id)
3329            .map(|a| a.status.clone())
3330            .unwrap_or_else(|| "ACTIVE".to_string());
3331
3332        if let Some(parent) = parent_ids.first() {
3333            let source = org
3334                .accounts
3335                .get(&account_id)
3336                .map(|a| a.parent_id.clone())
3337                .unwrap_or_else(|| org.root_id.clone());
3338            if parent != &source {
3339                org.move_account(&account_id, &source, parent)
3340                    .map_err(|e| format!("Failed to move account to parent {parent}: {e:?}"))?;
3341            }
3342        }
3343
3344        if !tags.is_empty() {
3345            org.set_resource_tags(&account_id, &tags);
3346        }
3347
3348        Ok(ProvisionResult::new(account_id.clone())
3349            .with("AccountId", account_id)
3350            .with("AccountName", name)
3351            .with("Email", email)
3352            .with("Arn", account_arn)
3353            .with("JoinedMethod", joined_method)
3354            .with("JoinedTimestamp", joined_timestamp)
3355            .with("Status", acct_status))
3356    }
3357
3358    /// Close the member account on stack delete. Real AWS leaves the
3359    /// account in `SUSPENDED` for 90 days; we just flip it via
3360    /// `close_account` so subsequent reads see it as suspended.
3361    fn delete_organization_account(&self, physical_id: &str) -> Result<(), String> {
3362        let mut org_lock = self.organizations_state.write();
3363        if let Some(org) = org_lock.as_mut() {
3364            let _ = org.close_account(physical_id);
3365        }
3366        Ok(())
3367    }
3368
3369    fn create_organization_policy(
3370        &self,
3371        resource: &ResourceDefinition,
3372    ) -> Result<ProvisionResult, String> {
3373        let props = &resource.properties;
3374        let name = props
3375            .get("Name")
3376            .and_then(|v| v.as_str())
3377            .unwrap_or(&resource.logical_id)
3378            .to_string();
3379        let description = props
3380            .get("Description")
3381            .and_then(|v| v.as_str())
3382            .unwrap_or("")
3383            .to_string();
3384        let policy_type = props
3385            .get("Type")
3386            .and_then(|v| v.as_str())
3387            .unwrap_or(POLICY_TYPE_SCP)
3388            .to_string();
3389        let content = props
3390            .get("Content")
3391            .map(|v| {
3392                if v.is_string() {
3393                    v.as_str().unwrap_or("").to_string()
3394                } else {
3395                    serde_json::to_string(v).unwrap_or_default()
3396                }
3397            })
3398            .unwrap_or_default();
3399        let target_ids: Vec<String> = props
3400            .get("TargetIds")
3401            .and_then(|v| v.as_array())
3402            .map(|arr| {
3403                arr.iter()
3404                    .filter_map(|t| t.as_str().map(|s| s.to_string()))
3405                    .collect()
3406            })
3407            .unwrap_or_default();
3408
3409        let mut org_lock = self.organizations_state.write();
3410        let org = org_lock
3411            .as_mut()
3412            .ok_or_else(|| "Organization not yet created".to_string())?;
3413        let id_suffix: String = Uuid::new_v4()
3414            .simple()
3415            .to_string()
3416            .chars()
3417            .take(8)
3418            .collect();
3419        let id = format!("p-{}", id_suffix);
3420        let arn = format!(
3421            "arn:aws:organizations::{}:policy/{}/{}/{}",
3422            org.management_account_id,
3423            org.org_id,
3424            policy_type.to_lowercase(),
3425            id
3426        );
3427        org.policies.insert(
3428            id.clone(),
3429            OrgPolicy {
3430                id: id.clone(),
3431                arn: arn.clone(),
3432                name: name.clone(),
3433                description,
3434                policy_type,
3435                aws_managed: false,
3436                content,
3437            },
3438        );
3439        for target in target_ids {
3440            org.attachments
3441                .entry(target)
3442                .or_default()
3443                .insert(id.clone());
3444        }
3445        Ok(ProvisionResult::new(id.clone())
3446            .with("Id", id)
3447            .with("Arn", arn)
3448            .with("Name", name))
3449    }
3450
3451    fn delete_organization_policy(&self, physical_id: &str) -> Result<(), String> {
3452        let mut org_lock = self.organizations_state.write();
3453        if let Some(org) = org_lock.as_mut() {
3454            org.policies.remove(physical_id);
3455            for attachments in org.attachments.values_mut() {
3456                attachments.remove(physical_id);
3457            }
3458        }
3459        Ok(())
3460    }
3461
3462    fn create_organization_resource_policy(
3463        &self,
3464        resource: &ResourceDefinition,
3465    ) -> Result<ProvisionResult, String> {
3466        let props = &resource.properties;
3467        let content = props
3468            .get("Content")
3469            .map(|v| {
3470                if v.is_string() {
3471                    v.as_str().unwrap_or("").to_string()
3472                } else {
3473                    serde_json::to_string(v).unwrap_or_default()
3474                }
3475            })
3476            .ok_or_else(|| "Content is required".to_string())?;
3477
3478        let mut org_lock = self.organizations_state.write();
3479        let org = org_lock
3480            .as_mut()
3481            .ok_or_else(|| "Organization not yet created".to_string())?;
3482        org.resource_policy = Some(content);
3483        let arn = format!(
3484            "arn:aws:organizations::{}:resourcepolicy/{}/rp",
3485            org.management_account_id, org.org_id
3486        );
3487        Ok(ProvisionResult::new(arn.clone()).with("Arn", arn))
3488    }
3489
3490    fn delete_organization_resource_policy(&self, _physical_id: &str) -> Result<(), String> {
3491        let mut org_lock = self.organizations_state.write();
3492        if let Some(org) = org_lock.as_mut() {
3493            org.resource_policy = None;
3494        }
3495        Ok(())
3496    }
3497
3498    fn delete_log_group(&self, physical_id: &str) -> Result<(), String> {
3499        let mut logs_accounts = self.logs_state.write();
3500        let state = logs_accounts.default_mut();
3501        // physical_id is the ARN; find the log group name
3502        let name = state
3503            .log_groups
3504            .iter()
3505            .find(|(_, g)| g.arn == physical_id)
3506            .map(|(name, _)| name.clone());
3507        if let Some(name) = name {
3508            state.log_groups.remove(&name);
3509        }
3510        Ok(())
3511    }
3512
3513    fn create_log_stream(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
3514        let props = &resource.properties;
3515        let log_group_name = props
3516            .get("LogGroupName")
3517            .and_then(|v| v.as_str())
3518            .map(parse_log_group_name)
3519            .ok_or_else(|| "LogGroupName is required".to_string())?;
3520        let log_stream_name = props
3521            .get("LogStreamName")
3522            .and_then(|v| v.as_str())
3523            .unwrap_or(&resource.logical_id)
3524            .to_string();
3525
3526        let mut logs_accounts = self.logs_state.write();
3527        let state = logs_accounts.get_or_create(&self.account_id);
3528        let group = state
3529            .log_groups
3530            .get_mut(&log_group_name)
3531            .ok_or_else(|| format!("Log group {log_group_name} does not exist"))?;
3532        let arn = format!(
3533            "arn:aws:logs:{}:{}:log-group:{}:log-stream:{}",
3534            self.region, self.account_id, log_group_name, log_stream_name
3535        );
3536        if group.log_streams.contains_key(&log_stream_name) {
3537            return Err(format!(
3538                "Log stream {log_stream_name} already exists in {log_group_name}"
3539            ));
3540        }
3541        group.log_streams.insert(
3542            log_stream_name.clone(),
3543            LogStream {
3544                name: log_stream_name.clone(),
3545                arn,
3546                creation_time: Utc::now().timestamp_millis(),
3547                first_event_timestamp: None,
3548                last_event_timestamp: None,
3549                last_ingestion_time: None,
3550                upload_sequence_token: String::new(),
3551                events: Vec::new(),
3552            },
3553        );
3554
3555        // Encode group + stream into the physical id so deletion can target both.
3556        let physical_id = format!("{log_group_name}|{log_stream_name}");
3557        Ok(ProvisionResult::new(physical_id))
3558    }
3559
3560    fn delete_log_stream(&self, physical_id: &str) -> Result<(), String> {
3561        let mut logs_accounts = self.logs_state.write();
3562        let state = logs_accounts.get_or_create(&self.account_id);
3563        if let Some((group_name, stream_name)) = physical_id.split_once('|') {
3564            if let Some(group) = state.log_groups.get_mut(group_name) {
3565                group.log_streams.remove(stream_name);
3566            }
3567        }
3568        Ok(())
3569    }
3570
3571    fn create_metric_filter(
3572        &self,
3573        resource: &ResourceDefinition,
3574    ) -> Result<ProvisionResult, String> {
3575        let props = &resource.properties;
3576        let log_group_name = props
3577            .get("LogGroupName")
3578            .and_then(|v| v.as_str())
3579            .map(parse_log_group_name)
3580            .ok_or_else(|| "LogGroupName is required".to_string())?;
3581        let filter_name = props
3582            .get("FilterName")
3583            .and_then(|v| v.as_str())
3584            .unwrap_or(&resource.logical_id)
3585            .to_string();
3586        let filter_pattern = props
3587            .get("FilterPattern")
3588            .and_then(|v| v.as_str())
3589            .unwrap_or("")
3590            .to_string();
3591
3592        let mut transformations: Vec<MetricTransformation> = Vec::new();
3593        if let Some(arr) = props
3594            .get("MetricTransformations")
3595            .and_then(|v| v.as_array())
3596        {
3597            for t in arr {
3598                let metric_name = t
3599                    .get("MetricName")
3600                    .and_then(|v| v.as_str())
3601                    .unwrap_or("")
3602                    .to_string();
3603                let metric_namespace = t
3604                    .get("MetricNamespace")
3605                    .and_then(|v| v.as_str())
3606                    .unwrap_or("")
3607                    .to_string();
3608                let metric_value = t
3609                    .get("MetricValue")
3610                    .and_then(|v| v.as_str())
3611                    .unwrap_or("1")
3612                    .to_string();
3613                let default_value = t.get("DefaultValue").and_then(|v| v.as_f64());
3614                let unit = t
3615                    .get("Unit")
3616                    .and_then(|v| v.as_str())
3617                    .map(|s| s.to_string());
3618                transformations.push(MetricTransformation {
3619                    metric_name,
3620                    metric_namespace,
3621                    metric_value,
3622                    default_value,
3623                    unit,
3624                });
3625            }
3626        }
3627
3628        let mut logs_accounts = self.logs_state.write();
3629        let state = logs_accounts.get_or_create(&self.account_id);
3630        if !state.log_groups.contains_key(&log_group_name) {
3631            return Err(format!("Log group {log_group_name} does not exist"));
3632        }
3633        state
3634            .metric_filters
3635            .retain(|f| !(f.log_group_name == log_group_name && f.filter_name == filter_name));
3636        state.metric_filters.push(MetricFilter {
3637            filter_name: filter_name.clone(),
3638            filter_pattern,
3639            log_group_name: log_group_name.clone(),
3640            metric_transformations: transformations,
3641            creation_time: Utc::now().timestamp_millis(),
3642        });
3643
3644        Ok(ProvisionResult::new(format!(
3645            "{log_group_name}|{filter_name}"
3646        )))
3647    }
3648
3649    fn delete_metric_filter(&self, physical_id: &str) -> Result<(), String> {
3650        let mut logs_accounts = self.logs_state.write();
3651        let state = logs_accounts.get_or_create(&self.account_id);
3652        if let Some((group_name, filter_name)) = physical_id.split_once('|') {
3653            state
3654                .metric_filters
3655                .retain(|f| !(f.log_group_name == group_name && f.filter_name == filter_name));
3656        }
3657        Ok(())
3658    }
3659
3660    fn create_subscription_filter(
3661        &self,
3662        resource: &ResourceDefinition,
3663    ) -> Result<ProvisionResult, String> {
3664        let props = &resource.properties;
3665        let log_group_name = props
3666            .get("LogGroupName")
3667            .and_then(|v| v.as_str())
3668            .map(parse_log_group_name)
3669            .ok_or_else(|| "LogGroupName is required".to_string())?;
3670        let filter_name = props
3671            .get("FilterName")
3672            .and_then(|v| v.as_str())
3673            .unwrap_or(&resource.logical_id)
3674            .to_string();
3675        let filter_pattern = props
3676            .get("FilterPattern")
3677            .and_then(|v| v.as_str())
3678            .unwrap_or("")
3679            .to_string();
3680        let destination_arn = props
3681            .get("DestinationArn")
3682            .and_then(|v| v.as_str())
3683            .ok_or_else(|| "DestinationArn is required".to_string())?
3684            .to_string();
3685        let role_arn = props
3686            .get("RoleArn")
3687            .and_then(|v| v.as_str())
3688            .map(|s| s.to_string());
3689        let distribution = props
3690            .get("Distribution")
3691            .and_then(|v| v.as_str())
3692            .unwrap_or("ByLogStream")
3693            .to_string();
3694
3695        let mut logs_accounts = self.logs_state.write();
3696        let state = logs_accounts.get_or_create(&self.account_id);
3697        let group = state
3698            .log_groups
3699            .get_mut(&log_group_name)
3700            .ok_or_else(|| format!("Log group {log_group_name} does not exist"))?;
3701        group
3702            .subscription_filters
3703            .retain(|f| f.filter_name != filter_name);
3704        group.subscription_filters.push(SubscriptionFilter {
3705            filter_name: filter_name.clone(),
3706            log_group_name: log_group_name.clone(),
3707            filter_pattern,
3708            destination_arn,
3709            role_arn,
3710            distribution,
3711            creation_time: Utc::now().timestamp_millis(),
3712        });
3713
3714        Ok(ProvisionResult::new(format!(
3715            "{log_group_name}|{filter_name}"
3716        )))
3717    }
3718
3719    fn delete_subscription_filter(&self, physical_id: &str) -> Result<(), String> {
3720        let mut logs_accounts = self.logs_state.write();
3721        let state = logs_accounts.get_or_create(&self.account_id);
3722        if let Some((group_name, filter_name)) = physical_id.split_once('|') {
3723            if let Some(group) = state.log_groups.get_mut(group_name) {
3724                group
3725                    .subscription_filters
3726                    .retain(|f| f.filter_name != filter_name);
3727            }
3728        }
3729        Ok(())
3730    }
3731
3732    // --- Custom Resources ---
3733
3734    /// Invoke a Lambda function synchronously via the delivery bus.
3735    fn invoke_lambda_sync(&self, function_arn: &str, payload: &str) -> Result<(), String> {
3736        let delivery = self.delivery.clone();
3737        let function_arn = function_arn.to_string();
3738        let payload = payload.to_string();
3739        std::thread::scope(|s| {
3740            s.spawn(|| {
3741                let rt = tokio::runtime::Builder::new_current_thread()
3742                    .enable_all()
3743                    .build()
3744                    .map_err(|e| format!("Failed to create runtime: {e}"))?;
3745                rt.block_on(async {
3746                    match delivery.invoke_lambda(&function_arn, &payload).await {
3747                        Some(Ok(_)) => {
3748                            tracing::info!(
3749                                "Custom resource Lambda {} invoked successfully",
3750                                function_arn
3751                            );
3752                            Ok(())
3753                        }
3754                        Some(Err(e)) => {
3755                            tracing::warn!(
3756                                "Custom resource Lambda {} invocation failed: {e}",
3757                                function_arn
3758                            );
3759                            Err(format!("Lambda invocation failed: {e}"))
3760                        }
3761                        None => {
3762                            tracing::warn!(
3763                                "No Lambda delivery configured; skipping custom resource invocation for {}",
3764                                function_arn
3765                            );
3766                            Ok(())
3767                        }
3768                    }
3769                })
3770            })
3771            .join()
3772            .map_err(|_| "Lambda invocation thread panicked".to_string())?
3773        })
3774    }
3775
3776    fn create_custom_resource(&self, resource: &ResourceDefinition) -> Result<String, String> {
3777        let props = &resource.properties;
3778        let service_token = props
3779            .get("ServiceToken")
3780            .and_then(|v| v.as_str())
3781            .ok_or("Custom resource requires ServiceToken property")?;
3782
3783        let request_id = Uuid::new_v4().to_string();
3784
3785        // Build the CloudFormation custom resource event
3786        let event = serde_json::json!({
3787            "RequestType": "Create",
3788            "ServiceToken": service_token,
3789            "StackId": self.stack_id,
3790            "RequestId": request_id,
3791            "ResourceType": resource.resource_type,
3792            "LogicalResourceId": resource.logical_id,
3793            "ResourceProperties": props,
3794        });
3795
3796        let payload = serde_json::to_string(&event).map_err(|e| e.to_string())?;
3797        if self.defer_custom_invokes {
3798            // Changeset/update path: queue the invoke so the caller runs it off
3799            // the request path after the state write lock is dropped. The Lambda
3800            // response is not consumed for `GetAtt` (the physical id is generated
3801            // below regardless), so deferring loses nothing.
3802            self.pending_custom_invokes.lock().push(CustomInvokeIntent {
3803                service_token: service_token.to_string(),
3804                payload,
3805            });
3806        } else {
3807            self.invoke_lambda_sync(service_token, &payload)?;
3808        }
3809
3810        // Physical resource ID: use a generated ID (the Lambda could return one,
3811        // but for simplicity we generate one here).
3812        let physical_id = format!("{}-{}", resource.logical_id, &request_id[..8]);
3813        Ok(physical_id)
3814    }
3815
3816    fn delete_custom_resource(&self, resource: &StackResource) -> Result<(), String> {
3817        let service_token = match &resource.service_token {
3818            Some(token) => token.clone(),
3819            None => {
3820                // No ServiceToken stored — nothing to invoke
3821                return Ok(());
3822            }
3823        };
3824
3825        let request_id = Uuid::new_v4().to_string();
3826
3827        let event = serde_json::json!({
3828            "RequestType": "Delete",
3829            "ServiceToken": service_token,
3830            "StackId": self.stack_id,
3831            "RequestId": request_id,
3832            "ResourceType": resource.resource_type,
3833            "LogicalResourceId": resource.logical_id,
3834            "PhysicalResourceId": resource.physical_id,
3835        });
3836
3837        let payload = serde_json::to_string(&event).map_err(|e| e.to_string())?;
3838
3839        if self.defer_custom_invokes {
3840            // Stack-delete / update-removed path: queue the Delete invoke so the
3841            // caller runs it off the request path after the lock is dropped.
3842            self.pending_custom_invokes.lock().push(CustomInvokeIntent {
3843                service_token,
3844                payload,
3845            });
3846        } else if let Err(e) = self.invoke_lambda_sync(&service_token, &payload) {
3847            // Best-effort: don't fail stack deletion if Lambda invocation fails
3848            tracing::warn!(
3849                "Custom resource delete Lambda invocation failed for {}: {e}",
3850                resource.logical_id
3851            );
3852        }
3853        Ok(())
3854    }
3855
3856    // --- Application Auto Scaling ---
3857
3858    fn create_application_autoscaling_scalable_target(
3859        &self,
3860        resource: &ResourceDefinition,
3861    ) -> Result<ProvisionResult, String> {
3862        let props = &resource.properties;
3863        let service_namespace = props
3864            .get("ServiceNamespace")
3865            .and_then(|v| v.as_str())
3866            .ok_or_else(|| "ServiceNamespace is required".to_string())?
3867            .to_string();
3868        let resource_id = props
3869            .get("ResourceId")
3870            .and_then(|v| v.as_str())
3871            .ok_or_else(|| "ResourceId is required".to_string())?
3872            .to_string();
3873        let scalable_dimension = props
3874            .get("ScalableDimension")
3875            .and_then(|v| v.as_str())
3876            .ok_or_else(|| "ScalableDimension is required".to_string())?
3877            .to_string();
3878        let min_capacity = props
3879            .get("MinCapacity")
3880            .and_then(|v| v.as_i64())
3881            .map(|n| n as i32)
3882            .ok_or_else(|| "MinCapacity is required".to_string())?;
3883        let max_capacity = props
3884            .get("MaxCapacity")
3885            .and_then(|v| v.as_i64())
3886            .map(|n| n as i32)
3887            .ok_or_else(|| "MaxCapacity is required".to_string())?;
3888        if min_capacity > max_capacity {
3889            return Err("MinCapacity must be <= MaxCapacity".to_string());
3890        }
3891        let role_arn = props
3892            .get("RoleARN")
3893            .and_then(|v| v.as_str())
3894            .map(|s| s.to_string());
3895        let suspended_state = props.get("SuspendedState").map(|v| AppasSuspendedState {
3896            dynamic_scaling_in_suspended: v
3897                .get("DynamicScalingInSuspended")
3898                .and_then(|x| x.as_bool()),
3899            dynamic_scaling_out_suspended: v
3900                .get("DynamicScalingOutSuspended")
3901                .and_then(|x| x.as_bool()),
3902            scheduled_scaling_suspended: v
3903                .get("ScheduledScalingSuspended")
3904                .and_then(|x| x.as_bool()),
3905        });
3906
3907        let arn = format!(
3908            "arn:aws:application-autoscaling:{}:{}:scalable-target/{}",
3909            self.region,
3910            self.account_id,
3911            &Uuid::new_v4().simple().to_string()[..10]
3912        );
3913        let role = role_arn.unwrap_or_else(|| {
3914            let suffix = match service_namespace.as_str() {
3915                "ecs" => "ECSService",
3916                "elasticmapreduce" => "EMRContainerService",
3917                "ec2" => "EC2SpotFleetRequest",
3918                "appstream" => "ApplicationAutoScaling_AppStreamFleet",
3919                "dynamodb" => "DynamoDBTable",
3920                "rds" => "RDSCluster",
3921                "sagemaker" => "SageMakerEndpoint",
3922                "lambda" => "LambdaConcurrency",
3923                "elasticache" => "ElastiCacheRG",
3924                "cassandra" => "CassandraTable",
3925                "kafka" => "KafkaCluster",
3926                _ => "ApplicationAutoScaling_Default",
3927            };
3928            format!(
3929                "arn:aws:iam::{}:role/aws-service-role/applicationautoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_{}",
3930                self.account_id, suffix
3931            )
3932        });
3933
3934        let mut state = self.app_autoscaling_state.write();
3935        let account = state.accounts.entry(self.account_id.clone()).or_default();
3936        let key = (
3937            service_namespace.clone(),
3938            resource_id.clone(),
3939            scalable_dimension.clone(),
3940        );
3941        let target = AppasScalableTarget {
3942            arn: arn.clone(),
3943            service_namespace: service_namespace.clone(),
3944            resource_id: resource_id.clone(),
3945            scalable_dimension: scalable_dimension.clone(),
3946            min_capacity,
3947            max_capacity,
3948            role_arn: role,
3949            creation_time: Utc::now(),
3950            suspended_state,
3951            predicted_capacity: None,
3952        };
3953        account.scalable_targets.insert(key, target);
3954
3955        Ok(ProvisionResult::new(resource_id.clone())
3956            .with("ScalableTargetARN", arn)
3957            .with("ServiceNamespace", service_namespace)
3958            .with("ScalableDimension", scalable_dimension))
3959    }
3960
3961    fn create_application_autoscaling_scaling_policy(
3962        &self,
3963        resource: &ResourceDefinition,
3964    ) -> Result<ProvisionResult, String> {
3965        let props = &resource.properties;
3966        let policy_name = props
3967            .get("PolicyName")
3968            .and_then(|v| v.as_str())
3969            .ok_or_else(|| "PolicyName is required".to_string())?
3970            .to_string();
3971        let service_namespace = props
3972            .get("ServiceNamespace")
3973            .and_then(|v| v.as_str())
3974            .ok_or_else(|| "ServiceNamespace is required".to_string())?
3975            .to_string();
3976        let resource_id = props
3977            .get("ResourceId")
3978            .and_then(|v| v.as_str())
3979            .ok_or_else(|| "ResourceId is required".to_string())?
3980            .to_string();
3981        let scalable_dimension = props
3982            .get("ScalableDimension")
3983            .and_then(|v| v.as_str())
3984            .ok_or_else(|| "ScalableDimension is required".to_string())?
3985            .to_string();
3986        let policy_type = props
3987            .get("PolicyType")
3988            .and_then(|v| v.as_str())
3989            .unwrap_or("StepScaling")
3990            .to_string();
3991        let step_cfg = props.get("StepScalingPolicyConfiguration").cloned();
3992        let tt_cfg = props
3993            .get("TargetTrackingScalingPolicyConfiguration")
3994            .cloned();
3995        let pred_cfg = props.get("PredictiveScalingPolicyConfiguration").cloned();
3996
3997        let target_key = (
3998            service_namespace.clone(),
3999            resource_id.clone(),
4000            scalable_dimension.clone(),
4001        );
4002        let policy_key = (
4003            service_namespace.clone(),
4004            resource_id.clone(),
4005            scalable_dimension.clone(),
4006            policy_name.clone(),
4007        );
4008
4009        let mut state = self.app_autoscaling_state.write();
4010        let account = state.accounts.entry(self.account_id.clone()).or_default();
4011        if !account.scalable_targets.contains_key(&target_key) {
4012            return Err(format!(
4013                "No scalable target registered for ServiceNamespace={} ResourceId={} ScalableDimension={}",
4014                service_namespace, resource_id, scalable_dimension
4015            ));
4016        }
4017        let arn = format!(
4018            "arn:aws:autoscaling:{}:{}:scalingPolicy:{}:resource/{}/{}:policyName/{}",
4019            self.region,
4020            self.account_id,
4021            Uuid::new_v4(),
4022            service_namespace,
4023            resource_id,
4024            policy_name
4025        );
4026        let policy = AppasScalingPolicy {
4027            arn: arn.clone(),
4028            policy_name: policy_name.clone(),
4029            service_namespace: service_namespace.clone(),
4030            resource_id: resource_id.clone(),
4031            scalable_dimension: scalable_dimension.clone(),
4032            policy_type: policy_type.clone(),
4033            creation_time: Utc::now(),
4034            step_scaling_policy_configuration: step_cfg,
4035            target_tracking_scaling_policy_configuration: tt_cfg,
4036            predictive_scaling_policy_configuration: pred_cfg,
4037            alarms: Vec::new(),
4038            last_applied_at: None,
4039        };
4040        account.scaling_policies.insert(policy_key, policy);
4041
4042        Ok(ProvisionResult::new(arn.clone())
4043            .with("PolicyName", policy_name)
4044            .with("ServiceNamespace", service_namespace)
4045            .with("ResourceId", resource_id)
4046            .with("ScalableDimension", scalable_dimension))
4047    }
4048
4049    fn delete_application_autoscaling_scalable_target(
4050        &self,
4051        physical_id: &str,
4052        attributes: &BTreeMap<String, String>,
4053    ) -> Result<(), String> {
4054        let namespace = attributes
4055            .get("ServiceNamespace")
4056            .cloned()
4057            .ok_or_else(|| "ServiceNamespace missing in attributes".to_string())?;
4058        let resource_id = physical_id.to_string();
4059        let dimension = attributes
4060            .get("ScalableDimension")
4061            .cloned()
4062            .ok_or_else(|| "ScalableDimension missing in attributes".to_string())?;
4063        let key = (namespace, resource_id.clone(), dimension);
4064
4065        let mut state = self.app_autoscaling_state.write();
4066        let account = state.accounts.entry(self.account_id.clone()).or_default();
4067        account.scalable_targets.remove(&key);
4068        account
4069            .scaling_policies
4070            .retain(|k, _| !(k.0 == key.0 && k.1 == key.1 && k.2 == key.2));
4071        account
4072            .scheduled_actions
4073            .retain(|k, _| !(k.0 == key.0 && k.1 == key.1 && k.2 == key.2));
4074        Ok(())
4075    }
4076
4077    fn delete_application_autoscaling_scaling_policy(
4078        &self,
4079        _physical_id: &str,
4080        attributes: &BTreeMap<String, String>,
4081    ) -> Result<(), String> {
4082        let policy_name = attributes
4083            .get("PolicyName")
4084            .cloned()
4085            .ok_or_else(|| "PolicyName missing in attributes".to_string())?;
4086        let namespace = attributes
4087            .get("ServiceNamespace")
4088            .cloned()
4089            .ok_or_else(|| "ServiceNamespace missing in attributes".to_string())?;
4090        let resource_id = attributes
4091            .get("ResourceId")
4092            .cloned()
4093            .ok_or_else(|| "ResourceId missing in attributes".to_string())?;
4094        let dimension = attributes
4095            .get("ScalableDimension")
4096            .cloned()
4097            .ok_or_else(|| "ScalableDimension missing in attributes".to_string())?;
4098        let key = (namespace, resource_id, dimension, policy_name);
4099
4100        let mut state = self.app_autoscaling_state.write();
4101        let account = state.accounts.entry(self.account_id.clone()).or_default();
4102        account.scaling_policies.remove(&key);
4103        Ok(())
4104    }
4105
4106    // --- ElastiCache ---
4107
4108    fn create_ec_parameter_group(
4109        &self,
4110        resource: &ResourceDefinition,
4111    ) -> Result<ProvisionResult, String> {
4112        let props = &resource.properties;
4113        let name = props
4114            .get("CacheParameterGroupName")
4115            .and_then(|v| v.as_str())
4116            .unwrap_or(&resource.logical_id)
4117            .to_string();
4118        let family = props
4119            .get("CacheParameterGroupFamily")
4120            .and_then(|v| v.as_str())
4121            .unwrap_or("redis7")
4122            .to_string();
4123        let description = props
4124            .get("Description")
4125            .and_then(|v| v.as_str())
4126            .unwrap_or("")
4127            .to_string();
4128        let arn = format!(
4129            "arn:aws:elasticache:{}:{}:parametergroup:{}",
4130            self.region, self.account_id, name
4131        );
4132        let group = CacheParameterGroup {
4133            cache_parameter_group_name: name.clone(),
4134            cache_parameter_group_family: family,
4135            description,
4136            is_global: false,
4137            arn: arn.clone(),
4138        };
4139        let mut accounts = self.elasticache_state.write();
4140        let state = accounts.get_or_create(&self.account_id);
4141        // ParameterGroups stored as Vec — replace any existing entry.
4142        state
4143            .parameter_groups
4144            .retain(|p| p.cache_parameter_group_name != name);
4145        state.parameter_groups.push(group);
4146        Ok(ProvisionResult::new(name).with("Arn", arn))
4147    }
4148
4149    fn delete_ec_parameter_group(&self, physical_id: &str) -> Result<(), String> {
4150        let mut accounts = self.elasticache_state.write();
4151        let state = accounts.get_or_create(&self.account_id);
4152        state
4153            .parameter_groups
4154            .retain(|p| p.cache_parameter_group_name != physical_id);
4155        Ok(())
4156    }
4157
4158    fn create_ec_subnet_group(
4159        &self,
4160        resource: &ResourceDefinition,
4161    ) -> Result<ProvisionResult, String> {
4162        let props = &resource.properties;
4163        let name = props
4164            .get("CacheSubnetGroupName")
4165            .and_then(|v| v.as_str())
4166            .unwrap_or(&resource.logical_id)
4167            .to_string();
4168        let description = props
4169            .get("Description")
4170            .and_then(|v| v.as_str())
4171            .unwrap_or("")
4172            .to_string();
4173        let subnet_ids: Vec<String> = props
4174            .get("SubnetIds")
4175            .and_then(|v| v.as_array())
4176            .map(|arr| {
4177                arr.iter()
4178                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
4179                    .collect()
4180            })
4181            .unwrap_or_default();
4182        let arn = format!(
4183            "arn:aws:elasticache:{}:{}:subnetgroup:{}",
4184            self.region, self.account_id, name
4185        );
4186        let group = CacheSubnetGroup {
4187            cache_subnet_group_name: name.clone(),
4188            cache_subnet_group_description: description,
4189            vpc_id: String::new(),
4190            subnet_ids,
4191            arn: arn.clone(),
4192        };
4193        let mut accounts = self.elasticache_state.write();
4194        let state = accounts.get_or_create(&self.account_id);
4195        state.subnet_groups.insert(name.clone(), group);
4196        Ok(ProvisionResult::new(name).with("Arn", arn))
4197    }
4198
4199    fn delete_ec_subnet_group(&self, physical_id: &str) -> Result<(), String> {
4200        let mut accounts = self.elasticache_state.write();
4201        let state = accounts.get_or_create(&self.account_id);
4202        state.subnet_groups.remove(physical_id);
4203        Ok(())
4204    }
4205
4206    fn create_ec_security_group(
4207        &self,
4208        resource: &ResourceDefinition,
4209    ) -> Result<ProvisionResult, String> {
4210        let props = &resource.properties;
4211        let name = props
4212            .get("CacheSecurityGroupName")
4213            .and_then(|v| v.as_str())
4214            .unwrap_or(&resource.logical_id)
4215            .to_string();
4216        let description = props
4217            .get("Description")
4218            .and_then(|v| v.as_str())
4219            .unwrap_or("")
4220            .to_string();
4221        let arn = format!(
4222            "arn:aws:elasticache:{}:{}:securitygroup:{}",
4223            self.region, self.account_id, name
4224        );
4225        let group = CacheSecurityGroup {
4226            cache_security_group_name: name.clone(),
4227            description,
4228            owner_id: self.account_id.clone(),
4229            arn: arn.clone(),
4230            ec2_security_groups: Vec::new(),
4231        };
4232        let mut accounts = self.elasticache_state.write();
4233        let state = accounts.get_or_create(&self.account_id);
4234        state.security_groups.insert(name.clone(), group);
4235        Ok(ProvisionResult::new(name).with("Arn", arn))
4236    }
4237
4238    fn delete_ec_security_group(&self, physical_id: &str) -> Result<(), String> {
4239        let mut accounts = self.elasticache_state.write();
4240        let state = accounts.get_or_create(&self.account_id);
4241        state.security_groups.remove(physical_id);
4242        Ok(())
4243    }
4244
4245    fn create_ec_user(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
4246        let props = &resource.properties;
4247        let user_id = props
4248            .get("UserId")
4249            .and_then(|v| v.as_str())
4250            .unwrap_or(&resource.logical_id)
4251            .to_string();
4252        let user_name = props
4253            .get("UserName")
4254            .and_then(|v| v.as_str())
4255            .unwrap_or(&user_id)
4256            .to_string();
4257        let engine = props
4258            .get("Engine")
4259            .and_then(|v| v.as_str())
4260            .unwrap_or("redis")
4261            .to_string();
4262        let access_string = props
4263            .get("AccessString")
4264            .and_then(|v| v.as_str())
4265            .unwrap_or("on ~* +@all")
4266            .to_string();
4267        let authentication_type = props
4268            .get("AuthenticationMode")
4269            .and_then(|v| v.get("Type"))
4270            .and_then(|v| v.as_str())
4271            .unwrap_or("no-password-required")
4272            .to_string();
4273        let arn = format!(
4274            "arn:aws:elasticache:{}:{}:user:{}",
4275            self.region, self.account_id, user_id
4276        );
4277        let user = EcUser {
4278            user_id: user_id.clone(),
4279            user_name,
4280            engine,
4281            access_string,
4282            status: "active".to_string(),
4283            authentication_type,
4284            password_count: 0,
4285            arn: arn.clone(),
4286            minimum_engine_version: "6.0".to_string(),
4287            user_group_ids: Vec::new(),
4288        };
4289        let mut accounts = self.elasticache_state.write();
4290        let state = accounts.get_or_create(&self.account_id);
4291        state.users.insert(user_id.clone(), user);
4292        Ok(ProvisionResult::new(user_id).with("Arn", arn))
4293    }
4294
4295    fn delete_ec_user(&self, physical_id: &str) -> Result<(), String> {
4296        let mut accounts = self.elasticache_state.write();
4297        let state = accounts.get_or_create(&self.account_id);
4298        state.users.remove(physical_id);
4299        Ok(())
4300    }
4301
4302    fn create_ec_user_group(
4303        &self,
4304        resource: &ResourceDefinition,
4305    ) -> Result<ProvisionResult, String> {
4306        let props = &resource.properties;
4307        let user_group_id = props
4308            .get("UserGroupId")
4309            .and_then(|v| v.as_str())
4310            .unwrap_or(&resource.logical_id)
4311            .to_string();
4312        let engine = props
4313            .get("Engine")
4314            .and_then(|v| v.as_str())
4315            .unwrap_or("redis")
4316            .to_string();
4317        let user_ids: Vec<String> = props
4318            .get("UserIds")
4319            .and_then(|v| v.as_array())
4320            .map(|arr| {
4321                arr.iter()
4322                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
4323                    .collect()
4324            })
4325            .unwrap_or_default();
4326        let arn = format!(
4327            "arn:aws:elasticache:{}:{}:usergroup:{}",
4328            self.region, self.account_id, user_group_id
4329        );
4330        let group = EcUserGroup {
4331            user_group_id: user_group_id.clone(),
4332            engine,
4333            status: "active".to_string(),
4334            user_ids,
4335            arn: arn.clone(),
4336            minimum_engine_version: "6.0".to_string(),
4337            pending_changes: None,
4338            replication_groups: Vec::new(),
4339        };
4340        let mut accounts = self.elasticache_state.write();
4341        let state = accounts.get_or_create(&self.account_id);
4342        state.user_groups.insert(user_group_id.clone(), group);
4343        Ok(ProvisionResult::new(user_group_id).with("Arn", arn))
4344    }
4345
4346    fn delete_ec_user_group(&self, physical_id: &str) -> Result<(), String> {
4347        let mut accounts = self.elasticache_state.write();
4348        let state = accounts.get_or_create(&self.account_id);
4349        state.user_groups.remove(physical_id);
4350        Ok(())
4351    }
4352
4353    fn create_ec_cache_cluster(
4354        &self,
4355        resource: &ResourceDefinition,
4356    ) -> Result<ProvisionResult, String> {
4357        let props = &resource.properties;
4358        let id = props
4359            .get("ClusterName")
4360            .and_then(|v| v.as_str())
4361            .map(String::from)
4362            .unwrap_or_else(|| format!("cfn-cc-{}", resource.logical_id.to_lowercase()));
4363        let cache_node_type = props
4364            .get("CacheNodeType")
4365            .and_then(|v| v.as_str())
4366            .unwrap_or("cache.t4g.micro")
4367            .to_string();
4368        let engine = props
4369            .get("Engine")
4370            .and_then(|v| v.as_str())
4371            .unwrap_or("redis")
4372            .to_string();
4373        let engine_version = props
4374            .get("EngineVersion")
4375            .and_then(|v| v.as_str())
4376            .unwrap_or("7.1")
4377            .to_string();
4378        let num_cache_nodes = props
4379            .get("NumCacheNodes")
4380            .and_then(|v| v.as_i64())
4381            .map(|n| n as i32)
4382            .unwrap_or(1);
4383        let preferred_az = props
4384            .get("PreferredAvailabilityZone")
4385            .and_then(|v| v.as_str())
4386            .unwrap_or("us-east-1a")
4387            .to_string();
4388        let cache_subnet_group_name = props
4389            .get("CacheSubnetGroupName")
4390            .and_then(|v| v.as_str())
4391            .map(String::from);
4392        let auto_minor_version_upgrade = props
4393            .get("AutoMinorVersionUpgrade")
4394            .and_then(|v| v.as_bool())
4395            .unwrap_or(true);
4396        let default_port = if engine == "memcached" { 11211 } else { 6379 };
4397        let port = props
4398            .get("Port")
4399            .and_then(|v| v.as_i64())
4400            .map(|n| n as u16)
4401            .unwrap_or(default_port);
4402        let cache_parameter_group_name = props
4403            .get("CacheParameterGroupName")
4404            .and_then(|v| v.as_str())
4405            .map(String::from);
4406        let security_group_ids: Vec<String> = props
4407            .get("VpcSecurityGroupIds")
4408            .and_then(|v| v.as_array())
4409            .map(|arr| {
4410                arr.iter()
4411                    .filter_map(|v| v.as_str().map(String::from))
4412                    .collect()
4413            })
4414            .unwrap_or_default();
4415        let cache_security_group_names: Vec<String> = props
4416            .get("CacheSecurityGroupNames")
4417            .and_then(|v| v.as_array())
4418            .map(|arr| {
4419                arr.iter()
4420                    .filter_map(|v| v.as_str().map(String::from))
4421                    .collect()
4422            })
4423            .unwrap_or_default();
4424        let preferred_availability_zones: Vec<String> = props
4425            .get("PreferredAvailabilityZones")
4426            .and_then(|v| v.as_array())
4427            .map(|arr| {
4428                arr.iter()
4429                    .filter_map(|v| v.as_str().map(String::from))
4430                    .collect()
4431            })
4432            .unwrap_or_default();
4433        let snapshot_arns: Vec<String> = props
4434            .get("SnapshotArns")
4435            .and_then(|v| v.as_array())
4436            .map(|arr| {
4437                arr.iter()
4438                    .filter_map(|v| v.as_str().map(String::from))
4439                    .collect()
4440            })
4441            .unwrap_or_default();
4442        let snapshot_name = props
4443            .get("SnapshotName")
4444            .and_then(|v| v.as_str())
4445            .map(String::from);
4446        let snapshot_retention_limit = props
4447            .get("SnapshotRetentionLimit")
4448            .and_then(|v| v.as_i64())
4449            .map(|n| n as i32)
4450            .unwrap_or(0);
4451        let snapshot_window = props
4452            .get("SnapshotWindow")
4453            .and_then(|v| v.as_str())
4454            .map(String::from);
4455        let preferred_maintenance_window = props
4456            .get("PreferredMaintenanceWindow")
4457            .and_then(|v| v.as_str())
4458            .map(String::from);
4459        let notification_topic_arn = props
4460            .get("NotificationTopicArn")
4461            .and_then(|v| v.as_str())
4462            .map(String::from);
4463        let transit_encryption_enabled = props
4464            .get("TransitEncryptionEnabled")
4465            .and_then(|v| v.as_bool())
4466            .unwrap_or(false);
4467        let auth_token = props
4468            .get("AuthToken")
4469            .and_then(|v| v.as_str())
4470            .filter(|s| !s.is_empty())
4471            .map(String::from);
4472        let auth_token_enabled = auth_token.is_some();
4473        let network_type = props
4474            .get("NetworkType")
4475            .and_then(|v| v.as_str())
4476            .map(String::from)
4477            .or_else(|| Some("ipv4".to_string()));
4478        let ip_discovery = props
4479            .get("IpDiscovery")
4480            .and_then(|v| v.as_str())
4481            .map(String::from)
4482            .or_else(|| Some("ipv4".to_string()));
4483        let az_mode = props
4484            .get("AZMode")
4485            .and_then(|v| v.as_str())
4486            .map(String::from)
4487            .or_else(|| Some("single-az".to_string()));
4488        let outpost_mode = props
4489            .get("OutpostMode")
4490            .and_then(|v| v.as_str())
4491            .map(String::from);
4492        let preferred_outpost_arn = props
4493            .get("PreferredOutpostArn")
4494            .and_then(|v| v.as_str())
4495            .map(String::from);
4496
4497        // When an ElastiCache container runtime is configured, the record is
4498        // inserted as "creating" and `CreateStack` drains a spawn intent that
4499        // backs it with a real Redis/Memcached container (flipping it to
4500        // "available" once up), matching the direct `CreateCacheCluster` path.
4501        // Without a runtime (CI / metadata-only) it stays "available" with no
4502        // container, as before.
4503        let back_with_container = self.elasticache_runtime.is_some();
4504        let mut accounts = self.elasticache_state.write();
4505        let state = accounts.get_or_create(&self.account_id);
4506        let arn = format!(
4507            "arn:aws:elasticache:{}:{}:cluster:{}",
4508            state.region, state.account_id, id
4509        );
4510        let endpoint_address = format!("{id}.fakecloud.{}.cache.amazonaws.com", state.region);
4511        let cluster = EcCacheCluster {
4512            cache_cluster_id: id.clone(),
4513            cache_node_type,
4514            engine,
4515            engine_version,
4516            cache_cluster_status: if back_with_container {
4517                "creating".to_string()
4518            } else {
4519                "available".to_string()
4520            },
4521            num_cache_nodes,
4522            preferred_availability_zone: preferred_az,
4523            cache_subnet_group_name,
4524            auto_minor_version_upgrade,
4525            arn: arn.clone(),
4526            created_at: Utc::now().to_rfc3339(),
4527            endpoint_address: endpoint_address.clone(),
4528            endpoint_port: port,
4529            container_id: String::new(),
4530            host_port: 0,
4531            replication_group_id: None,
4532            cache_parameter_group_name,
4533            security_group_ids,
4534            log_delivery_configurations: Vec::new(),
4535            transit_encryption_enabled,
4536            at_rest_encryption_enabled: false,
4537            auth_token_enabled,
4538            port,
4539            preferred_maintenance_window,
4540            preferred_availability_zones,
4541            notification_topic_arn,
4542            cache_security_group_names,
4543            snapshot_arns,
4544            snapshot_name,
4545            snapshot_retention_limit,
4546            snapshot_window,
4547            outpost_mode,
4548            preferred_outpost_arn,
4549            network_type,
4550            ip_discovery,
4551            az_mode,
4552            auth_token,
4553            kms_key_id: None,
4554            transit_encryption_mode: None,
4555            data_tiering_enabled: None,
4556            cluster_mode: None,
4557            preferred_outpost_arns: Vec::new(),
4558        };
4559        state.cache_clusters.insert(id.clone(), cluster);
4560        drop(accounts);
4561
4562        if back_with_container {
4563            self.pending_container_spawns
4564                .lock()
4565                .push(ContainerSpawnIntent::ElastiCacheCluster {
4566                    cache_cluster_id: id.clone(),
4567                });
4568        }
4569
4570        Ok(ProvisionResult::new(id.clone())
4571            .with("Arn", arn)
4572            .with("RedisEndpoint.Address", endpoint_address.clone())
4573            .with("RedisEndpoint.Port", port.to_string())
4574            .with("ConfigurationEndpoint.Address", endpoint_address)
4575            .with("ConfigurationEndpoint.Port", port.to_string()))
4576    }
4577
4578    fn delete_ec_cache_cluster(&self, physical_id: &str) -> Result<(), String> {
4579        {
4580            let mut accounts = self.elasticache_state.write();
4581            let state = accounts.get_or_create(&self.account_id);
4582            state.cache_clusters.remove(physical_id);
4583        }
4584        if self.elasticache_runtime.is_some() {
4585            self.pending_container_teardowns.lock().push(
4586                ContainerTeardownIntent::ElastiCacheCluster {
4587                    cache_cluster_id: physical_id.to_string(),
4588                },
4589            );
4590        }
4591        Ok(())
4592    }
4593
4594    fn create_ec_replication_group(
4595        &self,
4596        resource: &ResourceDefinition,
4597    ) -> Result<ProvisionResult, String> {
4598        let props = &resource.properties;
4599        let id = props
4600            .get("ReplicationGroupId")
4601            .and_then(|v| v.as_str())
4602            .map(String::from)
4603            .unwrap_or_else(|| format!("cfn-rg-{}", resource.logical_id.to_lowercase()));
4604        let description = props
4605            .get("ReplicationGroupDescription")
4606            .and_then(|v| v.as_str())
4607            .unwrap_or("CFN-provisioned replication group")
4608            .to_string();
4609        let cache_node_type = props
4610            .get("CacheNodeType")
4611            .and_then(|v| v.as_str())
4612            .unwrap_or("cache.t4g.micro")
4613            .to_string();
4614        let engine = props
4615            .get("Engine")
4616            .and_then(|v| v.as_str())
4617            .unwrap_or("redis")
4618            .to_string();
4619        let engine_version = props
4620            .get("EngineVersion")
4621            .and_then(|v| v.as_str())
4622            .unwrap_or("7.1")
4623            .to_string();
4624        let num_cache_clusters = props
4625            .get("NumCacheClusters")
4626            .and_then(|v| v.as_i64())
4627            .map(|n| n as i32)
4628            .unwrap_or(1);
4629        let num_node_groups = props
4630            .get("NumNodeGroups")
4631            .and_then(|v| v.as_i64())
4632            .map(|n| n as i32)
4633            .unwrap_or(0);
4634        let replicas_per_node_group = props
4635            .get("ReplicasPerNodeGroup")
4636            .and_then(|v| v.as_i64())
4637            .map(|n| n as i32);
4638        let automatic_failover_enabled = props
4639            .get("AutomaticFailoverEnabled")
4640            .and_then(|v| v.as_bool())
4641            .unwrap_or(false);
4642        let multi_az_enabled = props
4643            .get("MultiAZEnabled")
4644            .and_then(|v| v.as_bool())
4645            .unwrap_or(false);
4646        let transit_encryption_enabled = props
4647            .get("TransitEncryptionEnabled")
4648            .and_then(|v| v.as_bool())
4649            .unwrap_or(false);
4650        let at_rest_encryption_enabled = props
4651            .get("AtRestEncryptionEnabled")
4652            .and_then(|v| v.as_bool())
4653            .unwrap_or(false);
4654        let kms_key_id = props
4655            .get("KmsKeyId")
4656            .and_then(|v| v.as_str())
4657            .map(String::from);
4658        let auth_token_enabled = props
4659            .get("AuthToken")
4660            .and_then(|v| v.as_str())
4661            .filter(|s| !s.is_empty())
4662            .is_some();
4663        let user_group_ids: Vec<String> = props
4664            .get("UserGroupIds")
4665            .and_then(|v| v.as_array())
4666            .map(|arr| {
4667                arr.iter()
4668                    .filter_map(|v| v.as_str().map(String::from))
4669                    .collect()
4670            })
4671            .unwrap_or_default();
4672        let snapshot_retention_limit = props
4673            .get("SnapshotRetentionLimit")
4674            .and_then(|v| v.as_i64())
4675            .map(|n| n as i32)
4676            .unwrap_or(0);
4677        let snapshot_window = props
4678            .get("SnapshotWindow")
4679            .and_then(|v| v.as_str())
4680            .unwrap_or("00:00-01:00")
4681            .to_string();
4682        let port = props
4683            .get("Port")
4684            .and_then(|v| v.as_i64())
4685            .map(|n| n as u16)
4686            .unwrap_or(6379);
4687        let cluster_enabled = num_node_groups > 1
4688            || props
4689                .get("ClusterEnabled")
4690                .and_then(|v| v.as_bool())
4691                .unwrap_or(false);
4692
4693        // As with CacheCluster: with a runtime configured the group is inserted
4694        // as "creating" and `CreateStack` drains a spawn intent that backs it
4695        // with a real Redis container, matching the direct
4696        // `CreateReplicationGroup` path. Without a runtime it stays "available"
4697        // with no container.
4698        let back_with_container = self.elasticache_runtime.is_some();
4699        let mut accounts = self.elasticache_state.write();
4700        let state = accounts.get_or_create(&self.account_id);
4701        let arn = format!(
4702            "arn:aws:elasticache:{}:{}:replicationgroup:{}",
4703            state.region, state.account_id, id
4704        );
4705        let endpoint_address = format!(
4706            "{id}.fakecloud.ng.0001.{}.cache.amazonaws.com",
4707            state.region
4708        );
4709        let configuration_endpoint = if cluster_enabled {
4710            Some(format!(
4711                "{id}.fakecloud.cfg.{}.cache.amazonaws.com",
4712                state.region
4713            ))
4714        } else {
4715            None
4716        };
4717
4718        let group = EcReplicationGroup {
4719            replication_group_id: id.clone(),
4720            description,
4721            global_replication_group_id: None,
4722            global_replication_group_role: None,
4723            status: if back_with_container {
4724                "creating".to_string()
4725            } else {
4726                "available".to_string()
4727            },
4728            cache_node_type,
4729            engine,
4730            engine_version,
4731            num_cache_clusters,
4732            automatic_failover_enabled,
4733            endpoint_address: endpoint_address.clone(),
4734            endpoint_port: port,
4735            arn: arn.clone(),
4736            created_at: Utc::now().to_rfc3339(),
4737            container_id: String::new(),
4738            host_port: 0,
4739            member_clusters: Vec::new(),
4740            snapshot_retention_limit,
4741            snapshot_window,
4742            transit_encryption_enabled,
4743            at_rest_encryption_enabled,
4744            cluster_enabled,
4745            kms_key_id,
4746            auth_token_enabled,
4747            user_group_ids,
4748            multi_az_enabled,
4749            log_delivery_configurations: Vec::new(),
4750            data_tiering: props
4751                .get("DataTieringEnabled")
4752                .and_then(|v| v.as_bool())
4753                .map(|b| if b { "enabled" } else { "disabled" }.to_string()),
4754            ip_discovery: props
4755                .get("IpDiscovery")
4756                .and_then(|v| v.as_str())
4757                .map(String::from),
4758            network_type: props
4759                .get("NetworkType")
4760                .and_then(|v| v.as_str())
4761                .map(String::from),
4762            transit_encryption_mode: props
4763                .get("TransitEncryptionMode")
4764                .and_then(|v| v.as_str())
4765                .map(String::from),
4766            num_node_groups,
4767            configuration_endpoint_address: configuration_endpoint.clone(),
4768            configuration_endpoint_port: configuration_endpoint.as_ref().map(|_| port),
4769            replicas_per_node_group,
4770            auth_token: props
4771                .get("AuthToken")
4772                .and_then(|v| v.as_str())
4773                .filter(|s| !s.is_empty())
4774                .map(String::from),
4775            port,
4776            notification_topic_arn: props
4777                .get("NotificationTopicArn")
4778                .and_then(|v| v.as_str())
4779                .map(String::from),
4780            cluster_mode: props
4781                .get("ClusterMode")
4782                .and_then(|v| v.as_str())
4783                .map(String::from),
4784            data_tiering_enabled: props.get("DataTieringEnabled").and_then(|v| v.as_bool()),
4785            notification_topic_status: None,
4786            cache_parameter_group_name: props
4787                .get("CacheParameterGroupName")
4788                .and_then(|v| v.as_str())
4789                .map(String::from),
4790            cache_subnet_group_name: props
4791                .get("CacheSubnetGroupName")
4792                .and_then(|v| v.as_str())
4793                .map(String::from),
4794            security_group_ids: props
4795                .get("SecurityGroupIds")
4796                .and_then(|v| v.as_array())
4797                .map(|arr| {
4798                    arr.iter()
4799                        .filter_map(|v| v.as_str().map(String::from))
4800                        .collect()
4801                })
4802                .unwrap_or_default(),
4803            preferred_maintenance_window: props
4804                .get("PreferredMaintenanceWindow")
4805                .and_then(|v| v.as_str())
4806                .map(String::from),
4807            snapshot_name: props
4808                .get("SnapshotName")
4809                .and_then(|v| v.as_str())
4810                .map(String::from),
4811            snapshot_arns: props
4812                .get("SnapshotArns")
4813                .and_then(|v| v.as_array())
4814                .map(|arr| {
4815                    arr.iter()
4816                        .filter_map(|v| v.as_str().map(String::from))
4817                        .collect()
4818                })
4819                .unwrap_or_default(),
4820            auto_minor_version_upgrade: props
4821                .get("AutoMinorVersionUpgrade")
4822                .and_then(|v| v.as_bool())
4823                .unwrap_or(true),
4824        };
4825        state.replication_groups.insert(id.clone(), group);
4826        drop(accounts);
4827
4828        if back_with_container {
4829            self.pending_container_spawns.lock().push(
4830                ContainerSpawnIntent::ElastiCacheReplicationGroup {
4831                    replication_group_id: id.clone(),
4832                },
4833            );
4834        }
4835
4836        let mut result = ProvisionResult::new(id.clone())
4837            .with("Arn", arn)
4838            .with("PrimaryEndPoint.Address", endpoint_address.clone())
4839            .with("PrimaryEndPoint.Port", port.to_string())
4840            .with("ReadEndPoint.Addresses", endpoint_address.clone())
4841            .with("ReadEndPoint.Ports", port.to_string());
4842        if let Some(cfg) = configuration_endpoint {
4843            result = result
4844                .with("ConfigurationEndPoint.Address", cfg)
4845                .with("ConfigurationEndPoint.Port", port.to_string());
4846        }
4847        Ok(result)
4848    }
4849
4850    fn delete_ec_replication_group(&self, physical_id: &str) -> Result<(), String> {
4851        {
4852            let mut accounts = self.elasticache_state.write();
4853            let state = accounts.get_or_create(&self.account_id);
4854            state.replication_groups.remove(physical_id);
4855        }
4856        if self.elasticache_runtime.is_some() {
4857            self.pending_container_teardowns.lock().push(
4858                ContainerTeardownIntent::ElastiCacheReplicationGroup {
4859                    replication_group_id: physical_id.to_string(),
4860                },
4861            );
4862        }
4863        Ok(())
4864    }
4865
4866    // --- CloudFront ---
4867    //
4868    // CloudFront is a global service that stores data under the default
4869    // account bucket; mirror that here so SDK reads land on the same data
4870    // CFN wrote.
4871
4872    fn create_cf_origin_access_identity(
4873        &self,
4874        resource: &ResourceDefinition,
4875    ) -> Result<ProvisionResult, String> {
4876        let props = &resource.properties;
4877        let cfg = props
4878            .get("CloudFrontOriginAccessIdentityConfig")
4879            .ok_or("CloudFrontOriginAccessIdentityConfig is required")?;
4880        let comment = cfg
4881            .get("Comment")
4882            .and_then(|v| v.as_str())
4883            .unwrap_or("")
4884            .to_string();
4885        let caller_reference = format!("cfn-{}", resource.logical_id);
4886
4887        let id = format!(
4888            "E{}",
4889            Uuid::new_v4().simple().to_string()[..13].to_uppercase()
4890        );
4891        let etag = format!(
4892            "E{}",
4893            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
4894        );
4895        let s3_canonical_user_id = format!(
4896            "{:0<64}",
4897            Uuid::new_v4().simple().to_string().to_lowercase()
4898        );
4899
4900        let oai = StoredOriginAccessIdentity {
4901            id: id.clone(),
4902            etag,
4903            s3_canonical_user_id: s3_canonical_user_id.clone(),
4904            config: CloudFrontOriginAccessIdentityConfig {
4905                caller_reference,
4906                comment,
4907            },
4908        };
4909
4910        let mut accounts = self.cloudfront_state.write();
4911        let state = accounts.entry("000000000000");
4912        state.origin_access_identities.insert(id.clone(), oai);
4913
4914        Ok(ProvisionResult::new(id.clone())
4915            .with("Id", id)
4916            .with("S3CanonicalUserId", s3_canonical_user_id))
4917    }
4918
4919    fn delete_cf_origin_access_identity(&self, physical_id: &str) -> Result<(), String> {
4920        let mut accounts = self.cloudfront_state.write();
4921        let state = accounts.entry("000000000000");
4922        state.origin_access_identities.remove(physical_id);
4923        Ok(())
4924    }
4925
4926    /// Provision an `AWS::CloudFront::Distribution`. Reads
4927    /// DistributionConfig.Origins/DefaultCacheBehavior/etc. and persists
4928    /// a StoredDistribution in CloudFront state. CFN's Origins property
4929    /// is a flat array, so we wrap it back into the wire shape with a
4930    /// quantity + Items.Origin nesting.
4931    fn create_cf_distribution(
4932        &self,
4933        resource: &ResourceDefinition,
4934    ) -> Result<ProvisionResult, String> {
4935        let cfg = resource
4936            .properties
4937            .get("DistributionConfig")
4938            .ok_or_else(|| "DistributionConfig is required".to_string())?;
4939
4940        // CFN Origins is a flat JSON array; the wire shape is
4941        // { Quantity, Items: { Origin: [...] } }. Translate. CFN's
4942        // PascalCase doesn't always match the wire model — Origin's
4943        // CustomOriginConfig uses HTTPPort/HTTPSPort while the model
4944        // expects HttpPort/HttpsPort, so we patch a few well-known
4945        // renames before letting serde finish the parse.
4946        let origin_entries: Vec<Origin> = cfg
4947            .get("Origins")
4948            .and_then(|v| v.as_array())
4949            .ok_or_else(|| "DistributionConfig.Origins is required".to_string())?
4950            .iter()
4951            .map(|o| {
4952                let mut patched = o.clone();
4953                if let Some(custom) = patched
4954                    .get_mut("CustomOriginConfig")
4955                    .and_then(|v| v.as_object_mut())
4956                {
4957                    if let Some(v) = custom.remove("HTTPPort") {
4958                        custom.insert("HttpPort".to_string(), v);
4959                    }
4960                    if let Some(v) = custom.remove("HTTPSPort") {
4961                        custom.insert("HttpsPort".to_string(), v);
4962                    }
4963                }
4964                serde_json::from_value::<Origin>(patched)
4965                    .map_err(|e| format!("Invalid Origin entry: {e}"))
4966            })
4967            .collect::<Result<Vec<_>, _>>()?;
4968        if origin_entries.is_empty() {
4969            return Err("DistributionConfig.Origins must contain at least one origin".to_string());
4970        }
4971        let origins = Origins {
4972            quantity: origin_entries.len() as i32,
4973            items: Some(OriginItems {
4974                origin: origin_entries,
4975            }),
4976        };
4977
4978        let dcb_value = cfg
4979            .get("DefaultCacheBehavior")
4980            .ok_or_else(|| "DistributionConfig.DefaultCacheBehavior is required".to_string())?;
4981        let default_cache_behavior: DefaultCacheBehavior =
4982            serde_json::from_value(dcb_value.clone())
4983                .map_err(|e| format!("Invalid DefaultCacheBehavior: {e}"))?;
4984
4985        let comment = cfg
4986            .get("Comment")
4987            .and_then(|v| v.as_str())
4988            .unwrap_or("")
4989            .to_string();
4990        let enabled = cfg.get("Enabled").and_then(|v| v.as_bool()).unwrap_or(true);
4991        let price_class = cfg
4992            .get("PriceClass")
4993            .and_then(|v| v.as_str())
4994            .map(|s| s.to_string());
4995        let http_version = cfg
4996            .get("HttpVersion")
4997            .and_then(|v| v.as_str())
4998            .map(|s| s.to_string());
4999        let is_ipv6_enabled = cfg.get("IPV6Enabled").and_then(|v| v.as_bool());
5000        let default_root_object = cfg
5001            .get("DefaultRootObject")
5002            .and_then(|v| v.as_str())
5003            .map(|s| s.to_string());
5004        let web_acl_id = cfg
5005            .get("WebACLId")
5006            .and_then(|v| v.as_str())
5007            .map(|s| s.to_string());
5008
5009        let viewer_certificate: Option<ViewerCertificate> = cfg
5010            .get("ViewerCertificate")
5011            .map(|v| serde_json::from_value(v.clone()))
5012            .transpose()
5013            .map_err(|e| format!("Invalid ViewerCertificate: {e}"))?;
5014
5015        let caller_reference = format!("cfn-{}-{}", resource.logical_id, Uuid::new_v4().simple());
5016
5017        let mut config = DistributionConfig {
5018            caller_reference,
5019            comment,
5020            enabled,
5021            origins,
5022            default_cache_behavior,
5023            ..Default::default()
5024        };
5025        config.price_class = price_class;
5026        config.http_version = http_version;
5027        config.is_ipv6_enabled = is_ipv6_enabled;
5028        config.default_root_object = default_root_object;
5029        config.web_acl_id = web_acl_id;
5030        config.viewer_certificate = viewer_certificate;
5031
5032        // Mint distribution id + ARN + domain in the same shape the
5033        // CloudFront service uses.
5034        let id_suffix: String = Uuid::new_v4()
5035            .simple()
5036            .to_string()
5037            .chars()
5038            .take(13)
5039            .collect::<String>()
5040            .to_uppercase();
5041        let id = format!("E{id_suffix}");
5042        let etag_suffix: String = Uuid::new_v4()
5043            .simple()
5044            .to_string()
5045            .chars()
5046            .take(7)
5047            .collect::<String>()
5048            .to_uppercase();
5049        let etag = format!("E{etag_suffix}");
5050        let domain_name = format!("{}.cloudfront.net", id.to_lowercase());
5051        let arn = format!(
5052            "arn:aws:cloudfront::{}:distribution/{}",
5053            self.account_id, id
5054        );
5055
5056        let stored = StoredDistribution {
5057            id: id.clone(),
5058            arn: arn.clone(),
5059            // CloudFront flips this to Deployed on the first GetDistribution
5060            // poll, matching the rest of the service.
5061            status: "InProgress".to_string(),
5062            last_modified_time: Utc::now(),
5063            domain_name: domain_name.clone(),
5064            in_progress_invalidation_batches: 0,
5065            etag,
5066            config,
5067        };
5068
5069        let mut accounts = self.cloudfront_state.write();
5070        let state = accounts.entry("000000000000");
5071        state.distributions.insert(id.clone(), stored);
5072        Ok(ProvisionResult::new(id.clone())
5073            .with("Id", id)
5074            .with("DomainName", domain_name)
5075            .with("Arn", arn))
5076    }
5077
5078    fn delete_cf_distribution(&self, physical_id: &str) -> Result<(), String> {
5079        let mut accounts = self.cloudfront_state.write();
5080        let state = accounts.entry("000000000000");
5081        state.distributions.remove(physical_id);
5082        Ok(())
5083    }
5084
5085    fn create_cf_origin_access_control(
5086        &self,
5087        resource: &ResourceDefinition,
5088    ) -> Result<ProvisionResult, String> {
5089        let props = &resource.properties;
5090        let cfg = props
5091            .get("OriginAccessControlConfig")
5092            .ok_or("OriginAccessControlConfig is required")?;
5093        let name = cfg
5094            .get("Name")
5095            .and_then(|v| v.as_str())
5096            .ok_or("OriginAccessControlConfig.Name is required")?
5097            .to_string();
5098        let signing_protocol = cfg
5099            .get("SigningProtocol")
5100            .and_then(|v| v.as_str())
5101            .unwrap_or("sigv4")
5102            .to_string();
5103        let signing_behavior = cfg
5104            .get("SigningBehavior")
5105            .and_then(|v| v.as_str())
5106            .unwrap_or("always")
5107            .to_string();
5108        let origin_type = cfg
5109            .get("OriginAccessControlOriginType")
5110            .and_then(|v| v.as_str())
5111            .ok_or("OriginAccessControlConfig.OriginAccessControlOriginType is required")?
5112            .to_string();
5113        let description = cfg
5114            .get("Description")
5115            .and_then(|v| v.as_str())
5116            .map(String::from);
5117
5118        let id = format!(
5119            "E{}",
5120            Uuid::new_v4().simple().to_string()[..13].to_uppercase()
5121        );
5122        let etag = format!(
5123            "E{}",
5124            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
5125        );
5126        let oac = StoredOriginAccessControl {
5127            id: id.clone(),
5128            etag,
5129            config: OriginAccessControlConfig {
5130                name,
5131                description,
5132                signing_protocol,
5133                signing_behavior,
5134                origin_access_control_origin_type: origin_type,
5135            },
5136        };
5137
5138        let mut accounts = self.cloudfront_state.write();
5139        let state = accounts.entry("000000000000");
5140        state.origin_access_controls.insert(id.clone(), oac);
5141
5142        Ok(ProvisionResult::new(id.clone()).with("Id", id))
5143    }
5144
5145    fn delete_cf_origin_access_control(&self, physical_id: &str) -> Result<(), String> {
5146        let mut accounts = self.cloudfront_state.write();
5147        let state = accounts.entry("000000000000");
5148        state.origin_access_controls.remove(physical_id);
5149        Ok(())
5150    }
5151
5152    fn create_cf_public_key(
5153        &self,
5154        resource: &ResourceDefinition,
5155    ) -> Result<ProvisionResult, String> {
5156        let props = &resource.properties;
5157        let cfg = props
5158            .get("PublicKeyConfig")
5159            .ok_or("PublicKeyConfig is required")?;
5160        let name = cfg
5161            .get("Name")
5162            .and_then(|v| v.as_str())
5163            .ok_or("PublicKeyConfig.Name is required")?
5164            .to_string();
5165        let encoded_key = cfg
5166            .get("EncodedKey")
5167            .and_then(|v| v.as_str())
5168            .ok_or("PublicKeyConfig.EncodedKey is required")?
5169            .to_string();
5170        let comment = cfg
5171            .get("Comment")
5172            .and_then(|v| v.as_str())
5173            .map(String::from);
5174        let caller_reference = cfg
5175            .get("CallerReference")
5176            .and_then(|v| v.as_str())
5177            .unwrap_or("")
5178            .to_string();
5179        let caller_reference = if caller_reference.is_empty() {
5180            format!("cfn-{}", resource.logical_id)
5181        } else {
5182            caller_reference
5183        };
5184
5185        let id = format!(
5186            "K{}",
5187            Uuid::new_v4().simple().to_string()[..13].to_uppercase()
5188        );
5189        let etag = format!(
5190            "E{}",
5191            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
5192        );
5193
5194        let pk = StoredPublicKey {
5195            id: id.clone(),
5196            etag,
5197            created_time: Utc::now(),
5198            config: PublicKeyConfig {
5199                caller_reference,
5200                name,
5201                encoded_key,
5202                comment,
5203            },
5204        };
5205
5206        let mut accounts = self.cloudfront_state.write();
5207        let state = accounts.entry("000000000000");
5208        state.public_keys.insert(id.clone(), pk);
5209
5210        Ok(ProvisionResult::new(id.clone()).with("Id", id))
5211    }
5212
5213    fn delete_cf_public_key(&self, physical_id: &str) -> Result<(), String> {
5214        let mut accounts = self.cloudfront_state.write();
5215        let state = accounts.entry("000000000000");
5216        state.public_keys.remove(physical_id);
5217        Ok(())
5218    }
5219
5220    fn create_cf_key_group(
5221        &self,
5222        resource: &ResourceDefinition,
5223    ) -> Result<ProvisionResult, String> {
5224        let props = &resource.properties;
5225        let cfg = props
5226            .get("KeyGroupConfig")
5227            .ok_or("KeyGroupConfig is required")?;
5228        let name = cfg
5229            .get("Name")
5230            .and_then(|v| v.as_str())
5231            .ok_or("KeyGroupConfig.Name is required")?
5232            .to_string();
5233        let items: Vec<String> = cfg
5234            .get("Items")
5235            .and_then(|v| v.as_array())
5236            .map(|arr| {
5237                arr.iter()
5238                    .filter_map(|v| v.as_str().map(String::from))
5239                    .collect()
5240            })
5241            .unwrap_or_default();
5242        let comment = cfg
5243            .get("Comment")
5244            .and_then(|v| v.as_str())
5245            .map(String::from);
5246
5247        let id = format!(
5248            "KG{}",
5249            Uuid::new_v4().simple().to_string()[..12].to_uppercase()
5250        );
5251        let etag = format!(
5252            "E{}",
5253            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
5254        );
5255
5256        let kg = StoredKeyGroup {
5257            id: id.clone(),
5258            etag,
5259            last_modified_time: Utc::now(),
5260            config: KeyGroupConfig {
5261                name,
5262                items: KeyGroupItems { public_key: items },
5263                comment,
5264            },
5265        };
5266
5267        let mut accounts = self.cloudfront_state.write();
5268        let state = accounts.entry("000000000000");
5269        state.key_groups.insert(id.clone(), kg);
5270
5271        Ok(ProvisionResult::new(id.clone()).with("Id", id))
5272    }
5273
5274    fn delete_cf_key_group(&self, physical_id: &str) -> Result<(), String> {
5275        let mut accounts = self.cloudfront_state.write();
5276        let state = accounts.entry("000000000000");
5277        state.key_groups.remove(physical_id);
5278        Ok(())
5279    }
5280
5281    fn create_cf_function(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
5282        let props = &resource.properties;
5283        let name = props
5284            .get("Name")
5285            .and_then(|v| v.as_str())
5286            .ok_or("Name is required")?
5287            .to_string();
5288        let function_code = props
5289            .get("FunctionCode")
5290            .and_then(|v| v.as_str())
5291            .ok_or("FunctionCode is required")?
5292            .to_string();
5293        let cfg = props
5294            .get("FunctionConfig")
5295            .ok_or("FunctionConfig is required")?;
5296        let runtime = cfg
5297            .get("Runtime")
5298            .and_then(|v| v.as_str())
5299            .unwrap_or("cloudfront-js-2.0")
5300            .to_string();
5301        let comment = cfg
5302            .get("Comment")
5303            .and_then(|v| v.as_str())
5304            .map(String::from);
5305
5306        let id = format!(
5307            "FN{}",
5308            Uuid::new_v4().simple().to_string()[..12].to_uppercase()
5309        );
5310        let etag = format!(
5311            "E{}",
5312            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
5313        );
5314        let function_arn =
5315            Arn::global("cloudfront", &self.account_id, &format!("function/{name}")).to_string();
5316
5317        let now = Utc::now();
5318        let func = StoredFunction {
5319            name: name.clone(),
5320            etag,
5321            status: "UNPUBLISHED".to_string(),
5322            stage: "DEVELOPMENT".to_string(),
5323            function_arn: function_arn.clone(),
5324            created_time: now,
5325            last_modified_time: now,
5326            config: FunctionConfig {
5327                comment,
5328                runtime,
5329                key_value_store_associations: None,
5330            },
5331            function_code,
5332            live_function_code: None,
5333        };
5334
5335        let mut accounts = self.cloudfront_state.write();
5336        let state = accounts.entry("000000000000");
5337        // Use the function's ARN/name as the registry key so subsequent
5338        // operations (Get/Update/Delete) keyed by name resolve.
5339        state.functions.insert(name.clone(), func);
5340
5341        Ok(ProvisionResult::new(name.clone())
5342            .with("FunctionARN", function_arn)
5343            .with("FunctionMetadata.FunctionARN", id)
5344            .with("Stage", "DEVELOPMENT"))
5345    }
5346
5347    fn delete_cf_function(&self, physical_id: &str) -> Result<(), String> {
5348        let mut accounts = self.cloudfront_state.write();
5349        let state = accounts.entry("000000000000");
5350        state.functions.remove(physical_id);
5351        Ok(())
5352    }
5353
5354    fn create_cf_cache_policy(
5355        &self,
5356        resource: &ResourceDefinition,
5357    ) -> Result<ProvisionResult, String> {
5358        let props = &resource.properties;
5359        let cfg = props
5360            .get("CachePolicyConfig")
5361            .ok_or("CachePolicyConfig is required")?;
5362        let name = cfg
5363            .get("Name")
5364            .and_then(|v| v.as_str())
5365            .ok_or("CachePolicyConfig.Name is required")?
5366            .to_string();
5367        let min_ttl = cfg
5368            .get("MinTTL")
5369            .and_then(|v| {
5370                v.as_i64()
5371                    .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
5372            })
5373            .unwrap_or(0);
5374        let default_ttl = cfg.get("DefaultTTL").and_then(|v| {
5375            v.as_i64()
5376                .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
5377        });
5378        let max_ttl = cfg.get("MaxTTL").and_then(|v| {
5379            v.as_i64()
5380                .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
5381        });
5382        let comment = cfg
5383            .get("Comment")
5384            .and_then(|v| v.as_str())
5385            .map(String::from);
5386
5387        let id = format!(
5388            "CP{}",
5389            Uuid::new_v4().simple().to_string()[..12].to_uppercase()
5390        );
5391        let etag = format!(
5392            "E{}",
5393            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
5394        );
5395
5396        let cache_policy = StoredCachePolicy {
5397            id: id.clone(),
5398            etag,
5399            last_modified_time: Utc::now(),
5400            config: CachePolicyConfig {
5401                comment,
5402                name,
5403                default_ttl,
5404                max_ttl,
5405                min_ttl,
5406                parameters_in_cache_key_and_forwarded_to_origin: None,
5407            },
5408            policy_type: "custom".to_string(),
5409        };
5410
5411        let mut accounts = self.cloudfront_state.write();
5412        let state = accounts.entry("000000000000");
5413        state.cache_policies.insert(id.clone(), cache_policy);
5414
5415        Ok(ProvisionResult::new(id.clone()).with("Id", id))
5416    }
5417
5418    fn delete_cf_cache_policy(&self, physical_id: &str) -> Result<(), String> {
5419        let mut accounts = self.cloudfront_state.write();
5420        let state = accounts.entry("000000000000");
5421        state.cache_policies.remove(physical_id);
5422        Ok(())
5423    }
5424
5425    fn create_cf_origin_request_policy(
5426        &self,
5427        resource: &ResourceDefinition,
5428    ) -> Result<ProvisionResult, String> {
5429        let props = &resource.properties;
5430        let cfg = props
5431            .get("OriginRequestPolicyConfig")
5432            .ok_or("OriginRequestPolicyConfig is required")?;
5433        let name = cfg
5434            .get("Name")
5435            .and_then(|v| v.as_str())
5436            .ok_or("OriginRequestPolicyConfig.Name is required")?
5437            .to_string();
5438        let header_behavior = cfg
5439            .get("HeadersConfig")
5440            .and_then(|v| v.get("HeaderBehavior"))
5441            .and_then(|v| v.as_str())
5442            .unwrap_or("none")
5443            .to_string();
5444        let cookie_behavior = cfg
5445            .get("CookiesConfig")
5446            .and_then(|v| v.get("CookieBehavior"))
5447            .and_then(|v| v.as_str())
5448            .unwrap_or("none")
5449            .to_string();
5450        let query_string_behavior = cfg
5451            .get("QueryStringsConfig")
5452            .and_then(|v| v.get("QueryStringBehavior"))
5453            .and_then(|v| v.as_str())
5454            .unwrap_or("none")
5455            .to_string();
5456        let comment = cfg
5457            .get("Comment")
5458            .and_then(|v| v.as_str())
5459            .map(String::from);
5460
5461        let id = format!(
5462            "ORP{}",
5463            Uuid::new_v4().simple().to_string()[..11].to_uppercase()
5464        );
5465        let etag = format!(
5466            "E{}",
5467            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
5468        );
5469
5470        let policy = StoredOriginRequestPolicy {
5471            id: id.clone(),
5472            etag,
5473            last_modified_time: Utc::now(),
5474            config: OriginRequestPolicyConfig {
5475                comment,
5476                name,
5477                headers_config: OriginRequestPolicyHeadersConfig {
5478                    header_behavior,
5479                    headers: None,
5480                },
5481                cookies_config: OriginRequestPolicyCookiesConfig {
5482                    cookie_behavior,
5483                    cookies: None,
5484                },
5485                query_strings_config: OriginRequestPolicyQueryStringsConfig {
5486                    query_string_behavior,
5487                    query_strings: None,
5488                },
5489            },
5490            policy_type: "custom".to_string(),
5491        };
5492
5493        let mut accounts = self.cloudfront_state.write();
5494        let state = accounts.entry("000000000000");
5495        state.origin_request_policies.insert(id.clone(), policy);
5496
5497        Ok(ProvisionResult::new(id.clone()).with("Id", id))
5498    }
5499
5500    fn delete_cf_origin_request_policy(&self, physical_id: &str) -> Result<(), String> {
5501        let mut accounts = self.cloudfront_state.write();
5502        let state = accounts.entry("000000000000");
5503        state.origin_request_policies.remove(physical_id);
5504        Ok(())
5505    }
5506
5507    fn create_cf_response_headers_policy(
5508        &self,
5509        resource: &ResourceDefinition,
5510    ) -> Result<ProvisionResult, String> {
5511        let props = &resource.properties;
5512        let cfg = props
5513            .get("ResponseHeadersPolicyConfig")
5514            .ok_or("ResponseHeadersPolicyConfig is required")?;
5515        let name = cfg
5516            .get("Name")
5517            .and_then(|v| v.as_str())
5518            .ok_or("ResponseHeadersPolicyConfig.Name is required")?
5519            .to_string();
5520        let comment = cfg
5521            .get("Comment")
5522            .and_then(|v| v.as_str())
5523            .map(String::from);
5524
5525        let id = format!(
5526            "RHP{}",
5527            Uuid::new_v4().simple().to_string()[..11].to_uppercase()
5528        );
5529        let etag = format!(
5530            "E{}",
5531            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
5532        );
5533
5534        let policy = StoredResponseHeadersPolicy {
5535            id: id.clone(),
5536            etag,
5537            last_modified_time: Utc::now(),
5538            config: ResponseHeadersPolicyConfig {
5539                comment,
5540                name,
5541                cors_config: None,
5542                security_headers_config: None,
5543                server_timing_headers_config: None,
5544                custom_headers_config: None,
5545                remove_headers_config: None,
5546            },
5547            policy_type: "custom".to_string(),
5548        };
5549
5550        let mut accounts = self.cloudfront_state.write();
5551        let state = accounts.entry("000000000000");
5552        state.response_headers_policies.insert(id.clone(), policy);
5553
5554        Ok(ProvisionResult::new(id.clone()).with("Id", id))
5555    }
5556
5557    fn delete_cf_response_headers_policy(&self, physical_id: &str) -> Result<(), String> {
5558        let mut accounts = self.cloudfront_state.write();
5559        let state = accounts.entry("000000000000");
5560        state.response_headers_policies.remove(physical_id);
5561        Ok(())
5562    }
5563
5564    fn parse_athena_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
5565        let mut out = BTreeMap::new();
5566        let Some(arr) = value.and_then(|v| v.as_array()) else {
5567            return out;
5568        };
5569        for tag in arr {
5570            if let (Some(k), Some(v)) = (
5571                tag.get("Key").and_then(|v| v.as_str()),
5572                tag.get("Value").and_then(|v| v.as_str()),
5573            ) {
5574                out.insert(k.to_string(), v.to_string());
5575            }
5576        }
5577        out
5578    }
5579
5580    fn fetch_template_from_url(&self, url: &str) -> Result<String, String> {
5581        if let Some(rest) = url.strip_prefix("s3://") {
5582            let parts: Vec<&str> = rest.splitn(2, '/').collect();
5583            if parts.len() != 2 {
5584                return Err("Invalid s3:// URL".to_string());
5585            }
5586            return self.fetch_s3_template(parts[0], parts[1]);
5587        }
5588
5589        if let Some(rest) = url.strip_prefix("https://s3.amazonaws.com/") {
5590            let parts: Vec<&str> = rest.splitn(2, '/').collect();
5591            if parts.len() != 2 {
5592                return Err("Invalid S3 HTTPS URL".to_string());
5593            }
5594            return self.fetch_s3_template(parts[0], parts[1]);
5595        }
5596
5597        if let Some(host_rest) = url.strip_prefix("https://") {
5598            if let Some(slash_pos) = host_rest.find('/') {
5599                let host = &host_rest[..slash_pos];
5600                let key = &host_rest[slash_pos + 1..];
5601                if let Some(bucket) = host.strip_suffix(".s3.amazonaws.com") {
5602                    return self.fetch_s3_template(bucket, key);
5603                }
5604                if host.contains(".s3.") && host.ends_with(".amazonaws.com") {
5605                    let bucket = host.split(".s3.").next().unwrap_or("");
5606                    if !bucket.is_empty() {
5607                        return self.fetch_s3_template(bucket, key);
5608                    }
5609                }
5610            }
5611        }
5612
5613        Err(format!("Unsupported TemplateURL: {url}"))
5614    }
5615
5616    fn fetch_s3_template(&self, bucket: &str, key: &str) -> Result<String, String> {
5617        let mut s3_accounts = self.s3_state.write();
5618        let s3_state = s3_accounts.get_or_create(&self.account_id);
5619        let bucket_obj = s3_state
5620            .buckets
5621            .get(bucket)
5622            .ok_or_else(|| format!("S3 bucket not found: {bucket}"))?;
5623        let obj = bucket_obj
5624            .objects
5625            .get(key)
5626            .ok_or_else(|| format!("S3 object not found: {bucket}/{key}"))?;
5627        let bytes = s3_state
5628            .read_body(&obj.body)
5629            .map_err(|e| format!("Failed to read S3 object body: {e}"))?;
5630        String::from_utf8(bytes.to_vec()).map_err(|e| format!("S3 object is not valid UTF-8: {e}"))
5631    }
5632}
5633
5634/// Implements the CloudFormation `GenerateSecretString` shape on
5635/// `AWS::SecretsManager::Secret`. Produces the plaintext payload that
5636/// will become the AWSCURRENT version of the secret.
5637///
5638/// When `SecretStringTemplate` is set together with `GenerateStringKey`,
5639/// the generated password is inserted under `GenerateStringKey` in the
5640/// JSON template (this is the standard "DB credential" pattern).
5641/// Without those two, the bare generated password is returned.
5642fn generate_secret_string_payload(gen: &serde_json::Value) -> Result<String, String> {
5643    let length = gen
5644        .get("PasswordLength")
5645        .and_then(|v| v.as_i64())
5646        .unwrap_or(32) as usize;
5647    let exclude_lowercase = gen
5648        .get("ExcludeLowercase")
5649        .and_then(|v| v.as_bool())
5650        .unwrap_or(false);
5651    let exclude_uppercase = gen
5652        .get("ExcludeUppercase")
5653        .and_then(|v| v.as_bool())
5654        .unwrap_or(false);
5655    let exclude_numbers = gen
5656        .get("ExcludeNumbers")
5657        .and_then(|v| v.as_bool())
5658        .unwrap_or(false);
5659    let exclude_punctuation = gen
5660        .get("ExcludePunctuation")
5661        .and_then(|v| v.as_bool())
5662        .unwrap_or(false);
5663    let include_space = gen
5664        .get("IncludeSpace")
5665        .and_then(|v| v.as_bool())
5666        .unwrap_or(false);
5667    let exclude_chars = gen
5668        .get("ExcludeCharacters")
5669        .and_then(|v| v.as_str())
5670        .unwrap_or("")
5671        .to_string();
5672
5673    let lowercase = "abcdefghijklmnopqrstuvwxyz";
5674    let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
5675    let digits = "0123456789";
5676    let punctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
5677
5678    let mut pool = String::new();
5679    if !exclude_lowercase {
5680        pool.extend(lowercase.chars().filter(|c| !exclude_chars.contains(*c)));
5681    }
5682    if !exclude_uppercase {
5683        pool.extend(uppercase.chars().filter(|c| !exclude_chars.contains(*c)));
5684    }
5685    if !exclude_numbers {
5686        pool.extend(digits.chars().filter(|c| !exclude_chars.contains(*c)));
5687    }
5688    if !exclude_punctuation {
5689        pool.extend(punctuation.chars().filter(|c| !exclude_chars.contains(*c)));
5690    }
5691    if include_space && !exclude_chars.contains(' ') {
5692        pool.push(' ');
5693    }
5694    if pool.is_empty() {
5695        return Err("GenerateSecretString character pool is empty".to_string());
5696    }
5697
5698    let pool_chars: Vec<char> = pool.chars().collect();
5699    let mut password = String::with_capacity(length);
5700    let mut counter: u64 = std::time::SystemTime::now()
5701        .duration_since(std::time::UNIX_EPOCH)
5702        .map(|d| d.as_nanos() as u64)
5703        .unwrap_or(0);
5704    while password.len() < length {
5705        // Splitmix64 — deterministic, no_std-friendly, good enough for
5706        // fake-cloud password generation. Real entropy isn't a goal here.
5707        counter = counter.wrapping_add(0x9E3779B97F4A7C15);
5708        let mut z = counter;
5709        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
5710        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
5711        z ^= z >> 31;
5712        let idx = (z as usize) % pool_chars.len();
5713        password.push(pool_chars[idx]);
5714    }
5715
5716    let template = gen.get("SecretStringTemplate").and_then(|v| v.as_str());
5717    let key = gen.get("GenerateStringKey").and_then(|v| v.as_str());
5718    match (template, key) {
5719        (Some(tmpl), Some(k)) => {
5720            let mut value: serde_json::Value = serde_json::from_str(tmpl)
5721                .map_err(|e| format!("SecretStringTemplate is not valid JSON: {e}"))?;
5722            if let Some(obj) = value.as_object_mut() {
5723                obj.insert(k.to_string(), serde_json::Value::String(password));
5724                Ok(value.to_string())
5725            } else {
5726                Err("SecretStringTemplate must be a JSON object".to_string())
5727            }
5728        }
5729        _ => Ok(password),
5730    }
5731}
5732
5733fn parse_ses_receipt_action(value: &serde_json::Value) -> Option<SesReceiptAction> {
5734    let obj = value.as_object()?;
5735    if let Some(s3) = obj.get("S3Action").and_then(|v| v.as_object()) {
5736        let bucket_name = s3.get("BucketName").and_then(|v| v.as_str())?.to_string();
5737        return Some(SesReceiptAction::S3 {
5738            bucket_name,
5739            object_key_prefix: s3
5740                .get("ObjectKeyPrefix")
5741                .and_then(|v| v.as_str())
5742                .map(String::from),
5743            topic_arn: s3
5744                .get("TopicArn")
5745                .and_then(|v| v.as_str())
5746                .map(String::from),
5747            kms_key_arn: s3
5748                .get("KmsKeyArn")
5749                .and_then(|v| v.as_str())
5750                .map(String::from),
5751        });
5752    }
5753    if let Some(sns) = obj.get("SNSAction").and_then(|v| v.as_object()) {
5754        return Some(SesReceiptAction::Sns {
5755            topic_arn: sns.get("TopicArn").and_then(|v| v.as_str())?.to_string(),
5756            encoding: sns
5757                .get("Encoding")
5758                .and_then(|v| v.as_str())
5759                .map(String::from),
5760        });
5761    }
5762    if let Some(la) = obj.get("LambdaAction").and_then(|v| v.as_object()) {
5763        return Some(SesReceiptAction::Lambda {
5764            function_arn: la.get("FunctionArn").and_then(|v| v.as_str())?.to_string(),
5765            invocation_type: la
5766                .get("InvocationType")
5767                .and_then(|v| v.as_str())
5768                .map(String::from),
5769            topic_arn: la
5770                .get("TopicArn")
5771                .and_then(|v| v.as_str())
5772                .map(String::from),
5773        });
5774    }
5775    if let Some(b) = obj.get("BounceAction").and_then(|v| v.as_object()) {
5776        return Some(SesReceiptAction::Bounce {
5777            smtp_reply_code: b
5778                .get("SmtpReplyCode")
5779                .and_then(|v| v.as_str())
5780                .unwrap_or("550")
5781                .to_string(),
5782            message: b
5783                .get("Message")
5784                .and_then(|v| v.as_str())
5785                .unwrap_or("")
5786                .to_string(),
5787            sender: b
5788                .get("Sender")
5789                .and_then(|v| v.as_str())
5790                .unwrap_or("")
5791                .to_string(),
5792            status_code: b
5793                .get("StatusCode")
5794                .and_then(|v| v.as_str())
5795                .map(String::from),
5796            topic_arn: b.get("TopicArn").and_then(|v| v.as_str()).map(String::from),
5797        });
5798    }
5799    if let Some(ah) = obj.get("AddHeaderAction").and_then(|v| v.as_object()) {
5800        return Some(SesReceiptAction::AddHeader {
5801            header_name: ah.get("HeaderName").and_then(|v| v.as_str())?.to_string(),
5802            header_value: ah.get("HeaderValue").and_then(|v| v.as_str())?.to_string(),
5803        });
5804    }
5805    if let Some(s) = obj.get("StopAction").and_then(|v| v.as_object()) {
5806        return Some(SesReceiptAction::Stop {
5807            scope: s
5808                .get("Scope")
5809                .and_then(|v| v.as_str())
5810                .unwrap_or("RuleSet")
5811                .to_string(),
5812            topic_arn: s.get("TopicArn").and_then(|v| v.as_str()).map(String::from),
5813        });
5814    }
5815    None
5816}
5817
5818/// Generate an N-character alphanumeric id for API Gateway v2 resources.
5819/// AWS HTTP API ids are 10 chars; routes/integrations/etc are similarly
5820/// short. This mirrors the runtime crate's id shape.
5821fn make_apigwv2_id(n: usize) -> String {
5822    let s = uuid::Uuid::new_v4().simple().to_string();
5823    s[..n.min(s.len())].to_string()
5824}
5825
5826/// Lowercase the first letter of each key in a JSON object, recursively.
5827/// CloudFormation property names are PascalCase (`BurstLimit`,
5828/// `RateLimit`); the runtime API Gateway service stores values keyed in
5829/// camelCase (`burstLimit`, `rateLimit`). Used at the CFN/service
5830/// boundary so JSON pulled from the template can flow into the runtime
5831/// state without renaming each leaf by hand.
5832/// Coerce a CFN-resolved Number/String parameter into i64. CFN
5833/// parameters surface here as `Value::String` after `Ref` substitution,
5834/// even when the parameter `Type` is `Number`, so we need both paths.
5835fn cfn_as_i64(v: &serde_json::Value) -> Option<i64> {
5836    if let Some(n) = v.as_i64() {
5837        return Some(n);
5838    }
5839    v.as_str().and_then(|s| s.parse::<i64>().ok())
5840}
5841
5842fn lowercase_first_keys(value: serde_json::Value) -> serde_json::Value {
5843    match value {
5844        serde_json::Value::Object(map) => {
5845            let mut out = serde_json::Map::new();
5846            for (k, v) in map {
5847                let new_key = if let Some(first) = k.chars().next() {
5848                    let mut s = String::with_capacity(k.len());
5849                    s.extend(first.to_lowercase());
5850                    s.push_str(&k[first.len_utf8()..]);
5851                    s
5852                } else {
5853                    k
5854                };
5855                out.insert(new_key, lowercase_first_keys(v));
5856            }
5857            serde_json::Value::Object(out)
5858        }
5859        serde_json::Value::Array(arr) => {
5860            serde_json::Value::Array(arr.into_iter().map(lowercase_first_keys).collect())
5861        }
5862        other => other,
5863    }
5864}
5865
5866/// Synthesize the per-domain DNS validation record list for a
5867/// CFN-provisioned cert. Mirrors the runtime ACM path: each name gets
5868/// a SUCCESS record (since CFN-issued certs are auto-validated above)
5869/// and an `_amzn-validations.<domain>.` resource record so callers
5870/// that read DescribeCertificate see the same shape they'd expect
5871/// from a real ACM-issued cert.
5872fn synth_acm_domain_validation(
5873    domain_name: &str,
5874    sans: &[String],
5875    validation_method: &str,
5876) -> Vec<AcmDomainValidation> {
5877    let mut all = vec![domain_name.to_string()];
5878    for s in sans {
5879        if !all.contains(s) {
5880            all.push(s.clone());
5881        }
5882    }
5883    all.into_iter()
5884        .map(|name| AcmDomainValidation {
5885            domain_name: name.clone(),
5886            validation_status: "SUCCESS".to_string(),
5887            validation_method: validation_method.to_string(),
5888            resource_record_name: Some(format!("_amzn-validations.{name}.")),
5889            resource_record_type: Some("CNAME".to_string()),
5890            resource_record_value: Some(format!("{}.acm-validations.aws.", Uuid::new_v4())),
5891        })
5892        .collect()
5893}
5894
5895/// Convert CFN `Tags` array into the ACM crate's tag map form.
5896fn parse_acm_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
5897    let mut out = BTreeMap::new();
5898    if let Some(arr) = value.and_then(|v| v.as_array()) {
5899        for t in arr {
5900            if let (Some(k), Some(v)) = (
5901                t.get("Key").and_then(|v| v.as_str()),
5902                t.get("Value").and_then(|v| v.as_str()),
5903            ) {
5904                out.insert(k.to_string(), v.to_string());
5905            }
5906        }
5907    }
5908    out
5909}
5910
5911/// Convert CFN `Tags` array into the ECS `TagEntry` form.
5912fn parse_ecs_tags(value: Option<&serde_json::Value>) -> Vec<EcsTagEntry> {
5913    let Some(arr) = value.and_then(|v| v.as_array()) else {
5914        return Vec::new();
5915    };
5916    arr.iter()
5917        .filter_map(|t| {
5918            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
5919            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
5920            Some(EcsTagEntry { key, value })
5921        })
5922        .collect()
5923}
5924
5925/// Strip the cluster ARN prefix when CFN passes a Ref-resolved name or
5926/// a GetAtt-resolved ARN; ECS internal state keys clusters by name.
5927fn parse_ecs_cluster_name(input: &str) -> String {
5928    if let Some(after) = input.split(":cluster/").nth(1) {
5929        return after.to_string();
5930    }
5931    input.to_string()
5932}
5933
5934/// Pull `(family, revision)` out of a task definition ARN tail like
5935/// `task-definition/web:3`. Returns `(family, revision)` or
5936/// `(input, 1)` for unrecognised shapes.
5937fn parse_td_arn(input: &str) -> (String, i32) {
5938    let suffix = input.rsplit('/').next().unwrap_or(input);
5939    if let Some((family, rev)) = suffix.split_once(':') {
5940        if let Ok(revision) = rev.parse::<i32>() {
5941            return (family.to_string(), revision);
5942        }
5943    }
5944    (input.to_string(), 1)
5945}
5946
5947/// Pull `(cluster, service)` out of a service ARN like
5948/// `arn:aws:ecs:us-east-1:000000000000:service/<cluster>/<service>`.
5949fn parse_service_arn(input: &str) -> Option<(String, String)> {
5950    let after = input.split(":service/").nth(1)?;
5951    let mut parts = after.splitn(2, '/');
5952    let cluster = parts.next()?.to_string();
5953    let service = parts.next()?.to_string();
5954    Some((cluster, service))
5955}
5956
5957/// Parse CFN-shape Tags array into the RDS crate's tag form.
5958fn parse_rds_tags(value: Option<&serde_json::Value>) -> Vec<RdsTag> {
5959    let Some(arr) = value.and_then(|v| v.as_array()) else {
5960        return Vec::new();
5961    };
5962    arr.iter()
5963        .filter_map(|t| {
5964            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
5965            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
5966            Some(RdsTag { key, value })
5967        })
5968        .collect()
5969}
5970
5971/// Lazy-create an entry in the RDS `extras` bucket so the provisioner
5972/// doesn't have to re-implement the `BTreeMap::entry` boilerplate per
5973/// resource type.
5974fn rds_extras_mut<'a>(
5975    state: &'a mut fakecloud_rds::RdsState,
5976    category: &str,
5977) -> &'a mut BTreeMap<String, serde_json::Value> {
5978    state.extras.entry(category.to_string()).or_default()
5979}
5980
5981/// Parse a JSON array-of-strings property. Returns empty Vec when the
5982/// value is missing or shaped wrong; matches the tolerant input handling
5983/// used by the runtime Cognito service.
5984fn parse_cognito_string_array(value: Option<&serde_json::Value>) -> Vec<String> {
5985    value
5986        .and_then(|v| v.as_array())
5987        .map(|arr| {
5988            arr.iter()
5989                .filter_map(|v| v.as_str().map(|s| s.to_string()))
5990                .collect()
5991        })
5992        .unwrap_or_default()
5993}
5994
5995fn parse_cognito_password_policy(value: Option<&serde_json::Value>) -> PasswordPolicy {
5996    let Some(inner) = value
5997        .and_then(|v| v.get("PasswordPolicy"))
5998        .and_then(|v| v.as_object())
5999    else {
6000        return PasswordPolicy::default();
6001    };
6002    let mut p = PasswordPolicy::default();
6003    if let Some(n) = inner.get("MinimumLength").and_then(|v| v.as_i64()) {
6004        p.minimum_length = n;
6005    }
6006    if let Some(b) = inner.get("RequireUppercase").and_then(|v| v.as_bool()) {
6007        p.require_uppercase = b;
6008    }
6009    if let Some(b) = inner.get("RequireLowercase").and_then(|v| v.as_bool()) {
6010        p.require_lowercase = b;
6011    }
6012    if let Some(b) = inner.get("RequireNumbers").and_then(|v| v.as_bool()) {
6013        p.require_numbers = b;
6014    }
6015    if let Some(b) = inner.get("RequireSymbols").and_then(|v| v.as_bool()) {
6016        p.require_symbols = b;
6017    }
6018    if let Some(n) = inner
6019        .get("TemporaryPasswordValidityDays")
6020        .and_then(|v| v.as_i64())
6021    {
6022        p.temporary_password_validity_days = n;
6023    }
6024    p
6025}
6026
6027fn parse_cognito_schema_attribute(value: &serde_json::Value) -> Option<SchemaAttribute> {
6028    let name = value.get("Name").and_then(|v| v.as_str())?.to_string();
6029    Some(SchemaAttribute {
6030        name,
6031        attribute_data_type: value
6032            .get("AttributeDataType")
6033            .and_then(|v| v.as_str())
6034            .unwrap_or("String")
6035            .to_string(),
6036        developer_only_attribute: value
6037            .get("DeveloperOnlyAttribute")
6038            .and_then(|v| v.as_bool())
6039            .unwrap_or(false),
6040        mutable: value
6041            .get("Mutable")
6042            .and_then(|v| v.as_bool())
6043            .unwrap_or(true),
6044        required: value
6045            .get("Required")
6046            .and_then(|v| v.as_bool())
6047            .unwrap_or(false),
6048        string_attribute_constraints: None,
6049        number_attribute_constraints: None,
6050    })
6051}
6052
6053fn parse_cognito_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
6054    let mut out = BTreeMap::new();
6055    if let Some(obj) = value.and_then(|v| v.as_object()) {
6056        for (k, v) in obj {
6057            if let Some(s) = v.as_str() {
6058                out.insert(k.clone(), s.to_string());
6059            }
6060        }
6061    }
6062    out
6063}
6064
6065fn parse_cognito_email_configuration(
6066    value: Option<&serde_json::Value>,
6067) -> Option<EmailConfiguration> {
6068    let inner = value?.as_object()?;
6069    Some(EmailConfiguration {
6070        source_arn: inner
6071            .get("SourceArn")
6072            .and_then(|v| v.as_str())
6073            .map(|s| s.to_string()),
6074        reply_to_email_address: inner
6075            .get("ReplyToEmailAddress")
6076            .and_then(|v| v.as_str())
6077            .map(|s| s.to_string()),
6078        email_sending_account: inner
6079            .get("EmailSendingAccount")
6080            .and_then(|v| v.as_str())
6081            .map(|s| s.to_string()),
6082        from_email_address: inner
6083            .get("From")
6084            .and_then(|v| v.as_str())
6085            .map(|s| s.to_string()),
6086        configuration_set: inner
6087            .get("ConfigurationSet")
6088            .and_then(|v| v.as_str())
6089            .map(|s| s.to_string()),
6090    })
6091}
6092
6093fn parse_cognito_sms_configuration(value: Option<&serde_json::Value>) -> Option<SmsConfiguration> {
6094    let inner = value?.as_object()?;
6095    Some(SmsConfiguration {
6096        sns_caller_arn: inner
6097            .get("SnsCallerArn")
6098            .and_then(|v| v.as_str())
6099            .map(|s| s.to_string()),
6100        external_id: inner
6101            .get("ExternalId")
6102            .and_then(|v| v.as_str())
6103            .map(|s| s.to_string()),
6104        sns_region: inner
6105            .get("SnsRegion")
6106            .and_then(|v| v.as_str())
6107            .map(|s| s.to_string()),
6108    })
6109}
6110
6111fn parse_cognito_admin_create_user_config(
6112    value: Option<&serde_json::Value>,
6113) -> Option<AdminCreateUserConfig> {
6114    let inner = value?.as_object()?;
6115    Some(AdminCreateUserConfig {
6116        allow_admin_create_user_only: inner
6117            .get("AllowAdminCreateUserOnly")
6118            .and_then(|v| v.as_bool()),
6119        invite_message_template: None,
6120        unused_account_validity_days: inner
6121            .get("UnusedAccountValidityDays")
6122            .and_then(|v| v.as_i64()),
6123    })
6124}
6125
6126fn parse_cognito_account_recovery(
6127    value: Option<&serde_json::Value>,
6128) -> Option<AccountRecoverySetting> {
6129    let arr = value?.get("RecoveryMechanisms")?.as_array()?;
6130    Some(AccountRecoverySetting {
6131        recovery_mechanisms: arr
6132            .iter()
6133            .filter_map(|m| {
6134                let name = m.get("Name").and_then(|v| v.as_str())?.to_string();
6135                let priority = m.get("Priority").and_then(|v| v.as_i64()).unwrap_or(1);
6136                Some(RecoveryOption { name, priority })
6137            })
6138            .collect(),
6139    })
6140}
6141
6142fn parse_firehose_s3_destination(value: &serde_json::Value) -> Result<S3Destination, String> {
6143    let role_arn = value
6144        .get("RoleARN")
6145        .and_then(|v| v.as_str())
6146        .ok_or("S3 destination requires RoleARN")?
6147        .to_string();
6148    let bucket_arn = value
6149        .get("BucketARN")
6150        .and_then(|v| v.as_str())
6151        .ok_or("S3 destination requires BucketARN")?
6152        .to_string();
6153    let prefix = value
6154        .get("Prefix")
6155        .and_then(|v| v.as_str())
6156        .map(|s| s.to_string());
6157    let error_output_prefix = value
6158        .get("ErrorOutputPrefix")
6159        .and_then(|v| v.as_str())
6160        .map(|s| s.to_string());
6161    let mut buffering_size_mb = None;
6162    let mut buffering_interval_seconds = None;
6163    if let Some(hints) = value.get("BufferingHints") {
6164        buffering_size_mb = hints.get("SizeInMBs").and_then(|v| v.as_i64());
6165        buffering_interval_seconds = hints.get("IntervalInSeconds").and_then(|v| v.as_i64());
6166    }
6167    let compression_format = value
6168        .get("CompressionFormat")
6169        .and_then(|v| v.as_str())
6170        .map(|s| s.to_string());
6171
6172    Ok(S3Destination {
6173        destination_id: "destination-1".to_string(),
6174        role_arn,
6175        bucket_arn,
6176        prefix,
6177        error_output_prefix,
6178        buffering_size_mb,
6179        buffering_interval_seconds,
6180        compression_format,
6181        processing_configuration: None,
6182        data_format_conversion_configuration: None,
6183        cloudwatch_logging_options: None,
6184        custom_time_zone: None,
6185        s3_backup_mode: None,
6186        file_extension: None,
6187    })
6188}
6189
6190#[cfg(test)]
6191mod tests {
6192    use super::*;
6193    use parking_lot::RwLock;
6194
6195    fn make_provisioner() -> ResourceProvisioner {
6196        ResourceProvisioner {
6197            sqs_state: Arc::new(RwLock::new(
6198                fakecloud_core::multi_account::MultiAccountState::new(
6199                    "123456789012",
6200                    "us-east-1",
6201                    "http://localhost:4566",
6202                ),
6203            )),
6204            sns_state: Arc::new(RwLock::new(
6205                fakecloud_core::multi_account::MultiAccountState::new(
6206                    "123456789012",
6207                    "us-east-1",
6208                    "http://localhost:4566",
6209                ),
6210            )),
6211            ssm_state: Arc::new(RwLock::new(
6212                fakecloud_core::multi_account::MultiAccountState::new(
6213                    "123456789012",
6214                    "us-east-1",
6215                    "http://localhost:4566",
6216                ),
6217            )),
6218            iam_state: Arc::new(RwLock::new(
6219                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", "http://localhost:4566"),
6220            )),
6221            s3_state: Arc::new(RwLock::new(fakecloud_core::multi_account::MultiAccountState::new(
6222                "123456789012",
6223                "us-east-1", "",
6224            ))),
6225            eventbridge_state: Arc::new(RwLock::new(
6226                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6227            )),
6228            dynamodb_state: Arc::new(RwLock::new(fakecloud_core::multi_account::MultiAccountState::new(
6229                "123456789012",
6230                "us-east-1", "",
6231            ))),
6232            logs_state: Arc::new(RwLock::new(
6233                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6234            )),
6235            lambda_state: Arc::new(RwLock::new(
6236                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6237            )),
6238            secretsmanager_state: Arc::new(RwLock::new(
6239                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6240            )),
6241            kinesis_state: Arc::new(RwLock::new(
6242                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6243            )),
6244            kms_state: Arc::new(RwLock::new(
6245                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6246            )),
6247            ecr_state: Arc::new(RwLock::new(
6248                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6249            )),
6250            cloudwatch_state: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
6251            elbv2_state: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
6252            organizations_state: Arc::new(RwLock::new(None)),
6253            cognito_state: Arc::new(RwLock::new(
6254                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6255            )),
6256            rds_state: Arc::new(RwLock::new(
6257                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6258            )),
6259            ec2_state: Arc::new(RwLock::new(
6260                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6261            )),
6262            autoscaling_state: Arc::new(RwLock::new(
6263                fakecloud_autoscaling::AutoScalingAccounts::new(),
6264            )),
6265            batch_state: Arc::new(RwLock::new(fakecloud_batch::BatchAccounts::new())),
6266            ecs_state: Arc::new(RwLock::new(
6267                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6268            )),
6269            acm_state: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
6270            elasticache_state: Arc::new(RwLock::new(
6271                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6272            )),
6273            route53_state: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
6274            cloudfront_state: Arc::new(RwLock::new(
6275                fakecloud_cloudfront::CloudFrontAccounts::new(),
6276            )),
6277            cloudformation_state: Arc::new(RwLock::new(
6278                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6279            )),
6280            stepfunctions_state: Arc::new(RwLock::new(
6281                fakecloud_core::multi_account::MultiAccountState::new(
6282                    "123456789012",
6283                    "us-east-1",
6284                    "",
6285                ),
6286            )),
6287            wafv2_state: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
6288            apigateway_state: Arc::new(RwLock::new(
6289                fakecloud_core::multi_account::MultiAccountState::new(
6290                    "123456789012",
6291                    "us-east-1",
6292                    "",
6293                ),
6294            )),
6295            apigatewayv2_state: Arc::new(RwLock::new(
6296                fakecloud_core::multi_account::MultiAccountState::new(
6297                    "123456789012",
6298                    "us-east-1",
6299                    "",
6300                ),
6301            )),
6302            ses_state: Arc::new(RwLock::new(
6303                fakecloud_core::multi_account::MultiAccountState::new(
6304                    "123456789012",
6305                    "us-east-1",
6306                    "",
6307                ),
6308            )),
6309            app_autoscaling_state: Arc::new(parking_lot::RwLock::new(
6310                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
6311            )),
6312            athena_state: Arc::new(parking_lot::RwLock::new(
6313                fakecloud_athena::AthenaAccounts::new(),
6314            )),
6315            firehose_state: Arc::new(parking_lot::RwLock::new(
6316                fakecloud_firehose::FirehoseAccounts::new(),
6317            )),
6318            glue_state: Arc::new(parking_lot::RwLock::new(
6319                fakecloud_glue::GlueAccounts::new(),
6320            )),
6321            delivery: Arc::new(DeliveryBus::new()),
6322            lambda_runtime: None,
6323            rds_runtime: None,
6324            ec2_runtime: None,
6325            ecs_runtime: None,
6326            elasticache_runtime: None,
6327            pending_container_spawns: Arc::new(parking_lot::Mutex::new(Vec::new())),
6328            pending_container_teardowns: Arc::new(parking_lot::Mutex::new(Vec::new())),
6329            pending_custom_invokes: Arc::new(parking_lot::Mutex::new(Vec::new())),
6330            defer_custom_invokes: false,
6331            s3_store: Arc::new(fakecloud_persistence::s3::MemoryS3Store::new()),
6332            account_id: "123456789012".to_string(),
6333            region: "us-east-1".to_string(),
6334            stack_id: "arn:aws:cloudformation:us-east-1:123456789012:stack/test/00000000-0000-0000-0000-000000000000".to_string(),
6335        }
6336    }
6337
6338    fn make_resource(
6339        resource_type: &str,
6340        logical_id: &str,
6341        props: serde_json::Value,
6342    ) -> ResourceDefinition {
6343        ResourceDefinition {
6344            logical_id: logical_id.to_string(),
6345            resource_type: resource_type.to_string(),
6346            properties: props,
6347        }
6348    }
6349
6350    #[test]
6351    fn update_stack_reconciles_iam_role_policies() {
6352        let prov = make_provisioner();
6353        let created = prov
6354            .create_resource(&make_resource(
6355                "AWS::IAM::Role",
6356                "R",
6357                serde_json::json!({
6358                    "RoleName": "r1",
6359                    "AssumeRolePolicyDocument": {"Version": "2012-10-17"},
6360                    "ManagedPolicyArns": ["arn:aws:iam::aws:policy/ReadOnlyAccess"],
6361                }),
6362            ))
6363            .expect("role provisions");
6364        prov.update_resource(
6365            &created,
6366            &make_resource(
6367                "AWS::IAM::Role",
6368                "R",
6369                serde_json::json!({
6370                    "RoleName": "r1",
6371                    "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []},
6372                    "Description": "updated",
6373                    "ManagedPolicyArns": ["arn:aws:iam::aws:policy/AdministratorAccess"],
6374                    "Policies": [{"PolicyName": "inline1", "PolicyDocument": {"k": "v"}}],
6375                }),
6376            ),
6377        )
6378        .expect("update succeeds")
6379        .expect("IAM::Role is updatable");
6380        let iam = prov.iam_state.read();
6381        let acct = iam.get("123456789012").unwrap();
6382        let role = acct.roles.get("r1").unwrap();
6383        assert_eq!(role.description.as_deref(), Some("updated"));
6384        assert!(role.assume_role_policy_document.contains("Statement"));
6385        // Attached managed policies replaced, not appended.
6386        let attached = acct.role_policies.get("r1").unwrap();
6387        assert_eq!(
6388            attached,
6389            &vec!["arn:aws:iam::aws:policy/AdministratorAccess".to_string()]
6390        );
6391        assert!(acct
6392            .role_inline_policies
6393            .get("r1")
6394            .unwrap()
6395            .contains_key("inline1"));
6396    }
6397
6398    #[test]
6399    fn update_stack_bumps_managed_policy_version() {
6400        let prov = make_provisioner();
6401        let created = prov
6402            .create_resource(&make_resource(
6403                "AWS::IAM::ManagedPolicy",
6404                "P",
6405                serde_json::json!({
6406                    "ManagedPolicyName": "p1",
6407                    "PolicyDocument": {"Version": "2012-10-17", "Statement": [{"Effect": "Allow"}]},
6408                }),
6409            ))
6410            .expect("managed policy provisions");
6411        prov.update_resource(
6412            &created,
6413            &make_resource(
6414                "AWS::IAM::ManagedPolicy",
6415                "P",
6416                serde_json::json!({
6417                    "ManagedPolicyName": "p1",
6418                    "PolicyDocument": {"Version": "2012-10-17", "Statement": [{"Effect": "Deny"}]},
6419                }),
6420            ),
6421        )
6422        .expect("update succeeds")
6423        .expect("IAM::ManagedPolicy is updatable");
6424        let iam = prov.iam_state.read();
6425        let acct = iam.get("123456789012").unwrap();
6426        let policy = acct.policies.get(&created.physical_id).unwrap();
6427        assert_eq!(policy.versions.len(), 2);
6428        assert_eq!(policy.default_version_id, "v2");
6429        let default = policy.versions.iter().find(|v| v.is_default).unwrap();
6430        assert!(default.document.contains("Deny"));
6431    }
6432
6433    #[test]
6434    fn update_stack_applies_sqs_queue_property_change() {
6435        let prov = make_provisioner();
6436        let created = prov
6437            .create_resource(&make_resource(
6438                "AWS::SQS::Queue",
6439                "Q",
6440                serde_json::json!({ "QueueName": "q1", "VisibilityTimeout": "30" }),
6441            ))
6442            .expect("queue provisions");
6443        // UpdateStack changes a property; previously a no-op (`_ => None`).
6444        let updated = prov
6445            .update_resource(
6446                &created,
6447                &make_resource(
6448                    "AWS::SQS::Queue",
6449                    "Q",
6450                    serde_json::json!({ "QueueName": "q1", "VisibilityTimeout": "120" }),
6451                ),
6452            )
6453            .expect("update succeeds")
6454            .expect("SQS::Queue is an updatable type");
6455        assert_eq!(updated.physical_id, created.physical_id);
6456        let sqs = prov.sqs_state.read();
6457        let acct = sqs.get("123456789012").unwrap();
6458        let queue = acct.queues.get(&created.physical_id).unwrap();
6459        assert_eq!(
6460            queue
6461                .attributes
6462                .get("VisibilityTimeout")
6463                .map(String::as_str),
6464            Some("120")
6465        );
6466    }
6467
6468    #[test]
6469    fn update_stack_applies_sns_topic_property_change() {
6470        let prov = make_provisioner();
6471        let created = prov
6472            .create_resource(&make_resource(
6473                "AWS::SNS::Topic",
6474                "T",
6475                serde_json::json!({ "TopicName": "t1", "DisplayName": "before" }),
6476            ))
6477            .expect("topic provisions");
6478        let updated = prov
6479            .update_resource(
6480                &created,
6481                &make_resource(
6482                    "AWS::SNS::Topic",
6483                    "T",
6484                    serde_json::json!({ "TopicName": "t1", "DisplayName": "after" }),
6485                ),
6486            )
6487            .expect("update succeeds")
6488            .expect("SNS::Topic is an updatable type");
6489        assert_eq!(updated.physical_id, created.physical_id);
6490        let sns = prov.sns_state.read();
6491        let acct = sns.get("123456789012").unwrap();
6492        let topic = acct.topics.get(&created.physical_id).unwrap();
6493        assert_eq!(
6494            topic.attributes.get("DisplayName").map(String::as_str),
6495            Some("after")
6496        );
6497    }
6498
6499    #[test]
6500    fn ec2_vpc_subnet_provision_through_real_handlers() {
6501        let prov = make_provisioner();
6502        let vpc = prov
6503            .create_resource(&make_resource(
6504                "AWS::EC2::VPC",
6505                "Vpc",
6506                serde_json::json!({ "CidrBlock": "10.1.0.0/16" }),
6507            ))
6508            .expect("VPC provisions");
6509        assert!(
6510            vpc.physical_id.starts_with("vpc-"),
6511            "got {}",
6512            vpc.physical_id
6513        );
6514        // The real VPC handler ran: a VPC exists in EC2 state (with the default
6515        // SG/route-table/NACL the handler creates), not a recorded no-op.
6516        {
6517            let ec2 = prov.ec2_state.read();
6518            let acct = ec2.get("123456789012").unwrap();
6519            assert!(acct.vpcs.contains_key(&vpc.physical_id));
6520        }
6521        // GetAtt VpcId/CidrBlock resolve; Ref is the vpc id.
6522        assert_eq!(
6523            prov.get_att(&vpc, "VpcId").as_deref(),
6524            Some(vpc.physical_id.as_str())
6525        );
6526        assert_eq!(
6527            prov.get_att(&vpc, "CidrBlock").as_deref(),
6528            Some("10.1.0.0/16")
6529        );
6530
6531        // A subnet referencing the VPC (Ref already resolved to the physical id).
6532        let subnet = prov
6533            .create_resource(&make_resource(
6534                "AWS::EC2::Subnet",
6535                "Subnet",
6536                serde_json::json!({ "VpcId": vpc.physical_id, "CidrBlock": "10.1.1.0/24" }),
6537            ))
6538            .expect("subnet provisions");
6539        assert!(subnet.physical_id.starts_with("subnet-"));
6540        {
6541            let ec2 = prov.ec2_state.read();
6542            let acct = ec2.get("123456789012").unwrap();
6543            assert!(acct.subnets.contains_key(&subnet.physical_id));
6544        }
6545
6546        // Delete routes through the real handler.
6547        prov.delete_resource(&subnet).expect("subnet deletes");
6548        prov.delete_resource(&vpc).expect("vpc deletes");
6549    }
6550
6551    #[test]
6552    fn unknown_resource_type_records_instead_of_failing() {
6553        let prov = make_provisioner();
6554        // A real AWS type fakecloud has no provisioner for. It must NOT fail
6555        // the stack — it's recorded as provisioned with no backing state, and
6556        // `Ref` (its physical id) resolves to a stable value (the logical id).
6557        let sr = prov
6558            .create_resource(&make_resource(
6559                "AWS::CloudFormation::WaitConditionHandle",
6560                "Handle",
6561                serde_json::json!({}),
6562            ))
6563            .expect("unknown resource type should record, not fail");
6564        assert_eq!(sr.physical_id, "Handle");
6565        assert_eq!(sr.status, "CREATE_COMPLETE");
6566        // Teardown of a recorded no-op resource also succeeds.
6567        prov.delete_resource(&sr)
6568            .expect("delete no-op should succeed");
6569    }
6570
6571    #[test]
6572    fn ecr_repository_uri_uses_bound_endpoint_not_public_dns() {
6573        // A CFN-provisioned RepositoryUri must point at fakecloud's own registry
6574        // endpoint, not the public AWS DNS, so `docker push` against the GetAtt
6575        // RepositoryUri reaches it (bug-audit 2026-06-20, 1.4).
6576        let prov = make_provisioner();
6577        let sr = prov
6578            .create_resource(&make_resource(
6579                "AWS::ECR::Repository",
6580                "Repo",
6581                serde_json::json!({ "RepositoryName": "my-repo" }),
6582            ))
6583            .expect("ECR repo provisions");
6584        let uri = sr
6585            .attributes
6586            .get("RepositoryUri")
6587            .expect("RepositoryUri attribute");
6588        assert!(
6589            !uri.contains("amazonaws.com"),
6590            "RepositoryUri must not use the public ECR DNS, got {uri}"
6591        );
6592        assert!(uri.contains("my-repo"), "uri should name the repo: {uri}");
6593    }
6594
6595    #[test]
6596    fn sns_subscription_rejects_nonexistent_topic() {
6597        let prov = make_provisioner();
6598        let resource = make_resource(
6599            "AWS::SNS::Subscription",
6600            "MySub",
6601            serde_json::json!({
6602                "TopicArn": "arn:aws:sns:us-east-1:123456789012:NonExistent",
6603                "Protocol": "sqs",
6604                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
6605            }),
6606        );
6607        let result = prov.create_resource(&resource);
6608        assert!(result.is_err());
6609        assert!(result.unwrap_err().contains("does not exist"));
6610    }
6611
6612    #[test]
6613    fn sns_subscription_succeeds_when_topic_exists() {
6614        let prov = make_provisioner();
6615        // First create the topic
6616        let topic = make_resource(
6617            "AWS::SNS::Topic",
6618            "MyTopic",
6619            serde_json::json!({ "TopicName": "my-topic" }),
6620        );
6621        let topic_result = prov.create_resource(&topic);
6622        assert!(topic_result.is_ok());
6623        let topic_arn = topic_result.unwrap().physical_id;
6624
6625        // Now create subscription referencing that topic
6626        let sub = make_resource(
6627            "AWS::SNS::Subscription",
6628            "MySub",
6629            serde_json::json!({
6630                "TopicArn": topic_arn,
6631                "Protocol": "sqs",
6632                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
6633            }),
6634        );
6635        let result = prov.create_resource(&sub);
6636        assert!(result.is_ok());
6637    }
6638
6639    #[test]
6640    fn eventbridge_rule_arn_default_bus_omits_bus_name() {
6641        let prov = make_provisioner();
6642        let resource = make_resource(
6643            "AWS::Events::Rule",
6644            "MyRule",
6645            serde_json::json!({
6646                "Name": "my-rule",
6647                "ScheduleExpression": "rate(1 hour)"
6648            }),
6649        );
6650        let result = prov.create_resource(&resource).unwrap();
6651        // For default bus, ARN should be rule/<name> without /default/
6652        assert_eq!(
6653            result.physical_id,
6654            "arn:aws:events:us-east-1:123456789012:rule/my-rule"
6655        );
6656        assert!(!result.physical_id.contains("rule/default/"));
6657    }
6658
6659    #[test]
6660    fn eventbridge_rule_arn_custom_bus_includes_bus_name() {
6661        let prov = make_provisioner();
6662        // Create a custom bus first
6663        {
6664            let mut eb_accounts = prov.eventbridge_state.write();
6665            let state = eb_accounts.default_mut();
6666            state.buses.insert(
6667                "custom-bus".to_string(),
6668                fakecloud_eventbridge::EventBus {
6669                    name: "custom-bus".to_string(),
6670                    arn: "arn:aws:events:us-east-1:123456789012:event-bus/custom-bus".to_string(),
6671                    policy: None,
6672                    creation_time: Utc::now(),
6673                    last_modified_time: Utc::now(),
6674                    description: None,
6675                    kms_key_identifier: None,
6676                    dead_letter_config: None,
6677                    tags: std::collections::BTreeMap::new(),
6678                },
6679            );
6680        }
6681        let resource = make_resource(
6682            "AWS::Events::Rule",
6683            "MyRule",
6684            serde_json::json!({
6685                "Name": "my-rule",
6686                "EventBusName": "custom-bus",
6687                "ScheduleExpression": "rate(1 hour)"
6688            }),
6689        );
6690        let result = prov.create_resource(&resource).unwrap();
6691        assert_eq!(
6692            result.physical_id,
6693            "arn:aws:events:us-east-1:123456789012:rule/custom-bus/my-rule"
6694        );
6695    }
6696
6697    #[test]
6698    fn eventbridge_rule_rejects_nonexistent_bus() {
6699        let prov = make_provisioner();
6700        let resource = make_resource(
6701            "AWS::Events::Rule",
6702            "MyRule",
6703            serde_json::json!({
6704                "Name": "my-rule",
6705                "EventBusName": "nonexistent-bus",
6706                "ScheduleExpression": "rate(1 hour)"
6707            }),
6708        );
6709        let result = prov.create_resource(&resource);
6710        assert!(result.is_err());
6711        assert!(result.unwrap_err().contains("does not exist"));
6712    }
6713
6714    #[test]
6715    fn custom_resource_requires_service_token() {
6716        let prov = make_provisioner();
6717        let resource = make_resource(
6718            "Custom::MyResource",
6719            "MyCustom",
6720            serde_json::json!({
6721                "Foo": "bar"
6722            }),
6723        );
6724        let result = prov.create_resource(&resource);
6725        assert!(result.is_err());
6726        assert!(
6727            result.unwrap_err().contains("ServiceToken"),
6728            "Should require ServiceToken property"
6729        );
6730    }
6731
6732    #[test]
6733    fn custom_resource_succeeds_without_lambda_delivery() {
6734        // When no Lambda delivery is configured, custom resource creation
6735        // should still succeed (the invocation is silently skipped).
6736        let prov = make_provisioner();
6737        let resource = make_resource(
6738            "Custom::MyResource",
6739            "MyCustom",
6740            serde_json::json!({
6741                "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:my-func",
6742                "Foo": "bar"
6743            }),
6744        );
6745        let result = prov.create_resource(&resource);
6746        assert!(result.is_ok());
6747        let sr = result.unwrap();
6748        assert_eq!(sr.logical_id, "MyCustom");
6749        assert_eq!(sr.resource_type, "Custom::MyResource");
6750        assert!(sr.physical_id.starts_with("MyCustom-"));
6751    }
6752
6753    #[test]
6754    fn cloudformation_custom_resource_type_succeeds() {
6755        let prov = make_provisioner();
6756        let resource = make_resource(
6757            "AWS::CloudFormation::CustomResource",
6758            "MyCustom2",
6759            serde_json::json!({
6760                "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:my-func",
6761                "Key": "value"
6762            }),
6763        );
6764        let result = prov.create_resource(&resource);
6765        assert!(result.is_ok());
6766        let sr = result.unwrap();
6767        assert_eq!(sr.resource_type, "AWS::CloudFormation::CustomResource");
6768    }
6769
6770    // ── Resource create/delete lifecycle tests ──
6771
6772    #[test]
6773    fn sqs_queue_create_and_delete() {
6774        let prov = make_provisioner();
6775        let res = make_resource(
6776            "AWS::SQS::Queue",
6777            "MyQ",
6778            serde_json::json!({"QueueName": "my-q"}),
6779        );
6780        let sr = prov.create_resource(&res).unwrap();
6781        assert!(sr.physical_id.contains("my-q"));
6782        assert_eq!(sr.resource_type, "AWS::SQS::Queue");
6783        prov.delete_resource(&sr).unwrap();
6784    }
6785
6786    #[test]
6787    fn sqs_queue_fifo_with_suffix() {
6788        let prov = make_provisioner();
6789        let res = make_resource(
6790            "AWS::SQS::Queue",
6791            "FifoQ",
6792            serde_json::json!({"QueueName": "my-fifo.fifo", "FifoQueue": true}),
6793        );
6794        let sr = prov.create_resource(&res).unwrap();
6795        assert!(sr.physical_id.contains(".fifo"));
6796    }
6797
6798    #[test]
6799    fn sns_topic_create_and_delete() {
6800        let prov = make_provisioner();
6801        let res = make_resource(
6802            "AWS::SNS::Topic",
6803            "MyTopic",
6804            serde_json::json!({"TopicName": "t1"}),
6805        );
6806        let sr = prov.create_resource(&res).unwrap();
6807        assert!(sr.physical_id.contains("t1"));
6808        prov.delete_resource(&sr).unwrap();
6809    }
6810
6811    #[test]
6812    fn ssm_parameter_create_and_delete() {
6813        let prov = make_provisioner();
6814        let res = make_resource(
6815            "AWS::SSM::Parameter",
6816            "MyParam",
6817            serde_json::json!({
6818                "Name": "/my/param",
6819                "Type": "String",
6820                "Value": "v1"
6821            }),
6822        );
6823        let sr = prov.create_resource(&res).unwrap();
6824        assert_eq!(sr.physical_id, "/my/param");
6825        prov.delete_resource(&sr).unwrap();
6826    }
6827
6828    #[test]
6829    fn iam_role_create_and_delete() {
6830        let prov = make_provisioner();
6831        let res = make_resource(
6832            "AWS::IAM::Role",
6833            "MyRole",
6834            serde_json::json!({
6835                "RoleName": "my-role",
6836                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
6837            }),
6838        );
6839        let sr = prov.create_resource(&res).unwrap();
6840        assert!(sr.physical_id.contains("my-role"));
6841        prov.delete_resource(&sr).unwrap();
6842    }
6843
6844    #[test]
6845    fn iam_policy_create_and_delete() {
6846        let prov = make_provisioner();
6847        let res = make_resource(
6848            "AWS::IAM::Policy",
6849            "MyPolicy",
6850            serde_json::json!({
6851                "PolicyName": "my-policy",
6852                "PolicyDocument": {"Version": "2012-10-17", "Statement": []}
6853            }),
6854        );
6855        let sr = prov.create_resource(&res).unwrap();
6856        assert!(sr.physical_id.contains("my-policy"));
6857        prov.delete_resource(&sr).unwrap();
6858    }
6859
6860    #[test]
6861    fn s3_bucket_create_and_delete() {
6862        let prov = make_provisioner();
6863        let res = make_resource(
6864            "AWS::S3::Bucket",
6865            "MyBucket",
6866            serde_json::json!({"BucketName": "my-bucket"}),
6867        );
6868        let sr = prov.create_resource(&res).unwrap();
6869        assert_eq!(sr.physical_id, "my-bucket");
6870        prov.delete_resource(&sr).unwrap();
6871    }
6872
6873    #[test]
6874    fn sqs_queue_policy_stored_on_queue_and_cleared_on_delete() {
6875        let prov = make_provisioner();
6876        let queue = prov
6877            .create_resource(&make_resource(
6878                "AWS::SQS::Queue",
6879                "Q",
6880                serde_json::json!({"QueueName": "q1"}),
6881            ))
6882            .unwrap();
6883        // `Queues` carries the queue URL — what `Ref` resolves to.
6884        let policy = make_resource(
6885            "AWS::SQS::QueuePolicy",
6886            "QP",
6887            serde_json::json!({
6888                "Queues": [queue.physical_id.clone()],
6889                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
6890                    "Effect": "Allow",
6891                    "Principal": {"Service": "sns.amazonaws.com"},
6892                    "Action": "sqs:SendMessage",
6893                    "Resource": "*"
6894                }]}
6895            }),
6896        );
6897        let sr = prov.create_resource(&policy).unwrap();
6898
6899        {
6900            let mut accounts = prov.sqs_state.write();
6901            let state = accounts.get_or_create(&prov.account_id);
6902            let stored = state.queues[&queue.physical_id]
6903                .attributes
6904                .get("Policy")
6905                .expect("policy stored on queue");
6906            assert!(stored.contains("sqs:SendMessage"));
6907        }
6908
6909        prov.delete_resource(&sr).unwrap();
6910        {
6911            let mut accounts = prov.sqs_state.write();
6912            let state = accounts.get_or_create(&prov.account_id);
6913            assert!(!state.queues[&queue.physical_id]
6914                .attributes
6915                .contains_key("Policy"));
6916        }
6917    }
6918
6919    #[test]
6920    fn sns_topic_policy_stored_on_topic_and_cleared_on_delete() {
6921        let prov = make_provisioner();
6922        let topic = prov
6923            .create_resource(&make_resource(
6924                "AWS::SNS::Topic",
6925                "T",
6926                serde_json::json!({"TopicName": "t1"}),
6927            ))
6928            .unwrap();
6929        let policy = make_resource(
6930            "AWS::SNS::TopicPolicy",
6931            "TP",
6932            serde_json::json!({
6933                "Topics": [topic.physical_id.clone()],
6934                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
6935                    "Effect": "Allow",
6936                    "Principal": {"Service": "events.amazonaws.com"},
6937                    "Action": "sns:Publish",
6938                    "Resource": "*"
6939                }]}
6940            }),
6941        );
6942        let sr = prov.create_resource(&policy).unwrap();
6943
6944        {
6945            let mut accounts = prov.sns_state.write();
6946            let state = accounts.get_or_create(&prov.account_id);
6947            let stored = state.topics[&topic.physical_id]
6948                .attributes
6949                .get("Policy")
6950                .expect("policy stored on topic");
6951            assert!(stored.contains("sns:Publish"));
6952        }
6953
6954        prov.delete_resource(&sr).unwrap();
6955        {
6956            let mut accounts = prov.sns_state.write();
6957            let state = accounts.get_or_create(&prov.account_id);
6958            assert!(!state.topics[&topic.physical_id]
6959                .attributes
6960                .contains_key("Policy"));
6961        }
6962    }
6963
6964    #[test]
6965    fn s3_bucket_policy_stored_on_bucket_and_cleared_on_delete() {
6966        let prov = make_provisioner();
6967        let bucket = prov
6968            .create_resource(&make_resource(
6969                "AWS::S3::Bucket",
6970                "B",
6971                serde_json::json!({"BucketName": "b1"}),
6972            ))
6973            .unwrap();
6974        let policy = make_resource(
6975            "AWS::S3::BucketPolicy",
6976            "BP",
6977            serde_json::json!({
6978                "Bucket": bucket.physical_id.clone(),
6979                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
6980                    "Effect": "Allow",
6981                    "Principal": "*",
6982                    "Action": "s3:GetObject",
6983                    "Resource": "arn:aws:s3:::b1/*"
6984                }]}
6985            }),
6986        );
6987        let sr = prov.create_resource(&policy).unwrap();
6988        assert_eq!(sr.physical_id, "b1-policy");
6989
6990        {
6991            let mut accounts = prov.s3_state.write();
6992            let state = accounts.get_or_create(&prov.account_id);
6993            let stored = state.buckets[&bucket.physical_id]
6994                .policy
6995                .as_ref()
6996                .expect("policy stored on bucket");
6997            assert!(stored.contains("s3:GetObject"));
6998        }
6999
7000        prov.delete_resource(&sr).unwrap();
7001        {
7002            let mut accounts = prov.s3_state.write();
7003            let state = accounts.get_or_create(&prov.account_id);
7004            assert!(state.buckets[&bucket.physical_id].policy.is_none());
7005        }
7006    }
7007
7008    #[test]
7009    fn dynamodb_table_create_and_delete() {
7010        let prov = make_provisioner();
7011        let res = make_resource(
7012            "AWS::DynamoDB::Table",
7013            "MyTable",
7014            serde_json::json!({
7015                "TableName": "my-table",
7016                "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
7017                "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
7018                "BillingMode": "PAY_PER_REQUEST"
7019            }),
7020        );
7021        let sr = prov.create_resource(&res).unwrap();
7022        assert!(sr.physical_id.contains("my-table"));
7023        prov.delete_resource(&sr).unwrap();
7024    }
7025
7026    #[test]
7027    fn log_group_create_and_delete() {
7028        let prov = make_provisioner();
7029        let res = make_resource(
7030            "AWS::Logs::LogGroup",
7031            "MyLogs",
7032            serde_json::json!({"LogGroupName": "/app/logs"}),
7033        );
7034        let sr = prov.create_resource(&res).unwrap();
7035        assert!(sr.physical_id.contains("/app/logs"));
7036        prov.delete_resource(&sr).unwrap();
7037    }
7038
7039    #[test]
7040    fn lambda_function_create_and_delete() {
7041        let prov = make_provisioner();
7042        let res = make_resource(
7043            "AWS::Lambda::Function",
7044            "MyFn",
7045            serde_json::json!({
7046                "FunctionName": "my-fn",
7047                "Runtime": "nodejs20.x",
7048                "Role": "arn:aws:iam::123456789012:role/lambda-role",
7049                "Handler": "index.handler",
7050                "MemorySize": 256,
7051                "Timeout": 10,
7052                "Environment": {"Variables": {"FOO": "bar"}}
7053            }),
7054        );
7055        let sr = prov.create_resource(&res).unwrap();
7056        assert_eq!(sr.physical_id, "my-fn");
7057        assert_eq!(
7058            sr.attributes.get("Arn").map(String::as_str),
7059            Some("arn:aws:lambda:us-east-1:123456789012:function:my-fn")
7060        );
7061        // Verify it landed in lambda state
7062        {
7063            let lam = prov.lambda_state.read();
7064            let st = lam.get("123456789012").unwrap();
7065            let f = st.functions.get("my-fn").unwrap();
7066            assert_eq!(f.runtime, "nodejs20.x");
7067            assert_eq!(f.memory_size, 256);
7068            assert_eq!(f.environment.get("FOO").unwrap(), "bar");
7069        }
7070        prov.delete_resource(&sr).unwrap();
7071        let lam = prov.lambda_state.read();
7072        let st = lam.get("123456789012").unwrap();
7073        assert!(!st.functions.contains_key("my-fn"));
7074    }
7075
7076    #[test]
7077    fn unsupported_resource_type_is_recorded_not_failed() {
7078        // An unmodelled type is recorded as provisioned (no backing state)
7079        // rather than failing the stack — see
7080        // `unknown_resource_type_records_instead_of_failing`.
7081        let prov = make_provisioner();
7082        let res = make_resource("AWS::NonExistent::Thing", "X", serde_json::json!({}));
7083        let sr = prov.create_resource(&res).unwrap();
7084        assert_eq!(sr.physical_id, "X");
7085    }
7086
7087    #[test]
7088    fn iam_role_with_inline_policies() {
7089        let prov = make_provisioner();
7090        let res = make_resource(
7091            "AWS::IAM::Role",
7092            "MyRole",
7093            serde_json::json!({
7094                "RoleName": "role-inline",
7095                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []},
7096                "Policies": [
7097                    {
7098                        "PolicyName": "inline-1",
7099                        "PolicyDocument": {"Version": "2012-10-17", "Statement": []}
7100                    }
7101                ]
7102            }),
7103        );
7104        let sr = prov.create_resource(&res).unwrap();
7105        assert!(sr.physical_id.contains("role-inline"));
7106    }
7107
7108    #[test]
7109    fn sqs_queue_auto_name() {
7110        let prov = make_provisioner();
7111        let res = make_resource("AWS::SQS::Queue", "AutoQ", serde_json::json!({}));
7112        let sr = prov.create_resource(&res).unwrap();
7113        // Generated queue name should exist
7114        assert!(!sr.physical_id.is_empty());
7115    }
7116
7117    #[test]
7118    fn sns_topic_auto_name() {
7119        let prov = make_provisioner();
7120        let res = make_resource("AWS::SNS::Topic", "AutoT", serde_json::json!({}));
7121        let sr = prov.create_resource(&res).unwrap();
7122        assert!(!sr.physical_id.is_empty());
7123    }
7124
7125    // ── additional resource types ──
7126
7127    #[test]
7128    fn unsupported_resource_type_recorded_with_logical_id() {
7129        // Recorded, not failed; physical id is the logical id so `Ref` resolves.
7130        let prov = make_provisioner();
7131        let res = make_resource("AWS::FooBar::Thing", "X", serde_json::json!({}));
7132        let sr = prov.create_resource(&res).unwrap();
7133        assert_eq!(sr.physical_id, "X");
7134        assert_eq!(sr.status, "CREATE_COMPLETE");
7135    }
7136
7137    #[test]
7138    fn sqs_queue_with_redrive_policy() {
7139        let prov = make_provisioner();
7140        // Create DLQ first
7141        let dlq = make_resource(
7142            "AWS::SQS::Queue",
7143            "DLQ",
7144            serde_json::json!({"QueueName": "dlq1"}),
7145        );
7146        let dlq_resource = prov.create_resource(&dlq).unwrap();
7147        let _ = dlq_resource.physical_id;
7148
7149        // Create source queue with redrive policy
7150        let src = make_resource(
7151            "AWS::SQS::Queue",
7152            "Src",
7153            serde_json::json!({
7154                "QueueName": "src1",
7155                "RedrivePolicy": {
7156                    "deadLetterTargetArn": "arn:aws:sqs:us-east-1:123456789012:dlq1",
7157                    "maxReceiveCount": 3
7158                }
7159            }),
7160        );
7161        let sr = prov.create_resource(&src).unwrap();
7162        assert!(!sr.physical_id.is_empty());
7163    }
7164
7165    #[test]
7166    fn sns_topic_with_display_name() {
7167        let prov = make_provisioner();
7168        let res = make_resource(
7169            "AWS::SNS::Topic",
7170            "WithName",
7171            serde_json::json!({"TopicName": "named-topic", "DisplayName": "Named"}),
7172        );
7173        let sr = prov.create_resource(&res).unwrap();
7174        assert!(sr.physical_id.contains("named-topic"));
7175    }
7176
7177    #[test]
7178    fn ssm_parameter_with_explicit_name() {
7179        let prov = make_provisioner();
7180        let res = make_resource(
7181            "AWS::SSM::Parameter",
7182            "Param",
7183            serde_json::json!({"Name": "/my/param", "Value": "v", "Type": "String"}),
7184        );
7185        let sr = prov.create_resource(&res).unwrap();
7186        assert!(sr.physical_id.contains("/my/param"));
7187    }
7188
7189    #[test]
7190    fn ssm_parameter_missing_name_errors() {
7191        let prov = make_provisioner();
7192        let res = make_resource(
7193            "AWS::SSM::Parameter",
7194            "AutoP",
7195            serde_json::json!({"Value": "v", "Type": "String"}),
7196        );
7197        assert!(prov.create_resource(&res).is_err());
7198    }
7199
7200    #[test]
7201    fn iam_managed_policy_auto_name() {
7202        let prov = make_provisioner();
7203        let res = make_resource(
7204            "AWS::IAM::Policy",
7205            "AutoPol",
7206            serde_json::json!({
7207                "PolicyName": "inline-pol",
7208                "PolicyDocument": {"Version": "2012-10-17", "Statement": []},
7209                "Users": []
7210            }),
7211        );
7212        let sr = prov.create_resource(&res).unwrap();
7213        assert!(!sr.physical_id.is_empty());
7214    }
7215
7216    #[test]
7217    fn delete_resource_works_for_queue() {
7218        let prov = make_provisioner();
7219        let res = make_resource(
7220            "AWS::SQS::Queue",
7221            "ToDel",
7222            serde_json::json!({"QueueName": "todel"}),
7223        );
7224        let sr = prov.create_resource(&res).unwrap();
7225        assert!(prov.delete_resource(&sr).is_ok());
7226    }
7227
7228    #[test]
7229    fn delete_resource_works_for_topic() {
7230        let prov = make_provisioner();
7231        let res = make_resource(
7232            "AWS::SNS::Topic",
7233            "DelT",
7234            serde_json::json!({"TopicName": "delt"}),
7235        );
7236        let sr = prov.create_resource(&res).unwrap();
7237        assert!(prov.delete_resource(&sr).is_ok());
7238    }
7239
7240    #[test]
7241    fn application_autoscaling_scalable_target_round_trip() {
7242        let prov = make_provisioner();
7243        let res = make_resource(
7244            "AWS::ApplicationAutoScaling::ScalableTarget",
7245            "Target",
7246            serde_json::json!({
7247                "ServiceNamespace": "ecs",
7248                "ResourceId": "service/my-cluster/my-service",
7249                "ScalableDimension": "ecs:service:DesiredCount",
7250                "MinCapacity": 1,
7251                "MaxCapacity": 10,
7252                "RoleARN": "arn:aws:iam::123456789012:role/my-role",
7253            }),
7254        );
7255        let sr = prov.create_resource(&res).unwrap();
7256        assert_eq!(sr.physical_id, "service/my-cluster/my-service");
7257        assert!(sr.attributes.contains_key("ScalableTargetARN"));
7258        assert!(prov.delete_resource(&sr).is_ok());
7259    }
7260
7261    #[test]
7262    fn application_autoscaling_scaling_policy_requires_target() {
7263        let prov = make_provisioner();
7264        let res = make_resource(
7265            "AWS::ApplicationAutoScaling::ScalingPolicy",
7266            "Policy",
7267            serde_json::json!({
7268                "PolicyName": "my-policy",
7269                "ServiceNamespace": "ecs",
7270                "ResourceId": "service/my-cluster/my-service",
7271                "ScalableDimension": "ecs:service:DesiredCount",
7272                "PolicyType": "TargetTrackingScaling",
7273                "TargetTrackingScalingPolicyConfiguration": {
7274                    "TargetValue": 50.0,
7275                    "PredefinedMetricSpecification": {
7276                        "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
7277                    }
7278                },
7279            }),
7280        );
7281        // Should fail because scalable target does not exist
7282        assert!(prov.create_resource(&res).is_err());
7283    }
7284
7285    #[test]
7286    fn application_autoscaling_scaling_policy_round_trip() {
7287        let prov = make_provisioner();
7288        let target = make_resource(
7289            "AWS::ApplicationAutoScaling::ScalableTarget",
7290            "Target",
7291            serde_json::json!({
7292                "ServiceNamespace": "ecs",
7293                "ResourceId": "service/my-cluster/my-service",
7294                "ScalableDimension": "ecs:service:DesiredCount",
7295                "MinCapacity": 1,
7296                "MaxCapacity": 10,
7297            }),
7298        );
7299        let sr = prov.create_resource(&target).unwrap();
7300
7301        let policy = make_resource(
7302            "AWS::ApplicationAutoScaling::ScalingPolicy",
7303            "Policy",
7304            serde_json::json!({
7305                "PolicyName": "my-policy",
7306                "ServiceNamespace": "ecs",
7307                "ResourceId": "service/my-cluster/my-service",
7308                "ScalableDimension": "ecs:service:DesiredCount",
7309                "PolicyType": "TargetTrackingScaling",
7310                "TargetTrackingScalingPolicyConfiguration": {
7311                    "TargetValue": 50.0,
7312                    "PredefinedMetricSpecification": {
7313                        "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
7314                    }
7315                },
7316            }),
7317        );
7318        let psr = prov.create_resource(&policy).unwrap();
7319        assert!(psr.physical_id.starts_with("arn:aws:autoscaling:"));
7320        assert!(prov.delete_resource(&psr).is_ok());
7321        assert!(prov.delete_resource(&sr).is_ok());
7322    }
7323
7324    #[test]
7325    fn sqs_queue_with_fifo_suffix() {
7326        let prov = make_provisioner();
7327        let res = make_resource(
7328            "AWS::SQS::Queue",
7329            "Fifo",
7330            serde_json::json!({"QueueName": "fq.fifo", "FifoQueue": true}),
7331        );
7332        let sr = prov.create_resource(&res).unwrap();
7333        assert!(sr.physical_id.ends_with(".fifo"));
7334    }
7335
7336    // ── get_att dispatch ──
7337
7338    #[test]
7339    fn getatt_s3_bucket_arn_returns_arn() {
7340        let prov = make_provisioner();
7341        let bucket = make_resource(
7342            "AWS::S3::Bucket",
7343            "MyBucket",
7344            serde_json::json!({"BucketName": "my-bucket"}),
7345        );
7346        let sr = prov.create_resource(&bucket).unwrap();
7347        assert_eq!(
7348            prov.get_att(&sr, "Arn"),
7349            Some("arn:aws:s3:::my-bucket".to_string())
7350        );
7351    }
7352
7353    #[test]
7354    fn getatt_s3_bucket_domain_name_returns_dns_name() {
7355        let prov = make_provisioner();
7356        let bucket = make_resource(
7357            "AWS::S3::Bucket",
7358            "MyBucket",
7359            serde_json::json!({"BucketName": "my-bucket"}),
7360        );
7361        let sr = prov.create_resource(&bucket).unwrap();
7362        assert_eq!(
7363            prov.get_att(&sr, "DomainName"),
7364            Some("my-bucket.s3.amazonaws.com".to_string())
7365        );
7366    }
7367
7368    #[test]
7369    fn getatt_lambda_function_arn_returns_function_arn() {
7370        let prov = make_provisioner();
7371        // Lambda needs an existing IAM role to validate the function role.
7372        let role = make_resource(
7373            "AWS::IAM::Role",
7374            "MyRole",
7375            serde_json::json!({
7376                "RoleName": "my-role",
7377                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
7378            }),
7379        );
7380        let role_sr = prov.create_resource(&role).unwrap();
7381        let fn_res = make_resource(
7382            "AWS::Lambda::Function",
7383            "MyFn",
7384            serde_json::json!({
7385                "FunctionName": "my-fn",
7386                "Runtime": "python3.11",
7387                "Handler": "index.handler",
7388                "Role": role_sr.physical_id,
7389                "Code": {"ZipFile": "def handler(e,c): return e"}
7390            }),
7391        );
7392        let fn_sr = prov.create_resource(&fn_res).unwrap();
7393        let arn = prov.get_att(&fn_sr, "Arn").expect("Arn should resolve");
7394        assert!(arn.starts_with("arn:aws:lambda:"));
7395        assert!(arn.contains(":function:my-fn"));
7396    }
7397
7398    #[test]
7399    fn getatt_iam_role_arn_returns_role_arn() {
7400        let prov = make_provisioner();
7401        let role = make_resource(
7402            "AWS::IAM::Role",
7403            "MyRole",
7404            serde_json::json!({
7405                "RoleName": "my-role",
7406                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
7407            }),
7408        );
7409        let sr = prov.create_resource(&role).unwrap();
7410        assert_eq!(
7411            prov.get_att(&sr, "Arn"),
7412            Some("arn:aws:iam::123456789012:role/my-role".to_string())
7413        );
7414        // RoleId is a real value generated at create time (FKIA-prefixed).
7415        let role_id = prov.get_att(&sr, "RoleId").expect("RoleId should resolve");
7416        assert!(role_id.starts_with("FKIA"));
7417    }
7418
7419    #[test]
7420    fn getatt_unknown_attribute_returns_none() {
7421        let prov = make_provisioner();
7422        let bucket = make_resource(
7423            "AWS::S3::Bucket",
7424            "MyBucket",
7425            serde_json::json!({"BucketName": "my-bucket"}),
7426        );
7427        let sr = prov.create_resource(&bucket).unwrap();
7428        // Fall-back behaviour: unknown attribute on a known type returns
7429        // None; the resolver in template.rs surfaces a "Logical.Attr"
7430        // placeholder so the existing template still builds.
7431        assert_eq!(prov.get_att(&sr, "NotARealAttr"), None);
7432    }
7433
7434    #[test]
7435    fn getatt_unknown_resource_type_returns_none() {
7436        let prov = make_provisioner();
7437        // Hand-crafted StackResource with a resource_type we don't dispatch
7438        // on at all. The captured attributes map is also empty, so there's
7439        // nothing to return.
7440        let stack_resource = StackResource {
7441            logical_id: "Mystery".to_string(),
7442            physical_id: "mystery-id".to_string(),
7443            resource_type: "AWS::Made::Up".to_string(),
7444            status: "CREATE_COMPLETE".to_string(),
7445            service_token: None,
7446            attributes: BTreeMap::new(),
7447        };
7448        assert_eq!(prov.get_att(&stack_resource, "Arn"), None);
7449    }
7450
7451    #[test]
7452    fn getatt_falls_back_to_captured_attributes() {
7453        let prov = make_provisioner();
7454        // Captured attributes always win — even if live-state dispatch
7455        // would return something different. This keeps `Fn::GetAtt`
7456        // deterministic for resources with cached attrs.
7457        let stack_resource = StackResource {
7458            logical_id: "MyTopic".to_string(),
7459            physical_id: "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
7460            resource_type: "AWS::SNS::Topic".to_string(),
7461            status: "CREATE_COMPLETE".to_string(),
7462            service_token: None,
7463            attributes: {
7464                let mut m = BTreeMap::new();
7465                m.insert("TopicArn".to_string(), "captured-arn".to_string());
7466                m
7467            },
7468        };
7469        assert_eq!(
7470            prov.get_att(&stack_resource, "TopicArn"),
7471            Some("captured-arn".to_string())
7472        );
7473    }
7474
7475    #[test]
7476    fn getatt_secrets_manager_arn_resolves_via_live_state() {
7477        // Secrets create handler captures Id but not Arn; live-state
7478        // fallback fills in Arn.
7479        let prov = make_provisioner();
7480        let res = make_resource(
7481            "AWS::SecretsManager::Secret",
7482            "MySecret",
7483            serde_json::json!({"Name": "my-secret", "SecretString": "hunter2"}),
7484        );
7485        let sr = prov.create_resource(&res).unwrap();
7486        let arn = prov.get_att(&sr, "Arn").expect("Arn should resolve");
7487        assert!(arn.starts_with("arn:aws:secretsmanager:"));
7488        assert!(arn.ends_with(":secret:my-secret"));
7489    }
7490
7491    #[test]
7492    fn wafv2_web_acl_lifecycle() {
7493        let prov = make_provisioner();
7494        let res = make_resource(
7495            "AWS::WAFv2::WebACL",
7496            "MyAcl",
7497            serde_json::json!({
7498                "Name": "my-acl",
7499                "Scope": "REGIONAL",
7500                "DefaultAction": {"Allow": {}},
7501                "Rules": [{"Name": "rule1", "Priority": 1, "Statement": {}, "VisibilityConfig": {}}],
7502                "VisibilityConfig": {"SampledRequestsEnabled": true, "CloudWatchMetricsEnabled": true, "MetricName": "my-acl-metric"},
7503                "Capacity": 100,
7504            }),
7505        );
7506        let sr = prov.create_resource(&res).unwrap();
7507        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7508        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7509        assert_eq!(prov.get_att(&sr, "Name"), Some("my-acl".to_string()));
7510        assert!(prov.get_att(&sr, "Id").is_some());
7511        assert_eq!(prov.get_att(&sr, "Capacity"), Some("100".to_string()));
7512
7513        prov.delete_resource(&sr.clone()).unwrap();
7514        // Verify live-state fallback returns None by using a fresh resource
7515        // with empty attributes (captured attrs would still win).
7516        let fresh = StackResource {
7517            logical_id: "MyAcl".to_string(),
7518            physical_id: sr.physical_id.clone(),
7519            resource_type: "AWS::WAFv2::WebACL".to_string(),
7520            status: "CREATE_COMPLETE".to_string(),
7521            service_token: None,
7522            attributes: BTreeMap::new(),
7523        };
7524        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7525    }
7526
7527    #[test]
7528    fn wafv2_ip_set_lifecycle() {
7529        let prov = make_provisioner();
7530        let res = make_resource(
7531            "AWS::WAFv2::IPSet",
7532            "MyIpSet",
7533            serde_json::json!({
7534                "Name": "my-ipset",
7535                "Scope": "REGIONAL",
7536                "IPAddressVersion": "IPV4",
7537                "Addresses": ["10.0.0.0/8"],
7538            }),
7539        );
7540        let sr = prov.create_resource(&res).unwrap();
7541        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7542        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7543        assert_eq!(prov.get_att(&sr, "Name"), Some("my-ipset".to_string()));
7544
7545        prov.delete_resource(&sr.clone()).unwrap();
7546        let fresh = StackResource {
7547            logical_id: "MyIpSet".to_string(),
7548            physical_id: sr.physical_id.clone(),
7549            resource_type: "AWS::WAFv2::IPSet".to_string(),
7550            status: "CREATE_COMPLETE".to_string(),
7551            service_token: None,
7552            attributes: BTreeMap::new(),
7553        };
7554        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7555    }
7556
7557    #[test]
7558    fn wafv2_regex_pattern_set_lifecycle() {
7559        let prov = make_provisioner();
7560        let res = make_resource(
7561            "AWS::WAFv2::RegexPatternSet",
7562            "MyRegexSet",
7563            serde_json::json!({
7564                "Name": "my-regex",
7565                "Scope": "REGIONAL",
7566                "RegularExpressions": [{"RegexString": "^test"}],
7567            }),
7568        );
7569        let sr = prov.create_resource(&res).unwrap();
7570        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7571        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7572        assert_eq!(prov.get_att(&sr, "Name"), Some("my-regex".to_string()));
7573
7574        prov.delete_resource(&sr.clone()).unwrap();
7575        let fresh = StackResource {
7576            logical_id: "MyRegexSet".to_string(),
7577            physical_id: sr.physical_id.clone(),
7578            resource_type: "AWS::WAFv2::RegexPatternSet".to_string(),
7579            status: "CREATE_COMPLETE".to_string(),
7580            service_token: None,
7581            attributes: BTreeMap::new(),
7582        };
7583        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7584    }
7585
7586    #[test]
7587    fn wafv2_rule_group_lifecycle() {
7588        let prov = make_provisioner();
7589        let res = make_resource(
7590            "AWS::WAFv2::RuleGroup",
7591            "MyRuleGroup",
7592            serde_json::json!({
7593                "Name": "my-rg",
7594                "Scope": "REGIONAL",
7595                "Capacity": 50,
7596                "Rules": [{"Name": "r1", "Priority": 1, "Statement": {}, "VisibilityConfig": {}}],
7597                "VisibilityConfig": {"SampledRequestsEnabled": true, "CloudWatchMetricsEnabled": true, "MetricName": "rg-metric"},
7598            }),
7599        );
7600        let sr = prov.create_resource(&res).unwrap();
7601        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7602        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7603        assert_eq!(prov.get_att(&sr, "Name"), Some("my-rg".to_string()));
7604
7605        prov.delete_resource(&sr.clone()).unwrap();
7606        let fresh = StackResource {
7607            logical_id: "MyRuleGroup".to_string(),
7608            physical_id: sr.physical_id.clone(),
7609            resource_type: "AWS::WAFv2::RuleGroup".to_string(),
7610            status: "CREATE_COMPLETE".to_string(),
7611            service_token: None,
7612            attributes: BTreeMap::new(),
7613        };
7614        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7615    }
7616
7617    #[test]
7618    fn wafv2_logging_configuration_lifecycle() {
7619        let prov = make_provisioner();
7620        let res = make_resource(
7621            "AWS::WAFv2::LoggingConfiguration",
7622            "MyLogConfig",
7623            serde_json::json!({
7624                "ResourceArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/abc",
7625                "LogDestinationConfigs": ["arn:aws:logs:us-east-1:123456789012:log-group:/aws/waf"],
7626            }),
7627        );
7628        let sr = prov.create_resource(&res).unwrap();
7629        assert_eq!(
7630            sr.physical_id,
7631            "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/abc"
7632        );
7633
7634        prov.delete_resource(&sr.clone()).unwrap();
7635    }
7636
7637    #[test]
7638    fn wafv2_web_acl_association_lifecycle() {
7639        let prov = make_provisioner();
7640        let res = make_resource(
7641            "AWS::WAFv2::WebACLAssociation",
7642            "MyAssoc",
7643            serde_json::json!({
7644                "ResourceArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188",
7645                "WebACLArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/my-acl/abc",
7646            }),
7647        );
7648        let sr = prov.create_resource(&res).unwrap();
7649        assert_eq!(sr.physical_id, "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188");
7650
7651        prov.delete_resource(&sr.clone()).unwrap();
7652    }
7653
7654    #[test]
7655    fn ses_configuration_set_lifecycle() {
7656        let prov = make_provisioner();
7657        let res = make_resource(
7658            "AWS::SES::ConfigurationSet",
7659            "MyConfigSet",
7660            serde_json::json!({
7661                "Name": "my-cs",
7662                "SendingOptions": {"SendingEnabled": true},
7663                "DeliveryOptions": {"TlsPolicy": "REQUIRE"},
7664            }),
7665        );
7666        let sr = prov.create_resource(&res).unwrap();
7667        assert_eq!(sr.physical_id, "my-cs");
7668        assert_eq!(prov.get_att(&sr, "Name"), Some("my-cs".to_string()));
7669
7670        prov.delete_resource(&sr.clone()).unwrap();
7671        let fresh = StackResource {
7672            logical_id: "MyConfigSet".to_string(),
7673            physical_id: "my-cs".to_string(),
7674            resource_type: "AWS::SES::ConfigurationSet".to_string(),
7675            status: "CREATE_COMPLETE".to_string(),
7676            service_token: None,
7677            attributes: BTreeMap::new(),
7678        };
7679        assert_eq!(prov.get_att(&fresh, "Name"), None);
7680    }
7681
7682    #[test]
7683    fn ses_email_identity_lifecycle() {
7684        let prov = make_provisioner();
7685        let res = make_resource(
7686            "AWS::SES::EmailIdentity",
7687            "MyIdentity",
7688            serde_json::json!({"EmailIdentity": "example.com"}),
7689        );
7690        let sr = prov.create_resource(&res).unwrap();
7691        assert_eq!(sr.physical_id, "example.com");
7692        assert_eq!(
7693            prov.get_att(&sr, "IdentityName"),
7694            Some("example.com".to_string())
7695        );
7696
7697        prov.delete_resource(&sr.clone()).unwrap();
7698        let fresh = StackResource {
7699            logical_id: "MyIdentity".to_string(),
7700            physical_id: "example.com".to_string(),
7701            resource_type: "AWS::SES::EmailIdentity".to_string(),
7702            status: "CREATE_COMPLETE".to_string(),
7703            service_token: None,
7704            attributes: BTreeMap::new(),
7705        };
7706        assert_eq!(prov.get_att(&fresh, "IdentityName"), None);
7707    }
7708
7709    #[test]
7710    fn ses_template_lifecycle() {
7711        let prov = make_provisioner();
7712        let res = make_resource(
7713            "AWS::SES::Template",
7714            "MyTemplate",
7715            serde_json::json!({
7716                "Template": {
7717                    "TemplateName": "my-tpl",
7718                    "SubjectPart": "Hello",
7719                    "HtmlPart": "<h1>Hi</h1>",
7720                    "TextPart": "Hi",
7721                },
7722            }),
7723        );
7724        let sr = prov.create_resource(&res).unwrap();
7725        assert_eq!(sr.physical_id, "my-tpl");
7726        assert_eq!(
7727            prov.get_att(&sr, "TemplateName"),
7728            Some("my-tpl".to_string())
7729        );
7730
7731        prov.delete_resource(&sr.clone()).unwrap();
7732        let fresh = StackResource {
7733            logical_id: "MyTemplate".to_string(),
7734            physical_id: "my-tpl".to_string(),
7735            resource_type: "AWS::SES::Template".to_string(),
7736            status: "CREATE_COMPLETE".to_string(),
7737            service_token: None,
7738            attributes: BTreeMap::new(),
7739        };
7740        assert_eq!(prov.get_att(&fresh, "TemplateName"), None);
7741    }
7742
7743    #[test]
7744    fn ses_contact_list_lifecycle() {
7745        let prov = make_provisioner();
7746        let res = make_resource(
7747            "AWS::SES::ContactList",
7748            "MyContactList",
7749            serde_json::json!({
7750                "ContactListName": "my-cl",
7751                "Description": "Test contacts",
7752                "Topics": [{"TopicName": "news", "DisplayName": "Newsletter", "Description": "Weekly news"}],
7753            }),
7754        );
7755        let sr = prov.create_resource(&res).unwrap();
7756        assert_eq!(sr.physical_id, "my-cl");
7757        assert_eq!(
7758            prov.get_att(&sr, "ContactListName"),
7759            Some("my-cl".to_string())
7760        );
7761
7762        prov.delete_resource(&sr.clone()).unwrap();
7763        let fresh = StackResource {
7764            logical_id: "MyContactList".to_string(),
7765            physical_id: "my-cl".to_string(),
7766            resource_type: "AWS::SES::ContactList".to_string(),
7767            status: "CREATE_COMPLETE".to_string(),
7768            service_token: None,
7769            attributes: BTreeMap::new(),
7770        };
7771        assert_eq!(prov.get_att(&fresh, "ContactListName"), None);
7772    }
7773
7774    #[test]
7775    fn ses_dedicated_ip_pool_lifecycle() {
7776        let prov = make_provisioner();
7777        let res = make_resource(
7778            "AWS::SES::DedicatedIpPool",
7779            "MyPool",
7780            serde_json::json!({"PoolName": "my-pool", "ScalingMode": "STANDARD"}),
7781        );
7782        let sr = prov.create_resource(&res).unwrap();
7783        assert_eq!(sr.physical_id, "my-pool");
7784        assert_eq!(prov.get_att(&sr, "PoolName"), Some("my-pool".to_string()));
7785
7786        prov.delete_resource(&sr.clone()).unwrap();
7787        let fresh = StackResource {
7788            logical_id: "MyPool".to_string(),
7789            physical_id: "my-pool".to_string(),
7790            resource_type: "AWS::SES::DedicatedIpPool".to_string(),
7791            status: "CREATE_COMPLETE".to_string(),
7792            service_token: None,
7793            attributes: BTreeMap::new(),
7794        };
7795        assert_eq!(prov.get_att(&fresh, "PoolName"), None);
7796    }
7797
7798    #[test]
7799    fn ses_receipt_rule_set_lifecycle() {
7800        let prov = make_provisioner();
7801        let res = make_resource(
7802            "AWS::SES::ReceiptRuleSet",
7803            "MyRuleSet",
7804            serde_json::json!({"RuleSetName": "my-rs"}),
7805        );
7806        let sr = prov.create_resource(&res).unwrap();
7807        assert_eq!(sr.physical_id, "my-rs");
7808        assert_eq!(prov.get_att(&sr, "RuleSetName"), Some("my-rs".to_string()));
7809
7810        prov.delete_resource(&sr.clone()).unwrap();
7811        let fresh = StackResource {
7812            logical_id: "MyRuleSet".to_string(),
7813            physical_id: "my-rs".to_string(),
7814            resource_type: "AWS::SES::ReceiptRuleSet".to_string(),
7815            status: "CREATE_COMPLETE".to_string(),
7816            service_token: None,
7817            attributes: BTreeMap::new(),
7818        };
7819        assert_eq!(prov.get_att(&fresh, "RuleSetName"), None);
7820    }
7821
7822    #[test]
7823    fn ses_receipt_rule_lifecycle() {
7824        let prov = make_provisioner();
7825        let rs = make_resource(
7826            "AWS::SES::ReceiptRuleSet",
7827            "MyRuleSet",
7828            serde_json::json!({"RuleSetName": "my-rs2"}),
7829        );
7830        prov.create_resource(&rs).unwrap();
7831
7832        let res = make_resource(
7833            "AWS::SES::ReceiptRule",
7834            "MyRule",
7835            serde_json::json!({
7836                "RuleSetName": "my-rs2",
7837                "Rule": {
7838                    "Name": "rule1",
7839                    "Priority": 1,
7840                    "Enabled": true,
7841                    "Actions": [{"S3Action": {"BucketName": "my-bucket"}}],
7842                },
7843            }),
7844        );
7845        let sr = prov.create_resource(&res).unwrap();
7846        assert_eq!(sr.physical_id, "my-rs2|rule1");
7847
7848        prov.delete_resource(&sr.clone()).unwrap();
7849    }
7850
7851    #[test]
7852    fn ses_receipt_filter_lifecycle() {
7853        let prov = make_provisioner();
7854        let res = make_resource(
7855            "AWS::SES::ReceiptFilter",
7856            "MyFilter",
7857            serde_json::json!({
7858                "Filter": {
7859                    "Name": "my-filter",
7860                    "IpFilter": {"Policy": "Block", "Cidr": "10.0.0.0/8"},
7861                },
7862            }),
7863        );
7864        let sr = prov.create_resource(&res).unwrap();
7865        assert_eq!(sr.physical_id, "my-filter");
7866
7867        prov.delete_resource(&sr.clone()).unwrap();
7868    }
7869
7870    #[test]
7871    fn ses_vdm_attributes_lifecycle() {
7872        let prov = make_provisioner();
7873        let res = make_resource(
7874            "AWS::SES::VdmAttributes",
7875            "MyVdm",
7876            serde_json::json!({
7877                "DashboardAttributes": {"EngagementMetrics": "ENABLED"},
7878                "GuardianAttributes": {"OptimizedSharedDelivery": "ENABLED"},
7879            }),
7880        );
7881        let sr = prov.create_resource(&res).unwrap();
7882        assert_eq!(sr.physical_id, "vdm-MyVdm");
7883
7884        prov.delete_resource(&sr.clone()).unwrap();
7885    }
7886
7887    #[test]
7888    fn athena_work_group_lifecycle() {
7889        let prov = make_provisioner();
7890        let res = make_resource(
7891            "AWS::Athena::WorkGroup",
7892            "MyWg",
7893            serde_json::json!({
7894                "Name": "my-wg",
7895                "Description": "test wg",
7896                "Configuration": {"EnforceWorkGroupConfiguration": true},
7897            }),
7898        );
7899        let sr = prov.create_resource(&res).unwrap();
7900        assert_eq!(sr.physical_id, "my-wg");
7901        assert_eq!(sr.attributes.get("Name"), Some(&"my-wg".to_string()));
7902        assert!(sr
7903            .attributes
7904            .get("Arn")
7905            .unwrap()
7906            .contains("workgroup/my-wg"));
7907
7908        assert_eq!(
7909            prov.get_att(
7910                &StackResource {
7911                    resource_type: "AWS::Athena::WorkGroup".to_string(),
7912                    physical_id: sr.physical_id.clone(),
7913                    logical_id: "MyWg".to_string(),
7914                    status: "CREATE_COMPLETE".to_string(),
7915                    service_token: None,
7916                    attributes: BTreeMap::new(),
7917                },
7918                "Name",
7919            ),
7920            Some("my-wg".to_string()),
7921        );
7922
7923        prov.delete_resource(&sr.clone()).unwrap();
7924    }
7925
7926    #[test]
7927    fn athena_data_catalog_lifecycle() {
7928        let prov = make_provisioner();
7929        let res = make_resource(
7930            "AWS::Athena::DataCatalog",
7931            "MyCatalog",
7932            serde_json::json!({
7933                "Name": "my-catalog",
7934                "Type": "GLUE",
7935                "Description": "test catalog",
7936            }),
7937        );
7938        let sr = prov.create_resource(&res).unwrap();
7939        assert_eq!(sr.physical_id, "my-catalog");
7940        assert_eq!(sr.attributes.get("Name"), Some(&"my-catalog".to_string()));
7941        assert!(sr
7942            .attributes
7943            .get("Arn")
7944            .unwrap()
7945            .contains("datacatalog/my-catalog"));
7946
7947        prov.delete_resource(&sr.clone()).unwrap();
7948    }
7949
7950    #[test]
7951    fn athena_named_query_lifecycle() {
7952        let prov = make_provisioner();
7953        let res = make_resource(
7954            "AWS::Athena::NamedQuery",
7955            "MyQuery",
7956            serde_json::json!({
7957                "Name": "my-query",
7958                "Database": "mydb",
7959                "QueryString": "SELECT 1",
7960                "WorkGroup": "primary",
7961            }),
7962        );
7963        let sr = prov.create_resource(&res).unwrap();
7964        assert!(!sr.physical_id.is_empty());
7965        assert_eq!(sr.attributes.get("NamedQueryId"), Some(&sr.physical_id));
7966
7967        prov.delete_resource(&sr.clone()).unwrap();
7968    }
7969
7970    #[test]
7971    fn athena_prepared_statement_lifecycle() {
7972        let prov = make_provisioner();
7973        let res = make_resource(
7974            "AWS::Athena::PreparedStatement",
7975            "MyPs",
7976            serde_json::json!({
7977                "StatementName": "my-ps",
7978                "WorkGroupName": "primary",
7979                "QueryStatement": "SELECT 1",
7980            }),
7981        );
7982        let sr = prov.create_resource(&res).unwrap();
7983        assert_eq!(sr.physical_id, "primary|my-ps");
7984
7985        prov.delete_resource(&sr.clone()).unwrap();
7986    }
7987
7988    #[test]
7989    fn parse_lambda_function_name_handles_every_shape() {
7990        // Plain name, unqualified — passes through.
7991        assert_eq!(parse_lambda_function_name("my-func"), "my-func");
7992        // Bare name with an alias/version qualifier (what `Ref` on an
7993        // alias used to feed the ESM create path).
7994        assert_eq!(parse_lambda_function_name("my-func:live"), "my-func");
7995        assert_eq!(parse_lambda_function_name("my-func:42"), "my-func");
7996        // Full ARN, unqualified and qualified.
7997        assert_eq!(
7998            parse_lambda_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-func"),
7999            "my-func"
8000        );
8001        assert_eq!(
8002            parse_lambda_function_name(
8003                "arn:aws:lambda:us-east-1:123456789012:function:my-func:live"
8004            ),
8005            "my-func"
8006        );
8007        // Partial ARN, unqualified and qualified.
8008        assert_eq!(
8009            parse_lambda_function_name("123456789012:function:my-func"),
8010            "my-func"
8011        );
8012        assert_eq!(
8013            parse_lambda_function_name("123456789012:function:my-func:live"),
8014            "my-func"
8015        );
8016    }
8017
8018    #[test]
8019    fn alias_state_key_recovers_internal_key_from_arn() {
8020        // Alias ARN -> `{function}:{alias}` internal map key.
8021        assert_eq!(
8022            alias_state_key("arn:aws:lambda:us-east-1:123456789012:function:my-func:live"),
8023            "my-func:live"
8024        );
8025        // Legacy bare `name:alias` physical id is returned unchanged.
8026        assert_eq!(alias_state_key("my-func:live"), "my-func:live");
8027    }
8028}