Skip to main content

fakecloud_cloudformation/resource_provisioner/
mod.rs

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