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