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