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