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