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