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        cloudwatch_logging_options: None,
5933        custom_time_zone: None,
5934        s3_backup_mode: None,
5935        file_extension: None,
5936    })
5937}
5938
5939#[cfg(test)]
5940mod tests {
5941    use super::*;
5942    use parking_lot::RwLock;
5943
5944    fn make_provisioner() -> ResourceProvisioner {
5945        ResourceProvisioner {
5946            sqs_state: Arc::new(RwLock::new(
5947                fakecloud_core::multi_account::MultiAccountState::new(
5948                    "123456789012",
5949                    "us-east-1",
5950                    "http://localhost:4566",
5951                ),
5952            )),
5953            sns_state: Arc::new(RwLock::new(
5954                fakecloud_core::multi_account::MultiAccountState::new(
5955                    "123456789012",
5956                    "us-east-1",
5957                    "http://localhost:4566",
5958                ),
5959            )),
5960            ssm_state: Arc::new(RwLock::new(
5961                fakecloud_core::multi_account::MultiAccountState::new(
5962                    "123456789012",
5963                    "us-east-1",
5964                    "http://localhost:4566",
5965                ),
5966            )),
5967            iam_state: Arc::new(RwLock::new(
5968                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", "http://localhost:4566"),
5969            )),
5970            s3_state: Arc::new(RwLock::new(fakecloud_core::multi_account::MultiAccountState::new(
5971                "123456789012",
5972                "us-east-1", "",
5973            ))),
5974            eventbridge_state: Arc::new(RwLock::new(
5975                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5976            )),
5977            dynamodb_state: Arc::new(RwLock::new(fakecloud_core::multi_account::MultiAccountState::new(
5978                "123456789012",
5979                "us-east-1", "",
5980            ))),
5981            logs_state: Arc::new(RwLock::new(
5982                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5983            )),
5984            lambda_state: Arc::new(RwLock::new(
5985                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5986            )),
5987            secretsmanager_state: Arc::new(RwLock::new(
5988                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5989            )),
5990            kinesis_state: Arc::new(RwLock::new(
5991                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5992            )),
5993            kms_state: Arc::new(RwLock::new(
5994                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5995            )),
5996            ecr_state: Arc::new(RwLock::new(
5997                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5998            )),
5999            cloudwatch_state: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
6000            elbv2_state: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
6001            organizations_state: Arc::new(RwLock::new(None)),
6002            cognito_state: Arc::new(RwLock::new(
6003                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6004            )),
6005            rds_state: Arc::new(RwLock::new(
6006                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6007            )),
6008            ecs_state: Arc::new(RwLock::new(
6009                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6010            )),
6011            acm_state: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
6012            elasticache_state: Arc::new(RwLock::new(
6013                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6014            )),
6015            route53_state: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
6016            cloudfront_state: Arc::new(RwLock::new(
6017                fakecloud_cloudfront::CloudFrontAccounts::new(),
6018            )),
6019            cloudformation_state: Arc::new(RwLock::new(
6020                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6021            )),
6022            stepfunctions_state: Arc::new(RwLock::new(
6023                fakecloud_core::multi_account::MultiAccountState::new(
6024                    "123456789012",
6025                    "us-east-1",
6026                    "",
6027                ),
6028            )),
6029            wafv2_state: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
6030            apigateway_state: Arc::new(RwLock::new(
6031                fakecloud_core::multi_account::MultiAccountState::new(
6032                    "123456789012",
6033                    "us-east-1",
6034                    "",
6035                ),
6036            )),
6037            apigatewayv2_state: Arc::new(RwLock::new(
6038                fakecloud_core::multi_account::MultiAccountState::new(
6039                    "123456789012",
6040                    "us-east-1",
6041                    "",
6042                ),
6043            )),
6044            ses_state: Arc::new(RwLock::new(
6045                fakecloud_core::multi_account::MultiAccountState::new(
6046                    "123456789012",
6047                    "us-east-1",
6048                    "",
6049                ),
6050            )),
6051            app_autoscaling_state: Arc::new(parking_lot::RwLock::new(
6052                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
6053            )),
6054            athena_state: Arc::new(parking_lot::RwLock::new(
6055                fakecloud_athena::AthenaAccounts::new(),
6056            )),
6057            firehose_state: Arc::new(parking_lot::RwLock::new(
6058                fakecloud_firehose::FirehoseAccounts::new(),
6059            )),
6060            glue_state: Arc::new(parking_lot::RwLock::new(
6061                fakecloud_glue::GlueAccounts::new(),
6062            )),
6063            delivery: Arc::new(DeliveryBus::new()),
6064            lambda_runtime: None,
6065            s3_store: Arc::new(fakecloud_persistence::s3::MemoryS3Store::new()),
6066            account_id: "123456789012".to_string(),
6067            region: "us-east-1".to_string(),
6068            stack_id: "arn:aws:cloudformation:us-east-1:123456789012:stack/test/00000000-0000-0000-0000-000000000000".to_string(),
6069        }
6070    }
6071
6072    fn make_resource(
6073        resource_type: &str,
6074        logical_id: &str,
6075        props: serde_json::Value,
6076    ) -> ResourceDefinition {
6077        ResourceDefinition {
6078            logical_id: logical_id.to_string(),
6079            resource_type: resource_type.to_string(),
6080            properties: props,
6081        }
6082    }
6083
6084    #[test]
6085    fn unknown_resource_type_records_instead_of_failing() {
6086        let prov = make_provisioner();
6087        // A real AWS type fakecloud has no provisioner for. It must NOT fail
6088        // the stack — it's recorded as provisioned with no backing state, and
6089        // `Ref` (its physical id) resolves to a stable value (the logical id).
6090        let sr = prov
6091            .create_resource(&make_resource(
6092                "AWS::CloudFormation::WaitConditionHandle",
6093                "Handle",
6094                serde_json::json!({}),
6095            ))
6096            .expect("unknown resource type should record, not fail");
6097        assert_eq!(sr.physical_id, "Handle");
6098        assert_eq!(sr.status, "CREATE_COMPLETE");
6099        // Teardown of a recorded no-op resource also succeeds.
6100        prov.delete_resource(&sr)
6101            .expect("delete no-op should succeed");
6102    }
6103
6104    #[test]
6105    fn ecr_repository_uri_uses_bound_endpoint_not_public_dns() {
6106        // A CFN-provisioned RepositoryUri must point at fakecloud's own registry
6107        // endpoint, not the public AWS DNS, so `docker push` against the GetAtt
6108        // RepositoryUri reaches it (bug-audit 2026-06-20, 1.4).
6109        let prov = make_provisioner();
6110        let sr = prov
6111            .create_resource(&make_resource(
6112                "AWS::ECR::Repository",
6113                "Repo",
6114                serde_json::json!({ "RepositoryName": "my-repo" }),
6115            ))
6116            .expect("ECR repo provisions");
6117        let uri = sr
6118            .attributes
6119            .get("RepositoryUri")
6120            .expect("RepositoryUri attribute");
6121        assert!(
6122            !uri.contains("amazonaws.com"),
6123            "RepositoryUri must not use the public ECR DNS, got {uri}"
6124        );
6125        assert!(uri.contains("my-repo"), "uri should name the repo: {uri}");
6126    }
6127
6128    #[test]
6129    fn sns_subscription_rejects_nonexistent_topic() {
6130        let prov = make_provisioner();
6131        let resource = make_resource(
6132            "AWS::SNS::Subscription",
6133            "MySub",
6134            serde_json::json!({
6135                "TopicArn": "arn:aws:sns:us-east-1:123456789012:NonExistent",
6136                "Protocol": "sqs",
6137                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
6138            }),
6139        );
6140        let result = prov.create_resource(&resource);
6141        assert!(result.is_err());
6142        assert!(result.unwrap_err().contains("does not exist"));
6143    }
6144
6145    #[test]
6146    fn sns_subscription_succeeds_when_topic_exists() {
6147        let prov = make_provisioner();
6148        // First create the topic
6149        let topic = make_resource(
6150            "AWS::SNS::Topic",
6151            "MyTopic",
6152            serde_json::json!({ "TopicName": "my-topic" }),
6153        );
6154        let topic_result = prov.create_resource(&topic);
6155        assert!(topic_result.is_ok());
6156        let topic_arn = topic_result.unwrap().physical_id;
6157
6158        // Now create subscription referencing that topic
6159        let sub = make_resource(
6160            "AWS::SNS::Subscription",
6161            "MySub",
6162            serde_json::json!({
6163                "TopicArn": topic_arn,
6164                "Protocol": "sqs",
6165                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
6166            }),
6167        );
6168        let result = prov.create_resource(&sub);
6169        assert!(result.is_ok());
6170    }
6171
6172    #[test]
6173    fn eventbridge_rule_arn_default_bus_omits_bus_name() {
6174        let prov = make_provisioner();
6175        let resource = make_resource(
6176            "AWS::Events::Rule",
6177            "MyRule",
6178            serde_json::json!({
6179                "Name": "my-rule",
6180                "ScheduleExpression": "rate(1 hour)"
6181            }),
6182        );
6183        let result = prov.create_resource(&resource).unwrap();
6184        // For default bus, ARN should be rule/<name> without /default/
6185        assert_eq!(
6186            result.physical_id,
6187            "arn:aws:events:us-east-1:123456789012:rule/my-rule"
6188        );
6189        assert!(!result.physical_id.contains("rule/default/"));
6190    }
6191
6192    #[test]
6193    fn eventbridge_rule_arn_custom_bus_includes_bus_name() {
6194        let prov = make_provisioner();
6195        // Create a custom bus first
6196        {
6197            let mut eb_accounts = prov.eventbridge_state.write();
6198            let state = eb_accounts.default_mut();
6199            state.buses.insert(
6200                "custom-bus".to_string(),
6201                fakecloud_eventbridge::EventBus {
6202                    name: "custom-bus".to_string(),
6203                    arn: "arn:aws:events:us-east-1:123456789012:event-bus/custom-bus".to_string(),
6204                    policy: None,
6205                    creation_time: Utc::now(),
6206                    last_modified_time: Utc::now(),
6207                    description: None,
6208                    kms_key_identifier: None,
6209                    dead_letter_config: None,
6210                    tags: std::collections::BTreeMap::new(),
6211                },
6212            );
6213        }
6214        let resource = make_resource(
6215            "AWS::Events::Rule",
6216            "MyRule",
6217            serde_json::json!({
6218                "Name": "my-rule",
6219                "EventBusName": "custom-bus",
6220                "ScheduleExpression": "rate(1 hour)"
6221            }),
6222        );
6223        let result = prov.create_resource(&resource).unwrap();
6224        assert_eq!(
6225            result.physical_id,
6226            "arn:aws:events:us-east-1:123456789012:rule/custom-bus/my-rule"
6227        );
6228    }
6229
6230    #[test]
6231    fn eventbridge_rule_rejects_nonexistent_bus() {
6232        let prov = make_provisioner();
6233        let resource = make_resource(
6234            "AWS::Events::Rule",
6235            "MyRule",
6236            serde_json::json!({
6237                "Name": "my-rule",
6238                "EventBusName": "nonexistent-bus",
6239                "ScheduleExpression": "rate(1 hour)"
6240            }),
6241        );
6242        let result = prov.create_resource(&resource);
6243        assert!(result.is_err());
6244        assert!(result.unwrap_err().contains("does not exist"));
6245    }
6246
6247    #[test]
6248    fn custom_resource_requires_service_token() {
6249        let prov = make_provisioner();
6250        let resource = make_resource(
6251            "Custom::MyResource",
6252            "MyCustom",
6253            serde_json::json!({
6254                "Foo": "bar"
6255            }),
6256        );
6257        let result = prov.create_resource(&resource);
6258        assert!(result.is_err());
6259        assert!(
6260            result.unwrap_err().contains("ServiceToken"),
6261            "Should require ServiceToken property"
6262        );
6263    }
6264
6265    #[test]
6266    fn custom_resource_succeeds_without_lambda_delivery() {
6267        // When no Lambda delivery is configured, custom resource creation
6268        // should still succeed (the invocation is silently skipped).
6269        let prov = make_provisioner();
6270        let resource = make_resource(
6271            "Custom::MyResource",
6272            "MyCustom",
6273            serde_json::json!({
6274                "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:my-func",
6275                "Foo": "bar"
6276            }),
6277        );
6278        let result = prov.create_resource(&resource);
6279        assert!(result.is_ok());
6280        let sr = result.unwrap();
6281        assert_eq!(sr.logical_id, "MyCustom");
6282        assert_eq!(sr.resource_type, "Custom::MyResource");
6283        assert!(sr.physical_id.starts_with("MyCustom-"));
6284    }
6285
6286    #[test]
6287    fn cloudformation_custom_resource_type_succeeds() {
6288        let prov = make_provisioner();
6289        let resource = make_resource(
6290            "AWS::CloudFormation::CustomResource",
6291            "MyCustom2",
6292            serde_json::json!({
6293                "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:my-func",
6294                "Key": "value"
6295            }),
6296        );
6297        let result = prov.create_resource(&resource);
6298        assert!(result.is_ok());
6299        let sr = result.unwrap();
6300        assert_eq!(sr.resource_type, "AWS::CloudFormation::CustomResource");
6301    }
6302
6303    // ── Resource create/delete lifecycle tests ──
6304
6305    #[test]
6306    fn sqs_queue_create_and_delete() {
6307        let prov = make_provisioner();
6308        let res = make_resource(
6309            "AWS::SQS::Queue",
6310            "MyQ",
6311            serde_json::json!({"QueueName": "my-q"}),
6312        );
6313        let sr = prov.create_resource(&res).unwrap();
6314        assert!(sr.physical_id.contains("my-q"));
6315        assert_eq!(sr.resource_type, "AWS::SQS::Queue");
6316        prov.delete_resource(&sr).unwrap();
6317    }
6318
6319    #[test]
6320    fn sqs_queue_fifo_with_suffix() {
6321        let prov = make_provisioner();
6322        let res = make_resource(
6323            "AWS::SQS::Queue",
6324            "FifoQ",
6325            serde_json::json!({"QueueName": "my-fifo.fifo", "FifoQueue": true}),
6326        );
6327        let sr = prov.create_resource(&res).unwrap();
6328        assert!(sr.physical_id.contains(".fifo"));
6329    }
6330
6331    #[test]
6332    fn sns_topic_create_and_delete() {
6333        let prov = make_provisioner();
6334        let res = make_resource(
6335            "AWS::SNS::Topic",
6336            "MyTopic",
6337            serde_json::json!({"TopicName": "t1"}),
6338        );
6339        let sr = prov.create_resource(&res).unwrap();
6340        assert!(sr.physical_id.contains("t1"));
6341        prov.delete_resource(&sr).unwrap();
6342    }
6343
6344    #[test]
6345    fn ssm_parameter_create_and_delete() {
6346        let prov = make_provisioner();
6347        let res = make_resource(
6348            "AWS::SSM::Parameter",
6349            "MyParam",
6350            serde_json::json!({
6351                "Name": "/my/param",
6352                "Type": "String",
6353                "Value": "v1"
6354            }),
6355        );
6356        let sr = prov.create_resource(&res).unwrap();
6357        assert_eq!(sr.physical_id, "/my/param");
6358        prov.delete_resource(&sr).unwrap();
6359    }
6360
6361    #[test]
6362    fn iam_role_create_and_delete() {
6363        let prov = make_provisioner();
6364        let res = make_resource(
6365            "AWS::IAM::Role",
6366            "MyRole",
6367            serde_json::json!({
6368                "RoleName": "my-role",
6369                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
6370            }),
6371        );
6372        let sr = prov.create_resource(&res).unwrap();
6373        assert!(sr.physical_id.contains("my-role"));
6374        prov.delete_resource(&sr).unwrap();
6375    }
6376
6377    #[test]
6378    fn iam_policy_create_and_delete() {
6379        let prov = make_provisioner();
6380        let res = make_resource(
6381            "AWS::IAM::Policy",
6382            "MyPolicy",
6383            serde_json::json!({
6384                "PolicyName": "my-policy",
6385                "PolicyDocument": {"Version": "2012-10-17", "Statement": []}
6386            }),
6387        );
6388        let sr = prov.create_resource(&res).unwrap();
6389        assert!(sr.physical_id.contains("my-policy"));
6390        prov.delete_resource(&sr).unwrap();
6391    }
6392
6393    #[test]
6394    fn s3_bucket_create_and_delete() {
6395        let prov = make_provisioner();
6396        let res = make_resource(
6397            "AWS::S3::Bucket",
6398            "MyBucket",
6399            serde_json::json!({"BucketName": "my-bucket"}),
6400        );
6401        let sr = prov.create_resource(&res).unwrap();
6402        assert_eq!(sr.physical_id, "my-bucket");
6403        prov.delete_resource(&sr).unwrap();
6404    }
6405
6406    #[test]
6407    fn sqs_queue_policy_stored_on_queue_and_cleared_on_delete() {
6408        let prov = make_provisioner();
6409        let queue = prov
6410            .create_resource(&make_resource(
6411                "AWS::SQS::Queue",
6412                "Q",
6413                serde_json::json!({"QueueName": "q1"}),
6414            ))
6415            .unwrap();
6416        // `Queues` carries the queue URL — what `Ref` resolves to.
6417        let policy = make_resource(
6418            "AWS::SQS::QueuePolicy",
6419            "QP",
6420            serde_json::json!({
6421                "Queues": [queue.physical_id.clone()],
6422                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
6423                    "Effect": "Allow",
6424                    "Principal": {"Service": "sns.amazonaws.com"},
6425                    "Action": "sqs:SendMessage",
6426                    "Resource": "*"
6427                }]}
6428            }),
6429        );
6430        let sr = prov.create_resource(&policy).unwrap();
6431
6432        {
6433            let mut accounts = prov.sqs_state.write();
6434            let state = accounts.get_or_create(&prov.account_id);
6435            let stored = state.queues[&queue.physical_id]
6436                .attributes
6437                .get("Policy")
6438                .expect("policy stored on queue");
6439            assert!(stored.contains("sqs:SendMessage"));
6440        }
6441
6442        prov.delete_resource(&sr).unwrap();
6443        {
6444            let mut accounts = prov.sqs_state.write();
6445            let state = accounts.get_or_create(&prov.account_id);
6446            assert!(!state.queues[&queue.physical_id]
6447                .attributes
6448                .contains_key("Policy"));
6449        }
6450    }
6451
6452    #[test]
6453    fn sns_topic_policy_stored_on_topic_and_cleared_on_delete() {
6454        let prov = make_provisioner();
6455        let topic = prov
6456            .create_resource(&make_resource(
6457                "AWS::SNS::Topic",
6458                "T",
6459                serde_json::json!({"TopicName": "t1"}),
6460            ))
6461            .unwrap();
6462        let policy = make_resource(
6463            "AWS::SNS::TopicPolicy",
6464            "TP",
6465            serde_json::json!({
6466                "Topics": [topic.physical_id.clone()],
6467                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
6468                    "Effect": "Allow",
6469                    "Principal": {"Service": "events.amazonaws.com"},
6470                    "Action": "sns:Publish",
6471                    "Resource": "*"
6472                }]}
6473            }),
6474        );
6475        let sr = prov.create_resource(&policy).unwrap();
6476
6477        {
6478            let mut accounts = prov.sns_state.write();
6479            let state = accounts.get_or_create(&prov.account_id);
6480            let stored = state.topics[&topic.physical_id]
6481                .attributes
6482                .get("Policy")
6483                .expect("policy stored on topic");
6484            assert!(stored.contains("sns:Publish"));
6485        }
6486
6487        prov.delete_resource(&sr).unwrap();
6488        {
6489            let mut accounts = prov.sns_state.write();
6490            let state = accounts.get_or_create(&prov.account_id);
6491            assert!(!state.topics[&topic.physical_id]
6492                .attributes
6493                .contains_key("Policy"));
6494        }
6495    }
6496
6497    #[test]
6498    fn s3_bucket_policy_stored_on_bucket_and_cleared_on_delete() {
6499        let prov = make_provisioner();
6500        let bucket = prov
6501            .create_resource(&make_resource(
6502                "AWS::S3::Bucket",
6503                "B",
6504                serde_json::json!({"BucketName": "b1"}),
6505            ))
6506            .unwrap();
6507        let policy = make_resource(
6508            "AWS::S3::BucketPolicy",
6509            "BP",
6510            serde_json::json!({
6511                "Bucket": bucket.physical_id.clone(),
6512                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
6513                    "Effect": "Allow",
6514                    "Principal": "*",
6515                    "Action": "s3:GetObject",
6516                    "Resource": "arn:aws:s3:::b1/*"
6517                }]}
6518            }),
6519        );
6520        let sr = prov.create_resource(&policy).unwrap();
6521        assert_eq!(sr.physical_id, "b1-policy");
6522
6523        {
6524            let mut accounts = prov.s3_state.write();
6525            let state = accounts.get_or_create(&prov.account_id);
6526            let stored = state.buckets[&bucket.physical_id]
6527                .policy
6528                .as_ref()
6529                .expect("policy stored on bucket");
6530            assert!(stored.contains("s3:GetObject"));
6531        }
6532
6533        prov.delete_resource(&sr).unwrap();
6534        {
6535            let mut accounts = prov.s3_state.write();
6536            let state = accounts.get_or_create(&prov.account_id);
6537            assert!(state.buckets[&bucket.physical_id].policy.is_none());
6538        }
6539    }
6540
6541    #[test]
6542    fn dynamodb_table_create_and_delete() {
6543        let prov = make_provisioner();
6544        let res = make_resource(
6545            "AWS::DynamoDB::Table",
6546            "MyTable",
6547            serde_json::json!({
6548                "TableName": "my-table",
6549                "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
6550                "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
6551                "BillingMode": "PAY_PER_REQUEST"
6552            }),
6553        );
6554        let sr = prov.create_resource(&res).unwrap();
6555        assert!(sr.physical_id.contains("my-table"));
6556        prov.delete_resource(&sr).unwrap();
6557    }
6558
6559    #[test]
6560    fn log_group_create_and_delete() {
6561        let prov = make_provisioner();
6562        let res = make_resource(
6563            "AWS::Logs::LogGroup",
6564            "MyLogs",
6565            serde_json::json!({"LogGroupName": "/app/logs"}),
6566        );
6567        let sr = prov.create_resource(&res).unwrap();
6568        assert!(sr.physical_id.contains("/app/logs"));
6569        prov.delete_resource(&sr).unwrap();
6570    }
6571
6572    #[test]
6573    fn lambda_function_create_and_delete() {
6574        let prov = make_provisioner();
6575        let res = make_resource(
6576            "AWS::Lambda::Function",
6577            "MyFn",
6578            serde_json::json!({
6579                "FunctionName": "my-fn",
6580                "Runtime": "nodejs20.x",
6581                "Role": "arn:aws:iam::123456789012:role/lambda-role",
6582                "Handler": "index.handler",
6583                "MemorySize": 256,
6584                "Timeout": 10,
6585                "Environment": {"Variables": {"FOO": "bar"}}
6586            }),
6587        );
6588        let sr = prov.create_resource(&res).unwrap();
6589        assert_eq!(sr.physical_id, "my-fn");
6590        assert_eq!(
6591            sr.attributes.get("Arn").map(String::as_str),
6592            Some("arn:aws:lambda:us-east-1:123456789012:function:my-fn")
6593        );
6594        // Verify it landed in lambda state
6595        {
6596            let lam = prov.lambda_state.read();
6597            let st = lam.get("123456789012").unwrap();
6598            let f = st.functions.get("my-fn").unwrap();
6599            assert_eq!(f.runtime, "nodejs20.x");
6600            assert_eq!(f.memory_size, 256);
6601            assert_eq!(f.environment.get("FOO").unwrap(), "bar");
6602        }
6603        prov.delete_resource(&sr).unwrap();
6604        let lam = prov.lambda_state.read();
6605        let st = lam.get("123456789012").unwrap();
6606        assert!(!st.functions.contains_key("my-fn"));
6607    }
6608
6609    #[test]
6610    fn unsupported_resource_type_is_recorded_not_failed() {
6611        // An unmodelled type is recorded as provisioned (no backing state)
6612        // rather than failing the stack — see
6613        // `unknown_resource_type_records_instead_of_failing`.
6614        let prov = make_provisioner();
6615        let res = make_resource("AWS::NonExistent::Thing", "X", serde_json::json!({}));
6616        let sr = prov.create_resource(&res).unwrap();
6617        assert_eq!(sr.physical_id, "X");
6618    }
6619
6620    #[test]
6621    fn iam_role_with_inline_policies() {
6622        let prov = make_provisioner();
6623        let res = make_resource(
6624            "AWS::IAM::Role",
6625            "MyRole",
6626            serde_json::json!({
6627                "RoleName": "role-inline",
6628                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []},
6629                "Policies": [
6630                    {
6631                        "PolicyName": "inline-1",
6632                        "PolicyDocument": {"Version": "2012-10-17", "Statement": []}
6633                    }
6634                ]
6635            }),
6636        );
6637        let sr = prov.create_resource(&res).unwrap();
6638        assert!(sr.physical_id.contains("role-inline"));
6639    }
6640
6641    #[test]
6642    fn sqs_queue_auto_name() {
6643        let prov = make_provisioner();
6644        let res = make_resource("AWS::SQS::Queue", "AutoQ", serde_json::json!({}));
6645        let sr = prov.create_resource(&res).unwrap();
6646        // Generated queue name should exist
6647        assert!(!sr.physical_id.is_empty());
6648    }
6649
6650    #[test]
6651    fn sns_topic_auto_name() {
6652        let prov = make_provisioner();
6653        let res = make_resource("AWS::SNS::Topic", "AutoT", serde_json::json!({}));
6654        let sr = prov.create_resource(&res).unwrap();
6655        assert!(!sr.physical_id.is_empty());
6656    }
6657
6658    // ── additional resource types ──
6659
6660    #[test]
6661    fn unsupported_resource_type_recorded_with_logical_id() {
6662        // Recorded, not failed; physical id is the logical id so `Ref` resolves.
6663        let prov = make_provisioner();
6664        let res = make_resource("AWS::FooBar::Thing", "X", serde_json::json!({}));
6665        let sr = prov.create_resource(&res).unwrap();
6666        assert_eq!(sr.physical_id, "X");
6667        assert_eq!(sr.status, "CREATE_COMPLETE");
6668    }
6669
6670    #[test]
6671    fn sqs_queue_with_redrive_policy() {
6672        let prov = make_provisioner();
6673        // Create DLQ first
6674        let dlq = make_resource(
6675            "AWS::SQS::Queue",
6676            "DLQ",
6677            serde_json::json!({"QueueName": "dlq1"}),
6678        );
6679        let dlq_resource = prov.create_resource(&dlq).unwrap();
6680        let _ = dlq_resource.physical_id;
6681
6682        // Create source queue with redrive policy
6683        let src = make_resource(
6684            "AWS::SQS::Queue",
6685            "Src",
6686            serde_json::json!({
6687                "QueueName": "src1",
6688                "RedrivePolicy": {
6689                    "deadLetterTargetArn": "arn:aws:sqs:us-east-1:123456789012:dlq1",
6690                    "maxReceiveCount": 3
6691                }
6692            }),
6693        );
6694        let sr = prov.create_resource(&src).unwrap();
6695        assert!(!sr.physical_id.is_empty());
6696    }
6697
6698    #[test]
6699    fn sns_topic_with_display_name() {
6700        let prov = make_provisioner();
6701        let res = make_resource(
6702            "AWS::SNS::Topic",
6703            "WithName",
6704            serde_json::json!({"TopicName": "named-topic", "DisplayName": "Named"}),
6705        );
6706        let sr = prov.create_resource(&res).unwrap();
6707        assert!(sr.physical_id.contains("named-topic"));
6708    }
6709
6710    #[test]
6711    fn ssm_parameter_with_explicit_name() {
6712        let prov = make_provisioner();
6713        let res = make_resource(
6714            "AWS::SSM::Parameter",
6715            "Param",
6716            serde_json::json!({"Name": "/my/param", "Value": "v", "Type": "String"}),
6717        );
6718        let sr = prov.create_resource(&res).unwrap();
6719        assert!(sr.physical_id.contains("/my/param"));
6720    }
6721
6722    #[test]
6723    fn ssm_parameter_missing_name_errors() {
6724        let prov = make_provisioner();
6725        let res = make_resource(
6726            "AWS::SSM::Parameter",
6727            "AutoP",
6728            serde_json::json!({"Value": "v", "Type": "String"}),
6729        );
6730        assert!(prov.create_resource(&res).is_err());
6731    }
6732
6733    #[test]
6734    fn iam_managed_policy_auto_name() {
6735        let prov = make_provisioner();
6736        let res = make_resource(
6737            "AWS::IAM::Policy",
6738            "AutoPol",
6739            serde_json::json!({
6740                "PolicyName": "inline-pol",
6741                "PolicyDocument": {"Version": "2012-10-17", "Statement": []},
6742                "Users": []
6743            }),
6744        );
6745        let sr = prov.create_resource(&res).unwrap();
6746        assert!(!sr.physical_id.is_empty());
6747    }
6748
6749    #[test]
6750    fn delete_resource_works_for_queue() {
6751        let prov = make_provisioner();
6752        let res = make_resource(
6753            "AWS::SQS::Queue",
6754            "ToDel",
6755            serde_json::json!({"QueueName": "todel"}),
6756        );
6757        let sr = prov.create_resource(&res).unwrap();
6758        assert!(prov.delete_resource(&sr).is_ok());
6759    }
6760
6761    #[test]
6762    fn delete_resource_works_for_topic() {
6763        let prov = make_provisioner();
6764        let res = make_resource(
6765            "AWS::SNS::Topic",
6766            "DelT",
6767            serde_json::json!({"TopicName": "delt"}),
6768        );
6769        let sr = prov.create_resource(&res).unwrap();
6770        assert!(prov.delete_resource(&sr).is_ok());
6771    }
6772
6773    #[test]
6774    fn application_autoscaling_scalable_target_round_trip() {
6775        let prov = make_provisioner();
6776        let res = make_resource(
6777            "AWS::ApplicationAutoScaling::ScalableTarget",
6778            "Target",
6779            serde_json::json!({
6780                "ServiceNamespace": "ecs",
6781                "ResourceId": "service/my-cluster/my-service",
6782                "ScalableDimension": "ecs:service:DesiredCount",
6783                "MinCapacity": 1,
6784                "MaxCapacity": 10,
6785                "RoleARN": "arn:aws:iam::123456789012:role/my-role",
6786            }),
6787        );
6788        let sr = prov.create_resource(&res).unwrap();
6789        assert_eq!(sr.physical_id, "service/my-cluster/my-service");
6790        assert!(sr.attributes.contains_key("ScalableTargetARN"));
6791        assert!(prov.delete_resource(&sr).is_ok());
6792    }
6793
6794    #[test]
6795    fn application_autoscaling_scaling_policy_requires_target() {
6796        let prov = make_provisioner();
6797        let res = make_resource(
6798            "AWS::ApplicationAutoScaling::ScalingPolicy",
6799            "Policy",
6800            serde_json::json!({
6801                "PolicyName": "my-policy",
6802                "ServiceNamespace": "ecs",
6803                "ResourceId": "service/my-cluster/my-service",
6804                "ScalableDimension": "ecs:service:DesiredCount",
6805                "PolicyType": "TargetTrackingScaling",
6806                "TargetTrackingScalingPolicyConfiguration": {
6807                    "TargetValue": 50.0,
6808                    "PredefinedMetricSpecification": {
6809                        "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
6810                    }
6811                },
6812            }),
6813        );
6814        // Should fail because scalable target does not exist
6815        assert!(prov.create_resource(&res).is_err());
6816    }
6817
6818    #[test]
6819    fn application_autoscaling_scaling_policy_round_trip() {
6820        let prov = make_provisioner();
6821        let target = make_resource(
6822            "AWS::ApplicationAutoScaling::ScalableTarget",
6823            "Target",
6824            serde_json::json!({
6825                "ServiceNamespace": "ecs",
6826                "ResourceId": "service/my-cluster/my-service",
6827                "ScalableDimension": "ecs:service:DesiredCount",
6828                "MinCapacity": 1,
6829                "MaxCapacity": 10,
6830            }),
6831        );
6832        let sr = prov.create_resource(&target).unwrap();
6833
6834        let policy = make_resource(
6835            "AWS::ApplicationAutoScaling::ScalingPolicy",
6836            "Policy",
6837            serde_json::json!({
6838                "PolicyName": "my-policy",
6839                "ServiceNamespace": "ecs",
6840                "ResourceId": "service/my-cluster/my-service",
6841                "ScalableDimension": "ecs:service:DesiredCount",
6842                "PolicyType": "TargetTrackingScaling",
6843                "TargetTrackingScalingPolicyConfiguration": {
6844                    "TargetValue": 50.0,
6845                    "PredefinedMetricSpecification": {
6846                        "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
6847                    }
6848                },
6849            }),
6850        );
6851        let psr = prov.create_resource(&policy).unwrap();
6852        assert!(psr.physical_id.starts_with("arn:aws:autoscaling:"));
6853        assert!(prov.delete_resource(&psr).is_ok());
6854        assert!(prov.delete_resource(&sr).is_ok());
6855    }
6856
6857    #[test]
6858    fn sqs_queue_with_fifo_suffix() {
6859        let prov = make_provisioner();
6860        let res = make_resource(
6861            "AWS::SQS::Queue",
6862            "Fifo",
6863            serde_json::json!({"QueueName": "fq.fifo", "FifoQueue": true}),
6864        );
6865        let sr = prov.create_resource(&res).unwrap();
6866        assert!(sr.physical_id.ends_with(".fifo"));
6867    }
6868
6869    // ── get_att dispatch ──
6870
6871    #[test]
6872    fn getatt_s3_bucket_arn_returns_arn() {
6873        let prov = make_provisioner();
6874        let bucket = make_resource(
6875            "AWS::S3::Bucket",
6876            "MyBucket",
6877            serde_json::json!({"BucketName": "my-bucket"}),
6878        );
6879        let sr = prov.create_resource(&bucket).unwrap();
6880        assert_eq!(
6881            prov.get_att(&sr, "Arn"),
6882            Some("arn:aws:s3:::my-bucket".to_string())
6883        );
6884    }
6885
6886    #[test]
6887    fn getatt_s3_bucket_domain_name_returns_dns_name() {
6888        let prov = make_provisioner();
6889        let bucket = make_resource(
6890            "AWS::S3::Bucket",
6891            "MyBucket",
6892            serde_json::json!({"BucketName": "my-bucket"}),
6893        );
6894        let sr = prov.create_resource(&bucket).unwrap();
6895        assert_eq!(
6896            prov.get_att(&sr, "DomainName"),
6897            Some("my-bucket.s3.amazonaws.com".to_string())
6898        );
6899    }
6900
6901    #[test]
6902    fn getatt_lambda_function_arn_returns_function_arn() {
6903        let prov = make_provisioner();
6904        // Lambda needs an existing IAM role to validate the function role.
6905        let role = make_resource(
6906            "AWS::IAM::Role",
6907            "MyRole",
6908            serde_json::json!({
6909                "RoleName": "my-role",
6910                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
6911            }),
6912        );
6913        let role_sr = prov.create_resource(&role).unwrap();
6914        let fn_res = make_resource(
6915            "AWS::Lambda::Function",
6916            "MyFn",
6917            serde_json::json!({
6918                "FunctionName": "my-fn",
6919                "Runtime": "python3.11",
6920                "Handler": "index.handler",
6921                "Role": role_sr.physical_id,
6922                "Code": {"ZipFile": "def handler(e,c): return e"}
6923            }),
6924        );
6925        let fn_sr = prov.create_resource(&fn_res).unwrap();
6926        let arn = prov.get_att(&fn_sr, "Arn").expect("Arn should resolve");
6927        assert!(arn.starts_with("arn:aws:lambda:"));
6928        assert!(arn.contains(":function:my-fn"));
6929    }
6930
6931    #[test]
6932    fn getatt_iam_role_arn_returns_role_arn() {
6933        let prov = make_provisioner();
6934        let role = make_resource(
6935            "AWS::IAM::Role",
6936            "MyRole",
6937            serde_json::json!({
6938                "RoleName": "my-role",
6939                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
6940            }),
6941        );
6942        let sr = prov.create_resource(&role).unwrap();
6943        assert_eq!(
6944            prov.get_att(&sr, "Arn"),
6945            Some("arn:aws:iam::123456789012:role/my-role".to_string())
6946        );
6947        // RoleId is a real value generated at create time (FKIA-prefixed).
6948        let role_id = prov.get_att(&sr, "RoleId").expect("RoleId should resolve");
6949        assert!(role_id.starts_with("FKIA"));
6950    }
6951
6952    #[test]
6953    fn getatt_unknown_attribute_returns_none() {
6954        let prov = make_provisioner();
6955        let bucket = make_resource(
6956            "AWS::S3::Bucket",
6957            "MyBucket",
6958            serde_json::json!({"BucketName": "my-bucket"}),
6959        );
6960        let sr = prov.create_resource(&bucket).unwrap();
6961        // Fall-back behaviour: unknown attribute on a known type returns
6962        // None; the resolver in template.rs surfaces a "Logical.Attr"
6963        // placeholder so the existing template still builds.
6964        assert_eq!(prov.get_att(&sr, "NotARealAttr"), None);
6965    }
6966
6967    #[test]
6968    fn getatt_unknown_resource_type_returns_none() {
6969        let prov = make_provisioner();
6970        // Hand-crafted StackResource with a resource_type we don't dispatch
6971        // on at all. The captured attributes map is also empty, so there's
6972        // nothing to return.
6973        let stack_resource = StackResource {
6974            logical_id: "Mystery".to_string(),
6975            physical_id: "mystery-id".to_string(),
6976            resource_type: "AWS::Made::Up".to_string(),
6977            status: "CREATE_COMPLETE".to_string(),
6978            service_token: None,
6979            attributes: BTreeMap::new(),
6980        };
6981        assert_eq!(prov.get_att(&stack_resource, "Arn"), None);
6982    }
6983
6984    #[test]
6985    fn getatt_falls_back_to_captured_attributes() {
6986        let prov = make_provisioner();
6987        // Captured attributes always win — even if live-state dispatch
6988        // would return something different. This keeps `Fn::GetAtt`
6989        // deterministic for resources with cached attrs.
6990        let stack_resource = StackResource {
6991            logical_id: "MyTopic".to_string(),
6992            physical_id: "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
6993            resource_type: "AWS::SNS::Topic".to_string(),
6994            status: "CREATE_COMPLETE".to_string(),
6995            service_token: None,
6996            attributes: {
6997                let mut m = BTreeMap::new();
6998                m.insert("TopicArn".to_string(), "captured-arn".to_string());
6999                m
7000            },
7001        };
7002        assert_eq!(
7003            prov.get_att(&stack_resource, "TopicArn"),
7004            Some("captured-arn".to_string())
7005        );
7006    }
7007
7008    #[test]
7009    fn getatt_secrets_manager_arn_resolves_via_live_state() {
7010        // Secrets create handler captures Id but not Arn; live-state
7011        // fallback fills in Arn.
7012        let prov = make_provisioner();
7013        let res = make_resource(
7014            "AWS::SecretsManager::Secret",
7015            "MySecret",
7016            serde_json::json!({"Name": "my-secret", "SecretString": "hunter2"}),
7017        );
7018        let sr = prov.create_resource(&res).unwrap();
7019        let arn = prov.get_att(&sr, "Arn").expect("Arn should resolve");
7020        assert!(arn.starts_with("arn:aws:secretsmanager:"));
7021        assert!(arn.ends_with(":secret:my-secret"));
7022    }
7023
7024    #[test]
7025    fn wafv2_web_acl_lifecycle() {
7026        let prov = make_provisioner();
7027        let res = make_resource(
7028            "AWS::WAFv2::WebACL",
7029            "MyAcl",
7030            serde_json::json!({
7031                "Name": "my-acl",
7032                "Scope": "REGIONAL",
7033                "DefaultAction": {"Allow": {}},
7034                "Rules": [{"Name": "rule1", "Priority": 1, "Statement": {}, "VisibilityConfig": {}}],
7035                "VisibilityConfig": {"SampledRequestsEnabled": true, "CloudWatchMetricsEnabled": true, "MetricName": "my-acl-metric"},
7036                "Capacity": 100,
7037            }),
7038        );
7039        let sr = prov.create_resource(&res).unwrap();
7040        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7041        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7042        assert_eq!(prov.get_att(&sr, "Name"), Some("my-acl".to_string()));
7043        assert!(prov.get_att(&sr, "Id").is_some());
7044        assert_eq!(prov.get_att(&sr, "Capacity"), Some("100".to_string()));
7045
7046        prov.delete_resource(&sr.clone()).unwrap();
7047        // Verify live-state fallback returns None by using a fresh resource
7048        // with empty attributes (captured attrs would still win).
7049        let fresh = StackResource {
7050            logical_id: "MyAcl".to_string(),
7051            physical_id: sr.physical_id.clone(),
7052            resource_type: "AWS::WAFv2::WebACL".to_string(),
7053            status: "CREATE_COMPLETE".to_string(),
7054            service_token: None,
7055            attributes: BTreeMap::new(),
7056        };
7057        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7058    }
7059
7060    #[test]
7061    fn wafv2_ip_set_lifecycle() {
7062        let prov = make_provisioner();
7063        let res = make_resource(
7064            "AWS::WAFv2::IPSet",
7065            "MyIpSet",
7066            serde_json::json!({
7067                "Name": "my-ipset",
7068                "Scope": "REGIONAL",
7069                "IPAddressVersion": "IPV4",
7070                "Addresses": ["10.0.0.0/8"],
7071            }),
7072        );
7073        let sr = prov.create_resource(&res).unwrap();
7074        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7075        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7076        assert_eq!(prov.get_att(&sr, "Name"), Some("my-ipset".to_string()));
7077
7078        prov.delete_resource(&sr.clone()).unwrap();
7079        let fresh = StackResource {
7080            logical_id: "MyIpSet".to_string(),
7081            physical_id: sr.physical_id.clone(),
7082            resource_type: "AWS::WAFv2::IPSet".to_string(),
7083            status: "CREATE_COMPLETE".to_string(),
7084            service_token: None,
7085            attributes: BTreeMap::new(),
7086        };
7087        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7088    }
7089
7090    #[test]
7091    fn wafv2_regex_pattern_set_lifecycle() {
7092        let prov = make_provisioner();
7093        let res = make_resource(
7094            "AWS::WAFv2::RegexPatternSet",
7095            "MyRegexSet",
7096            serde_json::json!({
7097                "Name": "my-regex",
7098                "Scope": "REGIONAL",
7099                "RegularExpressions": [{"RegexString": "^test"}],
7100            }),
7101        );
7102        let sr = prov.create_resource(&res).unwrap();
7103        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7104        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7105        assert_eq!(prov.get_att(&sr, "Name"), Some("my-regex".to_string()));
7106
7107        prov.delete_resource(&sr.clone()).unwrap();
7108        let fresh = StackResource {
7109            logical_id: "MyRegexSet".to_string(),
7110            physical_id: sr.physical_id.clone(),
7111            resource_type: "AWS::WAFv2::RegexPatternSet".to_string(),
7112            status: "CREATE_COMPLETE".to_string(),
7113            service_token: None,
7114            attributes: BTreeMap::new(),
7115        };
7116        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7117    }
7118
7119    #[test]
7120    fn wafv2_rule_group_lifecycle() {
7121        let prov = make_provisioner();
7122        let res = make_resource(
7123            "AWS::WAFv2::RuleGroup",
7124            "MyRuleGroup",
7125            serde_json::json!({
7126                "Name": "my-rg",
7127                "Scope": "REGIONAL",
7128                "Capacity": 50,
7129                "Rules": [{"Name": "r1", "Priority": 1, "Statement": {}, "VisibilityConfig": {}}],
7130                "VisibilityConfig": {"SampledRequestsEnabled": true, "CloudWatchMetricsEnabled": true, "MetricName": "rg-metric"},
7131            }),
7132        );
7133        let sr = prov.create_resource(&res).unwrap();
7134        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7135        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7136        assert_eq!(prov.get_att(&sr, "Name"), Some("my-rg".to_string()));
7137
7138        prov.delete_resource(&sr.clone()).unwrap();
7139        let fresh = StackResource {
7140            logical_id: "MyRuleGroup".to_string(),
7141            physical_id: sr.physical_id.clone(),
7142            resource_type: "AWS::WAFv2::RuleGroup".to_string(),
7143            status: "CREATE_COMPLETE".to_string(),
7144            service_token: None,
7145            attributes: BTreeMap::new(),
7146        };
7147        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7148    }
7149
7150    #[test]
7151    fn wafv2_logging_configuration_lifecycle() {
7152        let prov = make_provisioner();
7153        let res = make_resource(
7154            "AWS::WAFv2::LoggingConfiguration",
7155            "MyLogConfig",
7156            serde_json::json!({
7157                "ResourceArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/abc",
7158                "LogDestinationConfigs": ["arn:aws:logs:us-east-1:123456789012:log-group:/aws/waf"],
7159            }),
7160        );
7161        let sr = prov.create_resource(&res).unwrap();
7162        assert_eq!(
7163            sr.physical_id,
7164            "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/abc"
7165        );
7166
7167        prov.delete_resource(&sr.clone()).unwrap();
7168    }
7169
7170    #[test]
7171    fn wafv2_web_acl_association_lifecycle() {
7172        let prov = make_provisioner();
7173        let res = make_resource(
7174            "AWS::WAFv2::WebACLAssociation",
7175            "MyAssoc",
7176            serde_json::json!({
7177                "ResourceArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188",
7178                "WebACLArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/my-acl/abc",
7179            }),
7180        );
7181        let sr = prov.create_resource(&res).unwrap();
7182        assert_eq!(sr.physical_id, "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188");
7183
7184        prov.delete_resource(&sr.clone()).unwrap();
7185    }
7186
7187    #[test]
7188    fn ses_configuration_set_lifecycle() {
7189        let prov = make_provisioner();
7190        let res = make_resource(
7191            "AWS::SES::ConfigurationSet",
7192            "MyConfigSet",
7193            serde_json::json!({
7194                "Name": "my-cs",
7195                "SendingOptions": {"SendingEnabled": true},
7196                "DeliveryOptions": {"TlsPolicy": "REQUIRE"},
7197            }),
7198        );
7199        let sr = prov.create_resource(&res).unwrap();
7200        assert_eq!(sr.physical_id, "my-cs");
7201        assert_eq!(prov.get_att(&sr, "Name"), Some("my-cs".to_string()));
7202
7203        prov.delete_resource(&sr.clone()).unwrap();
7204        let fresh = StackResource {
7205            logical_id: "MyConfigSet".to_string(),
7206            physical_id: "my-cs".to_string(),
7207            resource_type: "AWS::SES::ConfigurationSet".to_string(),
7208            status: "CREATE_COMPLETE".to_string(),
7209            service_token: None,
7210            attributes: BTreeMap::new(),
7211        };
7212        assert_eq!(prov.get_att(&fresh, "Name"), None);
7213    }
7214
7215    #[test]
7216    fn ses_email_identity_lifecycle() {
7217        let prov = make_provisioner();
7218        let res = make_resource(
7219            "AWS::SES::EmailIdentity",
7220            "MyIdentity",
7221            serde_json::json!({"EmailIdentity": "example.com"}),
7222        );
7223        let sr = prov.create_resource(&res).unwrap();
7224        assert_eq!(sr.physical_id, "example.com");
7225        assert_eq!(
7226            prov.get_att(&sr, "IdentityName"),
7227            Some("example.com".to_string())
7228        );
7229
7230        prov.delete_resource(&sr.clone()).unwrap();
7231        let fresh = StackResource {
7232            logical_id: "MyIdentity".to_string(),
7233            physical_id: "example.com".to_string(),
7234            resource_type: "AWS::SES::EmailIdentity".to_string(),
7235            status: "CREATE_COMPLETE".to_string(),
7236            service_token: None,
7237            attributes: BTreeMap::new(),
7238        };
7239        assert_eq!(prov.get_att(&fresh, "IdentityName"), None);
7240    }
7241
7242    #[test]
7243    fn ses_template_lifecycle() {
7244        let prov = make_provisioner();
7245        let res = make_resource(
7246            "AWS::SES::Template",
7247            "MyTemplate",
7248            serde_json::json!({
7249                "Template": {
7250                    "TemplateName": "my-tpl",
7251                    "SubjectPart": "Hello",
7252                    "HtmlPart": "<h1>Hi</h1>",
7253                    "TextPart": "Hi",
7254                },
7255            }),
7256        );
7257        let sr = prov.create_resource(&res).unwrap();
7258        assert_eq!(sr.physical_id, "my-tpl");
7259        assert_eq!(
7260            prov.get_att(&sr, "TemplateName"),
7261            Some("my-tpl".to_string())
7262        );
7263
7264        prov.delete_resource(&sr.clone()).unwrap();
7265        let fresh = StackResource {
7266            logical_id: "MyTemplate".to_string(),
7267            physical_id: "my-tpl".to_string(),
7268            resource_type: "AWS::SES::Template".to_string(),
7269            status: "CREATE_COMPLETE".to_string(),
7270            service_token: None,
7271            attributes: BTreeMap::new(),
7272        };
7273        assert_eq!(prov.get_att(&fresh, "TemplateName"), None);
7274    }
7275
7276    #[test]
7277    fn ses_contact_list_lifecycle() {
7278        let prov = make_provisioner();
7279        let res = make_resource(
7280            "AWS::SES::ContactList",
7281            "MyContactList",
7282            serde_json::json!({
7283                "ContactListName": "my-cl",
7284                "Description": "Test contacts",
7285                "Topics": [{"TopicName": "news", "DisplayName": "Newsletter", "Description": "Weekly news"}],
7286            }),
7287        );
7288        let sr = prov.create_resource(&res).unwrap();
7289        assert_eq!(sr.physical_id, "my-cl");
7290        assert_eq!(
7291            prov.get_att(&sr, "ContactListName"),
7292            Some("my-cl".to_string())
7293        );
7294
7295        prov.delete_resource(&sr.clone()).unwrap();
7296        let fresh = StackResource {
7297            logical_id: "MyContactList".to_string(),
7298            physical_id: "my-cl".to_string(),
7299            resource_type: "AWS::SES::ContactList".to_string(),
7300            status: "CREATE_COMPLETE".to_string(),
7301            service_token: None,
7302            attributes: BTreeMap::new(),
7303        };
7304        assert_eq!(prov.get_att(&fresh, "ContactListName"), None);
7305    }
7306
7307    #[test]
7308    fn ses_dedicated_ip_pool_lifecycle() {
7309        let prov = make_provisioner();
7310        let res = make_resource(
7311            "AWS::SES::DedicatedIpPool",
7312            "MyPool",
7313            serde_json::json!({"PoolName": "my-pool", "ScalingMode": "STANDARD"}),
7314        );
7315        let sr = prov.create_resource(&res).unwrap();
7316        assert_eq!(sr.physical_id, "my-pool");
7317        assert_eq!(prov.get_att(&sr, "PoolName"), Some("my-pool".to_string()));
7318
7319        prov.delete_resource(&sr.clone()).unwrap();
7320        let fresh = StackResource {
7321            logical_id: "MyPool".to_string(),
7322            physical_id: "my-pool".to_string(),
7323            resource_type: "AWS::SES::DedicatedIpPool".to_string(),
7324            status: "CREATE_COMPLETE".to_string(),
7325            service_token: None,
7326            attributes: BTreeMap::new(),
7327        };
7328        assert_eq!(prov.get_att(&fresh, "PoolName"), None);
7329    }
7330
7331    #[test]
7332    fn ses_receipt_rule_set_lifecycle() {
7333        let prov = make_provisioner();
7334        let res = make_resource(
7335            "AWS::SES::ReceiptRuleSet",
7336            "MyRuleSet",
7337            serde_json::json!({"RuleSetName": "my-rs"}),
7338        );
7339        let sr = prov.create_resource(&res).unwrap();
7340        assert_eq!(sr.physical_id, "my-rs");
7341        assert_eq!(prov.get_att(&sr, "RuleSetName"), Some("my-rs".to_string()));
7342
7343        prov.delete_resource(&sr.clone()).unwrap();
7344        let fresh = StackResource {
7345            logical_id: "MyRuleSet".to_string(),
7346            physical_id: "my-rs".to_string(),
7347            resource_type: "AWS::SES::ReceiptRuleSet".to_string(),
7348            status: "CREATE_COMPLETE".to_string(),
7349            service_token: None,
7350            attributes: BTreeMap::new(),
7351        };
7352        assert_eq!(prov.get_att(&fresh, "RuleSetName"), None);
7353    }
7354
7355    #[test]
7356    fn ses_receipt_rule_lifecycle() {
7357        let prov = make_provisioner();
7358        let rs = make_resource(
7359            "AWS::SES::ReceiptRuleSet",
7360            "MyRuleSet",
7361            serde_json::json!({"RuleSetName": "my-rs2"}),
7362        );
7363        prov.create_resource(&rs).unwrap();
7364
7365        let res = make_resource(
7366            "AWS::SES::ReceiptRule",
7367            "MyRule",
7368            serde_json::json!({
7369                "RuleSetName": "my-rs2",
7370                "Rule": {
7371                    "Name": "rule1",
7372                    "Priority": 1,
7373                    "Enabled": true,
7374                    "Actions": [{"S3Action": {"BucketName": "my-bucket"}}],
7375                },
7376            }),
7377        );
7378        let sr = prov.create_resource(&res).unwrap();
7379        assert_eq!(sr.physical_id, "my-rs2|rule1");
7380
7381        prov.delete_resource(&sr.clone()).unwrap();
7382    }
7383
7384    #[test]
7385    fn ses_receipt_filter_lifecycle() {
7386        let prov = make_provisioner();
7387        let res = make_resource(
7388            "AWS::SES::ReceiptFilter",
7389            "MyFilter",
7390            serde_json::json!({
7391                "Filter": {
7392                    "Name": "my-filter",
7393                    "IpFilter": {"Policy": "Block", "Cidr": "10.0.0.0/8"},
7394                },
7395            }),
7396        );
7397        let sr = prov.create_resource(&res).unwrap();
7398        assert_eq!(sr.physical_id, "my-filter");
7399
7400        prov.delete_resource(&sr.clone()).unwrap();
7401    }
7402
7403    #[test]
7404    fn ses_vdm_attributes_lifecycle() {
7405        let prov = make_provisioner();
7406        let res = make_resource(
7407            "AWS::SES::VdmAttributes",
7408            "MyVdm",
7409            serde_json::json!({
7410                "DashboardAttributes": {"EngagementMetrics": "ENABLED"},
7411                "GuardianAttributes": {"OptimizedSharedDelivery": "ENABLED"},
7412            }),
7413        );
7414        let sr = prov.create_resource(&res).unwrap();
7415        assert_eq!(sr.physical_id, "vdm-MyVdm");
7416
7417        prov.delete_resource(&sr.clone()).unwrap();
7418    }
7419
7420    #[test]
7421    fn athena_work_group_lifecycle() {
7422        let prov = make_provisioner();
7423        let res = make_resource(
7424            "AWS::Athena::WorkGroup",
7425            "MyWg",
7426            serde_json::json!({
7427                "Name": "my-wg",
7428                "Description": "test wg",
7429                "Configuration": {"EnforceWorkGroupConfiguration": true},
7430            }),
7431        );
7432        let sr = prov.create_resource(&res).unwrap();
7433        assert_eq!(sr.physical_id, "my-wg");
7434        assert_eq!(sr.attributes.get("Name"), Some(&"my-wg".to_string()));
7435        assert!(sr
7436            .attributes
7437            .get("Arn")
7438            .unwrap()
7439            .contains("workgroup/my-wg"));
7440
7441        assert_eq!(
7442            prov.get_att(
7443                &StackResource {
7444                    resource_type: "AWS::Athena::WorkGroup".to_string(),
7445                    physical_id: sr.physical_id.clone(),
7446                    logical_id: "MyWg".to_string(),
7447                    status: "CREATE_COMPLETE".to_string(),
7448                    service_token: None,
7449                    attributes: BTreeMap::new(),
7450                },
7451                "Name",
7452            ),
7453            Some("my-wg".to_string()),
7454        );
7455
7456        prov.delete_resource(&sr.clone()).unwrap();
7457    }
7458
7459    #[test]
7460    fn athena_data_catalog_lifecycle() {
7461        let prov = make_provisioner();
7462        let res = make_resource(
7463            "AWS::Athena::DataCatalog",
7464            "MyCatalog",
7465            serde_json::json!({
7466                "Name": "my-catalog",
7467                "Type": "GLUE",
7468                "Description": "test catalog",
7469            }),
7470        );
7471        let sr = prov.create_resource(&res).unwrap();
7472        assert_eq!(sr.physical_id, "my-catalog");
7473        assert_eq!(sr.attributes.get("Name"), Some(&"my-catalog".to_string()));
7474        assert!(sr
7475            .attributes
7476            .get("Arn")
7477            .unwrap()
7478            .contains("datacatalog/my-catalog"));
7479
7480        prov.delete_resource(&sr.clone()).unwrap();
7481    }
7482
7483    #[test]
7484    fn athena_named_query_lifecycle() {
7485        let prov = make_provisioner();
7486        let res = make_resource(
7487            "AWS::Athena::NamedQuery",
7488            "MyQuery",
7489            serde_json::json!({
7490                "Name": "my-query",
7491                "Database": "mydb",
7492                "QueryString": "SELECT 1",
7493                "WorkGroup": "primary",
7494            }),
7495        );
7496        let sr = prov.create_resource(&res).unwrap();
7497        assert!(!sr.physical_id.is_empty());
7498        assert_eq!(sr.attributes.get("NamedQueryId"), Some(&sr.physical_id));
7499
7500        prov.delete_resource(&sr.clone()).unwrap();
7501    }
7502
7503    #[test]
7504    fn athena_prepared_statement_lifecycle() {
7505        let prov = make_provisioner();
7506        let res = make_resource(
7507            "AWS::Athena::PreparedStatement",
7508            "MyPs",
7509            serde_json::json!({
7510                "StatementName": "my-ps",
7511                "WorkGroupName": "primary",
7512                "QueryStatement": "SELECT 1",
7513            }),
7514        );
7515        let sr = prov.create_resource(&res).unwrap();
7516        assert_eq!(sr.physical_id, "primary|my-ps");
7517
7518        prov.delete_resource(&sr.clone()).unwrap();
7519    }
7520
7521    #[test]
7522    fn parse_lambda_function_name_handles_every_shape() {
7523        // Plain name, unqualified — passes through.
7524        assert_eq!(parse_lambda_function_name("my-func"), "my-func");
7525        // Bare name with an alias/version qualifier (what `Ref` on an
7526        // alias used to feed the ESM create path).
7527        assert_eq!(parse_lambda_function_name("my-func:live"), "my-func");
7528        assert_eq!(parse_lambda_function_name("my-func:42"), "my-func");
7529        // Full ARN, unqualified and qualified.
7530        assert_eq!(
7531            parse_lambda_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-func"),
7532            "my-func"
7533        );
7534        assert_eq!(
7535            parse_lambda_function_name(
7536                "arn:aws:lambda:us-east-1:123456789012:function:my-func:live"
7537            ),
7538            "my-func"
7539        );
7540        // Partial ARN, unqualified and qualified.
7541        assert_eq!(
7542            parse_lambda_function_name("123456789012:function:my-func"),
7543            "my-func"
7544        );
7545        assert_eq!(
7546            parse_lambda_function_name("123456789012:function:my-func:live"),
7547            "my-func"
7548        );
7549    }
7550
7551    #[test]
7552    fn alias_state_key_recovers_internal_key_from_arn() {
7553        // Alias ARN -> `{function}:{alias}` internal map key.
7554        assert_eq!(
7555            alias_state_key("arn:aws:lambda:us-east-1:123456789012:function:my-func:live"),
7556            "my-func:live"
7557        );
7558        // Legacy bare `name:alias` physical id is returned unchanged.
7559        assert_eq!(alias_state_key("my-func:live"), "my-func:live");
7560    }
7561}