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