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                transformations.push(MetricTransformation {
3440                    metric_name,
3441                    metric_namespace,
3442                    metric_value,
3443                    default_value,
3444                });
3445            }
3446        }
3447
3448        let mut logs_accounts = self.logs_state.write();
3449        let state = logs_accounts.get_or_create(&self.account_id);
3450        if !state.log_groups.contains_key(&log_group_name) {
3451            return Err(format!("Log group {log_group_name} does not exist"));
3452        }
3453        state
3454            .metric_filters
3455            .retain(|f| !(f.log_group_name == log_group_name && f.filter_name == filter_name));
3456        state.metric_filters.push(MetricFilter {
3457            filter_name: filter_name.clone(),
3458            filter_pattern,
3459            log_group_name: log_group_name.clone(),
3460            metric_transformations: transformations,
3461            creation_time: Utc::now().timestamp_millis(),
3462        });
3463
3464        Ok(ProvisionResult::new(format!(
3465            "{log_group_name}|{filter_name}"
3466        )))
3467    }
3468
3469    fn delete_metric_filter(&self, physical_id: &str) -> Result<(), String> {
3470        let mut logs_accounts = self.logs_state.write();
3471        let state = logs_accounts.get_or_create(&self.account_id);
3472        if let Some((group_name, filter_name)) = physical_id.split_once('|') {
3473            state
3474                .metric_filters
3475                .retain(|f| !(f.log_group_name == group_name && f.filter_name == filter_name));
3476        }
3477        Ok(())
3478    }
3479
3480    fn create_subscription_filter(
3481        &self,
3482        resource: &ResourceDefinition,
3483    ) -> Result<ProvisionResult, String> {
3484        let props = &resource.properties;
3485        let log_group_name = props
3486            .get("LogGroupName")
3487            .and_then(|v| v.as_str())
3488            .map(parse_log_group_name)
3489            .ok_or_else(|| "LogGroupName is required".to_string())?;
3490        let filter_name = props
3491            .get("FilterName")
3492            .and_then(|v| v.as_str())
3493            .unwrap_or(&resource.logical_id)
3494            .to_string();
3495        let filter_pattern = props
3496            .get("FilterPattern")
3497            .and_then(|v| v.as_str())
3498            .unwrap_or("")
3499            .to_string();
3500        let destination_arn = props
3501            .get("DestinationArn")
3502            .and_then(|v| v.as_str())
3503            .ok_or_else(|| "DestinationArn is required".to_string())?
3504            .to_string();
3505        let role_arn = props
3506            .get("RoleArn")
3507            .and_then(|v| v.as_str())
3508            .map(|s| s.to_string());
3509        let distribution = props
3510            .get("Distribution")
3511            .and_then(|v| v.as_str())
3512            .unwrap_or("ByLogStream")
3513            .to_string();
3514
3515        let mut logs_accounts = self.logs_state.write();
3516        let state = logs_accounts.get_or_create(&self.account_id);
3517        let group = state
3518            .log_groups
3519            .get_mut(&log_group_name)
3520            .ok_or_else(|| format!("Log group {log_group_name} does not exist"))?;
3521        group
3522            .subscription_filters
3523            .retain(|f| f.filter_name != filter_name);
3524        group.subscription_filters.push(SubscriptionFilter {
3525            filter_name: filter_name.clone(),
3526            log_group_name: log_group_name.clone(),
3527            filter_pattern,
3528            destination_arn,
3529            role_arn,
3530            distribution,
3531            creation_time: Utc::now().timestamp_millis(),
3532        });
3533
3534        Ok(ProvisionResult::new(format!(
3535            "{log_group_name}|{filter_name}"
3536        )))
3537    }
3538
3539    fn delete_subscription_filter(&self, physical_id: &str) -> Result<(), String> {
3540        let mut logs_accounts = self.logs_state.write();
3541        let state = logs_accounts.get_or_create(&self.account_id);
3542        if let Some((group_name, filter_name)) = physical_id.split_once('|') {
3543            if let Some(group) = state.log_groups.get_mut(group_name) {
3544                group
3545                    .subscription_filters
3546                    .retain(|f| f.filter_name != filter_name);
3547            }
3548        }
3549        Ok(())
3550    }
3551
3552    // --- Custom Resources ---
3553
3554    /// Invoke a Lambda function synchronously via the delivery bus.
3555    fn invoke_lambda_sync(&self, function_arn: &str, payload: &str) -> Result<(), String> {
3556        let delivery = self.delivery.clone();
3557        let function_arn = function_arn.to_string();
3558        let payload = payload.to_string();
3559        std::thread::scope(|s| {
3560            s.spawn(|| {
3561                let rt = tokio::runtime::Builder::new_current_thread()
3562                    .enable_all()
3563                    .build()
3564                    .map_err(|e| format!("Failed to create runtime: {e}"))?;
3565                rt.block_on(async {
3566                    match delivery.invoke_lambda(&function_arn, &payload).await {
3567                        Some(Ok(_)) => {
3568                            tracing::info!(
3569                                "Custom resource Lambda {} invoked successfully",
3570                                function_arn
3571                            );
3572                            Ok(())
3573                        }
3574                        Some(Err(e)) => {
3575                            tracing::warn!(
3576                                "Custom resource Lambda {} invocation failed: {e}",
3577                                function_arn
3578                            );
3579                            Err(format!("Lambda invocation failed: {e}"))
3580                        }
3581                        None => {
3582                            tracing::warn!(
3583                                "No Lambda delivery configured; skipping custom resource invocation for {}",
3584                                function_arn
3585                            );
3586                            Ok(())
3587                        }
3588                    }
3589                })
3590            })
3591            .join()
3592            .map_err(|_| "Lambda invocation thread panicked".to_string())?
3593        })
3594    }
3595
3596    fn create_custom_resource(&self, resource: &ResourceDefinition) -> Result<String, String> {
3597        let props = &resource.properties;
3598        let service_token = props
3599            .get("ServiceToken")
3600            .and_then(|v| v.as_str())
3601            .ok_or("Custom resource requires ServiceToken property")?;
3602
3603        let request_id = Uuid::new_v4().to_string();
3604
3605        // Build the CloudFormation custom resource event
3606        let event = serde_json::json!({
3607            "RequestType": "Create",
3608            "ServiceToken": service_token,
3609            "StackId": self.stack_id,
3610            "RequestId": request_id,
3611            "ResourceType": resource.resource_type,
3612            "LogicalResourceId": resource.logical_id,
3613            "ResourceProperties": props,
3614        });
3615
3616        let payload = serde_json::to_string(&event).map_err(|e| e.to_string())?;
3617        self.invoke_lambda_sync(service_token, &payload)?;
3618
3619        // Physical resource ID: use a generated ID (the Lambda could return one,
3620        // but for simplicity we generate one here).
3621        let physical_id = format!("{}-{}", resource.logical_id, &request_id[..8]);
3622        Ok(physical_id)
3623    }
3624
3625    fn delete_custom_resource(&self, resource: &StackResource) -> Result<(), String> {
3626        let service_token = match &resource.service_token {
3627            Some(token) => token.clone(),
3628            None => {
3629                // No ServiceToken stored — nothing to invoke
3630                return Ok(());
3631            }
3632        };
3633
3634        let request_id = Uuid::new_v4().to_string();
3635
3636        let event = serde_json::json!({
3637            "RequestType": "Delete",
3638            "ServiceToken": service_token,
3639            "StackId": self.stack_id,
3640            "RequestId": request_id,
3641            "ResourceType": resource.resource_type,
3642            "LogicalResourceId": resource.logical_id,
3643            "PhysicalResourceId": resource.physical_id,
3644        });
3645
3646        let payload = serde_json::to_string(&event).map_err(|e| e.to_string())?;
3647
3648        // Best-effort: don't fail stack deletion if Lambda invocation fails
3649        if let Err(e) = self.invoke_lambda_sync(&service_token, &payload) {
3650            tracing::warn!(
3651                "Custom resource delete Lambda invocation failed for {}: {e}",
3652                resource.logical_id
3653            );
3654        }
3655        Ok(())
3656    }
3657
3658    // --- Application Auto Scaling ---
3659
3660    fn create_application_autoscaling_scalable_target(
3661        &self,
3662        resource: &ResourceDefinition,
3663    ) -> Result<ProvisionResult, String> {
3664        let props = &resource.properties;
3665        let service_namespace = props
3666            .get("ServiceNamespace")
3667            .and_then(|v| v.as_str())
3668            .ok_or_else(|| "ServiceNamespace is required".to_string())?
3669            .to_string();
3670        let resource_id = props
3671            .get("ResourceId")
3672            .and_then(|v| v.as_str())
3673            .ok_or_else(|| "ResourceId is required".to_string())?
3674            .to_string();
3675        let scalable_dimension = props
3676            .get("ScalableDimension")
3677            .and_then(|v| v.as_str())
3678            .ok_or_else(|| "ScalableDimension is required".to_string())?
3679            .to_string();
3680        let min_capacity = props
3681            .get("MinCapacity")
3682            .and_then(|v| v.as_i64())
3683            .map(|n| n as i32)
3684            .ok_or_else(|| "MinCapacity is required".to_string())?;
3685        let max_capacity = props
3686            .get("MaxCapacity")
3687            .and_then(|v| v.as_i64())
3688            .map(|n| n as i32)
3689            .ok_or_else(|| "MaxCapacity is required".to_string())?;
3690        if min_capacity > max_capacity {
3691            return Err("MinCapacity must be <= MaxCapacity".to_string());
3692        }
3693        let role_arn = props
3694            .get("RoleARN")
3695            .and_then(|v| v.as_str())
3696            .map(|s| s.to_string());
3697        let suspended_state = props.get("SuspendedState").map(|v| AppasSuspendedState {
3698            dynamic_scaling_in_suspended: v
3699                .get("DynamicScalingInSuspended")
3700                .and_then(|x| x.as_bool()),
3701            dynamic_scaling_out_suspended: v
3702                .get("DynamicScalingOutSuspended")
3703                .and_then(|x| x.as_bool()),
3704            scheduled_scaling_suspended: v
3705                .get("ScheduledScalingSuspended")
3706                .and_then(|x| x.as_bool()),
3707        });
3708
3709        let arn = format!(
3710            "arn:aws:application-autoscaling:{}:{}:scalable-target/{}",
3711            self.region,
3712            self.account_id,
3713            &Uuid::new_v4().simple().to_string()[..10]
3714        );
3715        let role = role_arn.unwrap_or_else(|| {
3716            let suffix = match service_namespace.as_str() {
3717                "ecs" => "ECSService",
3718                "elasticmapreduce" => "EMRContainerService",
3719                "ec2" => "EC2SpotFleetRequest",
3720                "appstream" => "ApplicationAutoScaling_AppStreamFleet",
3721                "dynamodb" => "DynamoDBTable",
3722                "rds" => "RDSCluster",
3723                "sagemaker" => "SageMakerEndpoint",
3724                "lambda" => "LambdaConcurrency",
3725                "elasticache" => "ElastiCacheRG",
3726                "cassandra" => "CassandraTable",
3727                "kafka" => "KafkaCluster",
3728                _ => "ApplicationAutoScaling_Default",
3729            };
3730            format!(
3731                "arn:aws:iam::{}:role/aws-service-role/applicationautoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_{}",
3732                self.account_id, suffix
3733            )
3734        });
3735
3736        let mut state = self.app_autoscaling_state.write();
3737        let account = state.accounts.entry(self.account_id.clone()).or_default();
3738        let key = (
3739            service_namespace.clone(),
3740            resource_id.clone(),
3741            scalable_dimension.clone(),
3742        );
3743        let target = AppasScalableTarget {
3744            arn: arn.clone(),
3745            service_namespace: service_namespace.clone(),
3746            resource_id: resource_id.clone(),
3747            scalable_dimension: scalable_dimension.clone(),
3748            min_capacity,
3749            max_capacity,
3750            role_arn: role,
3751            creation_time: Utc::now(),
3752            suspended_state,
3753            predicted_capacity: None,
3754        };
3755        account.scalable_targets.insert(key, target);
3756
3757        Ok(ProvisionResult::new(resource_id.clone())
3758            .with("ScalableTargetARN", arn)
3759            .with("ServiceNamespace", service_namespace)
3760            .with("ScalableDimension", scalable_dimension))
3761    }
3762
3763    fn create_application_autoscaling_scaling_policy(
3764        &self,
3765        resource: &ResourceDefinition,
3766    ) -> Result<ProvisionResult, String> {
3767        let props = &resource.properties;
3768        let policy_name = props
3769            .get("PolicyName")
3770            .and_then(|v| v.as_str())
3771            .ok_or_else(|| "PolicyName is required".to_string())?
3772            .to_string();
3773        let service_namespace = props
3774            .get("ServiceNamespace")
3775            .and_then(|v| v.as_str())
3776            .ok_or_else(|| "ServiceNamespace is required".to_string())?
3777            .to_string();
3778        let resource_id = props
3779            .get("ResourceId")
3780            .and_then(|v| v.as_str())
3781            .ok_or_else(|| "ResourceId is required".to_string())?
3782            .to_string();
3783        let scalable_dimension = props
3784            .get("ScalableDimension")
3785            .and_then(|v| v.as_str())
3786            .ok_or_else(|| "ScalableDimension is required".to_string())?
3787            .to_string();
3788        let policy_type = props
3789            .get("PolicyType")
3790            .and_then(|v| v.as_str())
3791            .unwrap_or("StepScaling")
3792            .to_string();
3793        let step_cfg = props.get("StepScalingPolicyConfiguration").cloned();
3794        let tt_cfg = props
3795            .get("TargetTrackingScalingPolicyConfiguration")
3796            .cloned();
3797        let pred_cfg = props.get("PredictiveScalingPolicyConfiguration").cloned();
3798
3799        let target_key = (
3800            service_namespace.clone(),
3801            resource_id.clone(),
3802            scalable_dimension.clone(),
3803        );
3804        let policy_key = (
3805            service_namespace.clone(),
3806            resource_id.clone(),
3807            scalable_dimension.clone(),
3808            policy_name.clone(),
3809        );
3810
3811        let mut state = self.app_autoscaling_state.write();
3812        let account = state.accounts.entry(self.account_id.clone()).or_default();
3813        if !account.scalable_targets.contains_key(&target_key) {
3814            return Err(format!(
3815                "No scalable target registered for ServiceNamespace={} ResourceId={} ScalableDimension={}",
3816                service_namespace, resource_id, scalable_dimension
3817            ));
3818        }
3819        let arn = format!(
3820            "arn:aws:autoscaling:{}:{}:scalingPolicy:{}:resource/{}/{}:policyName/{}",
3821            self.region,
3822            self.account_id,
3823            Uuid::new_v4(),
3824            service_namespace,
3825            resource_id,
3826            policy_name
3827        );
3828        let policy = AppasScalingPolicy {
3829            arn: arn.clone(),
3830            policy_name: policy_name.clone(),
3831            service_namespace: service_namespace.clone(),
3832            resource_id: resource_id.clone(),
3833            scalable_dimension: scalable_dimension.clone(),
3834            policy_type: policy_type.clone(),
3835            creation_time: Utc::now(),
3836            step_scaling_policy_configuration: step_cfg,
3837            target_tracking_scaling_policy_configuration: tt_cfg,
3838            predictive_scaling_policy_configuration: pred_cfg,
3839            alarms: Vec::new(),
3840            last_applied_at: None,
3841        };
3842        account.scaling_policies.insert(policy_key, policy);
3843
3844        Ok(ProvisionResult::new(arn.clone())
3845            .with("PolicyName", policy_name)
3846            .with("ServiceNamespace", service_namespace)
3847            .with("ResourceId", resource_id)
3848            .with("ScalableDimension", scalable_dimension))
3849    }
3850
3851    fn delete_application_autoscaling_scalable_target(
3852        &self,
3853        physical_id: &str,
3854        attributes: &BTreeMap<String, String>,
3855    ) -> Result<(), String> {
3856        let namespace = attributes
3857            .get("ServiceNamespace")
3858            .cloned()
3859            .ok_or_else(|| "ServiceNamespace missing in attributes".to_string())?;
3860        let resource_id = physical_id.to_string();
3861        let dimension = attributes
3862            .get("ScalableDimension")
3863            .cloned()
3864            .ok_or_else(|| "ScalableDimension missing in attributes".to_string())?;
3865        let key = (namespace, resource_id.clone(), dimension);
3866
3867        let mut state = self.app_autoscaling_state.write();
3868        let account = state.accounts.entry(self.account_id.clone()).or_default();
3869        account.scalable_targets.remove(&key);
3870        account
3871            .scaling_policies
3872            .retain(|k, _| !(k.0 == key.0 && k.1 == key.1 && k.2 == key.2));
3873        account
3874            .scheduled_actions
3875            .retain(|k, _| !(k.0 == key.0 && k.1 == key.1 && k.2 == key.2));
3876        Ok(())
3877    }
3878
3879    fn delete_application_autoscaling_scaling_policy(
3880        &self,
3881        _physical_id: &str,
3882        attributes: &BTreeMap<String, String>,
3883    ) -> Result<(), String> {
3884        let policy_name = attributes
3885            .get("PolicyName")
3886            .cloned()
3887            .ok_or_else(|| "PolicyName missing in attributes".to_string())?;
3888        let namespace = attributes
3889            .get("ServiceNamespace")
3890            .cloned()
3891            .ok_or_else(|| "ServiceNamespace missing in attributes".to_string())?;
3892        let resource_id = attributes
3893            .get("ResourceId")
3894            .cloned()
3895            .ok_or_else(|| "ResourceId missing in attributes".to_string())?;
3896        let dimension = attributes
3897            .get("ScalableDimension")
3898            .cloned()
3899            .ok_or_else(|| "ScalableDimension missing in attributes".to_string())?;
3900        let key = (namespace, resource_id, dimension, policy_name);
3901
3902        let mut state = self.app_autoscaling_state.write();
3903        let account = state.accounts.entry(self.account_id.clone()).or_default();
3904        account.scaling_policies.remove(&key);
3905        Ok(())
3906    }
3907
3908    // --- ElastiCache ---
3909
3910    fn create_ec_parameter_group(
3911        &self,
3912        resource: &ResourceDefinition,
3913    ) -> Result<ProvisionResult, String> {
3914        let props = &resource.properties;
3915        let name = props
3916            .get("CacheParameterGroupName")
3917            .and_then(|v| v.as_str())
3918            .unwrap_or(&resource.logical_id)
3919            .to_string();
3920        let family = props
3921            .get("CacheParameterGroupFamily")
3922            .and_then(|v| v.as_str())
3923            .unwrap_or("redis7")
3924            .to_string();
3925        let description = props
3926            .get("Description")
3927            .and_then(|v| v.as_str())
3928            .unwrap_or("")
3929            .to_string();
3930        let arn = format!(
3931            "arn:aws:elasticache:{}:{}:parametergroup:{}",
3932            self.region, self.account_id, name
3933        );
3934        let group = CacheParameterGroup {
3935            cache_parameter_group_name: name.clone(),
3936            cache_parameter_group_family: family,
3937            description,
3938            is_global: false,
3939            arn: arn.clone(),
3940        };
3941        let mut accounts = self.elasticache_state.write();
3942        let state = accounts.get_or_create(&self.account_id);
3943        // ParameterGroups stored as Vec — replace any existing entry.
3944        state
3945            .parameter_groups
3946            .retain(|p| p.cache_parameter_group_name != name);
3947        state.parameter_groups.push(group);
3948        Ok(ProvisionResult::new(name).with("Arn", arn))
3949    }
3950
3951    fn delete_ec_parameter_group(&self, physical_id: &str) -> Result<(), String> {
3952        let mut accounts = self.elasticache_state.write();
3953        let state = accounts.get_or_create(&self.account_id);
3954        state
3955            .parameter_groups
3956            .retain(|p| p.cache_parameter_group_name != physical_id);
3957        Ok(())
3958    }
3959
3960    fn create_ec_subnet_group(
3961        &self,
3962        resource: &ResourceDefinition,
3963    ) -> Result<ProvisionResult, String> {
3964        let props = &resource.properties;
3965        let name = props
3966            .get("CacheSubnetGroupName")
3967            .and_then(|v| v.as_str())
3968            .unwrap_or(&resource.logical_id)
3969            .to_string();
3970        let description = props
3971            .get("Description")
3972            .and_then(|v| v.as_str())
3973            .unwrap_or("")
3974            .to_string();
3975        let subnet_ids: Vec<String> = props
3976            .get("SubnetIds")
3977            .and_then(|v| v.as_array())
3978            .map(|arr| {
3979                arr.iter()
3980                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
3981                    .collect()
3982            })
3983            .unwrap_or_default();
3984        let arn = format!(
3985            "arn:aws:elasticache:{}:{}:subnetgroup:{}",
3986            self.region, self.account_id, name
3987        );
3988        let group = CacheSubnetGroup {
3989            cache_subnet_group_name: name.clone(),
3990            cache_subnet_group_description: description,
3991            vpc_id: String::new(),
3992            subnet_ids,
3993            arn: arn.clone(),
3994        };
3995        let mut accounts = self.elasticache_state.write();
3996        let state = accounts.get_or_create(&self.account_id);
3997        state.subnet_groups.insert(name.clone(), group);
3998        Ok(ProvisionResult::new(name).with("Arn", arn))
3999    }
4000
4001    fn delete_ec_subnet_group(&self, physical_id: &str) -> Result<(), String> {
4002        let mut accounts = self.elasticache_state.write();
4003        let state = accounts.get_or_create(&self.account_id);
4004        state.subnet_groups.remove(physical_id);
4005        Ok(())
4006    }
4007
4008    fn create_ec_security_group(
4009        &self,
4010        resource: &ResourceDefinition,
4011    ) -> Result<ProvisionResult, String> {
4012        let props = &resource.properties;
4013        let name = props
4014            .get("CacheSecurityGroupName")
4015            .and_then(|v| v.as_str())
4016            .unwrap_or(&resource.logical_id)
4017            .to_string();
4018        let description = props
4019            .get("Description")
4020            .and_then(|v| v.as_str())
4021            .unwrap_or("")
4022            .to_string();
4023        let arn = format!(
4024            "arn:aws:elasticache:{}:{}:securitygroup:{}",
4025            self.region, self.account_id, name
4026        );
4027        let group = CacheSecurityGroup {
4028            cache_security_group_name: name.clone(),
4029            description,
4030            owner_id: self.account_id.clone(),
4031            arn: arn.clone(),
4032            ec2_security_groups: Vec::new(),
4033        };
4034        let mut accounts = self.elasticache_state.write();
4035        let state = accounts.get_or_create(&self.account_id);
4036        state.security_groups.insert(name.clone(), group);
4037        Ok(ProvisionResult::new(name).with("Arn", arn))
4038    }
4039
4040    fn delete_ec_security_group(&self, physical_id: &str) -> Result<(), String> {
4041        let mut accounts = self.elasticache_state.write();
4042        let state = accounts.get_or_create(&self.account_id);
4043        state.security_groups.remove(physical_id);
4044        Ok(())
4045    }
4046
4047    fn create_ec_user(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
4048        let props = &resource.properties;
4049        let user_id = props
4050            .get("UserId")
4051            .and_then(|v| v.as_str())
4052            .unwrap_or(&resource.logical_id)
4053            .to_string();
4054        let user_name = props
4055            .get("UserName")
4056            .and_then(|v| v.as_str())
4057            .unwrap_or(&user_id)
4058            .to_string();
4059        let engine = props
4060            .get("Engine")
4061            .and_then(|v| v.as_str())
4062            .unwrap_or("redis")
4063            .to_string();
4064        let access_string = props
4065            .get("AccessString")
4066            .and_then(|v| v.as_str())
4067            .unwrap_or("on ~* +@all")
4068            .to_string();
4069        let authentication_type = props
4070            .get("AuthenticationMode")
4071            .and_then(|v| v.get("Type"))
4072            .and_then(|v| v.as_str())
4073            .unwrap_or("no-password-required")
4074            .to_string();
4075        let arn = format!(
4076            "arn:aws:elasticache:{}:{}:user:{}",
4077            self.region, self.account_id, user_id
4078        );
4079        let user = EcUser {
4080            user_id: user_id.clone(),
4081            user_name,
4082            engine,
4083            access_string,
4084            status: "active".to_string(),
4085            authentication_type,
4086            password_count: 0,
4087            arn: arn.clone(),
4088            minimum_engine_version: "6.0".to_string(),
4089            user_group_ids: Vec::new(),
4090        };
4091        let mut accounts = self.elasticache_state.write();
4092        let state = accounts.get_or_create(&self.account_id);
4093        state.users.insert(user_id.clone(), user);
4094        Ok(ProvisionResult::new(user_id).with("Arn", arn))
4095    }
4096
4097    fn delete_ec_user(&self, physical_id: &str) -> Result<(), String> {
4098        let mut accounts = self.elasticache_state.write();
4099        let state = accounts.get_or_create(&self.account_id);
4100        state.users.remove(physical_id);
4101        Ok(())
4102    }
4103
4104    fn create_ec_user_group(
4105        &self,
4106        resource: &ResourceDefinition,
4107    ) -> Result<ProvisionResult, String> {
4108        let props = &resource.properties;
4109        let user_group_id = props
4110            .get("UserGroupId")
4111            .and_then(|v| v.as_str())
4112            .unwrap_or(&resource.logical_id)
4113            .to_string();
4114        let engine = props
4115            .get("Engine")
4116            .and_then(|v| v.as_str())
4117            .unwrap_or("redis")
4118            .to_string();
4119        let user_ids: Vec<String> = props
4120            .get("UserIds")
4121            .and_then(|v| v.as_array())
4122            .map(|arr| {
4123                arr.iter()
4124                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
4125                    .collect()
4126            })
4127            .unwrap_or_default();
4128        let arn = format!(
4129            "arn:aws:elasticache:{}:{}:usergroup:{}",
4130            self.region, self.account_id, user_group_id
4131        );
4132        let group = EcUserGroup {
4133            user_group_id: user_group_id.clone(),
4134            engine,
4135            status: "active".to_string(),
4136            user_ids,
4137            arn: arn.clone(),
4138            minimum_engine_version: "6.0".to_string(),
4139            pending_changes: None,
4140            replication_groups: Vec::new(),
4141        };
4142        let mut accounts = self.elasticache_state.write();
4143        let state = accounts.get_or_create(&self.account_id);
4144        state.user_groups.insert(user_group_id.clone(), group);
4145        Ok(ProvisionResult::new(user_group_id).with("Arn", arn))
4146    }
4147
4148    fn delete_ec_user_group(&self, physical_id: &str) -> Result<(), String> {
4149        let mut accounts = self.elasticache_state.write();
4150        let state = accounts.get_or_create(&self.account_id);
4151        state.user_groups.remove(physical_id);
4152        Ok(())
4153    }
4154
4155    fn create_ec_cache_cluster(
4156        &self,
4157        resource: &ResourceDefinition,
4158    ) -> Result<ProvisionResult, String> {
4159        let props = &resource.properties;
4160        let id = props
4161            .get("ClusterName")
4162            .and_then(|v| v.as_str())
4163            .map(String::from)
4164            .unwrap_or_else(|| format!("cfn-cc-{}", resource.logical_id.to_lowercase()));
4165        let cache_node_type = props
4166            .get("CacheNodeType")
4167            .and_then(|v| v.as_str())
4168            .unwrap_or("cache.t4g.micro")
4169            .to_string();
4170        let engine = props
4171            .get("Engine")
4172            .and_then(|v| v.as_str())
4173            .unwrap_or("redis")
4174            .to_string();
4175        let engine_version = props
4176            .get("EngineVersion")
4177            .and_then(|v| v.as_str())
4178            .unwrap_or("7.1")
4179            .to_string();
4180        let num_cache_nodes = props
4181            .get("NumCacheNodes")
4182            .and_then(|v| v.as_i64())
4183            .map(|n| n as i32)
4184            .unwrap_or(1);
4185        let preferred_az = props
4186            .get("PreferredAvailabilityZone")
4187            .and_then(|v| v.as_str())
4188            .unwrap_or("us-east-1a")
4189            .to_string();
4190        let cache_subnet_group_name = props
4191            .get("CacheSubnetGroupName")
4192            .and_then(|v| v.as_str())
4193            .map(String::from);
4194        let auto_minor_version_upgrade = props
4195            .get("AutoMinorVersionUpgrade")
4196            .and_then(|v| v.as_bool())
4197            .unwrap_or(true);
4198        let default_port = if engine == "memcached" { 11211 } else { 6379 };
4199        let port = props
4200            .get("Port")
4201            .and_then(|v| v.as_i64())
4202            .map(|n| n as u16)
4203            .unwrap_or(default_port);
4204        let cache_parameter_group_name = props
4205            .get("CacheParameterGroupName")
4206            .and_then(|v| v.as_str())
4207            .map(String::from);
4208        let security_group_ids: Vec<String> = props
4209            .get("VpcSecurityGroupIds")
4210            .and_then(|v| v.as_array())
4211            .map(|arr| {
4212                arr.iter()
4213                    .filter_map(|v| v.as_str().map(String::from))
4214                    .collect()
4215            })
4216            .unwrap_or_default();
4217        let cache_security_group_names: Vec<String> = props
4218            .get("CacheSecurityGroupNames")
4219            .and_then(|v| v.as_array())
4220            .map(|arr| {
4221                arr.iter()
4222                    .filter_map(|v| v.as_str().map(String::from))
4223                    .collect()
4224            })
4225            .unwrap_or_default();
4226        let preferred_availability_zones: Vec<String> = props
4227            .get("PreferredAvailabilityZones")
4228            .and_then(|v| v.as_array())
4229            .map(|arr| {
4230                arr.iter()
4231                    .filter_map(|v| v.as_str().map(String::from))
4232                    .collect()
4233            })
4234            .unwrap_or_default();
4235        let snapshot_arns: Vec<String> = props
4236            .get("SnapshotArns")
4237            .and_then(|v| v.as_array())
4238            .map(|arr| {
4239                arr.iter()
4240                    .filter_map(|v| v.as_str().map(String::from))
4241                    .collect()
4242            })
4243            .unwrap_or_default();
4244        let snapshot_name = props
4245            .get("SnapshotName")
4246            .and_then(|v| v.as_str())
4247            .map(String::from);
4248        let snapshot_retention_limit = props
4249            .get("SnapshotRetentionLimit")
4250            .and_then(|v| v.as_i64())
4251            .map(|n| n as i32)
4252            .unwrap_or(0);
4253        let snapshot_window = props
4254            .get("SnapshotWindow")
4255            .and_then(|v| v.as_str())
4256            .map(String::from);
4257        let preferred_maintenance_window = props
4258            .get("PreferredMaintenanceWindow")
4259            .and_then(|v| v.as_str())
4260            .map(String::from);
4261        let notification_topic_arn = props
4262            .get("NotificationTopicArn")
4263            .and_then(|v| v.as_str())
4264            .map(String::from);
4265        let transit_encryption_enabled = props
4266            .get("TransitEncryptionEnabled")
4267            .and_then(|v| v.as_bool())
4268            .unwrap_or(false);
4269        let auth_token = props
4270            .get("AuthToken")
4271            .and_then(|v| v.as_str())
4272            .filter(|s| !s.is_empty())
4273            .map(String::from);
4274        let auth_token_enabled = auth_token.is_some();
4275        let network_type = props
4276            .get("NetworkType")
4277            .and_then(|v| v.as_str())
4278            .map(String::from)
4279            .or_else(|| Some("ipv4".to_string()));
4280        let ip_discovery = props
4281            .get("IpDiscovery")
4282            .and_then(|v| v.as_str())
4283            .map(String::from)
4284            .or_else(|| Some("ipv4".to_string()));
4285        let az_mode = props
4286            .get("AZMode")
4287            .and_then(|v| v.as_str())
4288            .map(String::from)
4289            .or_else(|| Some("single-az".to_string()));
4290        let outpost_mode = props
4291            .get("OutpostMode")
4292            .and_then(|v| v.as_str())
4293            .map(String::from);
4294        let preferred_outpost_arn = props
4295            .get("PreferredOutpostArn")
4296            .and_then(|v| v.as_str())
4297            .map(String::from);
4298
4299        let mut accounts = self.elasticache_state.write();
4300        let state = accounts.get_or_create(&self.account_id);
4301        let arn = format!(
4302            "arn:aws:elasticache:{}:{}:cluster:{}",
4303            state.region, state.account_id, id
4304        );
4305        let endpoint_address = format!("{id}.fakecloud.{}.cache.amazonaws.com", state.region);
4306        let cluster = EcCacheCluster {
4307            cache_cluster_id: id.clone(),
4308            cache_node_type,
4309            engine,
4310            engine_version,
4311            cache_cluster_status: "available".to_string(),
4312            num_cache_nodes,
4313            preferred_availability_zone: preferred_az,
4314            cache_subnet_group_name,
4315            auto_minor_version_upgrade,
4316            arn: arn.clone(),
4317            created_at: Utc::now().to_rfc3339(),
4318            endpoint_address: endpoint_address.clone(),
4319            endpoint_port: port,
4320            container_id: String::new(),
4321            host_port: 0,
4322            replication_group_id: None,
4323            cache_parameter_group_name,
4324            security_group_ids,
4325            log_delivery_configurations: Vec::new(),
4326            transit_encryption_enabled,
4327            at_rest_encryption_enabled: false,
4328            auth_token_enabled,
4329            port,
4330            preferred_maintenance_window,
4331            preferred_availability_zones,
4332            notification_topic_arn,
4333            cache_security_group_names,
4334            snapshot_arns,
4335            snapshot_name,
4336            snapshot_retention_limit,
4337            snapshot_window,
4338            outpost_mode,
4339            preferred_outpost_arn,
4340            network_type,
4341            ip_discovery,
4342            az_mode,
4343            auth_token,
4344            kms_key_id: None,
4345            transit_encryption_mode: None,
4346            data_tiering_enabled: None,
4347            cluster_mode: None,
4348            preferred_outpost_arns: Vec::new(),
4349        };
4350        state.cache_clusters.insert(id.clone(), cluster);
4351        Ok(ProvisionResult::new(id.clone())
4352            .with("Arn", arn)
4353            .with("RedisEndpoint.Address", endpoint_address.clone())
4354            .with("RedisEndpoint.Port", port.to_string())
4355            .with("ConfigurationEndpoint.Address", endpoint_address)
4356            .with("ConfigurationEndpoint.Port", port.to_string()))
4357    }
4358
4359    fn delete_ec_cache_cluster(&self, physical_id: &str) -> Result<(), String> {
4360        let mut accounts = self.elasticache_state.write();
4361        let state = accounts.get_or_create(&self.account_id);
4362        state.cache_clusters.remove(physical_id);
4363        Ok(())
4364    }
4365
4366    fn create_ec_replication_group(
4367        &self,
4368        resource: &ResourceDefinition,
4369    ) -> Result<ProvisionResult, String> {
4370        let props = &resource.properties;
4371        let id = props
4372            .get("ReplicationGroupId")
4373            .and_then(|v| v.as_str())
4374            .map(String::from)
4375            .unwrap_or_else(|| format!("cfn-rg-{}", resource.logical_id.to_lowercase()));
4376        let description = props
4377            .get("ReplicationGroupDescription")
4378            .and_then(|v| v.as_str())
4379            .unwrap_or("CFN-provisioned replication group")
4380            .to_string();
4381        let cache_node_type = props
4382            .get("CacheNodeType")
4383            .and_then(|v| v.as_str())
4384            .unwrap_or("cache.t4g.micro")
4385            .to_string();
4386        let engine = props
4387            .get("Engine")
4388            .and_then(|v| v.as_str())
4389            .unwrap_or("redis")
4390            .to_string();
4391        let engine_version = props
4392            .get("EngineVersion")
4393            .and_then(|v| v.as_str())
4394            .unwrap_or("7.1")
4395            .to_string();
4396        let num_cache_clusters = props
4397            .get("NumCacheClusters")
4398            .and_then(|v| v.as_i64())
4399            .map(|n| n as i32)
4400            .unwrap_or(1);
4401        let num_node_groups = props
4402            .get("NumNodeGroups")
4403            .and_then(|v| v.as_i64())
4404            .map(|n| n as i32)
4405            .unwrap_or(0);
4406        let replicas_per_node_group = props
4407            .get("ReplicasPerNodeGroup")
4408            .and_then(|v| v.as_i64())
4409            .map(|n| n as i32);
4410        let automatic_failover_enabled = props
4411            .get("AutomaticFailoverEnabled")
4412            .and_then(|v| v.as_bool())
4413            .unwrap_or(false);
4414        let multi_az_enabled = props
4415            .get("MultiAZEnabled")
4416            .and_then(|v| v.as_bool())
4417            .unwrap_or(false);
4418        let transit_encryption_enabled = props
4419            .get("TransitEncryptionEnabled")
4420            .and_then(|v| v.as_bool())
4421            .unwrap_or(false);
4422        let at_rest_encryption_enabled = props
4423            .get("AtRestEncryptionEnabled")
4424            .and_then(|v| v.as_bool())
4425            .unwrap_or(false);
4426        let kms_key_id = props
4427            .get("KmsKeyId")
4428            .and_then(|v| v.as_str())
4429            .map(String::from);
4430        let auth_token_enabled = props
4431            .get("AuthToken")
4432            .and_then(|v| v.as_str())
4433            .filter(|s| !s.is_empty())
4434            .is_some();
4435        let user_group_ids: Vec<String> = props
4436            .get("UserGroupIds")
4437            .and_then(|v| v.as_array())
4438            .map(|arr| {
4439                arr.iter()
4440                    .filter_map(|v| v.as_str().map(String::from))
4441                    .collect()
4442            })
4443            .unwrap_or_default();
4444        let snapshot_retention_limit = props
4445            .get("SnapshotRetentionLimit")
4446            .and_then(|v| v.as_i64())
4447            .map(|n| n as i32)
4448            .unwrap_or(0);
4449        let snapshot_window = props
4450            .get("SnapshotWindow")
4451            .and_then(|v| v.as_str())
4452            .unwrap_or("00:00-01:00")
4453            .to_string();
4454        let port = props
4455            .get("Port")
4456            .and_then(|v| v.as_i64())
4457            .map(|n| n as u16)
4458            .unwrap_or(6379);
4459        let cluster_enabled = num_node_groups > 1
4460            || props
4461                .get("ClusterEnabled")
4462                .and_then(|v| v.as_bool())
4463                .unwrap_or(false);
4464
4465        let mut accounts = self.elasticache_state.write();
4466        let state = accounts.get_or_create(&self.account_id);
4467        let arn = format!(
4468            "arn:aws:elasticache:{}:{}:replicationgroup:{}",
4469            state.region, state.account_id, id
4470        );
4471        let endpoint_address = format!(
4472            "{id}.fakecloud.ng.0001.{}.cache.amazonaws.com",
4473            state.region
4474        );
4475        let configuration_endpoint = if cluster_enabled {
4476            Some(format!(
4477                "{id}.fakecloud.cfg.{}.cache.amazonaws.com",
4478                state.region
4479            ))
4480        } else {
4481            None
4482        };
4483
4484        let group = EcReplicationGroup {
4485            replication_group_id: id.clone(),
4486            description,
4487            global_replication_group_id: None,
4488            global_replication_group_role: None,
4489            status: "available".to_string(),
4490            cache_node_type,
4491            engine,
4492            engine_version,
4493            num_cache_clusters,
4494            automatic_failover_enabled,
4495            endpoint_address: endpoint_address.clone(),
4496            endpoint_port: port,
4497            arn: arn.clone(),
4498            created_at: Utc::now().to_rfc3339(),
4499            container_id: String::new(),
4500            host_port: 0,
4501            member_clusters: Vec::new(),
4502            snapshot_retention_limit,
4503            snapshot_window,
4504            transit_encryption_enabled,
4505            at_rest_encryption_enabled,
4506            cluster_enabled,
4507            kms_key_id,
4508            auth_token_enabled,
4509            user_group_ids,
4510            multi_az_enabled,
4511            log_delivery_configurations: Vec::new(),
4512            data_tiering: props
4513                .get("DataTieringEnabled")
4514                .and_then(|v| v.as_bool())
4515                .map(|b| if b { "enabled" } else { "disabled" }.to_string()),
4516            ip_discovery: props
4517                .get("IpDiscovery")
4518                .and_then(|v| v.as_str())
4519                .map(String::from),
4520            network_type: props
4521                .get("NetworkType")
4522                .and_then(|v| v.as_str())
4523                .map(String::from),
4524            transit_encryption_mode: props
4525                .get("TransitEncryptionMode")
4526                .and_then(|v| v.as_str())
4527                .map(String::from),
4528            num_node_groups,
4529            configuration_endpoint_address: configuration_endpoint.clone(),
4530            configuration_endpoint_port: configuration_endpoint.as_ref().map(|_| port),
4531            replicas_per_node_group,
4532            auth_token: props
4533                .get("AuthToken")
4534                .and_then(|v| v.as_str())
4535                .filter(|s| !s.is_empty())
4536                .map(String::from),
4537            port,
4538            notification_topic_arn: props
4539                .get("NotificationTopicArn")
4540                .and_then(|v| v.as_str())
4541                .map(String::from),
4542            cluster_mode: props
4543                .get("ClusterMode")
4544                .and_then(|v| v.as_str())
4545                .map(String::from),
4546            data_tiering_enabled: props.get("DataTieringEnabled").and_then(|v| v.as_bool()),
4547            notification_topic_status: None,
4548            cache_parameter_group_name: props
4549                .get("CacheParameterGroupName")
4550                .and_then(|v| v.as_str())
4551                .map(String::from),
4552            cache_subnet_group_name: props
4553                .get("CacheSubnetGroupName")
4554                .and_then(|v| v.as_str())
4555                .map(String::from),
4556            security_group_ids: props
4557                .get("SecurityGroupIds")
4558                .and_then(|v| v.as_array())
4559                .map(|arr| {
4560                    arr.iter()
4561                        .filter_map(|v| v.as_str().map(String::from))
4562                        .collect()
4563                })
4564                .unwrap_or_default(),
4565            preferred_maintenance_window: props
4566                .get("PreferredMaintenanceWindow")
4567                .and_then(|v| v.as_str())
4568                .map(String::from),
4569            snapshot_name: props
4570                .get("SnapshotName")
4571                .and_then(|v| v.as_str())
4572                .map(String::from),
4573            snapshot_arns: props
4574                .get("SnapshotArns")
4575                .and_then(|v| v.as_array())
4576                .map(|arr| {
4577                    arr.iter()
4578                        .filter_map(|v| v.as_str().map(String::from))
4579                        .collect()
4580                })
4581                .unwrap_or_default(),
4582            auto_minor_version_upgrade: props
4583                .get("AutoMinorVersionUpgrade")
4584                .and_then(|v| v.as_bool())
4585                .unwrap_or(true),
4586        };
4587        state.replication_groups.insert(id.clone(), group);
4588
4589        let mut result = ProvisionResult::new(id.clone())
4590            .with("Arn", arn)
4591            .with("PrimaryEndPoint.Address", endpoint_address.clone())
4592            .with("PrimaryEndPoint.Port", port.to_string())
4593            .with("ReadEndPoint.Addresses", endpoint_address.clone())
4594            .with("ReadEndPoint.Ports", port.to_string());
4595        if let Some(cfg) = configuration_endpoint {
4596            result = result
4597                .with("ConfigurationEndPoint.Address", cfg)
4598                .with("ConfigurationEndPoint.Port", port.to_string());
4599        }
4600        Ok(result)
4601    }
4602
4603    fn delete_ec_replication_group(&self, physical_id: &str) -> Result<(), String> {
4604        let mut accounts = self.elasticache_state.write();
4605        let state = accounts.get_or_create(&self.account_id);
4606        state.replication_groups.remove(physical_id);
4607        Ok(())
4608    }
4609
4610    // --- CloudFront ---
4611    //
4612    // CloudFront is a global service that stores data under the default
4613    // account bucket; mirror that here so SDK reads land on the same data
4614    // CFN wrote.
4615
4616    fn create_cf_origin_access_identity(
4617        &self,
4618        resource: &ResourceDefinition,
4619    ) -> Result<ProvisionResult, String> {
4620        let props = &resource.properties;
4621        let cfg = props
4622            .get("CloudFrontOriginAccessIdentityConfig")
4623            .ok_or("CloudFrontOriginAccessIdentityConfig is required")?;
4624        let comment = cfg
4625            .get("Comment")
4626            .and_then(|v| v.as_str())
4627            .unwrap_or("")
4628            .to_string();
4629        let caller_reference = format!("cfn-{}", resource.logical_id);
4630
4631        let id = format!(
4632            "E{}",
4633            Uuid::new_v4().simple().to_string()[..13].to_uppercase()
4634        );
4635        let etag = format!(
4636            "E{}",
4637            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
4638        );
4639        let s3_canonical_user_id = format!(
4640            "{:0<64}",
4641            Uuid::new_v4().simple().to_string().to_lowercase()
4642        );
4643
4644        let oai = StoredOriginAccessIdentity {
4645            id: id.clone(),
4646            etag,
4647            s3_canonical_user_id: s3_canonical_user_id.clone(),
4648            config: CloudFrontOriginAccessIdentityConfig {
4649                caller_reference,
4650                comment,
4651            },
4652        };
4653
4654        let mut accounts = self.cloudfront_state.write();
4655        let state = accounts.entry("000000000000");
4656        state.origin_access_identities.insert(id.clone(), oai);
4657
4658        Ok(ProvisionResult::new(id.clone())
4659            .with("Id", id)
4660            .with("S3CanonicalUserId", s3_canonical_user_id))
4661    }
4662
4663    fn delete_cf_origin_access_identity(&self, physical_id: &str) -> Result<(), String> {
4664        let mut accounts = self.cloudfront_state.write();
4665        let state = accounts.entry("000000000000");
4666        state.origin_access_identities.remove(physical_id);
4667        Ok(())
4668    }
4669
4670    /// Provision an `AWS::CloudFront::Distribution`. Reads
4671    /// DistributionConfig.Origins/DefaultCacheBehavior/etc. and persists
4672    /// a StoredDistribution in CloudFront state. CFN's Origins property
4673    /// is a flat array, so we wrap it back into the wire shape with a
4674    /// quantity + Items.Origin nesting.
4675    fn create_cf_distribution(
4676        &self,
4677        resource: &ResourceDefinition,
4678    ) -> Result<ProvisionResult, String> {
4679        let cfg = resource
4680            .properties
4681            .get("DistributionConfig")
4682            .ok_or_else(|| "DistributionConfig is required".to_string())?;
4683
4684        // CFN Origins is a flat JSON array; the wire shape is
4685        // { Quantity, Items: { Origin: [...] } }. Translate. CFN's
4686        // PascalCase doesn't always match the wire model — Origin's
4687        // CustomOriginConfig uses HTTPPort/HTTPSPort while the model
4688        // expects HttpPort/HttpsPort, so we patch a few well-known
4689        // renames before letting serde finish the parse.
4690        let origin_entries: Vec<Origin> = cfg
4691            .get("Origins")
4692            .and_then(|v| v.as_array())
4693            .ok_or_else(|| "DistributionConfig.Origins is required".to_string())?
4694            .iter()
4695            .map(|o| {
4696                let mut patched = o.clone();
4697                if let Some(custom) = patched
4698                    .get_mut("CustomOriginConfig")
4699                    .and_then(|v| v.as_object_mut())
4700                {
4701                    if let Some(v) = custom.remove("HTTPPort") {
4702                        custom.insert("HttpPort".to_string(), v);
4703                    }
4704                    if let Some(v) = custom.remove("HTTPSPort") {
4705                        custom.insert("HttpsPort".to_string(), v);
4706                    }
4707                }
4708                serde_json::from_value::<Origin>(patched)
4709                    .map_err(|e| format!("Invalid Origin entry: {e}"))
4710            })
4711            .collect::<Result<Vec<_>, _>>()?;
4712        if origin_entries.is_empty() {
4713            return Err("DistributionConfig.Origins must contain at least one origin".to_string());
4714        }
4715        let origins = Origins {
4716            quantity: origin_entries.len() as i32,
4717            items: Some(OriginItems {
4718                origin: origin_entries,
4719            }),
4720        };
4721
4722        let dcb_value = cfg
4723            .get("DefaultCacheBehavior")
4724            .ok_or_else(|| "DistributionConfig.DefaultCacheBehavior is required".to_string())?;
4725        let default_cache_behavior: DefaultCacheBehavior =
4726            serde_json::from_value(dcb_value.clone())
4727                .map_err(|e| format!("Invalid DefaultCacheBehavior: {e}"))?;
4728
4729        let comment = cfg
4730            .get("Comment")
4731            .and_then(|v| v.as_str())
4732            .unwrap_or("")
4733            .to_string();
4734        let enabled = cfg.get("Enabled").and_then(|v| v.as_bool()).unwrap_or(true);
4735        let price_class = cfg
4736            .get("PriceClass")
4737            .and_then(|v| v.as_str())
4738            .map(|s| s.to_string());
4739        let http_version = cfg
4740            .get("HttpVersion")
4741            .and_then(|v| v.as_str())
4742            .map(|s| s.to_string());
4743        let is_ipv6_enabled = cfg.get("IPV6Enabled").and_then(|v| v.as_bool());
4744        let default_root_object = cfg
4745            .get("DefaultRootObject")
4746            .and_then(|v| v.as_str())
4747            .map(|s| s.to_string());
4748        let web_acl_id = cfg
4749            .get("WebACLId")
4750            .and_then(|v| v.as_str())
4751            .map(|s| s.to_string());
4752
4753        let viewer_certificate: Option<ViewerCertificate> = cfg
4754            .get("ViewerCertificate")
4755            .map(|v| serde_json::from_value(v.clone()))
4756            .transpose()
4757            .map_err(|e| format!("Invalid ViewerCertificate: {e}"))?;
4758
4759        let caller_reference = format!("cfn-{}-{}", resource.logical_id, Uuid::new_v4().simple());
4760
4761        let mut config = DistributionConfig {
4762            caller_reference,
4763            comment,
4764            enabled,
4765            origins,
4766            default_cache_behavior,
4767            ..Default::default()
4768        };
4769        config.price_class = price_class;
4770        config.http_version = http_version;
4771        config.is_ipv6_enabled = is_ipv6_enabled;
4772        config.default_root_object = default_root_object;
4773        config.web_acl_id = web_acl_id;
4774        config.viewer_certificate = viewer_certificate;
4775
4776        // Mint distribution id + ARN + domain in the same shape the
4777        // CloudFront service uses.
4778        let id_suffix: String = Uuid::new_v4()
4779            .simple()
4780            .to_string()
4781            .chars()
4782            .take(13)
4783            .collect::<String>()
4784            .to_uppercase();
4785        let id = format!("E{id_suffix}");
4786        let etag_suffix: String = Uuid::new_v4()
4787            .simple()
4788            .to_string()
4789            .chars()
4790            .take(7)
4791            .collect::<String>()
4792            .to_uppercase();
4793        let etag = format!("E{etag_suffix}");
4794        let domain_name = format!("{}.cloudfront.net", id.to_lowercase());
4795        let arn = format!(
4796            "arn:aws:cloudfront::{}:distribution/{}",
4797            self.account_id, id
4798        );
4799
4800        let stored = StoredDistribution {
4801            id: id.clone(),
4802            arn: arn.clone(),
4803            // CloudFront flips this to Deployed on the first GetDistribution
4804            // poll, matching the rest of the service.
4805            status: "InProgress".to_string(),
4806            last_modified_time: Utc::now(),
4807            domain_name: domain_name.clone(),
4808            in_progress_invalidation_batches: 0,
4809            etag,
4810            config,
4811        };
4812
4813        let mut accounts = self.cloudfront_state.write();
4814        let state = accounts.entry("000000000000");
4815        state.distributions.insert(id.clone(), stored);
4816        Ok(ProvisionResult::new(id.clone())
4817            .with("Id", id)
4818            .with("DomainName", domain_name)
4819            .with("Arn", arn))
4820    }
4821
4822    fn delete_cf_distribution(&self, physical_id: &str) -> Result<(), String> {
4823        let mut accounts = self.cloudfront_state.write();
4824        let state = accounts.entry("000000000000");
4825        state.distributions.remove(physical_id);
4826        Ok(())
4827    }
4828
4829    fn create_cf_origin_access_control(
4830        &self,
4831        resource: &ResourceDefinition,
4832    ) -> Result<ProvisionResult, String> {
4833        let props = &resource.properties;
4834        let cfg = props
4835            .get("OriginAccessControlConfig")
4836            .ok_or("OriginAccessControlConfig is required")?;
4837        let name = cfg
4838            .get("Name")
4839            .and_then(|v| v.as_str())
4840            .ok_or("OriginAccessControlConfig.Name is required")?
4841            .to_string();
4842        let signing_protocol = cfg
4843            .get("SigningProtocol")
4844            .and_then(|v| v.as_str())
4845            .unwrap_or("sigv4")
4846            .to_string();
4847        let signing_behavior = cfg
4848            .get("SigningBehavior")
4849            .and_then(|v| v.as_str())
4850            .unwrap_or("always")
4851            .to_string();
4852        let origin_type = cfg
4853            .get("OriginAccessControlOriginType")
4854            .and_then(|v| v.as_str())
4855            .ok_or("OriginAccessControlConfig.OriginAccessControlOriginType is required")?
4856            .to_string();
4857        let description = cfg
4858            .get("Description")
4859            .and_then(|v| v.as_str())
4860            .map(String::from);
4861
4862        let id = format!(
4863            "E{}",
4864            Uuid::new_v4().simple().to_string()[..13].to_uppercase()
4865        );
4866        let etag = format!(
4867            "E{}",
4868            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
4869        );
4870        let oac = StoredOriginAccessControl {
4871            id: id.clone(),
4872            etag,
4873            config: OriginAccessControlConfig {
4874                name,
4875                description,
4876                signing_protocol,
4877                signing_behavior,
4878                origin_access_control_origin_type: origin_type,
4879            },
4880        };
4881
4882        let mut accounts = self.cloudfront_state.write();
4883        let state = accounts.entry("000000000000");
4884        state.origin_access_controls.insert(id.clone(), oac);
4885
4886        Ok(ProvisionResult::new(id.clone()).with("Id", id))
4887    }
4888
4889    fn delete_cf_origin_access_control(&self, physical_id: &str) -> Result<(), String> {
4890        let mut accounts = self.cloudfront_state.write();
4891        let state = accounts.entry("000000000000");
4892        state.origin_access_controls.remove(physical_id);
4893        Ok(())
4894    }
4895
4896    fn create_cf_public_key(
4897        &self,
4898        resource: &ResourceDefinition,
4899    ) -> Result<ProvisionResult, String> {
4900        let props = &resource.properties;
4901        let cfg = props
4902            .get("PublicKeyConfig")
4903            .ok_or("PublicKeyConfig is required")?;
4904        let name = cfg
4905            .get("Name")
4906            .and_then(|v| v.as_str())
4907            .ok_or("PublicKeyConfig.Name is required")?
4908            .to_string();
4909        let encoded_key = cfg
4910            .get("EncodedKey")
4911            .and_then(|v| v.as_str())
4912            .ok_or("PublicKeyConfig.EncodedKey is required")?
4913            .to_string();
4914        let comment = cfg
4915            .get("Comment")
4916            .and_then(|v| v.as_str())
4917            .map(String::from);
4918        let caller_reference = cfg
4919            .get("CallerReference")
4920            .and_then(|v| v.as_str())
4921            .unwrap_or("")
4922            .to_string();
4923        let caller_reference = if caller_reference.is_empty() {
4924            format!("cfn-{}", resource.logical_id)
4925        } else {
4926            caller_reference
4927        };
4928
4929        let id = format!(
4930            "K{}",
4931            Uuid::new_v4().simple().to_string()[..13].to_uppercase()
4932        );
4933        let etag = format!(
4934            "E{}",
4935            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
4936        );
4937
4938        let pk = StoredPublicKey {
4939            id: id.clone(),
4940            etag,
4941            created_time: Utc::now(),
4942            config: PublicKeyConfig {
4943                caller_reference,
4944                name,
4945                encoded_key,
4946                comment,
4947            },
4948        };
4949
4950        let mut accounts = self.cloudfront_state.write();
4951        let state = accounts.entry("000000000000");
4952        state.public_keys.insert(id.clone(), pk);
4953
4954        Ok(ProvisionResult::new(id.clone()).with("Id", id))
4955    }
4956
4957    fn delete_cf_public_key(&self, physical_id: &str) -> Result<(), String> {
4958        let mut accounts = self.cloudfront_state.write();
4959        let state = accounts.entry("000000000000");
4960        state.public_keys.remove(physical_id);
4961        Ok(())
4962    }
4963
4964    fn create_cf_key_group(
4965        &self,
4966        resource: &ResourceDefinition,
4967    ) -> Result<ProvisionResult, String> {
4968        let props = &resource.properties;
4969        let cfg = props
4970            .get("KeyGroupConfig")
4971            .ok_or("KeyGroupConfig is required")?;
4972        let name = cfg
4973            .get("Name")
4974            .and_then(|v| v.as_str())
4975            .ok_or("KeyGroupConfig.Name is required")?
4976            .to_string();
4977        let items: Vec<String> = cfg
4978            .get("Items")
4979            .and_then(|v| v.as_array())
4980            .map(|arr| {
4981                arr.iter()
4982                    .filter_map(|v| v.as_str().map(String::from))
4983                    .collect()
4984            })
4985            .unwrap_or_default();
4986        let comment = cfg
4987            .get("Comment")
4988            .and_then(|v| v.as_str())
4989            .map(String::from);
4990
4991        let id = format!(
4992            "KG{}",
4993            Uuid::new_v4().simple().to_string()[..12].to_uppercase()
4994        );
4995        let etag = format!(
4996            "E{}",
4997            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
4998        );
4999
5000        let kg = StoredKeyGroup {
5001            id: id.clone(),
5002            etag,
5003            last_modified_time: Utc::now(),
5004            config: KeyGroupConfig {
5005                name,
5006                items: KeyGroupItems { public_key: items },
5007                comment,
5008            },
5009        };
5010
5011        let mut accounts = self.cloudfront_state.write();
5012        let state = accounts.entry("000000000000");
5013        state.key_groups.insert(id.clone(), kg);
5014
5015        Ok(ProvisionResult::new(id.clone()).with("Id", id))
5016    }
5017
5018    fn delete_cf_key_group(&self, physical_id: &str) -> Result<(), String> {
5019        let mut accounts = self.cloudfront_state.write();
5020        let state = accounts.entry("000000000000");
5021        state.key_groups.remove(physical_id);
5022        Ok(())
5023    }
5024
5025    fn create_cf_function(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
5026        let props = &resource.properties;
5027        let name = props
5028            .get("Name")
5029            .and_then(|v| v.as_str())
5030            .ok_or("Name is required")?
5031            .to_string();
5032        let function_code = props
5033            .get("FunctionCode")
5034            .and_then(|v| v.as_str())
5035            .ok_or("FunctionCode is required")?
5036            .to_string();
5037        let cfg = props
5038            .get("FunctionConfig")
5039            .ok_or("FunctionConfig is required")?;
5040        let runtime = cfg
5041            .get("Runtime")
5042            .and_then(|v| v.as_str())
5043            .unwrap_or("cloudfront-js-2.0")
5044            .to_string();
5045        let comment = cfg
5046            .get("Comment")
5047            .and_then(|v| v.as_str())
5048            .map(String::from);
5049
5050        let id = format!(
5051            "FN{}",
5052            Uuid::new_v4().simple().to_string()[..12].to_uppercase()
5053        );
5054        let etag = format!(
5055            "E{}",
5056            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
5057        );
5058        let function_arn =
5059            Arn::global("cloudfront", &self.account_id, &format!("function/{name}")).to_string();
5060
5061        let now = Utc::now();
5062        let func = StoredFunction {
5063            name: name.clone(),
5064            etag,
5065            status: "UNPUBLISHED".to_string(),
5066            stage: "DEVELOPMENT".to_string(),
5067            function_arn: function_arn.clone(),
5068            created_time: now,
5069            last_modified_time: now,
5070            config: FunctionConfig {
5071                comment,
5072                runtime,
5073                key_value_store_associations: None,
5074            },
5075            function_code,
5076            live_function_code: None,
5077        };
5078
5079        let mut accounts = self.cloudfront_state.write();
5080        let state = accounts.entry("000000000000");
5081        // Use the function's ARN/name as the registry key so subsequent
5082        // operations (Get/Update/Delete) keyed by name resolve.
5083        state.functions.insert(name.clone(), func);
5084
5085        Ok(ProvisionResult::new(name.clone())
5086            .with("FunctionARN", function_arn)
5087            .with("FunctionMetadata.FunctionARN", id)
5088            .with("Stage", "DEVELOPMENT"))
5089    }
5090
5091    fn delete_cf_function(&self, physical_id: &str) -> Result<(), String> {
5092        let mut accounts = self.cloudfront_state.write();
5093        let state = accounts.entry("000000000000");
5094        state.functions.remove(physical_id);
5095        Ok(())
5096    }
5097
5098    fn create_cf_cache_policy(
5099        &self,
5100        resource: &ResourceDefinition,
5101    ) -> Result<ProvisionResult, String> {
5102        let props = &resource.properties;
5103        let cfg = props
5104            .get("CachePolicyConfig")
5105            .ok_or("CachePolicyConfig is required")?;
5106        let name = cfg
5107            .get("Name")
5108            .and_then(|v| v.as_str())
5109            .ok_or("CachePolicyConfig.Name is required")?
5110            .to_string();
5111        let min_ttl = cfg
5112            .get("MinTTL")
5113            .and_then(|v| {
5114                v.as_i64()
5115                    .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
5116            })
5117            .unwrap_or(0);
5118        let default_ttl = cfg.get("DefaultTTL").and_then(|v| {
5119            v.as_i64()
5120                .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
5121        });
5122        let max_ttl = cfg.get("MaxTTL").and_then(|v| {
5123            v.as_i64()
5124                .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
5125        });
5126        let comment = cfg
5127            .get("Comment")
5128            .and_then(|v| v.as_str())
5129            .map(String::from);
5130
5131        let id = format!(
5132            "CP{}",
5133            Uuid::new_v4().simple().to_string()[..12].to_uppercase()
5134        );
5135        let etag = format!(
5136            "E{}",
5137            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
5138        );
5139
5140        let cache_policy = StoredCachePolicy {
5141            id: id.clone(),
5142            etag,
5143            last_modified_time: Utc::now(),
5144            config: CachePolicyConfig {
5145                comment,
5146                name,
5147                default_ttl,
5148                max_ttl,
5149                min_ttl,
5150                parameters_in_cache_key_and_forwarded_to_origin: None,
5151            },
5152            policy_type: "custom".to_string(),
5153        };
5154
5155        let mut accounts = self.cloudfront_state.write();
5156        let state = accounts.entry("000000000000");
5157        state.cache_policies.insert(id.clone(), cache_policy);
5158
5159        Ok(ProvisionResult::new(id.clone()).with("Id", id))
5160    }
5161
5162    fn delete_cf_cache_policy(&self, physical_id: &str) -> Result<(), String> {
5163        let mut accounts = self.cloudfront_state.write();
5164        let state = accounts.entry("000000000000");
5165        state.cache_policies.remove(physical_id);
5166        Ok(())
5167    }
5168
5169    fn create_cf_origin_request_policy(
5170        &self,
5171        resource: &ResourceDefinition,
5172    ) -> Result<ProvisionResult, String> {
5173        let props = &resource.properties;
5174        let cfg = props
5175            .get("OriginRequestPolicyConfig")
5176            .ok_or("OriginRequestPolicyConfig is required")?;
5177        let name = cfg
5178            .get("Name")
5179            .and_then(|v| v.as_str())
5180            .ok_or("OriginRequestPolicyConfig.Name is required")?
5181            .to_string();
5182        let header_behavior = cfg
5183            .get("HeadersConfig")
5184            .and_then(|v| v.get("HeaderBehavior"))
5185            .and_then(|v| v.as_str())
5186            .unwrap_or("none")
5187            .to_string();
5188        let cookie_behavior = cfg
5189            .get("CookiesConfig")
5190            .and_then(|v| v.get("CookieBehavior"))
5191            .and_then(|v| v.as_str())
5192            .unwrap_or("none")
5193            .to_string();
5194        let query_string_behavior = cfg
5195            .get("QueryStringsConfig")
5196            .and_then(|v| v.get("QueryStringBehavior"))
5197            .and_then(|v| v.as_str())
5198            .unwrap_or("none")
5199            .to_string();
5200        let comment = cfg
5201            .get("Comment")
5202            .and_then(|v| v.as_str())
5203            .map(String::from);
5204
5205        let id = format!(
5206            "ORP{}",
5207            Uuid::new_v4().simple().to_string()[..11].to_uppercase()
5208        );
5209        let etag = format!(
5210            "E{}",
5211            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
5212        );
5213
5214        let policy = StoredOriginRequestPolicy {
5215            id: id.clone(),
5216            etag,
5217            last_modified_time: Utc::now(),
5218            config: OriginRequestPolicyConfig {
5219                comment,
5220                name,
5221                headers_config: OriginRequestPolicyHeadersConfig {
5222                    header_behavior,
5223                    headers: None,
5224                },
5225                cookies_config: OriginRequestPolicyCookiesConfig {
5226                    cookie_behavior,
5227                    cookies: None,
5228                },
5229                query_strings_config: OriginRequestPolicyQueryStringsConfig {
5230                    query_string_behavior,
5231                    query_strings: None,
5232                },
5233            },
5234            policy_type: "custom".to_string(),
5235        };
5236
5237        let mut accounts = self.cloudfront_state.write();
5238        let state = accounts.entry("000000000000");
5239        state.origin_request_policies.insert(id.clone(), policy);
5240
5241        Ok(ProvisionResult::new(id.clone()).with("Id", id))
5242    }
5243
5244    fn delete_cf_origin_request_policy(&self, physical_id: &str) -> Result<(), String> {
5245        let mut accounts = self.cloudfront_state.write();
5246        let state = accounts.entry("000000000000");
5247        state.origin_request_policies.remove(physical_id);
5248        Ok(())
5249    }
5250
5251    fn create_cf_response_headers_policy(
5252        &self,
5253        resource: &ResourceDefinition,
5254    ) -> Result<ProvisionResult, String> {
5255        let props = &resource.properties;
5256        let cfg = props
5257            .get("ResponseHeadersPolicyConfig")
5258            .ok_or("ResponseHeadersPolicyConfig is required")?;
5259        let name = cfg
5260            .get("Name")
5261            .and_then(|v| v.as_str())
5262            .ok_or("ResponseHeadersPolicyConfig.Name is required")?
5263            .to_string();
5264        let comment = cfg
5265            .get("Comment")
5266            .and_then(|v| v.as_str())
5267            .map(String::from);
5268
5269        let id = format!(
5270            "RHP{}",
5271            Uuid::new_v4().simple().to_string()[..11].to_uppercase()
5272        );
5273        let etag = format!(
5274            "E{}",
5275            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
5276        );
5277
5278        let policy = StoredResponseHeadersPolicy {
5279            id: id.clone(),
5280            etag,
5281            last_modified_time: Utc::now(),
5282            config: ResponseHeadersPolicyConfig {
5283                comment,
5284                name,
5285                cors_config: None,
5286                security_headers_config: None,
5287                server_timing_headers_config: None,
5288                custom_headers_config: None,
5289                remove_headers_config: None,
5290            },
5291            policy_type: "custom".to_string(),
5292        };
5293
5294        let mut accounts = self.cloudfront_state.write();
5295        let state = accounts.entry("000000000000");
5296        state.response_headers_policies.insert(id.clone(), policy);
5297
5298        Ok(ProvisionResult::new(id.clone()).with("Id", id))
5299    }
5300
5301    fn delete_cf_response_headers_policy(&self, physical_id: &str) -> Result<(), String> {
5302        let mut accounts = self.cloudfront_state.write();
5303        let state = accounts.entry("000000000000");
5304        state.response_headers_policies.remove(physical_id);
5305        Ok(())
5306    }
5307
5308    fn parse_athena_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
5309        let mut out = BTreeMap::new();
5310        let Some(arr) = value.and_then(|v| v.as_array()) else {
5311            return out;
5312        };
5313        for tag in arr {
5314            if let (Some(k), Some(v)) = (
5315                tag.get("Key").and_then(|v| v.as_str()),
5316                tag.get("Value").and_then(|v| v.as_str()),
5317            ) {
5318                out.insert(k.to_string(), v.to_string());
5319            }
5320        }
5321        out
5322    }
5323
5324    fn fetch_template_from_url(&self, url: &str) -> Result<String, String> {
5325        if let Some(rest) = url.strip_prefix("s3://") {
5326            let parts: Vec<&str> = rest.splitn(2, '/').collect();
5327            if parts.len() != 2 {
5328                return Err("Invalid s3:// URL".to_string());
5329            }
5330            return self.fetch_s3_template(parts[0], parts[1]);
5331        }
5332
5333        if let Some(rest) = url.strip_prefix("https://s3.amazonaws.com/") {
5334            let parts: Vec<&str> = rest.splitn(2, '/').collect();
5335            if parts.len() != 2 {
5336                return Err("Invalid S3 HTTPS URL".to_string());
5337            }
5338            return self.fetch_s3_template(parts[0], parts[1]);
5339        }
5340
5341        if let Some(host_rest) = url.strip_prefix("https://") {
5342            if let Some(slash_pos) = host_rest.find('/') {
5343                let host = &host_rest[..slash_pos];
5344                let key = &host_rest[slash_pos + 1..];
5345                if let Some(bucket) = host.strip_suffix(".s3.amazonaws.com") {
5346                    return self.fetch_s3_template(bucket, key);
5347                }
5348                if host.contains(".s3.") && host.ends_with(".amazonaws.com") {
5349                    let bucket = host.split(".s3.").next().unwrap_or("");
5350                    if !bucket.is_empty() {
5351                        return self.fetch_s3_template(bucket, key);
5352                    }
5353                }
5354            }
5355        }
5356
5357        Err(format!("Unsupported TemplateURL: {url}"))
5358    }
5359
5360    fn fetch_s3_template(&self, bucket: &str, key: &str) -> Result<String, String> {
5361        let mut s3_accounts = self.s3_state.write();
5362        let s3_state = s3_accounts.get_or_create(&self.account_id);
5363        let bucket_obj = s3_state
5364            .buckets
5365            .get(bucket)
5366            .ok_or_else(|| format!("S3 bucket not found: {bucket}"))?;
5367        let obj = bucket_obj
5368            .objects
5369            .get(key)
5370            .ok_or_else(|| format!("S3 object not found: {bucket}/{key}"))?;
5371        let bytes = s3_state
5372            .read_body(&obj.body)
5373            .map_err(|e| format!("Failed to read S3 object body: {e}"))?;
5374        String::from_utf8(bytes.to_vec()).map_err(|e| format!("S3 object is not valid UTF-8: {e}"))
5375    }
5376}
5377
5378/// Implements the CloudFormation `GenerateSecretString` shape on
5379/// `AWS::SecretsManager::Secret`. Produces the plaintext payload that
5380/// will become the AWSCURRENT version of the secret.
5381///
5382/// When `SecretStringTemplate` is set together with `GenerateStringKey`,
5383/// the generated password is inserted under `GenerateStringKey` in the
5384/// JSON template (this is the standard "DB credential" pattern).
5385/// Without those two, the bare generated password is returned.
5386fn generate_secret_string_payload(gen: &serde_json::Value) -> Result<String, String> {
5387    let length = gen
5388        .get("PasswordLength")
5389        .and_then(|v| v.as_i64())
5390        .unwrap_or(32) as usize;
5391    let exclude_lowercase = gen
5392        .get("ExcludeLowercase")
5393        .and_then(|v| v.as_bool())
5394        .unwrap_or(false);
5395    let exclude_uppercase = gen
5396        .get("ExcludeUppercase")
5397        .and_then(|v| v.as_bool())
5398        .unwrap_or(false);
5399    let exclude_numbers = gen
5400        .get("ExcludeNumbers")
5401        .and_then(|v| v.as_bool())
5402        .unwrap_or(false);
5403    let exclude_punctuation = gen
5404        .get("ExcludePunctuation")
5405        .and_then(|v| v.as_bool())
5406        .unwrap_or(false);
5407    let include_space = gen
5408        .get("IncludeSpace")
5409        .and_then(|v| v.as_bool())
5410        .unwrap_or(false);
5411    let exclude_chars = gen
5412        .get("ExcludeCharacters")
5413        .and_then(|v| v.as_str())
5414        .unwrap_or("")
5415        .to_string();
5416
5417    let lowercase = "abcdefghijklmnopqrstuvwxyz";
5418    let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
5419    let digits = "0123456789";
5420    let punctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
5421
5422    let mut pool = String::new();
5423    if !exclude_lowercase {
5424        pool.extend(lowercase.chars().filter(|c| !exclude_chars.contains(*c)));
5425    }
5426    if !exclude_uppercase {
5427        pool.extend(uppercase.chars().filter(|c| !exclude_chars.contains(*c)));
5428    }
5429    if !exclude_numbers {
5430        pool.extend(digits.chars().filter(|c| !exclude_chars.contains(*c)));
5431    }
5432    if !exclude_punctuation {
5433        pool.extend(punctuation.chars().filter(|c| !exclude_chars.contains(*c)));
5434    }
5435    if include_space && !exclude_chars.contains(' ') {
5436        pool.push(' ');
5437    }
5438    if pool.is_empty() {
5439        return Err("GenerateSecretString character pool is empty".to_string());
5440    }
5441
5442    let pool_chars: Vec<char> = pool.chars().collect();
5443    let mut password = String::with_capacity(length);
5444    let mut counter: u64 = std::time::SystemTime::now()
5445        .duration_since(std::time::UNIX_EPOCH)
5446        .map(|d| d.as_nanos() as u64)
5447        .unwrap_or(0);
5448    while password.len() < length {
5449        // Splitmix64 — deterministic, no_std-friendly, good enough for
5450        // fake-cloud password generation. Real entropy isn't a goal here.
5451        counter = counter.wrapping_add(0x9E3779B97F4A7C15);
5452        let mut z = counter;
5453        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
5454        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
5455        z ^= z >> 31;
5456        let idx = (z as usize) % pool_chars.len();
5457        password.push(pool_chars[idx]);
5458    }
5459
5460    let template = gen.get("SecretStringTemplate").and_then(|v| v.as_str());
5461    let key = gen.get("GenerateStringKey").and_then(|v| v.as_str());
5462    match (template, key) {
5463        (Some(tmpl), Some(k)) => {
5464            let mut value: serde_json::Value = serde_json::from_str(tmpl)
5465                .map_err(|e| format!("SecretStringTemplate is not valid JSON: {e}"))?;
5466            if let Some(obj) = value.as_object_mut() {
5467                obj.insert(k.to_string(), serde_json::Value::String(password));
5468                Ok(value.to_string())
5469            } else {
5470                Err("SecretStringTemplate must be a JSON object".to_string())
5471            }
5472        }
5473        _ => Ok(password),
5474    }
5475}
5476
5477fn parse_ses_receipt_action(value: &serde_json::Value) -> Option<SesReceiptAction> {
5478    let obj = value.as_object()?;
5479    if let Some(s3) = obj.get("S3Action").and_then(|v| v.as_object()) {
5480        let bucket_name = s3.get("BucketName").and_then(|v| v.as_str())?.to_string();
5481        return Some(SesReceiptAction::S3 {
5482            bucket_name,
5483            object_key_prefix: s3
5484                .get("ObjectKeyPrefix")
5485                .and_then(|v| v.as_str())
5486                .map(String::from),
5487            topic_arn: s3
5488                .get("TopicArn")
5489                .and_then(|v| v.as_str())
5490                .map(String::from),
5491            kms_key_arn: s3
5492                .get("KmsKeyArn")
5493                .and_then(|v| v.as_str())
5494                .map(String::from),
5495        });
5496    }
5497    if let Some(sns) = obj.get("SNSAction").and_then(|v| v.as_object()) {
5498        return Some(SesReceiptAction::Sns {
5499            topic_arn: sns.get("TopicArn").and_then(|v| v.as_str())?.to_string(),
5500            encoding: sns
5501                .get("Encoding")
5502                .and_then(|v| v.as_str())
5503                .map(String::from),
5504        });
5505    }
5506    if let Some(la) = obj.get("LambdaAction").and_then(|v| v.as_object()) {
5507        return Some(SesReceiptAction::Lambda {
5508            function_arn: la.get("FunctionArn").and_then(|v| v.as_str())?.to_string(),
5509            invocation_type: la
5510                .get("InvocationType")
5511                .and_then(|v| v.as_str())
5512                .map(String::from),
5513            topic_arn: la
5514                .get("TopicArn")
5515                .and_then(|v| v.as_str())
5516                .map(String::from),
5517        });
5518    }
5519    if let Some(b) = obj.get("BounceAction").and_then(|v| v.as_object()) {
5520        return Some(SesReceiptAction::Bounce {
5521            smtp_reply_code: b
5522                .get("SmtpReplyCode")
5523                .and_then(|v| v.as_str())
5524                .unwrap_or("550")
5525                .to_string(),
5526            message: b
5527                .get("Message")
5528                .and_then(|v| v.as_str())
5529                .unwrap_or("")
5530                .to_string(),
5531            sender: b
5532                .get("Sender")
5533                .and_then(|v| v.as_str())
5534                .unwrap_or("")
5535                .to_string(),
5536            status_code: b
5537                .get("StatusCode")
5538                .and_then(|v| v.as_str())
5539                .map(String::from),
5540            topic_arn: b.get("TopicArn").and_then(|v| v.as_str()).map(String::from),
5541        });
5542    }
5543    if let Some(ah) = obj.get("AddHeaderAction").and_then(|v| v.as_object()) {
5544        return Some(SesReceiptAction::AddHeader {
5545            header_name: ah.get("HeaderName").and_then(|v| v.as_str())?.to_string(),
5546            header_value: ah.get("HeaderValue").and_then(|v| v.as_str())?.to_string(),
5547        });
5548    }
5549    if let Some(s) = obj.get("StopAction").and_then(|v| v.as_object()) {
5550        return Some(SesReceiptAction::Stop {
5551            scope: s
5552                .get("Scope")
5553                .and_then(|v| v.as_str())
5554                .unwrap_or("RuleSet")
5555                .to_string(),
5556            topic_arn: s.get("TopicArn").and_then(|v| v.as_str()).map(String::from),
5557        });
5558    }
5559    None
5560}
5561
5562/// Generate an N-character alphanumeric id for API Gateway v2 resources.
5563/// AWS HTTP API ids are 10 chars; routes/integrations/etc are similarly
5564/// short. This mirrors the runtime crate's id shape.
5565fn make_apigwv2_id(n: usize) -> String {
5566    let s = uuid::Uuid::new_v4().simple().to_string();
5567    s[..n.min(s.len())].to_string()
5568}
5569
5570/// Lowercase the first letter of each key in a JSON object, recursively.
5571/// CloudFormation property names are PascalCase (`BurstLimit`,
5572/// `RateLimit`); the runtime API Gateway service stores values keyed in
5573/// camelCase (`burstLimit`, `rateLimit`). Used at the CFN/service
5574/// boundary so JSON pulled from the template can flow into the runtime
5575/// state without renaming each leaf by hand.
5576/// Coerce a CFN-resolved Number/String parameter into i64. CFN
5577/// parameters surface here as `Value::String` after `Ref` substitution,
5578/// even when the parameter `Type` is `Number`, so we need both paths.
5579fn cfn_as_i64(v: &serde_json::Value) -> Option<i64> {
5580    if let Some(n) = v.as_i64() {
5581        return Some(n);
5582    }
5583    v.as_str().and_then(|s| s.parse::<i64>().ok())
5584}
5585
5586fn lowercase_first_keys(value: serde_json::Value) -> serde_json::Value {
5587    match value {
5588        serde_json::Value::Object(map) => {
5589            let mut out = serde_json::Map::new();
5590            for (k, v) in map {
5591                let new_key = if let Some(first) = k.chars().next() {
5592                    let mut s = String::with_capacity(k.len());
5593                    s.extend(first.to_lowercase());
5594                    s.push_str(&k[first.len_utf8()..]);
5595                    s
5596                } else {
5597                    k
5598                };
5599                out.insert(new_key, lowercase_first_keys(v));
5600            }
5601            serde_json::Value::Object(out)
5602        }
5603        serde_json::Value::Array(arr) => {
5604            serde_json::Value::Array(arr.into_iter().map(lowercase_first_keys).collect())
5605        }
5606        other => other,
5607    }
5608}
5609
5610/// Synthesize the per-domain DNS validation record list for a
5611/// CFN-provisioned cert. Mirrors the runtime ACM path: each name gets
5612/// a SUCCESS record (since CFN-issued certs are auto-validated above)
5613/// and an `_amzn-validations.<domain>.` resource record so callers
5614/// that read DescribeCertificate see the same shape they'd expect
5615/// from a real ACM-issued cert.
5616fn synth_acm_domain_validation(
5617    domain_name: &str,
5618    sans: &[String],
5619    validation_method: &str,
5620) -> Vec<AcmDomainValidation> {
5621    let mut all = vec![domain_name.to_string()];
5622    for s in sans {
5623        if !all.contains(s) {
5624            all.push(s.clone());
5625        }
5626    }
5627    all.into_iter()
5628        .map(|name| AcmDomainValidation {
5629            domain_name: name.clone(),
5630            validation_status: "SUCCESS".to_string(),
5631            validation_method: validation_method.to_string(),
5632            resource_record_name: Some(format!("_amzn-validations.{name}.")),
5633            resource_record_type: Some("CNAME".to_string()),
5634            resource_record_value: Some(format!("{}.acm-validations.aws.", Uuid::new_v4())),
5635        })
5636        .collect()
5637}
5638
5639/// Convert CFN `Tags` array into the ACM crate's tag map form.
5640fn parse_acm_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
5641    let mut out = BTreeMap::new();
5642    if let Some(arr) = value.and_then(|v| v.as_array()) {
5643        for t in arr {
5644            if let (Some(k), Some(v)) = (
5645                t.get("Key").and_then(|v| v.as_str()),
5646                t.get("Value").and_then(|v| v.as_str()),
5647            ) {
5648                out.insert(k.to_string(), v.to_string());
5649            }
5650        }
5651    }
5652    out
5653}
5654
5655/// Convert CFN `Tags` array into the ECS `TagEntry` form.
5656fn parse_ecs_tags(value: Option<&serde_json::Value>) -> Vec<EcsTagEntry> {
5657    let Some(arr) = value.and_then(|v| v.as_array()) else {
5658        return Vec::new();
5659    };
5660    arr.iter()
5661        .filter_map(|t| {
5662            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
5663            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
5664            Some(EcsTagEntry { key, value })
5665        })
5666        .collect()
5667}
5668
5669/// Strip the cluster ARN prefix when CFN passes a Ref-resolved name or
5670/// a GetAtt-resolved ARN; ECS internal state keys clusters by name.
5671fn parse_ecs_cluster_name(input: &str) -> String {
5672    if let Some(after) = input.split(":cluster/").nth(1) {
5673        return after.to_string();
5674    }
5675    input.to_string()
5676}
5677
5678/// Pull `(family, revision)` out of a task definition ARN tail like
5679/// `task-definition/web:3`. Returns `(family, revision)` or
5680/// `(input, 1)` for unrecognised shapes.
5681fn parse_td_arn(input: &str) -> (String, i32) {
5682    let suffix = input.rsplit('/').next().unwrap_or(input);
5683    if let Some((family, rev)) = suffix.split_once(':') {
5684        if let Ok(revision) = rev.parse::<i32>() {
5685            return (family.to_string(), revision);
5686        }
5687    }
5688    (input.to_string(), 1)
5689}
5690
5691/// Pull `(cluster, service)` out of a service ARN like
5692/// `arn:aws:ecs:us-east-1:000000000000:service/<cluster>/<service>`.
5693fn parse_service_arn(input: &str) -> Option<(String, String)> {
5694    let after = input.split(":service/").nth(1)?;
5695    let mut parts = after.splitn(2, '/');
5696    let cluster = parts.next()?.to_string();
5697    let service = parts.next()?.to_string();
5698    Some((cluster, service))
5699}
5700
5701/// Parse CFN-shape Tags array into the RDS crate's tag form.
5702fn parse_rds_tags(value: Option<&serde_json::Value>) -> Vec<RdsTag> {
5703    let Some(arr) = value.and_then(|v| v.as_array()) else {
5704        return Vec::new();
5705    };
5706    arr.iter()
5707        .filter_map(|t| {
5708            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
5709            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
5710            Some(RdsTag { key, value })
5711        })
5712        .collect()
5713}
5714
5715/// Lazy-create an entry in the RDS `extras` bucket so the provisioner
5716/// doesn't have to re-implement the `BTreeMap::entry` boilerplate per
5717/// resource type.
5718fn rds_extras_mut<'a>(
5719    state: &'a mut fakecloud_rds::RdsState,
5720    category: &str,
5721) -> &'a mut BTreeMap<String, serde_json::Value> {
5722    state.extras.entry(category.to_string()).or_default()
5723}
5724
5725/// Parse a JSON array-of-strings property. Returns empty Vec when the
5726/// value is missing or shaped wrong; matches the tolerant input handling
5727/// used by the runtime Cognito service.
5728fn parse_cognito_string_array(value: Option<&serde_json::Value>) -> Vec<String> {
5729    value
5730        .and_then(|v| v.as_array())
5731        .map(|arr| {
5732            arr.iter()
5733                .filter_map(|v| v.as_str().map(|s| s.to_string()))
5734                .collect()
5735        })
5736        .unwrap_or_default()
5737}
5738
5739fn parse_cognito_password_policy(value: Option<&serde_json::Value>) -> PasswordPolicy {
5740    let Some(inner) = value
5741        .and_then(|v| v.get("PasswordPolicy"))
5742        .and_then(|v| v.as_object())
5743    else {
5744        return PasswordPolicy::default();
5745    };
5746    let mut p = PasswordPolicy::default();
5747    if let Some(n) = inner.get("MinimumLength").and_then(|v| v.as_i64()) {
5748        p.minimum_length = n;
5749    }
5750    if let Some(b) = inner.get("RequireUppercase").and_then(|v| v.as_bool()) {
5751        p.require_uppercase = b;
5752    }
5753    if let Some(b) = inner.get("RequireLowercase").and_then(|v| v.as_bool()) {
5754        p.require_lowercase = b;
5755    }
5756    if let Some(b) = inner.get("RequireNumbers").and_then(|v| v.as_bool()) {
5757        p.require_numbers = b;
5758    }
5759    if let Some(b) = inner.get("RequireSymbols").and_then(|v| v.as_bool()) {
5760        p.require_symbols = b;
5761    }
5762    if let Some(n) = inner
5763        .get("TemporaryPasswordValidityDays")
5764        .and_then(|v| v.as_i64())
5765    {
5766        p.temporary_password_validity_days = n;
5767    }
5768    p
5769}
5770
5771fn parse_cognito_schema_attribute(value: &serde_json::Value) -> Option<SchemaAttribute> {
5772    let name = value.get("Name").and_then(|v| v.as_str())?.to_string();
5773    Some(SchemaAttribute {
5774        name,
5775        attribute_data_type: value
5776            .get("AttributeDataType")
5777            .and_then(|v| v.as_str())
5778            .unwrap_or("String")
5779            .to_string(),
5780        developer_only_attribute: value
5781            .get("DeveloperOnlyAttribute")
5782            .and_then(|v| v.as_bool())
5783            .unwrap_or(false),
5784        mutable: value
5785            .get("Mutable")
5786            .and_then(|v| v.as_bool())
5787            .unwrap_or(true),
5788        required: value
5789            .get("Required")
5790            .and_then(|v| v.as_bool())
5791            .unwrap_or(false),
5792        string_attribute_constraints: None,
5793        number_attribute_constraints: None,
5794    })
5795}
5796
5797fn parse_cognito_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
5798    let mut out = BTreeMap::new();
5799    if let Some(obj) = value.and_then(|v| v.as_object()) {
5800        for (k, v) in obj {
5801            if let Some(s) = v.as_str() {
5802                out.insert(k.clone(), s.to_string());
5803            }
5804        }
5805    }
5806    out
5807}
5808
5809fn parse_cognito_email_configuration(
5810    value: Option<&serde_json::Value>,
5811) -> Option<EmailConfiguration> {
5812    let inner = value?.as_object()?;
5813    Some(EmailConfiguration {
5814        source_arn: inner
5815            .get("SourceArn")
5816            .and_then(|v| v.as_str())
5817            .map(|s| s.to_string()),
5818        reply_to_email_address: inner
5819            .get("ReplyToEmailAddress")
5820            .and_then(|v| v.as_str())
5821            .map(|s| s.to_string()),
5822        email_sending_account: inner
5823            .get("EmailSendingAccount")
5824            .and_then(|v| v.as_str())
5825            .map(|s| s.to_string()),
5826        from_email_address: inner
5827            .get("From")
5828            .and_then(|v| v.as_str())
5829            .map(|s| s.to_string()),
5830        configuration_set: inner
5831            .get("ConfigurationSet")
5832            .and_then(|v| v.as_str())
5833            .map(|s| s.to_string()),
5834    })
5835}
5836
5837fn parse_cognito_sms_configuration(value: Option<&serde_json::Value>) -> Option<SmsConfiguration> {
5838    let inner = value?.as_object()?;
5839    Some(SmsConfiguration {
5840        sns_caller_arn: inner
5841            .get("SnsCallerArn")
5842            .and_then(|v| v.as_str())
5843            .map(|s| s.to_string()),
5844        external_id: inner
5845            .get("ExternalId")
5846            .and_then(|v| v.as_str())
5847            .map(|s| s.to_string()),
5848        sns_region: inner
5849            .get("SnsRegion")
5850            .and_then(|v| v.as_str())
5851            .map(|s| s.to_string()),
5852    })
5853}
5854
5855fn parse_cognito_admin_create_user_config(
5856    value: Option<&serde_json::Value>,
5857) -> Option<AdminCreateUserConfig> {
5858    let inner = value?.as_object()?;
5859    Some(AdminCreateUserConfig {
5860        allow_admin_create_user_only: inner
5861            .get("AllowAdminCreateUserOnly")
5862            .and_then(|v| v.as_bool()),
5863        invite_message_template: None,
5864        unused_account_validity_days: inner
5865            .get("UnusedAccountValidityDays")
5866            .and_then(|v| v.as_i64()),
5867    })
5868}
5869
5870fn parse_cognito_account_recovery(
5871    value: Option<&serde_json::Value>,
5872) -> Option<AccountRecoverySetting> {
5873    let arr = value?.get("RecoveryMechanisms")?.as_array()?;
5874    Some(AccountRecoverySetting {
5875        recovery_mechanisms: arr
5876            .iter()
5877            .filter_map(|m| {
5878                let name = m.get("Name").and_then(|v| v.as_str())?.to_string();
5879                let priority = m.get("Priority").and_then(|v| v.as_i64()).unwrap_or(1);
5880                Some(RecoveryOption { name, priority })
5881            })
5882            .collect(),
5883    })
5884}
5885
5886fn parse_firehose_s3_destination(value: &serde_json::Value) -> Result<S3Destination, String> {
5887    let role_arn = value
5888        .get("RoleARN")
5889        .and_then(|v| v.as_str())
5890        .ok_or("S3 destination requires RoleARN")?
5891        .to_string();
5892    let bucket_arn = value
5893        .get("BucketARN")
5894        .and_then(|v| v.as_str())
5895        .ok_or("S3 destination requires BucketARN")?
5896        .to_string();
5897    let prefix = value
5898        .get("Prefix")
5899        .and_then(|v| v.as_str())
5900        .map(|s| s.to_string());
5901    let error_output_prefix = value
5902        .get("ErrorOutputPrefix")
5903        .and_then(|v| v.as_str())
5904        .map(|s| s.to_string());
5905    let mut buffering_size_mb = None;
5906    let mut buffering_interval_seconds = None;
5907    if let Some(hints) = value.get("BufferingHints") {
5908        buffering_size_mb = hints.get("SizeInMBs").and_then(|v| v.as_i64());
5909        buffering_interval_seconds = hints.get("IntervalInSeconds").and_then(|v| v.as_i64());
5910    }
5911    let compression_format = value
5912        .get("CompressionFormat")
5913        .and_then(|v| v.as_str())
5914        .map(|s| s.to_string());
5915
5916    Ok(S3Destination {
5917        destination_id: "destination-1".to_string(),
5918        role_arn,
5919        bucket_arn,
5920        prefix,
5921        error_output_prefix,
5922        buffering_size_mb,
5923        buffering_interval_seconds,
5924        compression_format,
5925        processing_configuration: None,
5926        data_format_conversion_configuration: None,
5927    })
5928}
5929
5930#[cfg(test)]
5931mod tests {
5932    use super::*;
5933    use parking_lot::RwLock;
5934
5935    fn make_provisioner() -> ResourceProvisioner {
5936        ResourceProvisioner {
5937            sqs_state: Arc::new(RwLock::new(
5938                fakecloud_core::multi_account::MultiAccountState::new(
5939                    "123456789012",
5940                    "us-east-1",
5941                    "http://localhost:4566",
5942                ),
5943            )),
5944            sns_state: Arc::new(RwLock::new(
5945                fakecloud_core::multi_account::MultiAccountState::new(
5946                    "123456789012",
5947                    "us-east-1",
5948                    "http://localhost:4566",
5949                ),
5950            )),
5951            ssm_state: Arc::new(RwLock::new(
5952                fakecloud_core::multi_account::MultiAccountState::new(
5953                    "123456789012",
5954                    "us-east-1",
5955                    "http://localhost:4566",
5956                ),
5957            )),
5958            iam_state: Arc::new(RwLock::new(
5959                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", "http://localhost:4566"),
5960            )),
5961            s3_state: Arc::new(RwLock::new(fakecloud_core::multi_account::MultiAccountState::new(
5962                "123456789012",
5963                "us-east-1", "",
5964            ))),
5965            eventbridge_state: Arc::new(RwLock::new(
5966                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5967            )),
5968            dynamodb_state: Arc::new(RwLock::new(fakecloud_core::multi_account::MultiAccountState::new(
5969                "123456789012",
5970                "us-east-1", "",
5971            ))),
5972            logs_state: Arc::new(RwLock::new(
5973                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5974            )),
5975            lambda_state: Arc::new(RwLock::new(
5976                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5977            )),
5978            secretsmanager_state: Arc::new(RwLock::new(
5979                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5980            )),
5981            kinesis_state: Arc::new(RwLock::new(
5982                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5983            )),
5984            kms_state: Arc::new(RwLock::new(
5985                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5986            )),
5987            ecr_state: Arc::new(RwLock::new(
5988                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5989            )),
5990            cloudwatch_state: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
5991            elbv2_state: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
5992            organizations_state: Arc::new(RwLock::new(None)),
5993            cognito_state: Arc::new(RwLock::new(
5994                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5995            )),
5996            rds_state: Arc::new(RwLock::new(
5997                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
5998            )),
5999            ecs_state: Arc::new(RwLock::new(
6000                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6001            )),
6002            acm_state: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
6003            elasticache_state: Arc::new(RwLock::new(
6004                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6005            )),
6006            route53_state: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
6007            cloudfront_state: Arc::new(RwLock::new(
6008                fakecloud_cloudfront::CloudFrontAccounts::new(),
6009            )),
6010            cloudformation_state: Arc::new(RwLock::new(
6011                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
6012            )),
6013            stepfunctions_state: Arc::new(RwLock::new(
6014                fakecloud_core::multi_account::MultiAccountState::new(
6015                    "123456789012",
6016                    "us-east-1",
6017                    "",
6018                ),
6019            )),
6020            wafv2_state: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
6021            apigateway_state: Arc::new(RwLock::new(
6022                fakecloud_core::multi_account::MultiAccountState::new(
6023                    "123456789012",
6024                    "us-east-1",
6025                    "",
6026                ),
6027            )),
6028            apigatewayv2_state: Arc::new(RwLock::new(
6029                fakecloud_core::multi_account::MultiAccountState::new(
6030                    "123456789012",
6031                    "us-east-1",
6032                    "",
6033                ),
6034            )),
6035            ses_state: Arc::new(RwLock::new(
6036                fakecloud_core::multi_account::MultiAccountState::new(
6037                    "123456789012",
6038                    "us-east-1",
6039                    "",
6040                ),
6041            )),
6042            app_autoscaling_state: Arc::new(parking_lot::RwLock::new(
6043                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
6044            )),
6045            athena_state: Arc::new(parking_lot::RwLock::new(
6046                fakecloud_athena::AthenaAccounts::new(),
6047            )),
6048            firehose_state: Arc::new(parking_lot::RwLock::new(
6049                fakecloud_firehose::FirehoseAccounts::new(),
6050            )),
6051            glue_state: Arc::new(parking_lot::RwLock::new(
6052                fakecloud_glue::GlueAccounts::new(),
6053            )),
6054            delivery: Arc::new(DeliveryBus::new()),
6055            lambda_runtime: None,
6056            s3_store: Arc::new(fakecloud_persistence::s3::MemoryS3Store::new()),
6057            account_id: "123456789012".to_string(),
6058            region: "us-east-1".to_string(),
6059            stack_id: "arn:aws:cloudformation:us-east-1:123456789012:stack/test/00000000-0000-0000-0000-000000000000".to_string(),
6060        }
6061    }
6062
6063    fn make_resource(
6064        resource_type: &str,
6065        logical_id: &str,
6066        props: serde_json::Value,
6067    ) -> ResourceDefinition {
6068        ResourceDefinition {
6069            logical_id: logical_id.to_string(),
6070            resource_type: resource_type.to_string(),
6071            properties: props,
6072        }
6073    }
6074
6075    #[test]
6076    fn unknown_resource_type_records_instead_of_failing() {
6077        let prov = make_provisioner();
6078        // A real AWS type fakecloud has no provisioner for. It must NOT fail
6079        // the stack — it's recorded as provisioned with no backing state, and
6080        // `Ref` (its physical id) resolves to a stable value (the logical id).
6081        let sr = prov
6082            .create_resource(&make_resource(
6083                "AWS::CloudFormation::WaitConditionHandle",
6084                "Handle",
6085                serde_json::json!({}),
6086            ))
6087            .expect("unknown resource type should record, not fail");
6088        assert_eq!(sr.physical_id, "Handle");
6089        assert_eq!(sr.status, "CREATE_COMPLETE");
6090        // Teardown of a recorded no-op resource also succeeds.
6091        prov.delete_resource(&sr)
6092            .expect("delete no-op should succeed");
6093    }
6094
6095    #[test]
6096    fn ecr_repository_uri_uses_bound_endpoint_not_public_dns() {
6097        // A CFN-provisioned RepositoryUri must point at fakecloud's own registry
6098        // endpoint, not the public AWS DNS, so `docker push` against the GetAtt
6099        // RepositoryUri reaches it (bug-audit 2026-06-20, 1.4).
6100        let prov = make_provisioner();
6101        let sr = prov
6102            .create_resource(&make_resource(
6103                "AWS::ECR::Repository",
6104                "Repo",
6105                serde_json::json!({ "RepositoryName": "my-repo" }),
6106            ))
6107            .expect("ECR repo provisions");
6108        let uri = sr
6109            .attributes
6110            .get("RepositoryUri")
6111            .expect("RepositoryUri attribute");
6112        assert!(
6113            !uri.contains("amazonaws.com"),
6114            "RepositoryUri must not use the public ECR DNS, got {uri}"
6115        );
6116        assert!(uri.contains("my-repo"), "uri should name the repo: {uri}");
6117    }
6118
6119    #[test]
6120    fn sns_subscription_rejects_nonexistent_topic() {
6121        let prov = make_provisioner();
6122        let resource = make_resource(
6123            "AWS::SNS::Subscription",
6124            "MySub",
6125            serde_json::json!({
6126                "TopicArn": "arn:aws:sns:us-east-1:123456789012:NonExistent",
6127                "Protocol": "sqs",
6128                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
6129            }),
6130        );
6131        let result = prov.create_resource(&resource);
6132        assert!(result.is_err());
6133        assert!(result.unwrap_err().contains("does not exist"));
6134    }
6135
6136    #[test]
6137    fn sns_subscription_succeeds_when_topic_exists() {
6138        let prov = make_provisioner();
6139        // First create the topic
6140        let topic = make_resource(
6141            "AWS::SNS::Topic",
6142            "MyTopic",
6143            serde_json::json!({ "TopicName": "my-topic" }),
6144        );
6145        let topic_result = prov.create_resource(&topic);
6146        assert!(topic_result.is_ok());
6147        let topic_arn = topic_result.unwrap().physical_id;
6148
6149        // Now create subscription referencing that topic
6150        let sub = make_resource(
6151            "AWS::SNS::Subscription",
6152            "MySub",
6153            serde_json::json!({
6154                "TopicArn": topic_arn,
6155                "Protocol": "sqs",
6156                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
6157            }),
6158        );
6159        let result = prov.create_resource(&sub);
6160        assert!(result.is_ok());
6161    }
6162
6163    #[test]
6164    fn eventbridge_rule_arn_default_bus_omits_bus_name() {
6165        let prov = make_provisioner();
6166        let resource = make_resource(
6167            "AWS::Events::Rule",
6168            "MyRule",
6169            serde_json::json!({
6170                "Name": "my-rule",
6171                "ScheduleExpression": "rate(1 hour)"
6172            }),
6173        );
6174        let result = prov.create_resource(&resource).unwrap();
6175        // For default bus, ARN should be rule/<name> without /default/
6176        assert_eq!(
6177            result.physical_id,
6178            "arn:aws:events:us-east-1:123456789012:rule/my-rule"
6179        );
6180        assert!(!result.physical_id.contains("rule/default/"));
6181    }
6182
6183    #[test]
6184    fn eventbridge_rule_arn_custom_bus_includes_bus_name() {
6185        let prov = make_provisioner();
6186        // Create a custom bus first
6187        {
6188            let mut eb_accounts = prov.eventbridge_state.write();
6189            let state = eb_accounts.default_mut();
6190            state.buses.insert(
6191                "custom-bus".to_string(),
6192                fakecloud_eventbridge::EventBus {
6193                    name: "custom-bus".to_string(),
6194                    arn: "arn:aws:events:us-east-1:123456789012:event-bus/custom-bus".to_string(),
6195                    policy: None,
6196                    creation_time: Utc::now(),
6197                    last_modified_time: Utc::now(),
6198                    description: None,
6199                    kms_key_identifier: None,
6200                    dead_letter_config: None,
6201                    tags: std::collections::BTreeMap::new(),
6202                },
6203            );
6204        }
6205        let resource = make_resource(
6206            "AWS::Events::Rule",
6207            "MyRule",
6208            serde_json::json!({
6209                "Name": "my-rule",
6210                "EventBusName": "custom-bus",
6211                "ScheduleExpression": "rate(1 hour)"
6212            }),
6213        );
6214        let result = prov.create_resource(&resource).unwrap();
6215        assert_eq!(
6216            result.physical_id,
6217            "arn:aws:events:us-east-1:123456789012:rule/custom-bus/my-rule"
6218        );
6219    }
6220
6221    #[test]
6222    fn eventbridge_rule_rejects_nonexistent_bus() {
6223        let prov = make_provisioner();
6224        let resource = make_resource(
6225            "AWS::Events::Rule",
6226            "MyRule",
6227            serde_json::json!({
6228                "Name": "my-rule",
6229                "EventBusName": "nonexistent-bus",
6230                "ScheduleExpression": "rate(1 hour)"
6231            }),
6232        );
6233        let result = prov.create_resource(&resource);
6234        assert!(result.is_err());
6235        assert!(result.unwrap_err().contains("does not exist"));
6236    }
6237
6238    #[test]
6239    fn custom_resource_requires_service_token() {
6240        let prov = make_provisioner();
6241        let resource = make_resource(
6242            "Custom::MyResource",
6243            "MyCustom",
6244            serde_json::json!({
6245                "Foo": "bar"
6246            }),
6247        );
6248        let result = prov.create_resource(&resource);
6249        assert!(result.is_err());
6250        assert!(
6251            result.unwrap_err().contains("ServiceToken"),
6252            "Should require ServiceToken property"
6253        );
6254    }
6255
6256    #[test]
6257    fn custom_resource_succeeds_without_lambda_delivery() {
6258        // When no Lambda delivery is configured, custom resource creation
6259        // should still succeed (the invocation is silently skipped).
6260        let prov = make_provisioner();
6261        let resource = make_resource(
6262            "Custom::MyResource",
6263            "MyCustom",
6264            serde_json::json!({
6265                "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:my-func",
6266                "Foo": "bar"
6267            }),
6268        );
6269        let result = prov.create_resource(&resource);
6270        assert!(result.is_ok());
6271        let sr = result.unwrap();
6272        assert_eq!(sr.logical_id, "MyCustom");
6273        assert_eq!(sr.resource_type, "Custom::MyResource");
6274        assert!(sr.physical_id.starts_with("MyCustom-"));
6275    }
6276
6277    #[test]
6278    fn cloudformation_custom_resource_type_succeeds() {
6279        let prov = make_provisioner();
6280        let resource = make_resource(
6281            "AWS::CloudFormation::CustomResource",
6282            "MyCustom2",
6283            serde_json::json!({
6284                "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:my-func",
6285                "Key": "value"
6286            }),
6287        );
6288        let result = prov.create_resource(&resource);
6289        assert!(result.is_ok());
6290        let sr = result.unwrap();
6291        assert_eq!(sr.resource_type, "AWS::CloudFormation::CustomResource");
6292    }
6293
6294    // ── Resource create/delete lifecycle tests ──
6295
6296    #[test]
6297    fn sqs_queue_create_and_delete() {
6298        let prov = make_provisioner();
6299        let res = make_resource(
6300            "AWS::SQS::Queue",
6301            "MyQ",
6302            serde_json::json!({"QueueName": "my-q"}),
6303        );
6304        let sr = prov.create_resource(&res).unwrap();
6305        assert!(sr.physical_id.contains("my-q"));
6306        assert_eq!(sr.resource_type, "AWS::SQS::Queue");
6307        prov.delete_resource(&sr).unwrap();
6308    }
6309
6310    #[test]
6311    fn sqs_queue_fifo_with_suffix() {
6312        let prov = make_provisioner();
6313        let res = make_resource(
6314            "AWS::SQS::Queue",
6315            "FifoQ",
6316            serde_json::json!({"QueueName": "my-fifo.fifo", "FifoQueue": true}),
6317        );
6318        let sr = prov.create_resource(&res).unwrap();
6319        assert!(sr.physical_id.contains(".fifo"));
6320    }
6321
6322    #[test]
6323    fn sns_topic_create_and_delete() {
6324        let prov = make_provisioner();
6325        let res = make_resource(
6326            "AWS::SNS::Topic",
6327            "MyTopic",
6328            serde_json::json!({"TopicName": "t1"}),
6329        );
6330        let sr = prov.create_resource(&res).unwrap();
6331        assert!(sr.physical_id.contains("t1"));
6332        prov.delete_resource(&sr).unwrap();
6333    }
6334
6335    #[test]
6336    fn ssm_parameter_create_and_delete() {
6337        let prov = make_provisioner();
6338        let res = make_resource(
6339            "AWS::SSM::Parameter",
6340            "MyParam",
6341            serde_json::json!({
6342                "Name": "/my/param",
6343                "Type": "String",
6344                "Value": "v1"
6345            }),
6346        );
6347        let sr = prov.create_resource(&res).unwrap();
6348        assert_eq!(sr.physical_id, "/my/param");
6349        prov.delete_resource(&sr).unwrap();
6350    }
6351
6352    #[test]
6353    fn iam_role_create_and_delete() {
6354        let prov = make_provisioner();
6355        let res = make_resource(
6356            "AWS::IAM::Role",
6357            "MyRole",
6358            serde_json::json!({
6359                "RoleName": "my-role",
6360                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
6361            }),
6362        );
6363        let sr = prov.create_resource(&res).unwrap();
6364        assert!(sr.physical_id.contains("my-role"));
6365        prov.delete_resource(&sr).unwrap();
6366    }
6367
6368    #[test]
6369    fn iam_policy_create_and_delete() {
6370        let prov = make_provisioner();
6371        let res = make_resource(
6372            "AWS::IAM::Policy",
6373            "MyPolicy",
6374            serde_json::json!({
6375                "PolicyName": "my-policy",
6376                "PolicyDocument": {"Version": "2012-10-17", "Statement": []}
6377            }),
6378        );
6379        let sr = prov.create_resource(&res).unwrap();
6380        assert!(sr.physical_id.contains("my-policy"));
6381        prov.delete_resource(&sr).unwrap();
6382    }
6383
6384    #[test]
6385    fn s3_bucket_create_and_delete() {
6386        let prov = make_provisioner();
6387        let res = make_resource(
6388            "AWS::S3::Bucket",
6389            "MyBucket",
6390            serde_json::json!({"BucketName": "my-bucket"}),
6391        );
6392        let sr = prov.create_resource(&res).unwrap();
6393        assert_eq!(sr.physical_id, "my-bucket");
6394        prov.delete_resource(&sr).unwrap();
6395    }
6396
6397    #[test]
6398    fn sqs_queue_policy_stored_on_queue_and_cleared_on_delete() {
6399        let prov = make_provisioner();
6400        let queue = prov
6401            .create_resource(&make_resource(
6402                "AWS::SQS::Queue",
6403                "Q",
6404                serde_json::json!({"QueueName": "q1"}),
6405            ))
6406            .unwrap();
6407        // `Queues` carries the queue URL — what `Ref` resolves to.
6408        let policy = make_resource(
6409            "AWS::SQS::QueuePolicy",
6410            "QP",
6411            serde_json::json!({
6412                "Queues": [queue.physical_id.clone()],
6413                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
6414                    "Effect": "Allow",
6415                    "Principal": {"Service": "sns.amazonaws.com"},
6416                    "Action": "sqs:SendMessage",
6417                    "Resource": "*"
6418                }]}
6419            }),
6420        );
6421        let sr = prov.create_resource(&policy).unwrap();
6422
6423        {
6424            let mut accounts = prov.sqs_state.write();
6425            let state = accounts.get_or_create(&prov.account_id);
6426            let stored = state.queues[&queue.physical_id]
6427                .attributes
6428                .get("Policy")
6429                .expect("policy stored on queue");
6430            assert!(stored.contains("sqs:SendMessage"));
6431        }
6432
6433        prov.delete_resource(&sr).unwrap();
6434        {
6435            let mut accounts = prov.sqs_state.write();
6436            let state = accounts.get_or_create(&prov.account_id);
6437            assert!(!state.queues[&queue.physical_id]
6438                .attributes
6439                .contains_key("Policy"));
6440        }
6441    }
6442
6443    #[test]
6444    fn sns_topic_policy_stored_on_topic_and_cleared_on_delete() {
6445        let prov = make_provisioner();
6446        let topic = prov
6447            .create_resource(&make_resource(
6448                "AWS::SNS::Topic",
6449                "T",
6450                serde_json::json!({"TopicName": "t1"}),
6451            ))
6452            .unwrap();
6453        let policy = make_resource(
6454            "AWS::SNS::TopicPolicy",
6455            "TP",
6456            serde_json::json!({
6457                "Topics": [topic.physical_id.clone()],
6458                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
6459                    "Effect": "Allow",
6460                    "Principal": {"Service": "events.amazonaws.com"},
6461                    "Action": "sns:Publish",
6462                    "Resource": "*"
6463                }]}
6464            }),
6465        );
6466        let sr = prov.create_resource(&policy).unwrap();
6467
6468        {
6469            let mut accounts = prov.sns_state.write();
6470            let state = accounts.get_or_create(&prov.account_id);
6471            let stored = state.topics[&topic.physical_id]
6472                .attributes
6473                .get("Policy")
6474                .expect("policy stored on topic");
6475            assert!(stored.contains("sns:Publish"));
6476        }
6477
6478        prov.delete_resource(&sr).unwrap();
6479        {
6480            let mut accounts = prov.sns_state.write();
6481            let state = accounts.get_or_create(&prov.account_id);
6482            assert!(!state.topics[&topic.physical_id]
6483                .attributes
6484                .contains_key("Policy"));
6485        }
6486    }
6487
6488    #[test]
6489    fn s3_bucket_policy_stored_on_bucket_and_cleared_on_delete() {
6490        let prov = make_provisioner();
6491        let bucket = prov
6492            .create_resource(&make_resource(
6493                "AWS::S3::Bucket",
6494                "B",
6495                serde_json::json!({"BucketName": "b1"}),
6496            ))
6497            .unwrap();
6498        let policy = make_resource(
6499            "AWS::S3::BucketPolicy",
6500            "BP",
6501            serde_json::json!({
6502                "Bucket": bucket.physical_id.clone(),
6503                "PolicyDocument": {"Version": "2012-10-17", "Statement": [{
6504                    "Effect": "Allow",
6505                    "Principal": "*",
6506                    "Action": "s3:GetObject",
6507                    "Resource": "arn:aws:s3:::b1/*"
6508                }]}
6509            }),
6510        );
6511        let sr = prov.create_resource(&policy).unwrap();
6512        assert_eq!(sr.physical_id, "b1-policy");
6513
6514        {
6515            let mut accounts = prov.s3_state.write();
6516            let state = accounts.get_or_create(&prov.account_id);
6517            let stored = state.buckets[&bucket.physical_id]
6518                .policy
6519                .as_ref()
6520                .expect("policy stored on bucket");
6521            assert!(stored.contains("s3:GetObject"));
6522        }
6523
6524        prov.delete_resource(&sr).unwrap();
6525        {
6526            let mut accounts = prov.s3_state.write();
6527            let state = accounts.get_or_create(&prov.account_id);
6528            assert!(state.buckets[&bucket.physical_id].policy.is_none());
6529        }
6530    }
6531
6532    #[test]
6533    fn dynamodb_table_create_and_delete() {
6534        let prov = make_provisioner();
6535        let res = make_resource(
6536            "AWS::DynamoDB::Table",
6537            "MyTable",
6538            serde_json::json!({
6539                "TableName": "my-table",
6540                "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
6541                "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
6542                "BillingMode": "PAY_PER_REQUEST"
6543            }),
6544        );
6545        let sr = prov.create_resource(&res).unwrap();
6546        assert!(sr.physical_id.contains("my-table"));
6547        prov.delete_resource(&sr).unwrap();
6548    }
6549
6550    #[test]
6551    fn log_group_create_and_delete() {
6552        let prov = make_provisioner();
6553        let res = make_resource(
6554            "AWS::Logs::LogGroup",
6555            "MyLogs",
6556            serde_json::json!({"LogGroupName": "/app/logs"}),
6557        );
6558        let sr = prov.create_resource(&res).unwrap();
6559        assert!(sr.physical_id.contains("/app/logs"));
6560        prov.delete_resource(&sr).unwrap();
6561    }
6562
6563    #[test]
6564    fn lambda_function_create_and_delete() {
6565        let prov = make_provisioner();
6566        let res = make_resource(
6567            "AWS::Lambda::Function",
6568            "MyFn",
6569            serde_json::json!({
6570                "FunctionName": "my-fn",
6571                "Runtime": "nodejs20.x",
6572                "Role": "arn:aws:iam::123456789012:role/lambda-role",
6573                "Handler": "index.handler",
6574                "MemorySize": 256,
6575                "Timeout": 10,
6576                "Environment": {"Variables": {"FOO": "bar"}}
6577            }),
6578        );
6579        let sr = prov.create_resource(&res).unwrap();
6580        assert_eq!(sr.physical_id, "my-fn");
6581        assert_eq!(
6582            sr.attributes.get("Arn").map(String::as_str),
6583            Some("arn:aws:lambda:us-east-1:123456789012:function:my-fn")
6584        );
6585        // Verify it landed in lambda state
6586        {
6587            let lam = prov.lambda_state.read();
6588            let st = lam.get("123456789012").unwrap();
6589            let f = st.functions.get("my-fn").unwrap();
6590            assert_eq!(f.runtime, "nodejs20.x");
6591            assert_eq!(f.memory_size, 256);
6592            assert_eq!(f.environment.get("FOO").unwrap(), "bar");
6593        }
6594        prov.delete_resource(&sr).unwrap();
6595        let lam = prov.lambda_state.read();
6596        let st = lam.get("123456789012").unwrap();
6597        assert!(!st.functions.contains_key("my-fn"));
6598    }
6599
6600    #[test]
6601    fn unsupported_resource_type_is_recorded_not_failed() {
6602        // An unmodelled type is recorded as provisioned (no backing state)
6603        // rather than failing the stack — see
6604        // `unknown_resource_type_records_instead_of_failing`.
6605        let prov = make_provisioner();
6606        let res = make_resource("AWS::NonExistent::Thing", "X", serde_json::json!({}));
6607        let sr = prov.create_resource(&res).unwrap();
6608        assert_eq!(sr.physical_id, "X");
6609    }
6610
6611    #[test]
6612    fn iam_role_with_inline_policies() {
6613        let prov = make_provisioner();
6614        let res = make_resource(
6615            "AWS::IAM::Role",
6616            "MyRole",
6617            serde_json::json!({
6618                "RoleName": "role-inline",
6619                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []},
6620                "Policies": [
6621                    {
6622                        "PolicyName": "inline-1",
6623                        "PolicyDocument": {"Version": "2012-10-17", "Statement": []}
6624                    }
6625                ]
6626            }),
6627        );
6628        let sr = prov.create_resource(&res).unwrap();
6629        assert!(sr.physical_id.contains("role-inline"));
6630    }
6631
6632    #[test]
6633    fn sqs_queue_auto_name() {
6634        let prov = make_provisioner();
6635        let res = make_resource("AWS::SQS::Queue", "AutoQ", serde_json::json!({}));
6636        let sr = prov.create_resource(&res).unwrap();
6637        // Generated queue name should exist
6638        assert!(!sr.physical_id.is_empty());
6639    }
6640
6641    #[test]
6642    fn sns_topic_auto_name() {
6643        let prov = make_provisioner();
6644        let res = make_resource("AWS::SNS::Topic", "AutoT", serde_json::json!({}));
6645        let sr = prov.create_resource(&res).unwrap();
6646        assert!(!sr.physical_id.is_empty());
6647    }
6648
6649    // ── additional resource types ──
6650
6651    #[test]
6652    fn unsupported_resource_type_recorded_with_logical_id() {
6653        // Recorded, not failed; physical id is the logical id so `Ref` resolves.
6654        let prov = make_provisioner();
6655        let res = make_resource("AWS::FooBar::Thing", "X", serde_json::json!({}));
6656        let sr = prov.create_resource(&res).unwrap();
6657        assert_eq!(sr.physical_id, "X");
6658        assert_eq!(sr.status, "CREATE_COMPLETE");
6659    }
6660
6661    #[test]
6662    fn sqs_queue_with_redrive_policy() {
6663        let prov = make_provisioner();
6664        // Create DLQ first
6665        let dlq = make_resource(
6666            "AWS::SQS::Queue",
6667            "DLQ",
6668            serde_json::json!({"QueueName": "dlq1"}),
6669        );
6670        let dlq_resource = prov.create_resource(&dlq).unwrap();
6671        let _ = dlq_resource.physical_id;
6672
6673        // Create source queue with redrive policy
6674        let src = make_resource(
6675            "AWS::SQS::Queue",
6676            "Src",
6677            serde_json::json!({
6678                "QueueName": "src1",
6679                "RedrivePolicy": {
6680                    "deadLetterTargetArn": "arn:aws:sqs:us-east-1:123456789012:dlq1",
6681                    "maxReceiveCount": 3
6682                }
6683            }),
6684        );
6685        let sr = prov.create_resource(&src).unwrap();
6686        assert!(!sr.physical_id.is_empty());
6687    }
6688
6689    #[test]
6690    fn sns_topic_with_display_name() {
6691        let prov = make_provisioner();
6692        let res = make_resource(
6693            "AWS::SNS::Topic",
6694            "WithName",
6695            serde_json::json!({"TopicName": "named-topic", "DisplayName": "Named"}),
6696        );
6697        let sr = prov.create_resource(&res).unwrap();
6698        assert!(sr.physical_id.contains("named-topic"));
6699    }
6700
6701    #[test]
6702    fn ssm_parameter_with_explicit_name() {
6703        let prov = make_provisioner();
6704        let res = make_resource(
6705            "AWS::SSM::Parameter",
6706            "Param",
6707            serde_json::json!({"Name": "/my/param", "Value": "v", "Type": "String"}),
6708        );
6709        let sr = prov.create_resource(&res).unwrap();
6710        assert!(sr.physical_id.contains("/my/param"));
6711    }
6712
6713    #[test]
6714    fn ssm_parameter_missing_name_errors() {
6715        let prov = make_provisioner();
6716        let res = make_resource(
6717            "AWS::SSM::Parameter",
6718            "AutoP",
6719            serde_json::json!({"Value": "v", "Type": "String"}),
6720        );
6721        assert!(prov.create_resource(&res).is_err());
6722    }
6723
6724    #[test]
6725    fn iam_managed_policy_auto_name() {
6726        let prov = make_provisioner();
6727        let res = make_resource(
6728            "AWS::IAM::Policy",
6729            "AutoPol",
6730            serde_json::json!({
6731                "PolicyName": "inline-pol",
6732                "PolicyDocument": {"Version": "2012-10-17", "Statement": []},
6733                "Users": []
6734            }),
6735        );
6736        let sr = prov.create_resource(&res).unwrap();
6737        assert!(!sr.physical_id.is_empty());
6738    }
6739
6740    #[test]
6741    fn delete_resource_works_for_queue() {
6742        let prov = make_provisioner();
6743        let res = make_resource(
6744            "AWS::SQS::Queue",
6745            "ToDel",
6746            serde_json::json!({"QueueName": "todel"}),
6747        );
6748        let sr = prov.create_resource(&res).unwrap();
6749        assert!(prov.delete_resource(&sr).is_ok());
6750    }
6751
6752    #[test]
6753    fn delete_resource_works_for_topic() {
6754        let prov = make_provisioner();
6755        let res = make_resource(
6756            "AWS::SNS::Topic",
6757            "DelT",
6758            serde_json::json!({"TopicName": "delt"}),
6759        );
6760        let sr = prov.create_resource(&res).unwrap();
6761        assert!(prov.delete_resource(&sr).is_ok());
6762    }
6763
6764    #[test]
6765    fn application_autoscaling_scalable_target_round_trip() {
6766        let prov = make_provisioner();
6767        let res = make_resource(
6768            "AWS::ApplicationAutoScaling::ScalableTarget",
6769            "Target",
6770            serde_json::json!({
6771                "ServiceNamespace": "ecs",
6772                "ResourceId": "service/my-cluster/my-service",
6773                "ScalableDimension": "ecs:service:DesiredCount",
6774                "MinCapacity": 1,
6775                "MaxCapacity": 10,
6776                "RoleARN": "arn:aws:iam::123456789012:role/my-role",
6777            }),
6778        );
6779        let sr = prov.create_resource(&res).unwrap();
6780        assert_eq!(sr.physical_id, "service/my-cluster/my-service");
6781        assert!(sr.attributes.contains_key("ScalableTargetARN"));
6782        assert!(prov.delete_resource(&sr).is_ok());
6783    }
6784
6785    #[test]
6786    fn application_autoscaling_scaling_policy_requires_target() {
6787        let prov = make_provisioner();
6788        let res = make_resource(
6789            "AWS::ApplicationAutoScaling::ScalingPolicy",
6790            "Policy",
6791            serde_json::json!({
6792                "PolicyName": "my-policy",
6793                "ServiceNamespace": "ecs",
6794                "ResourceId": "service/my-cluster/my-service",
6795                "ScalableDimension": "ecs:service:DesiredCount",
6796                "PolicyType": "TargetTrackingScaling",
6797                "TargetTrackingScalingPolicyConfiguration": {
6798                    "TargetValue": 50.0,
6799                    "PredefinedMetricSpecification": {
6800                        "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
6801                    }
6802                },
6803            }),
6804        );
6805        // Should fail because scalable target does not exist
6806        assert!(prov.create_resource(&res).is_err());
6807    }
6808
6809    #[test]
6810    fn application_autoscaling_scaling_policy_round_trip() {
6811        let prov = make_provisioner();
6812        let target = make_resource(
6813            "AWS::ApplicationAutoScaling::ScalableTarget",
6814            "Target",
6815            serde_json::json!({
6816                "ServiceNamespace": "ecs",
6817                "ResourceId": "service/my-cluster/my-service",
6818                "ScalableDimension": "ecs:service:DesiredCount",
6819                "MinCapacity": 1,
6820                "MaxCapacity": 10,
6821            }),
6822        );
6823        let sr = prov.create_resource(&target).unwrap();
6824
6825        let policy = make_resource(
6826            "AWS::ApplicationAutoScaling::ScalingPolicy",
6827            "Policy",
6828            serde_json::json!({
6829                "PolicyName": "my-policy",
6830                "ServiceNamespace": "ecs",
6831                "ResourceId": "service/my-cluster/my-service",
6832                "ScalableDimension": "ecs:service:DesiredCount",
6833                "PolicyType": "TargetTrackingScaling",
6834                "TargetTrackingScalingPolicyConfiguration": {
6835                    "TargetValue": 50.0,
6836                    "PredefinedMetricSpecification": {
6837                        "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
6838                    }
6839                },
6840            }),
6841        );
6842        let psr = prov.create_resource(&policy).unwrap();
6843        assert!(psr.physical_id.starts_with("arn:aws:autoscaling:"));
6844        assert!(prov.delete_resource(&psr).is_ok());
6845        assert!(prov.delete_resource(&sr).is_ok());
6846    }
6847
6848    #[test]
6849    fn sqs_queue_with_fifo_suffix() {
6850        let prov = make_provisioner();
6851        let res = make_resource(
6852            "AWS::SQS::Queue",
6853            "Fifo",
6854            serde_json::json!({"QueueName": "fq.fifo", "FifoQueue": true}),
6855        );
6856        let sr = prov.create_resource(&res).unwrap();
6857        assert!(sr.physical_id.ends_with(".fifo"));
6858    }
6859
6860    // ── get_att dispatch ──
6861
6862    #[test]
6863    fn getatt_s3_bucket_arn_returns_arn() {
6864        let prov = make_provisioner();
6865        let bucket = make_resource(
6866            "AWS::S3::Bucket",
6867            "MyBucket",
6868            serde_json::json!({"BucketName": "my-bucket"}),
6869        );
6870        let sr = prov.create_resource(&bucket).unwrap();
6871        assert_eq!(
6872            prov.get_att(&sr, "Arn"),
6873            Some("arn:aws:s3:::my-bucket".to_string())
6874        );
6875    }
6876
6877    #[test]
6878    fn getatt_s3_bucket_domain_name_returns_dns_name() {
6879        let prov = make_provisioner();
6880        let bucket = make_resource(
6881            "AWS::S3::Bucket",
6882            "MyBucket",
6883            serde_json::json!({"BucketName": "my-bucket"}),
6884        );
6885        let sr = prov.create_resource(&bucket).unwrap();
6886        assert_eq!(
6887            prov.get_att(&sr, "DomainName"),
6888            Some("my-bucket.s3.amazonaws.com".to_string())
6889        );
6890    }
6891
6892    #[test]
6893    fn getatt_lambda_function_arn_returns_function_arn() {
6894        let prov = make_provisioner();
6895        // Lambda needs an existing IAM role to validate the function role.
6896        let role = make_resource(
6897            "AWS::IAM::Role",
6898            "MyRole",
6899            serde_json::json!({
6900                "RoleName": "my-role",
6901                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
6902            }),
6903        );
6904        let role_sr = prov.create_resource(&role).unwrap();
6905        let fn_res = make_resource(
6906            "AWS::Lambda::Function",
6907            "MyFn",
6908            serde_json::json!({
6909                "FunctionName": "my-fn",
6910                "Runtime": "python3.11",
6911                "Handler": "index.handler",
6912                "Role": role_sr.physical_id,
6913                "Code": {"ZipFile": "def handler(e,c): return e"}
6914            }),
6915        );
6916        let fn_sr = prov.create_resource(&fn_res).unwrap();
6917        let arn = prov.get_att(&fn_sr, "Arn").expect("Arn should resolve");
6918        assert!(arn.starts_with("arn:aws:lambda:"));
6919        assert!(arn.contains(":function:my-fn"));
6920    }
6921
6922    #[test]
6923    fn getatt_iam_role_arn_returns_role_arn() {
6924        let prov = make_provisioner();
6925        let role = make_resource(
6926            "AWS::IAM::Role",
6927            "MyRole",
6928            serde_json::json!({
6929                "RoleName": "my-role",
6930                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
6931            }),
6932        );
6933        let sr = prov.create_resource(&role).unwrap();
6934        assert_eq!(
6935            prov.get_att(&sr, "Arn"),
6936            Some("arn:aws:iam::123456789012:role/my-role".to_string())
6937        );
6938        // RoleId is a real value generated at create time (FKIA-prefixed).
6939        let role_id = prov.get_att(&sr, "RoleId").expect("RoleId should resolve");
6940        assert!(role_id.starts_with("FKIA"));
6941    }
6942
6943    #[test]
6944    fn getatt_unknown_attribute_returns_none() {
6945        let prov = make_provisioner();
6946        let bucket = make_resource(
6947            "AWS::S3::Bucket",
6948            "MyBucket",
6949            serde_json::json!({"BucketName": "my-bucket"}),
6950        );
6951        let sr = prov.create_resource(&bucket).unwrap();
6952        // Fall-back behaviour: unknown attribute on a known type returns
6953        // None; the resolver in template.rs surfaces a "Logical.Attr"
6954        // placeholder so the existing template still builds.
6955        assert_eq!(prov.get_att(&sr, "NotARealAttr"), None);
6956    }
6957
6958    #[test]
6959    fn getatt_unknown_resource_type_returns_none() {
6960        let prov = make_provisioner();
6961        // Hand-crafted StackResource with a resource_type we don't dispatch
6962        // on at all. The captured attributes map is also empty, so there's
6963        // nothing to return.
6964        let stack_resource = StackResource {
6965            logical_id: "Mystery".to_string(),
6966            physical_id: "mystery-id".to_string(),
6967            resource_type: "AWS::Made::Up".to_string(),
6968            status: "CREATE_COMPLETE".to_string(),
6969            service_token: None,
6970            attributes: BTreeMap::new(),
6971        };
6972        assert_eq!(prov.get_att(&stack_resource, "Arn"), None);
6973    }
6974
6975    #[test]
6976    fn getatt_falls_back_to_captured_attributes() {
6977        let prov = make_provisioner();
6978        // Captured attributes always win — even if live-state dispatch
6979        // would return something different. This keeps `Fn::GetAtt`
6980        // deterministic for resources with cached attrs.
6981        let stack_resource = StackResource {
6982            logical_id: "MyTopic".to_string(),
6983            physical_id: "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
6984            resource_type: "AWS::SNS::Topic".to_string(),
6985            status: "CREATE_COMPLETE".to_string(),
6986            service_token: None,
6987            attributes: {
6988                let mut m = BTreeMap::new();
6989                m.insert("TopicArn".to_string(), "captured-arn".to_string());
6990                m
6991            },
6992        };
6993        assert_eq!(
6994            prov.get_att(&stack_resource, "TopicArn"),
6995            Some("captured-arn".to_string())
6996        );
6997    }
6998
6999    #[test]
7000    fn getatt_secrets_manager_arn_resolves_via_live_state() {
7001        // Secrets create handler captures Id but not Arn; live-state
7002        // fallback fills in Arn.
7003        let prov = make_provisioner();
7004        let res = make_resource(
7005            "AWS::SecretsManager::Secret",
7006            "MySecret",
7007            serde_json::json!({"Name": "my-secret", "SecretString": "hunter2"}),
7008        );
7009        let sr = prov.create_resource(&res).unwrap();
7010        let arn = prov.get_att(&sr, "Arn").expect("Arn should resolve");
7011        assert!(arn.starts_with("arn:aws:secretsmanager:"));
7012        assert!(arn.ends_with(":secret:my-secret"));
7013    }
7014
7015    #[test]
7016    fn wafv2_web_acl_lifecycle() {
7017        let prov = make_provisioner();
7018        let res = make_resource(
7019            "AWS::WAFv2::WebACL",
7020            "MyAcl",
7021            serde_json::json!({
7022                "Name": "my-acl",
7023                "Scope": "REGIONAL",
7024                "DefaultAction": {"Allow": {}},
7025                "Rules": [{"Name": "rule1", "Priority": 1, "Statement": {}, "VisibilityConfig": {}}],
7026                "VisibilityConfig": {"SampledRequestsEnabled": true, "CloudWatchMetricsEnabled": true, "MetricName": "my-acl-metric"},
7027                "Capacity": 100,
7028            }),
7029        );
7030        let sr = prov.create_resource(&res).unwrap();
7031        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7032        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7033        assert_eq!(prov.get_att(&sr, "Name"), Some("my-acl".to_string()));
7034        assert!(prov.get_att(&sr, "Id").is_some());
7035        assert_eq!(prov.get_att(&sr, "Capacity"), Some("100".to_string()));
7036
7037        prov.delete_resource(&sr.clone()).unwrap();
7038        // Verify live-state fallback returns None by using a fresh resource
7039        // with empty attributes (captured attrs would still win).
7040        let fresh = StackResource {
7041            logical_id: "MyAcl".to_string(),
7042            physical_id: sr.physical_id.clone(),
7043            resource_type: "AWS::WAFv2::WebACL".to_string(),
7044            status: "CREATE_COMPLETE".to_string(),
7045            service_token: None,
7046            attributes: BTreeMap::new(),
7047        };
7048        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7049    }
7050
7051    #[test]
7052    fn wafv2_ip_set_lifecycle() {
7053        let prov = make_provisioner();
7054        let res = make_resource(
7055            "AWS::WAFv2::IPSet",
7056            "MyIpSet",
7057            serde_json::json!({
7058                "Name": "my-ipset",
7059                "Scope": "REGIONAL",
7060                "IPAddressVersion": "IPV4",
7061                "Addresses": ["10.0.0.0/8"],
7062            }),
7063        );
7064        let sr = prov.create_resource(&res).unwrap();
7065        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7066        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7067        assert_eq!(prov.get_att(&sr, "Name"), Some("my-ipset".to_string()));
7068
7069        prov.delete_resource(&sr.clone()).unwrap();
7070        let fresh = StackResource {
7071            logical_id: "MyIpSet".to_string(),
7072            physical_id: sr.physical_id.clone(),
7073            resource_type: "AWS::WAFv2::IPSet".to_string(),
7074            status: "CREATE_COMPLETE".to_string(),
7075            service_token: None,
7076            attributes: BTreeMap::new(),
7077        };
7078        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7079    }
7080
7081    #[test]
7082    fn wafv2_regex_pattern_set_lifecycle() {
7083        let prov = make_provisioner();
7084        let res = make_resource(
7085            "AWS::WAFv2::RegexPatternSet",
7086            "MyRegexSet",
7087            serde_json::json!({
7088                "Name": "my-regex",
7089                "Scope": "REGIONAL",
7090                "RegularExpressions": [{"RegexString": "^test"}],
7091            }),
7092        );
7093        let sr = prov.create_resource(&res).unwrap();
7094        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7095        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7096        assert_eq!(prov.get_att(&sr, "Name"), Some("my-regex".to_string()));
7097
7098        prov.delete_resource(&sr.clone()).unwrap();
7099        let fresh = StackResource {
7100            logical_id: "MyRegexSet".to_string(),
7101            physical_id: sr.physical_id.clone(),
7102            resource_type: "AWS::WAFv2::RegexPatternSet".to_string(),
7103            status: "CREATE_COMPLETE".to_string(),
7104            service_token: None,
7105            attributes: BTreeMap::new(),
7106        };
7107        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7108    }
7109
7110    #[test]
7111    fn wafv2_rule_group_lifecycle() {
7112        let prov = make_provisioner();
7113        let res = make_resource(
7114            "AWS::WAFv2::RuleGroup",
7115            "MyRuleGroup",
7116            serde_json::json!({
7117                "Name": "my-rg",
7118                "Scope": "REGIONAL",
7119                "Capacity": 50,
7120                "Rules": [{"Name": "r1", "Priority": 1, "Statement": {}, "VisibilityConfig": {}}],
7121                "VisibilityConfig": {"SampledRequestsEnabled": true, "CloudWatchMetricsEnabled": true, "MetricName": "rg-metric"},
7122            }),
7123        );
7124        let sr = prov.create_resource(&res).unwrap();
7125        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
7126        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
7127        assert_eq!(prov.get_att(&sr, "Name"), Some("my-rg".to_string()));
7128
7129        prov.delete_resource(&sr.clone()).unwrap();
7130        let fresh = StackResource {
7131            logical_id: "MyRuleGroup".to_string(),
7132            physical_id: sr.physical_id.clone(),
7133            resource_type: "AWS::WAFv2::RuleGroup".to_string(),
7134            status: "CREATE_COMPLETE".to_string(),
7135            service_token: None,
7136            attributes: BTreeMap::new(),
7137        };
7138        assert_eq!(prov.get_att(&fresh, "Arn"), None);
7139    }
7140
7141    #[test]
7142    fn wafv2_logging_configuration_lifecycle() {
7143        let prov = make_provisioner();
7144        let res = make_resource(
7145            "AWS::WAFv2::LoggingConfiguration",
7146            "MyLogConfig",
7147            serde_json::json!({
7148                "ResourceArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/abc",
7149                "LogDestinationConfigs": ["arn:aws:logs:us-east-1:123456789012:log-group:/aws/waf"],
7150            }),
7151        );
7152        let sr = prov.create_resource(&res).unwrap();
7153        assert_eq!(
7154            sr.physical_id,
7155            "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/abc"
7156        );
7157
7158        prov.delete_resource(&sr.clone()).unwrap();
7159    }
7160
7161    #[test]
7162    fn wafv2_web_acl_association_lifecycle() {
7163        let prov = make_provisioner();
7164        let res = make_resource(
7165            "AWS::WAFv2::WebACLAssociation",
7166            "MyAssoc",
7167            serde_json::json!({
7168                "ResourceArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188",
7169                "WebACLArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/my-acl/abc",
7170            }),
7171        );
7172        let sr = prov.create_resource(&res).unwrap();
7173        assert_eq!(sr.physical_id, "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188");
7174
7175        prov.delete_resource(&sr.clone()).unwrap();
7176    }
7177
7178    #[test]
7179    fn ses_configuration_set_lifecycle() {
7180        let prov = make_provisioner();
7181        let res = make_resource(
7182            "AWS::SES::ConfigurationSet",
7183            "MyConfigSet",
7184            serde_json::json!({
7185                "Name": "my-cs",
7186                "SendingOptions": {"SendingEnabled": true},
7187                "DeliveryOptions": {"TlsPolicy": "REQUIRE"},
7188            }),
7189        );
7190        let sr = prov.create_resource(&res).unwrap();
7191        assert_eq!(sr.physical_id, "my-cs");
7192        assert_eq!(prov.get_att(&sr, "Name"), Some("my-cs".to_string()));
7193
7194        prov.delete_resource(&sr.clone()).unwrap();
7195        let fresh = StackResource {
7196            logical_id: "MyConfigSet".to_string(),
7197            physical_id: "my-cs".to_string(),
7198            resource_type: "AWS::SES::ConfigurationSet".to_string(),
7199            status: "CREATE_COMPLETE".to_string(),
7200            service_token: None,
7201            attributes: BTreeMap::new(),
7202        };
7203        assert_eq!(prov.get_att(&fresh, "Name"), None);
7204    }
7205
7206    #[test]
7207    fn ses_email_identity_lifecycle() {
7208        let prov = make_provisioner();
7209        let res = make_resource(
7210            "AWS::SES::EmailIdentity",
7211            "MyIdentity",
7212            serde_json::json!({"EmailIdentity": "example.com"}),
7213        );
7214        let sr = prov.create_resource(&res).unwrap();
7215        assert_eq!(sr.physical_id, "example.com");
7216        assert_eq!(
7217            prov.get_att(&sr, "IdentityName"),
7218            Some("example.com".to_string())
7219        );
7220
7221        prov.delete_resource(&sr.clone()).unwrap();
7222        let fresh = StackResource {
7223            logical_id: "MyIdentity".to_string(),
7224            physical_id: "example.com".to_string(),
7225            resource_type: "AWS::SES::EmailIdentity".to_string(),
7226            status: "CREATE_COMPLETE".to_string(),
7227            service_token: None,
7228            attributes: BTreeMap::new(),
7229        };
7230        assert_eq!(prov.get_att(&fresh, "IdentityName"), None);
7231    }
7232
7233    #[test]
7234    fn ses_template_lifecycle() {
7235        let prov = make_provisioner();
7236        let res = make_resource(
7237            "AWS::SES::Template",
7238            "MyTemplate",
7239            serde_json::json!({
7240                "Template": {
7241                    "TemplateName": "my-tpl",
7242                    "SubjectPart": "Hello",
7243                    "HtmlPart": "<h1>Hi</h1>",
7244                    "TextPart": "Hi",
7245                },
7246            }),
7247        );
7248        let sr = prov.create_resource(&res).unwrap();
7249        assert_eq!(sr.physical_id, "my-tpl");
7250        assert_eq!(
7251            prov.get_att(&sr, "TemplateName"),
7252            Some("my-tpl".to_string())
7253        );
7254
7255        prov.delete_resource(&sr.clone()).unwrap();
7256        let fresh = StackResource {
7257            logical_id: "MyTemplate".to_string(),
7258            physical_id: "my-tpl".to_string(),
7259            resource_type: "AWS::SES::Template".to_string(),
7260            status: "CREATE_COMPLETE".to_string(),
7261            service_token: None,
7262            attributes: BTreeMap::new(),
7263        };
7264        assert_eq!(prov.get_att(&fresh, "TemplateName"), None);
7265    }
7266
7267    #[test]
7268    fn ses_contact_list_lifecycle() {
7269        let prov = make_provisioner();
7270        let res = make_resource(
7271            "AWS::SES::ContactList",
7272            "MyContactList",
7273            serde_json::json!({
7274                "ContactListName": "my-cl",
7275                "Description": "Test contacts",
7276                "Topics": [{"TopicName": "news", "DisplayName": "Newsletter", "Description": "Weekly news"}],
7277            }),
7278        );
7279        let sr = prov.create_resource(&res).unwrap();
7280        assert_eq!(sr.physical_id, "my-cl");
7281        assert_eq!(
7282            prov.get_att(&sr, "ContactListName"),
7283            Some("my-cl".to_string())
7284        );
7285
7286        prov.delete_resource(&sr.clone()).unwrap();
7287        let fresh = StackResource {
7288            logical_id: "MyContactList".to_string(),
7289            physical_id: "my-cl".to_string(),
7290            resource_type: "AWS::SES::ContactList".to_string(),
7291            status: "CREATE_COMPLETE".to_string(),
7292            service_token: None,
7293            attributes: BTreeMap::new(),
7294        };
7295        assert_eq!(prov.get_att(&fresh, "ContactListName"), None);
7296    }
7297
7298    #[test]
7299    fn ses_dedicated_ip_pool_lifecycle() {
7300        let prov = make_provisioner();
7301        let res = make_resource(
7302            "AWS::SES::DedicatedIpPool",
7303            "MyPool",
7304            serde_json::json!({"PoolName": "my-pool", "ScalingMode": "STANDARD"}),
7305        );
7306        let sr = prov.create_resource(&res).unwrap();
7307        assert_eq!(sr.physical_id, "my-pool");
7308        assert_eq!(prov.get_att(&sr, "PoolName"), Some("my-pool".to_string()));
7309
7310        prov.delete_resource(&sr.clone()).unwrap();
7311        let fresh = StackResource {
7312            logical_id: "MyPool".to_string(),
7313            physical_id: "my-pool".to_string(),
7314            resource_type: "AWS::SES::DedicatedIpPool".to_string(),
7315            status: "CREATE_COMPLETE".to_string(),
7316            service_token: None,
7317            attributes: BTreeMap::new(),
7318        };
7319        assert_eq!(prov.get_att(&fresh, "PoolName"), None);
7320    }
7321
7322    #[test]
7323    fn ses_receipt_rule_set_lifecycle() {
7324        let prov = make_provisioner();
7325        let res = make_resource(
7326            "AWS::SES::ReceiptRuleSet",
7327            "MyRuleSet",
7328            serde_json::json!({"RuleSetName": "my-rs"}),
7329        );
7330        let sr = prov.create_resource(&res).unwrap();
7331        assert_eq!(sr.physical_id, "my-rs");
7332        assert_eq!(prov.get_att(&sr, "RuleSetName"), Some("my-rs".to_string()));
7333
7334        prov.delete_resource(&sr.clone()).unwrap();
7335        let fresh = StackResource {
7336            logical_id: "MyRuleSet".to_string(),
7337            physical_id: "my-rs".to_string(),
7338            resource_type: "AWS::SES::ReceiptRuleSet".to_string(),
7339            status: "CREATE_COMPLETE".to_string(),
7340            service_token: None,
7341            attributes: BTreeMap::new(),
7342        };
7343        assert_eq!(prov.get_att(&fresh, "RuleSetName"), None);
7344    }
7345
7346    #[test]
7347    fn ses_receipt_rule_lifecycle() {
7348        let prov = make_provisioner();
7349        let rs = make_resource(
7350            "AWS::SES::ReceiptRuleSet",
7351            "MyRuleSet",
7352            serde_json::json!({"RuleSetName": "my-rs2"}),
7353        );
7354        prov.create_resource(&rs).unwrap();
7355
7356        let res = make_resource(
7357            "AWS::SES::ReceiptRule",
7358            "MyRule",
7359            serde_json::json!({
7360                "RuleSetName": "my-rs2",
7361                "Rule": {
7362                    "Name": "rule1",
7363                    "Priority": 1,
7364                    "Enabled": true,
7365                    "Actions": [{"S3Action": {"BucketName": "my-bucket"}}],
7366                },
7367            }),
7368        );
7369        let sr = prov.create_resource(&res).unwrap();
7370        assert_eq!(sr.physical_id, "my-rs2|rule1");
7371
7372        prov.delete_resource(&sr.clone()).unwrap();
7373    }
7374
7375    #[test]
7376    fn ses_receipt_filter_lifecycle() {
7377        let prov = make_provisioner();
7378        let res = make_resource(
7379            "AWS::SES::ReceiptFilter",
7380            "MyFilter",
7381            serde_json::json!({
7382                "Filter": {
7383                    "Name": "my-filter",
7384                    "IpFilter": {"Policy": "Block", "Cidr": "10.0.0.0/8"},
7385                },
7386            }),
7387        );
7388        let sr = prov.create_resource(&res).unwrap();
7389        assert_eq!(sr.physical_id, "my-filter");
7390
7391        prov.delete_resource(&sr.clone()).unwrap();
7392    }
7393
7394    #[test]
7395    fn ses_vdm_attributes_lifecycle() {
7396        let prov = make_provisioner();
7397        let res = make_resource(
7398            "AWS::SES::VdmAttributes",
7399            "MyVdm",
7400            serde_json::json!({
7401                "DashboardAttributes": {"EngagementMetrics": "ENABLED"},
7402                "GuardianAttributes": {"OptimizedSharedDelivery": "ENABLED"},
7403            }),
7404        );
7405        let sr = prov.create_resource(&res).unwrap();
7406        assert_eq!(sr.physical_id, "vdm-MyVdm");
7407
7408        prov.delete_resource(&sr.clone()).unwrap();
7409    }
7410
7411    #[test]
7412    fn athena_work_group_lifecycle() {
7413        let prov = make_provisioner();
7414        let res = make_resource(
7415            "AWS::Athena::WorkGroup",
7416            "MyWg",
7417            serde_json::json!({
7418                "Name": "my-wg",
7419                "Description": "test wg",
7420                "Configuration": {"EnforceWorkGroupConfiguration": true},
7421            }),
7422        );
7423        let sr = prov.create_resource(&res).unwrap();
7424        assert_eq!(sr.physical_id, "my-wg");
7425        assert_eq!(sr.attributes.get("Name"), Some(&"my-wg".to_string()));
7426        assert!(sr
7427            .attributes
7428            .get("Arn")
7429            .unwrap()
7430            .contains("workgroup/my-wg"));
7431
7432        assert_eq!(
7433            prov.get_att(
7434                &StackResource {
7435                    resource_type: "AWS::Athena::WorkGroup".to_string(),
7436                    physical_id: sr.physical_id.clone(),
7437                    logical_id: "MyWg".to_string(),
7438                    status: "CREATE_COMPLETE".to_string(),
7439                    service_token: None,
7440                    attributes: BTreeMap::new(),
7441                },
7442                "Name",
7443            ),
7444            Some("my-wg".to_string()),
7445        );
7446
7447        prov.delete_resource(&sr.clone()).unwrap();
7448    }
7449
7450    #[test]
7451    fn athena_data_catalog_lifecycle() {
7452        let prov = make_provisioner();
7453        let res = make_resource(
7454            "AWS::Athena::DataCatalog",
7455            "MyCatalog",
7456            serde_json::json!({
7457                "Name": "my-catalog",
7458                "Type": "GLUE",
7459                "Description": "test catalog",
7460            }),
7461        );
7462        let sr = prov.create_resource(&res).unwrap();
7463        assert_eq!(sr.physical_id, "my-catalog");
7464        assert_eq!(sr.attributes.get("Name"), Some(&"my-catalog".to_string()));
7465        assert!(sr
7466            .attributes
7467            .get("Arn")
7468            .unwrap()
7469            .contains("datacatalog/my-catalog"));
7470
7471        prov.delete_resource(&sr.clone()).unwrap();
7472    }
7473
7474    #[test]
7475    fn athena_named_query_lifecycle() {
7476        let prov = make_provisioner();
7477        let res = make_resource(
7478            "AWS::Athena::NamedQuery",
7479            "MyQuery",
7480            serde_json::json!({
7481                "Name": "my-query",
7482                "Database": "mydb",
7483                "QueryString": "SELECT 1",
7484                "WorkGroup": "primary",
7485            }),
7486        );
7487        let sr = prov.create_resource(&res).unwrap();
7488        assert!(!sr.physical_id.is_empty());
7489        assert_eq!(sr.attributes.get("NamedQueryId"), Some(&sr.physical_id));
7490
7491        prov.delete_resource(&sr.clone()).unwrap();
7492    }
7493
7494    #[test]
7495    fn athena_prepared_statement_lifecycle() {
7496        let prov = make_provisioner();
7497        let res = make_resource(
7498            "AWS::Athena::PreparedStatement",
7499            "MyPs",
7500            serde_json::json!({
7501                "StatementName": "my-ps",
7502                "WorkGroupName": "primary",
7503                "QueryStatement": "SELECT 1",
7504            }),
7505        );
7506        let sr = prov.create_resource(&res).unwrap();
7507        assert_eq!(sr.physical_id, "primary|my-ps");
7508
7509        prov.delete_resource(&sr.clone()).unwrap();
7510    }
7511
7512    #[test]
7513    fn parse_lambda_function_name_handles_every_shape() {
7514        // Plain name, unqualified — passes through.
7515        assert_eq!(parse_lambda_function_name("my-func"), "my-func");
7516        // Bare name with an alias/version qualifier (what `Ref` on an
7517        // alias used to feed the ESM create path).
7518        assert_eq!(parse_lambda_function_name("my-func:live"), "my-func");
7519        assert_eq!(parse_lambda_function_name("my-func:42"), "my-func");
7520        // Full ARN, unqualified and qualified.
7521        assert_eq!(
7522            parse_lambda_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-func"),
7523            "my-func"
7524        );
7525        assert_eq!(
7526            parse_lambda_function_name(
7527                "arn:aws:lambda:us-east-1:123456789012:function:my-func:live"
7528            ),
7529            "my-func"
7530        );
7531        // Partial ARN, unqualified and qualified.
7532        assert_eq!(
7533            parse_lambda_function_name("123456789012:function:my-func"),
7534            "my-func"
7535        );
7536        assert_eq!(
7537            parse_lambda_function_name("123456789012:function:my-func:live"),
7538            "my-func"
7539        );
7540    }
7541
7542    #[test]
7543    fn alias_state_key_recovers_internal_key_from_arn() {
7544        // Alias ARN -> `{function}:{alias}` internal map key.
7545        assert_eq!(
7546            alias_state_key("arn:aws:lambda:us-east-1:123456789012:function:my-func:live"),
7547            "my-func:live"
7548        );
7549        // Legacy bare `name:alias` physical id is returned unchanged.
7550        assert_eq!(alias_state_key("my-func:live"), "my-func:live");
7551    }
7552}