Skip to main content

fakecloud_cloudformation/resource_provisioner/
mod.rs

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