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