Skip to main content

fakecloud_cloudformation/resource_provisioner/
mod.rs

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