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