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