Skip to main content

fakecloud_cloudformation/
resource_provisioner.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_cloudfront::{
30    functions::{
31        CloudFrontOriginAccessIdentityConfig, FunctionConfig, KeyGroupConfig, KeyGroupItems,
32        PublicKeyConfig, StoredFunction, StoredKeyGroup, StoredOriginAccessIdentity,
33        StoredPublicKey,
34    },
35    model::{
36        DefaultCacheBehavior, DistributionConfig, Origin, OriginItems, Origins, ViewerCertificate,
37    },
38    policies::{
39        CachePolicyConfig, OriginAccessControlConfig, OriginRequestPolicyConfig,
40        OriginRequestPolicyCookiesConfig, OriginRequestPolicyHeadersConfig,
41        OriginRequestPolicyQueryStringsConfig, ResponseHeadersPolicyConfig, StoredCachePolicy,
42        StoredOriginAccessControl, StoredOriginRequestPolicy, StoredResponseHeadersPolicy,
43    },
44    state::StoredDistribution,
45    SharedCloudFrontState,
46};
47use fakecloud_cloudwatch::{AlarmState, Dashboard, MetricAlarm, SharedCloudWatchState};
48use fakecloud_cognito::{
49    default_schema_attributes, AccountRecoverySetting, AdminCreateUserConfig,
50    CognitoIdentityProvider, CustomDomainConfig, EmailConfiguration, IdentityPool,
51    IdentityPoolRoleAttachment, PasswordPolicy, PoolPolicies, RecoveryOption, SchemaAttribute,
52    SharedCognitoState, SignInPolicy, SmsConfiguration, UserPool, UserPoolClient, UserPoolDomain,
53};
54use fakecloud_core::delivery::DeliveryBus;
55use fakecloud_dynamodb::{
56    AttributeDefinition, DynamoTable, KeySchemaElement, OnDemandThroughput, ProvisionedThroughput,
57    SharedDynamoDbState,
58};
59use fakecloud_ecr::{Repository, SharedEcrState};
60use fakecloud_ecs::{
61    CapacityProvider as EcsCapacityProvider, Cluster as EcsCluster, Service as EcsService,
62    SharedEcsState, TagEntry as EcsTagEntry, TaskDefinition as EcsTaskDefinition,
63};
64use fakecloud_elasticache::{
65    CacheCluster as EcCacheCluster, CacheParameterGroup, CacheSecurityGroup, CacheSubnetGroup,
66    ElastiCacheUser as EcUser, ElastiCacheUserGroup as EcUserGroup,
67    ReplicationGroup as EcReplicationGroup, SharedElastiCacheState,
68};
69use fakecloud_elbv2::{
70    Action as ElbAction, Listener, LoadBalancer, Rule as ElbRule, RuleCondition, SharedElbv2State,
71    Tag as ElbTag, TargetGroup, TargetGroupTuple,
72};
73use fakecloud_eventbridge::{
74    ApiDestination, Archive, Connection, Endpoint, EventBus, EventRule, SharedEventBridgeState,
75};
76use fakecloud_firehose::state::{DeliveryStream, S3Destination};
77use fakecloud_iam::{
78    IamAccessKey, IamGroup, IamInstanceProfile, IamPolicy, IamRole, IamUser, OidcProvider,
79    PolicyVersion, SamlProvider, SharedIamState, Tag, VirtualMfaDevice,
80};
81use fakecloud_kinesis::{build_stream_shards, KinesisConsumer, KinesisStream, SharedKinesisState};
82use fakecloud_kms::provisioner as kms_provisioner;
83use fakecloud_kms::SharedKmsState;
84use fakecloud_lambda::{
85    AttachedLayer, EventSourceMapping, FunctionAlias, FunctionUrlConfig, Layer, LayerVersion,
86    SharedLambdaState,
87};
88use fakecloud_logs::{
89    Delivery, DeliveryDestination, DeliverySource, Destination, LogStream, MetricFilter,
90    MetricTransformation, QueryDefinition, ResourcePolicy, SharedLogsState, SubscriptionFilter,
91};
92use fakecloud_organizations::{
93    OrganizationState, OrganizationalUnit, Policy as OrgPolicy, SharedOrganizationsState,
94    POLICY_TYPE_SCP,
95};
96use fakecloud_rds::{DbInstance, DbParameterGroup, DbSubnetGroup, RdsTag, SharedRdsState};
97use fakecloud_route53::{
98    model::{HealthCheckConfig, HostedZoneFeatures, ResourceRecordSet},
99    SharedRoute53State, StoredHealthCheck, StoredHostedZone,
100};
101use fakecloud_s3::{S3Bucket, SharedS3State};
102use fakecloud_secretsmanager::{RotationRules, Secret, SecretVersion, SharedSecretsManagerState};
103use fakecloud_ses::{
104    ConfigurationSet as SesConfigurationSet, ContactList as SesContactList,
105    DedicatedIpPool as SesDedicatedIpPool, EmailIdentity as SesEmailIdentity,
106    EmailTemplate as SesEmailTemplate, EventDestination as SesEventDestination,
107    IpFilter as SesIpFilter, ReceiptAction as SesReceiptAction, ReceiptFilter as SesReceiptFilter,
108    ReceiptRule as SesReceiptRule, ReceiptRuleSet as SesReceiptRuleSet, SharedSesState,
109};
110use fakecloud_sns::{SharedSnsState, SnsSubscription, SnsTopic};
111use fakecloud_sqs::{SharedSqsState, SqsQueue};
112use fakecloud_ssm::{SharedSsmState, SsmParameter};
113use fakecloud_stepfunctions::{
114    Activity as SfnActivity, AliasRoute, SharedStepFunctionsState, StateMachine, StateMachineAlias,
115    StateMachineStatus, StateMachineType, StateMachineVersion,
116};
117use fakecloud_wafv2::{IpSet, RegexPatternSet, RuleGroup, SharedWafv2State, WebAcl};
118
119use crate::state::StackResource;
120use crate::template::ResourceDefinition;
121
122/// Convert a CFN `Tags` property (`[{Key, Value}, ...]`) into the IAM
123/// crate's `Tag` Vec form. Silently skips malformed entries — the same
124/// tolerant behaviour the existing IAM service uses for runtime input.
125fn parse_iam_tags(value: Option<&serde_json::Value>) -> Vec<Tag> {
126    let Some(arr) = value.and_then(|v| v.as_array()) else {
127        return Vec::new();
128    };
129    arr.iter()
130        .filter_map(|t| {
131            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
132            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
133            Some(Tag { key, value })
134        })
135        .collect()
136}
137
138/// Mirror of `parse_iam_tags` but for the ELBv2 crate's separate `Tag`
139/// type. Same `[{Key, Value}, ...]` JSON shape, ignored on malformed entries.
140fn parse_elb_tags(value: Option<&serde_json::Value>) -> Vec<ElbTag> {
141    let Some(arr) = value.and_then(|v| v.as_array()) else {
142        return Vec::new();
143    };
144    arr.iter()
145        .filter_map(|t| {
146            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
147            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
148            Some(ElbTag { key, value })
149        })
150        .collect()
151}
152
153/// Translate CFN-shape Listener/ListenerRule actions into ELBv2 internal
154/// `Action`s. Only the action-type knobs CFN exposes are wired; anything
155/// not recognised becomes a bare action with no target.
156fn parse_elb_actions(value: Option<&serde_json::Value>) -> Vec<ElbAction> {
157    let Some(arr) = value.and_then(|v| v.as_array()) else {
158        return Vec::new();
159    };
160    arr.iter()
161        .map(|a| {
162            let action_type = a
163                .get("Type")
164                .and_then(|v| v.as_str())
165                .unwrap_or("forward")
166                .to_string();
167            let target_group_arn = a
168                .get("TargetGroupArn")
169                .and_then(|v| v.as_str())
170                .map(|s| s.to_string());
171            let order = a.get("Order").and_then(|v| v.as_i64()).map(|n| n as i32);
172            let redirect = a
173                .get("RedirectConfig")
174                .map(|r| fakecloud_elbv2::RedirectConfig {
175                    protocol: r
176                        .get("Protocol")
177                        .and_then(|v| v.as_str())
178                        .map(|s| s.to_string()),
179                    port: r
180                        .get("Port")
181                        .and_then(|v| v.as_str())
182                        .map(|s| s.to_string()),
183                    host: r
184                        .get("Host")
185                        .and_then(|v| v.as_str())
186                        .map(|s| s.to_string()),
187                    path: r
188                        .get("Path")
189                        .and_then(|v| v.as_str())
190                        .map(|s| s.to_string()),
191                    query: r
192                        .get("Query")
193                        .and_then(|v| v.as_str())
194                        .map(|s| s.to_string()),
195                    status_code: r
196                        .get("StatusCode")
197                        .and_then(|v| v.as_str())
198                        .unwrap_or("HTTP_302")
199                        .to_string(),
200                });
201            let fixed_response =
202                a.get("FixedResponseConfig")
203                    .map(|f| fakecloud_elbv2::FixedResponseConfig {
204                        message_body: f
205                            .get("MessageBody")
206                            .and_then(|v| v.as_str())
207                            .map(|s| s.to_string()),
208                        status_code: f
209                            .get("StatusCode")
210                            .and_then(|v| v.as_str())
211                            .unwrap_or("200")
212                            .to_string(),
213                        content_type: f
214                            .get("ContentType")
215                            .and_then(|v| v.as_str())
216                            .map(|s| s.to_string()),
217                    });
218            let forward = a.get("ForwardConfig").map(|f| {
219                let target_groups: Vec<TargetGroupTuple> = f
220                    .get("TargetGroups")
221                    .and_then(|v| v.as_array())
222                    .map(|arr| {
223                        arr.iter()
224                            .filter_map(|t| {
225                                let target_group_arn = t
226                                    .get("TargetGroupArn")
227                                    .and_then(|v| v.as_str())?
228                                    .to_string();
229                                let weight =
230                                    t.get("Weight").and_then(|v| v.as_i64()).map(|n| n as i32);
231                                Some(TargetGroupTuple {
232                                    target_group_arn,
233                                    weight,
234                                })
235                            })
236                            .collect()
237                    })
238                    .unwrap_or_default();
239                fakecloud_elbv2::ForwardConfig {
240                    target_groups,
241                    stickiness: None,
242                }
243            });
244            ElbAction {
245                action_type,
246                target_group_arn,
247                order,
248                redirect,
249                fixed_response,
250                forward,
251                authenticate_cognito: None,
252                authenticate_oidc: None,
253            }
254        })
255        .collect()
256}
257
258fn parse_elb_rule_conditions(value: Option<&serde_json::Value>) -> Vec<RuleCondition> {
259    let Some(arr) = value.and_then(|v| v.as_array()) else {
260        return Vec::new();
261    };
262    arr.iter()
263        .map(|c| {
264            let field = c
265                .get("Field")
266                .and_then(|v| v.as_str())
267                .unwrap_or("")
268                .to_string();
269            let values: Vec<String> = c
270                .get("Values")
271                .and_then(|v| v.as_array())
272                .map(|arr| {
273                    arr.iter()
274                        .filter_map(|s| s.as_str().map(|s| s.to_string()))
275                        .collect()
276                })
277                .unwrap_or_default();
278            let host_header_values: Vec<String> = c
279                .get("HostHeaderConfig")
280                .and_then(|v| v.get("Values"))
281                .and_then(|v| v.as_array())
282                .map(|arr| {
283                    arr.iter()
284                        .filter_map(|s| s.as_str().map(|s| s.to_string()))
285                        .collect()
286                })
287                .unwrap_or_default();
288            RuleCondition {
289                field,
290                values,
291                host_header_values,
292                path_pattern_values: Vec::new(),
293                http_header_name: None,
294                http_header_values: Vec::new(),
295                query_string_values: Vec::new(),
296                http_request_method_values: Vec::new(),
297                source_ip_values: Vec::new(),
298            }
299        })
300        .collect()
301}
302
303/// Parse the `KeyPolicy` field of an `AWS::KMS::Key` /
304/// `AWS::KMS::ReplicaKey` resource. CFN allows either a JSON string or
305/// an inline JSON object; we serialize the object form back to a string
306/// to match the [`KmsKey.policy`](fakecloud_kms::KmsKey) shape.
307fn parse_key_policy(props: &serde_json::Value) -> Option<String> {
308    match props.get("KeyPolicy") {
309        Some(v) if v.is_string() => Some(v.as_str().unwrap_or("").to_string()),
310        Some(v) => Some(serde_json::to_string(v).unwrap_or_default()),
311        None => None,
312    }
313}
314
315/// Parse the `Tags` field common to KMS CFN resources: an array of
316/// `{Key, Value}` pairs into a sorted map.
317fn parse_tag_list(props: &serde_json::Value) -> BTreeMap<String, String> {
318    let mut tags: BTreeMap<String, String> = BTreeMap::new();
319    if let Some(arr) = props.get("Tags").and_then(|v| v.as_array()) {
320        for t in arr {
321            if let (Some(k), Some(v)) = (
322                t.get("Key").and_then(|x| x.as_str()),
323                t.get("Value").and_then(|x| x.as_str()),
324            ) {
325                tags.insert(k.to_string(), v.to_string());
326            }
327        }
328    }
329    tags
330}
331
332/// Parse the `Properties` of an `AWS::KMS::Key` resource into the
333/// shared [`fakecloud_kms::provisioner::KeyCreationInput`]. Defaults
334/// match AWS: `ENCRYPT_DECRYPT` / `SYMMETRIC_DEFAULT` / `AWS_KMS` /
335/// `Enabled=true`. `RotationPeriodInDays`, `PendingWindowInDays`, and
336/// `BypassPolicyLockoutSafetyCheck` are accepted by the parser for
337/// CFN compatibility but not persisted on the key — the underlying
338/// KMS state doesn't model per-key rotation periods, and the deletion
339/// window only matters at scheduled-deletion time which CFN delete
340/// short-circuits.
341fn parse_kms_key_input(props: &serde_json::Value) -> kms_provisioner::KeyCreationInput {
342    kms_provisioner::KeyCreationInput {
343        description: props
344            .get("Description")
345            .and_then(|v| v.as_str())
346            .unwrap_or("")
347            .to_string(),
348        key_usage: props
349            .get("KeyUsage")
350            .and_then(|v| v.as_str())
351            .unwrap_or("ENCRYPT_DECRYPT")
352            .to_string(),
353        key_spec: props
354            .get("KeySpec")
355            .and_then(|v| v.as_str())
356            .unwrap_or("SYMMETRIC_DEFAULT")
357            .to_string(),
358        origin: props
359            .get("Origin")
360            .and_then(|v| v.as_str())
361            .unwrap_or("AWS_KMS")
362            .to_string(),
363        enabled: props
364            .get("Enabled")
365            .and_then(|v| v.as_bool())
366            .unwrap_or(true),
367        multi_region: props
368            .get("MultiRegion")
369            .and_then(|v| v.as_bool())
370            .unwrap_or(false),
371        key_rotation_enabled: props
372            .get("EnableKeyRotation")
373            .and_then(|v| v.as_bool())
374            .unwrap_or(false),
375        policy: parse_key_policy(props),
376        tags: parse_tag_list(props),
377    }
378}
379
380/// `LogGroupName` properties on Logs CFN resources may carry either a
381/// log-group ARN (when they come from `{Ref: SomeLogGroup}` in the same
382/// template) or a plain name. Extract the name in either case.
383fn parse_log_group_name(input: &str) -> String {
384    if let Some(rest) = input.strip_prefix("arn:aws:logs:") {
385        if let Some(after) = rest.split(":log-group:").nth(1) {
386            // ARN ends with `:*`; trim it if present.
387            return after.trim_end_matches(":*").to_string();
388        }
389    }
390    input.to_string()
391}
392
393/// Pull the function name out of either a bare name or a Lambda
394/// function ARN. CFN passes `{Ref: SomeFunction}` which resolves to the
395/// function name today, but `{Fn::GetAtt: [F, Arn]}` resolves to the
396/// full ARN; both shapes need to land at the same map key.
397fn parse_lambda_function_name(input: &str) -> String {
398    if let Some(rest) = input.strip_prefix("arn:aws:lambda:") {
399        if let Some(after) = rest.split(":function:").nth(1) {
400            // Trim trailing `:qualifier` (alias / version).
401            return after.split(':').next().unwrap_or(after).to_string();
402        }
403    }
404    input.to_string()
405}
406
407/// All AWS::Lambda::Function CFN properties parsed and pre-defaulted into
408/// their lambda-state shapes. Mirrors the lambda service's
409/// `CreateFunctionInput` so create + update share one parse path.
410struct LambdaFunctionProps {
411    runtime: String,
412    role: String,
413    handler: String,
414    description: String,
415    timeout: i64,
416    memory_size: i64,
417    package_type: String,
418    tags: BTreeMap<String, String>,
419    environment: BTreeMap<String, String>,
420    architectures: Vec<String>,
421    /// Decoded `Code.ZipFile` bytes (base64 → raw). `None` when the
422    /// caller specified `Code.S3Bucket`/`Code.S3Key` or `Code.ImageUri`
423    /// instead — the create/update path resolves S3 separately.
424    code_zip: Option<Vec<u8>>,
425    s3_bucket: Option<String>,
426    s3_key: Option<String>,
427    image_uri: Option<String>,
428    layers: Vec<String>,
429    tracing_mode: Option<String>,
430    kms_key_arn: Option<String>,
431    ephemeral_storage_size: Option<i64>,
432    vpc_config: Option<serde_json::Value>,
433    snap_start: Option<serde_json::Value>,
434    dead_letter_config_arn: Option<String>,
435    file_system_configs: Vec<serde_json::Value>,
436    logging_config: Option<serde_json::Value>,
437}
438
439/// Parse the `Properties` value of an `AWS::Lambda::Function` resource
440/// into a `LambdaFunctionProps`. Defaults match the AWS Lambda
441/// CreateFunction API: `python3.12` runtime, `index.handler` handler,
442/// `Zip` package type, `x86_64` architecture, 3s timeout, 128MB memory.
443fn parse_lambda_function_props(props: &serde_json::Value) -> Result<LambdaFunctionProps, String> {
444    let runtime = props
445        .get("Runtime")
446        .and_then(|v| v.as_str())
447        .unwrap_or("python3.12")
448        .to_string();
449    let role = props
450        .get("Role")
451        .and_then(|v| v.as_str())
452        .unwrap_or_default()
453        .to_string();
454    let handler = props
455        .get("Handler")
456        .and_then(|v| v.as_str())
457        .unwrap_or("index.handler")
458        .to_string();
459    let description = props
460        .get("Description")
461        .and_then(|v| v.as_str())
462        .unwrap_or_default()
463        .to_string();
464    let timeout = props.get("Timeout").and_then(|v| v.as_i64()).unwrap_or(3);
465    let memory_size = props
466        .get("MemorySize")
467        .and_then(|v| v.as_i64())
468        .unwrap_or(128);
469    let architectures = props
470        .get("Architectures")
471        .and_then(|v| v.as_array())
472        .map(|a| {
473            a.iter()
474                .filter_map(|v| v.as_str().map(|s| s.to_string()))
475                .collect::<Vec<_>>()
476        })
477        .unwrap_or_else(|| vec!["x86_64".to_string()]);
478    let package_type = props
479        .get("PackageType")
480        .and_then(|v| v.as_str())
481        .unwrap_or("Zip")
482        .to_string();
483    let environment = props
484        .get("Environment")
485        .and_then(|v| v.get("Variables"))
486        .and_then(|v| v.as_object())
487        .map(|o| {
488            o.iter()
489                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
490                .collect::<BTreeMap<String, String>>()
491        })
492        .unwrap_or_default();
493
494    // CFN tags ride as `[{Key, Value}, ...]`; flatten to the map shape
495    // the lambda crate stores tags in.
496    let tags: BTreeMap<String, String> = props
497        .get("Tags")
498        .and_then(|v| v.as_array())
499        .map(|arr| {
500            arr.iter()
501                .filter_map(|t| {
502                    let k = t.get("Key").and_then(|v| v.as_str())?.to_string();
503                    let v = t.get("Value").and_then(|v| v.as_str())?.to_string();
504                    Some((k, v))
505                })
506                .collect()
507        })
508        .unwrap_or_default();
509
510    let code = props.get("Code");
511    // CFN's `Code.ZipFile` is the raw source code inline (per the
512    // CloudFormation user guide), not base64 like the Lambda
513    // CreateFunction API. We store it as the raw bytes — fakecloud's
514    // Lambda runtime is content-agnostic and just needs *some* deployable
515    // payload to execute.
516    let code_zip = code
517        .and_then(|c| c.get("ZipFile"))
518        .and_then(|v| v.as_str())
519        .map(|s| s.as_bytes().to_vec());
520    let s3_bucket = code
521        .and_then(|c| c.get("S3Bucket"))
522        .and_then(|v| v.as_str())
523        .map(|s| s.to_string());
524    let s3_key = code
525        .and_then(|c| c.get("S3Key"))
526        .and_then(|v| v.as_str())
527        .map(|s| s.to_string());
528    // ImageUri is only meaningful for `PackageType=Image`. Mirroring the
529    // lambda service path, drop it on Zip functions so GetFunction
530    // doesn't return ECR metadata for a ZIP-based function.
531    let image_uri = if package_type == "Image" {
532        code.and_then(|c| c.get("ImageUri"))
533            .and_then(|v| v.as_str())
534            .map(|s| s.to_string())
535    } else {
536        None
537    };
538    if package_type == "Image" && image_uri.is_none() {
539        return Err("Code.ImageUri is required when PackageType is Image".to_string());
540    }
541
542    let layers: Vec<String> = props
543        .get("Layers")
544        .and_then(|v| v.as_array())
545        .map(|arr| {
546            arr.iter()
547                .filter_map(|v| v.as_str().map(String::from))
548                .collect()
549        })
550        .unwrap_or_default();
551
552    let tracing_mode = props
553        .get("TracingConfig")
554        .and_then(|v| v.get("Mode"))
555        .and_then(|v| v.as_str())
556        .map(String::from);
557    let kms_key_arn = props
558        .get("KmsKeyArn")
559        .and_then(|v| v.as_str())
560        .map(String::from);
561    let ephemeral_storage_size = props
562        .get("EphemeralStorage")
563        .and_then(|v| v.get("Size"))
564        .and_then(|v| v.as_i64());
565    let vpc_config = props.get("VpcConfig").filter(|v| v.is_object()).cloned();
566    let snap_start = props.get("SnapStart").filter(|v| v.is_object()).cloned();
567    let dead_letter_config_arn = props
568        .get("DeadLetterConfig")
569        .and_then(|v| v.get("TargetArn"))
570        .and_then(|v| v.as_str())
571        .map(String::from);
572    let file_system_configs = props
573        .get("FileSystemConfigs")
574        .and_then(|v| v.as_array())
575        .cloned()
576        .unwrap_or_default();
577    let logging_config = props
578        .get("LoggingConfig")
579        .filter(|v| v.is_object())
580        .cloned();
581
582    Ok(LambdaFunctionProps {
583        runtime,
584        role,
585        handler,
586        description,
587        timeout,
588        memory_size,
589        package_type,
590        tags,
591        environment,
592        architectures,
593        code_zip,
594        s3_bucket,
595        s3_key,
596        image_uri,
597        layers,
598        tracing_mode,
599        kms_key_arn,
600        ephemeral_storage_size,
601        vpc_config,
602        snap_start,
603        dead_letter_config_arn,
604        file_system_configs,
605        logging_config,
606    })
607}
608
609/// Properties for `AWS::Lambda::EventSourceMapping` shared between the
610/// create and update provisioners. Mirrors the lambda crate
611/// `EventSourceMapping` shape one-for-one — every CFN-mutable field
612/// lands here so update can rewrite without re-parsing.
613struct LambdaEventSourceMappingProps {
614    event_source_arn: String,
615    batch_size: i64,
616    enabled: bool,
617    starting_position: Option<String>,
618    starting_position_timestamp: Option<f64>,
619    parallelization_factor: Option<i64>,
620    maximum_batching_window_in_seconds: Option<i64>,
621    function_response_types: Vec<String>,
622    filter_patterns: Vec<String>,
623    kms_key_arn: Option<String>,
624    metrics_config: Option<serde_json::Value>,
625    destination_config: Option<serde_json::Value>,
626    maximum_retry_attempts: Option<i64>,
627    maximum_record_age_in_seconds: Option<i64>,
628    bisect_batch_on_function_error: Option<bool>,
629    tumbling_window_in_seconds: Option<i64>,
630    topics: Vec<String>,
631    queues: Vec<String>,
632}
633
634/// Parse the `Properties` value of an `AWS::Lambda::EventSourceMapping`
635/// resource. `EventSourceArn` is required on create; updates re-parse but
636/// the value is ignored since the field is immutable.
637fn parse_lambda_event_source_mapping_props(
638    props: &serde_json::Value,
639) -> Result<LambdaEventSourceMappingProps, String> {
640    let event_source_arn = props
641        .get("EventSourceArn")
642        .and_then(|v| v.as_str())
643        .unwrap_or_default()
644        .to_string();
645    let batch_size = props
646        .get("BatchSize")
647        .and_then(|v| v.as_i64())
648        .unwrap_or(10);
649    let enabled = props
650        .get("Enabled")
651        .and_then(|v| v.as_bool())
652        .unwrap_or(true);
653    let starting_position = props
654        .get("StartingPosition")
655        .and_then(|v| v.as_str())
656        .map(|s| s.to_string());
657    let starting_position_timestamp = props
658        .get("StartingPositionTimestamp")
659        .and_then(|v| v.as_f64());
660    let parallelization_factor = props.get("ParallelizationFactor").and_then(|v| v.as_i64());
661    let maximum_batching_window_in_seconds = props
662        .get("MaximumBatchingWindowInSeconds")
663        .and_then(|v| v.as_i64());
664    let function_response_types: Vec<String> = props
665        .get("FunctionResponseTypes")
666        .and_then(|v| v.as_array())
667        .map(|arr| {
668            arr.iter()
669                .filter_map(|v| v.as_str().map(|s| s.to_string()))
670                .collect()
671        })
672        .unwrap_or_default();
673    let filter_patterns: Vec<String> = props
674        .get("FilterCriteria")
675        .and_then(|v| v.get("Filters"))
676        .and_then(|v| v.as_array())
677        .map(|arr| {
678            arr.iter()
679                .filter_map(|f| {
680                    f.get("Pattern")
681                        .and_then(|p| p.as_str())
682                        .map(|s| s.to_string())
683                })
684                .collect()
685        })
686        .unwrap_or_default();
687    let kms_key_arn = props
688        .get("KmsKeyArn")
689        .and_then(|v| v.as_str())
690        .map(|s| s.to_string());
691    let metrics_config = props
692        .get("MetricsConfig")
693        .filter(|v| v.is_object())
694        .cloned();
695    let destination_config = props
696        .get("DestinationConfig")
697        .filter(|v| v.is_object())
698        .cloned();
699    let maximum_retry_attempts = props.get("MaximumRetryAttempts").and_then(|v| v.as_i64());
700    let maximum_record_age_in_seconds = props
701        .get("MaximumRecordAgeInSeconds")
702        .and_then(|v| v.as_i64());
703    let bisect_batch_on_function_error = props
704        .get("BisectBatchOnFunctionError")
705        .and_then(|v| v.as_bool());
706    let tumbling_window_in_seconds = props
707        .get("TumblingWindowInSeconds")
708        .and_then(|v| v.as_i64());
709    let topics: Vec<String> = props
710        .get("Topics")
711        .and_then(|v| v.as_array())
712        .map(|arr| {
713            arr.iter()
714                .filter_map(|v| v.as_str().map(|s| s.to_string()))
715                .collect()
716        })
717        .unwrap_or_default();
718    let queues: Vec<String> = props
719        .get("Queues")
720        .and_then(|v| v.as_array())
721        .map(|arr| {
722            arr.iter()
723                .filter_map(|v| v.as_str().map(|s| s.to_string()))
724                .collect()
725        })
726        .unwrap_or_default();
727
728    Ok(LambdaEventSourceMappingProps {
729        event_source_arn,
730        batch_size,
731        enabled,
732        starting_position,
733        starting_position_timestamp,
734        parallelization_factor,
735        maximum_batching_window_in_seconds,
736        function_response_types,
737        filter_patterns,
738        kms_key_arn,
739        metrics_config,
740        destination_config,
741        maximum_retry_attempts,
742        maximum_record_age_in_seconds,
743        bisect_batch_on_function_error,
744        tumbling_window_in_seconds,
745        topics,
746        queues,
747    })
748}
749
750/// Compute base64-encoded SHA-256 of code bytes — matches what the
751/// lambda service stores in `LambdaFunction.code_sha256`.
752fn sha256_b64(bytes: &[u8]) -> String {
753    use sha2::Digest;
754    let hash = sha2::Sha256::digest(bytes);
755    base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash)
756}
757
758/// Look up the `code_size` for an attached layer-version ARN by walking
759/// the in-process lambda state. Falls back to 0 when the ARN is
760/// unparseable or the layer/version is unknown — same fallback as the
761/// lambda service helper.
762fn layer_code_size(
763    accounts: &fakecloud_core::multi_account::MultiAccountState<fakecloud_lambda::LambdaState>,
764    arn: &str,
765) -> i64 {
766    // arn:aws:lambda:<region>:<account>:layer:<name>:<version>
767    let Some(rest) = arn.strip_prefix("arn:aws:lambda:") else {
768        return 0;
769    };
770    let mut parts = rest.split(':');
771    let _region = parts.next();
772    let Some(account) = parts.next() else {
773        return 0;
774    };
775    if parts.next() != Some("layer") {
776        return 0;
777    }
778    let Some(name) = parts.next() else {
779        return 0;
780    };
781    let Some(ver_str) = parts.next() else {
782        return 0;
783    };
784    let Ok(ver) = ver_str.parse::<i64>() else {
785        return 0;
786    };
787    accounts
788        .get(account)
789        .and_then(|s| s.layers.get(name))
790        .and_then(|l| l.versions.iter().find(|v| v.version == ver))
791        .map(|v| v.code_size)
792        .unwrap_or(0)
793}
794
795/// What a resource provisioner returns. The physical id is what `Ref` resolves
796/// to; `attributes` is what `Fn::GetAtt` resolves to (per-resource-type).
797pub struct ProvisionResult {
798    pub physical_id: String,
799    pub attributes: BTreeMap<String, String>,
800}
801
802impl ProvisionResult {
803    pub fn new(physical_id: impl Into<String>) -> Self {
804        Self {
805            physical_id: physical_id.into(),
806            attributes: BTreeMap::new(),
807        }
808    }
809
810    pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
811        self.attributes.insert(key.to_string(), value.into());
812        self
813    }
814
815    /// Merge another attribute map into this result. Used by update
816    /// handlers that delegate to a create handler but need to keep the
817    /// existing physical id while inheriting the freshly computed
818    /// attributes.
819    pub fn merge_attributes(mut self, other: BTreeMap<String, String>) -> Self {
820        for (k, v) in other {
821            self.attributes.insert(k, v);
822        }
823        self
824    }
825}
826
827/// Holds references to all service states so CloudFormation can provision resources.
828pub struct ResourceProvisioner {
829    pub sqs_state: SharedSqsState,
830    pub sns_state: SharedSnsState,
831    pub ssm_state: SharedSsmState,
832    pub iam_state: SharedIamState,
833    pub s3_state: SharedS3State,
834    pub eventbridge_state: SharedEventBridgeState,
835    pub dynamodb_state: SharedDynamoDbState,
836    pub logs_state: SharedLogsState,
837    pub lambda_state: SharedLambdaState,
838    pub secretsmanager_state: SharedSecretsManagerState,
839    pub kinesis_state: SharedKinesisState,
840    pub kms_state: SharedKmsState,
841    pub ecr_state: SharedEcrState,
842    pub cloudwatch_state: SharedCloudWatchState,
843    pub elbv2_state: SharedElbv2State,
844    pub organizations_state: SharedOrganizationsState,
845    pub cognito_state: SharedCognitoState,
846    pub rds_state: SharedRdsState,
847    pub ecs_state: SharedEcsState,
848    pub acm_state: SharedAcmState,
849    pub elasticache_state: SharedElastiCacheState,
850    pub route53_state: SharedRoute53State,
851    pub cloudfront_state: SharedCloudFrontState,
852    pub stepfunctions_state: SharedStepFunctionsState,
853    pub wafv2_state: SharedWafv2State,
854    pub apigateway_state: SharedApiGatewayState,
855    pub apigatewayv2_state: SharedApiGatewayV2State,
856    pub ses_state: SharedSesState,
857    pub app_autoscaling_state: AppasState,
858    pub athena_state: SharedAthenaState,
859    pub firehose_state: fakecloud_firehose::SharedFirehoseState,
860    pub glue_state: fakecloud_glue::SharedGlueState,
861    pub cloudformation_state: SharedCloudFormationState,
862    pub delivery: Arc<DeliveryBus>,
863    pub account_id: String,
864    pub region: String,
865    pub stack_id: String,
866}
867
868impl ResourceProvisioner {
869    /// Create a resource and return the StackResource with physical ID.
870    pub fn create_resource(&self, resource: &ResourceDefinition) -> Result<StackResource, String> {
871        let result = match resource.resource_type.as_str() {
872            "AWS::SQS::Queue" => self.create_sqs_queue(resource),
873            "AWS::SNS::Topic" => self.create_sns_topic(resource),
874            "AWS::SNS::Subscription" => self.create_sns_subscription(resource),
875            "AWS::SSM::Parameter" => self.create_ssm_parameter(resource),
876            "AWS::IAM::Role" => self.create_iam_role(resource),
877            "AWS::IAM::Policy" => self.create_iam_policy(resource),
878            "AWS::IAM::User" => self.create_iam_user(resource),
879            "AWS::IAM::Group" => self.create_iam_group(resource),
880            "AWS::IAM::ManagedPolicy" => self.create_iam_managed_policy(resource),
881            "AWS::IAM::UserToGroupAddition" => self.create_iam_user_to_group_addition(resource),
882            "AWS::IAM::AccessKey" => self.create_iam_access_key(resource),
883            "AWS::IAM::InstanceProfile" => self.create_iam_instance_profile(resource),
884            "AWS::IAM::OIDCProvider" => self.create_iam_oidc_provider(resource),
885            "AWS::IAM::SAMLProvider" => self.create_iam_saml_provider(resource),
886            "AWS::IAM::ServiceLinkedRole" => self.create_iam_service_linked_role(resource),
887            "AWS::IAM::VirtualMFADevice" => self.create_iam_virtual_mfa_device(resource),
888            "AWS::S3::Bucket" => self.create_s3_bucket(resource),
889            "AWS::Events::Rule" => self.create_eventbridge_rule(resource),
890            "AWS::Events::Connection" => self.create_eventbridge_connection(resource),
891            "AWS::Events::ApiDestination" => self.create_eventbridge_api_destination(resource),
892            "AWS::Events::Archive" => self.create_eventbridge_archive(resource),
893            "AWS::Events::EventBus" => self.create_eventbridge_event_bus(resource),
894            "AWS::Events::EventBusPolicy" => self.create_eventbridge_event_bus_policy(resource),
895            "AWS::Events::Endpoint" => self.create_eventbridge_endpoint(resource),
896            "AWS::DynamoDB::Table" => self.create_dynamodb_table(resource),
897            "AWS::Logs::LogGroup" => self.create_log_group(resource),
898            "AWS::Logs::LogStream" => self.create_log_stream(resource),
899            "AWS::Logs::MetricFilter" => self.create_metric_filter(resource),
900            "AWS::Logs::SubscriptionFilter" => self.create_subscription_filter(resource),
901            "AWS::Logs::Destination" => self.create_logs_destination(resource),
902            "AWS::Logs::ResourcePolicy" => self.create_logs_resource_policy(resource),
903            "AWS::Logs::QueryDefinition" => self.create_logs_query_definition(resource),
904            "AWS::Logs::Delivery" => self.create_logs_delivery(resource),
905            "AWS::Logs::DeliveryDestination" => self.create_logs_delivery_destination(resource),
906            "AWS::Logs::DeliverySource" => self.create_logs_delivery_source(resource),
907            "AWS::Lambda::Function" => self.create_lambda_function(resource),
908            "AWS::Lambda::Permission" => self.create_lambda_permission(resource),
909            "AWS::Lambda::EventSourceMapping" => self.create_lambda_event_source_mapping(resource),
910            "AWS::Lambda::LayerVersion" => self.create_lambda_layer_version(resource),
911            "AWS::Lambda::Url" => self.create_lambda_url(resource),
912            "AWS::Lambda::Alias" => self.create_lambda_alias(resource),
913            "AWS::Lambda::Version" => self.create_lambda_version(resource),
914            "AWS::SecretsManager::Secret" => self.create_secrets_manager_secret(resource),
915            "AWS::Kinesis::Stream" => self.create_kinesis_stream(resource),
916            "AWS::Kinesis::StreamConsumer" => self.create_kinesis_stream_consumer(resource),
917            "AWS::KMS::Key" => self.create_kms_key(resource),
918            "AWS::KMS::Alias" => self.create_kms_alias(resource),
919            "AWS::KMS::ReplicaKey" => self.create_kms_replica_key(resource),
920            "AWS::ECR::Repository" => self.create_ecr_repository(resource),
921            "AWS::ECR::RepositoryPolicy" => self.create_ecr_repository_policy(resource),
922            "AWS::ECR::LifecyclePolicy" => self.create_ecr_lifecycle_policy(resource),
923            "AWS::ECR::RegistryPolicy" => self.create_ecr_registry_policy(resource),
924            "AWS::ECR::ReplicationConfiguration" => {
925                self.create_ecr_replication_configuration(resource)
926            }
927            "AWS::ECR::RegistryScanningConfiguration" => {
928                self.create_ecr_registry_scanning_configuration(resource)
929            }
930            "AWS::ECR::PullThroughCacheRule" => self.create_ecr_pull_through_cache_rule(resource),
931            "AWS::CloudWatch::Alarm" => self.create_cloudwatch_alarm(resource),
932            "AWS::CloudWatch::Dashboard" => self.create_cloudwatch_dashboard(resource),
933            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
934                self.create_elbv2_load_balancer(resource)
935            }
936            "AWS::ElasticLoadBalancingV2::TargetGroup" => self.create_elbv2_target_group(resource),
937            "AWS::ElasticLoadBalancingV2::Listener" => self.create_elbv2_listener(resource),
938            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
939                self.create_elbv2_listener_rule(resource)
940            }
941            "AWS::ElasticLoadBalancingV2::ListenerCertificate" => {
942                self.create_elbv2_listener_certificate(resource)
943            }
944            "AWS::ElasticLoadBalancingV2::TrustStore" => self.create_elbv2_trust_store(resource),
945            "AWS::Organizations::Organization" => self.create_organization(resource),
946            "AWS::Organizations::OrganizationalUnit" => self.create_organization_unit(resource),
947            "AWS::Organizations::Account" => self.create_organization_account(resource),
948            "AWS::Organizations::Policy" => self.create_organization_policy(resource),
949            "AWS::Organizations::ResourcePolicy" => {
950                self.create_organization_resource_policy(resource)
951            }
952            "AWS::Cognito::UserPool" => self.create_cognito_user_pool(resource),
953            "AWS::Cognito::UserPoolClient" => self.create_cognito_user_pool_client(resource),
954            "AWS::Cognito::UserPoolDomain" => self.create_cognito_user_pool_domain(resource),
955            "AWS::Cognito::IdentityPool" => self.create_cognito_identity_pool(resource),
956            "AWS::Cognito::IdentityPoolRoleAttachment" => {
957                self.create_cognito_identity_pool_role_attachment(resource)
958            }
959            "AWS::RDS::DBSubnetGroup" => self.create_rds_subnet_group(resource),
960            "AWS::RDS::DBParameterGroup" => self.create_rds_parameter_group(resource),
961            "AWS::RDS::DBClusterParameterGroup" => {
962                self.create_rds_cluster_parameter_group(resource)
963            }
964            "AWS::RDS::OptionGroup" => self.create_rds_option_group(resource),
965            "AWS::RDS::EventSubscription" => self.create_rds_event_subscription(resource),
966            "AWS::RDS::DBSecurityGroup" => self.create_rds_security_group(resource),
967            "AWS::RDS::DBProxy" => self.create_rds_db_proxy(resource),
968            "AWS::RDS::DBInstance" => self.create_rds_db_instance(resource),
969            "AWS::RDS::DBCluster" => self.create_rds_db_cluster(resource),
970            "AWS::ECS::Cluster" => self.create_ecs_cluster(resource),
971            "AWS::ECS::TaskDefinition" => self.create_ecs_task_definition(resource),
972            "AWS::ECS::Service" => self.create_ecs_service(resource),
973            "AWS::ECS::CapacityProvider" => self.create_ecs_capacity_provider(resource),
974            "AWS::CertificateManager::Certificate" => self.create_acm_certificate(resource),
975            "AWS::CertificateManager::Account" => self.create_acm_account(resource),
976            "AWS::ElastiCache::ParameterGroup" => self.create_ec_parameter_group(resource),
977            "AWS::ElastiCache::SubnetGroup" => self.create_ec_subnet_group(resource),
978            "AWS::ElastiCache::SecurityGroup" => self.create_ec_security_group(resource),
979            "AWS::ElastiCache::User" => self.create_ec_user(resource),
980            "AWS::ElastiCache::UserGroup" => self.create_ec_user_group(resource),
981            "AWS::ElastiCache::CacheCluster" => self.create_ec_cache_cluster(resource),
982            "AWS::ElastiCache::ReplicationGroup" => self.create_ec_replication_group(resource),
983            "AWS::Route53::HostedZone" => self.create_route53_hosted_zone(resource),
984            "AWS::Route53::RecordSet" => self.create_route53_record_set(resource),
985            "AWS::Route53::HealthCheck" => self.create_route53_health_check(resource),
986            "AWS::Route53::DNSSEC" => self.create_route53_dnssec(resource),
987            "AWS::Route53::KeySigningKey" => self.create_route53_key_signing_key(resource),
988            "AWS::CloudFront::CloudFrontOriginAccessIdentity" => {
989                self.create_cf_origin_access_identity(resource)
990            }
991            "AWS::CloudFront::Distribution" => self.create_cf_distribution(resource),
992            "AWS::CloudFront::OriginAccessControl" => {
993                self.create_cf_origin_access_control(resource)
994            }
995            "AWS::CloudFront::PublicKey" => self.create_cf_public_key(resource),
996            "AWS::CloudFront::KeyGroup" => self.create_cf_key_group(resource),
997            "AWS::CloudFront::Function" => self.create_cf_function(resource),
998            "AWS::CloudFront::CachePolicy" => self.create_cf_cache_policy(resource),
999            "AWS::CloudFront::OriginRequestPolicy" => {
1000                self.create_cf_origin_request_policy(resource)
1001            }
1002            "AWS::CloudFront::ResponseHeadersPolicy" => {
1003                self.create_cf_response_headers_policy(resource)
1004            }
1005            "AWS::StepFunctions::StateMachine" => self.create_sfn_state_machine(resource),
1006            "AWS::StepFunctions::Activity" => self.create_sfn_activity(resource),
1007            "AWS::StepFunctions::StateMachineVersion" => self.create_sfn_version(resource),
1008            "AWS::StepFunctions::StateMachineAlias" => self.create_sfn_alias(resource),
1009            "AWS::WAFv2::WebACL" => self.create_wafv2_web_acl(resource),
1010            "AWS::WAFv2::IPSet" => self.create_wafv2_ip_set(resource),
1011            "AWS::WAFv2::RegexPatternSet" => self.create_wafv2_regex_pattern_set(resource),
1012            "AWS::WAFv2::RuleGroup" => self.create_wafv2_rule_group(resource),
1013            "AWS::WAFv2::LoggingConfiguration" => self.create_wafv2_logging_configuration(resource),
1014            "AWS::WAFv2::WebACLAssociation" => self.create_wafv2_web_acl_association(resource),
1015            "AWS::ApiGateway::RestApi" => self.create_apigw_rest_api(resource),
1016            "AWS::ApiGateway::Resource" => self.create_apigw_resource(resource),
1017            "AWS::ApiGateway::Method" => self.create_apigw_method(resource),
1018            "AWS::ApiGateway::Deployment" => self.create_apigw_deployment(resource),
1019            "AWS::ApiGateway::Stage" => self.create_apigw_stage(resource),
1020            "AWS::ApiGateway::Authorizer" => self.create_apigw_authorizer(resource),
1021            "AWS::ApiGateway::RequestValidator" => self.create_apigw_request_validator(resource),
1022            "AWS::ApiGateway::Model" => self.create_apigw_model(resource),
1023            "AWS::ApiGateway::GatewayResponse" => self.create_apigw_gateway_response(resource),
1024            "AWS::ApiGateway::UsagePlan" => self.create_apigw_usage_plan(resource),
1025            "AWS::ApiGateway::ApiKey" => self.create_apigw_api_key(resource),
1026            "AWS::ApiGateway::UsagePlanKey" => self.create_apigw_usage_plan_key(resource),
1027            "AWS::ApiGateway::DomainName" => self.create_apigw_domain_name(resource),
1028            "AWS::ApiGateway::BasePathMapping" => self.create_apigw_base_path_mapping(resource),
1029            "AWS::ApiGatewayV2::Api" => self.create_apigwv2_api(resource),
1030            "AWS::ApiGatewayV2::Route" => self.create_apigwv2_route(resource),
1031            "AWS::ApiGatewayV2::Integration" => self.create_apigwv2_integration(resource),
1032            "AWS::ApiGatewayV2::IntegrationResponse" => {
1033                self.create_apigwv2_integration_response(resource)
1034            }
1035            "AWS::ApiGatewayV2::RouteResponse" => self.create_apigwv2_route_response(resource),
1036            "AWS::ApiGatewayV2::Stage" => self.create_apigwv2_stage(resource),
1037            "AWS::ApiGatewayV2::Deployment" => self.create_apigwv2_deployment(resource),
1038            "AWS::ApiGatewayV2::Authorizer" => self.create_apigwv2_authorizer(resource),
1039            "AWS::ApiGatewayV2::DomainName" => self.create_apigwv2_domain_name(resource),
1040            "AWS::ApiGatewayV2::ApiMapping" => self.create_apigwv2_api_mapping(resource),
1041            "AWS::ApiGatewayV2::VpcLink" => self.create_apigwv2_vpc_link(resource),
1042            "AWS::ApiGatewayV2::Model" => self.create_apigwv2_model(resource),
1043            "AWS::SES::ConfigurationSet" => self.create_ses_configuration_set(resource),
1044            "AWS::SES::ConfigurationSetEventDestination" => {
1045                self.create_ses_event_destination(resource)
1046            }
1047            "AWS::SES::EmailIdentity" => self.create_ses_email_identity(resource),
1048            "AWS::SES::Template" => self.create_ses_template(resource),
1049            "AWS::SES::ContactList" => self.create_ses_contact_list(resource),
1050            "AWS::SES::DedicatedIpPool" => self.create_ses_dedicated_ip_pool(resource),
1051            "AWS::SES::ReceiptRule" => self.create_ses_receipt_rule(resource),
1052            "AWS::SES::ReceiptRuleSet" => self.create_ses_receipt_rule_set(resource),
1053            "AWS::SES::ReceiptFilter" => self.create_ses_receipt_filter(resource),
1054            "AWS::SES::VdmAttributes" => self.create_ses_vdm_attributes(resource),
1055            "AWS::SecretsManager::RotationSchedule" => {
1056                self.create_secrets_manager_rotation_schedule(resource)
1057            }
1058            "AWS::SecretsManager::ResourcePolicy" => {
1059                self.create_secrets_manager_resource_policy(resource)
1060            }
1061            "AWS::SecretsManager::SecretTargetAttachment" => {
1062                self.create_secrets_manager_target_attachment(resource)
1063            }
1064            "AWS::ApplicationAutoScaling::ScalableTarget" => {
1065                self.create_application_autoscaling_scalable_target(resource)
1066            }
1067            "AWS::ApplicationAutoScaling::ScalingPolicy" => {
1068                self.create_application_autoscaling_scaling_policy(resource)
1069            }
1070            "AWS::Athena::DataCatalog" => self.create_athena_data_catalog(resource),
1071            "AWS::Athena::NamedQuery" => self.create_athena_named_query(resource),
1072            "AWS::Athena::WorkGroup" => self.create_athena_work_group(resource),
1073            "AWS::Athena::PreparedStatement" => self.create_athena_prepared_statement(resource),
1074            "AWS::KinesisFirehose::DeliveryStream" => {
1075                self.create_firehose_delivery_stream(resource)
1076            }
1077            "AWS::Glue::Database" => self.create_glue_database(resource),
1078            "AWS::CloudFormation::Stack" => self.create_cloudformation_stack(resource),
1079            "AWS::Glue::Table" => self.create_glue_table(resource),
1080            "AWS::Glue::Partition" => self.create_glue_partition(resource),
1081            t if t.starts_with("Custom::") || t == "AWS::CloudFormation::CustomResource" => self
1082                .create_custom_resource(resource)
1083                .map(ProvisionResult::new),
1084            other => Err(format!("Unsupported resource type: {other}")),
1085        };
1086
1087        let is_custom = resource.resource_type.starts_with("Custom::")
1088            || resource.resource_type == "AWS::CloudFormation::CustomResource";
1089        let service_token = if is_custom {
1090            resource
1091                .properties
1092                .get("ServiceToken")
1093                .and_then(|v| v.as_str())
1094                .map(|s| s.to_string())
1095        } else {
1096            None
1097        };
1098
1099        result.map(|res| StackResource {
1100            logical_id: resource.logical_id.clone(),
1101            physical_id: res.physical_id,
1102            resource_type: resource.resource_type.clone(),
1103            status: "CREATE_COMPLETE".to_string(),
1104            service_token,
1105            attributes: res.attributes,
1106        })
1107    }
1108
1109    /// Apply a property update to an existing stack resource. Returns
1110    /// `Ok(Some(updated))` when the resource type supports in-place updates
1111    /// (the caller swaps the resulting `StackResource` for the old one) or
1112    /// `Ok(None)` when the type has no update path defined (the caller
1113    /// leaves the existing resource alone). `Err` propagates a
1114    /// resource-level failure up to the stack-level UPDATE_FAILED status.
1115    pub fn update_resource(
1116        &self,
1117        existing: &StackResource,
1118        new_def: &ResourceDefinition,
1119    ) -> Result<Option<StackResource>, String> {
1120        let result = match new_def.resource_type.as_str() {
1121            "AWS::Lambda::Function" => Some(self.update_lambda_function(existing, new_def)?),
1122            "AWS::Lambda::Permission" => Some(self.update_lambda_permission(existing, new_def)?),
1123            "AWS::Lambda::EventSourceMapping" => {
1124                Some(self.update_lambda_event_source_mapping(existing, new_def)?)
1125            }
1126            "AWS::Lambda::LayerVersion" => {
1127                Some(self.update_lambda_layer_version(existing, new_def)?)
1128            }
1129            "AWS::Lambda::Url" => Some(self.update_lambda_url(existing, new_def)?),
1130            "AWS::Lambda::Alias" => Some(self.update_lambda_alias(existing, new_def)?),
1131            "AWS::Lambda::Version" => Some(self.update_lambda_version(existing, new_def)?),
1132            "AWS::ApiGateway::RestApi" => Some(self.update_apigw_rest_api(existing, new_def)?),
1133            "AWS::ApiGateway::Resource" => Some(self.update_apigw_resource(existing, new_def)?),
1134            "AWS::ApiGateway::Method" => Some(self.update_apigw_method(existing, new_def)?),
1135            "AWS::ApiGateway::Deployment" => Some(self.update_apigw_deployment(existing, new_def)?),
1136            "AWS::ApiGateway::Stage" => Some(self.update_apigw_stage(existing, new_def)?),
1137            "AWS::ApiGateway::Authorizer" => Some(self.update_apigw_authorizer(existing, new_def)?),
1138            "AWS::ApiGateway::RequestValidator" => {
1139                Some(self.update_apigw_request_validator(existing, new_def)?)
1140            }
1141            "AWS::ApiGateway::Model" => Some(self.update_apigw_model(existing, new_def)?),
1142            "AWS::ApiGateway::GatewayResponse" => {
1143                Some(self.update_apigw_gateway_response(existing, new_def)?)
1144            }
1145            "AWS::ApiGateway::UsagePlan" => Some(self.update_apigw_usage_plan(existing, new_def)?),
1146            "AWS::ApiGateway::ApiKey" => Some(self.update_apigw_api_key(existing, new_def)?),
1147            "AWS::ApiGateway::UsagePlanKey" => {
1148                Some(self.update_apigw_usage_plan_key(existing, new_def)?)
1149            }
1150            "AWS::ApiGateway::DomainName" => {
1151                Some(self.update_apigw_domain_name(existing, new_def)?)
1152            }
1153            "AWS::ApiGateway::BasePathMapping" => {
1154                Some(self.update_apigw_base_path_mapping(existing, new_def)?)
1155            }
1156            "AWS::ApiGatewayV2::Api" => Some(self.update_apigwv2_api(existing, new_def)?),
1157            "AWS::ApiGatewayV2::Route" => Some(self.update_apigwv2_route(existing, new_def)?),
1158            "AWS::ApiGatewayV2::Integration" => {
1159                Some(self.update_apigwv2_integration(existing, new_def)?)
1160            }
1161            "AWS::ApiGatewayV2::IntegrationResponse" => {
1162                Some(self.update_apigwv2_integration_response(existing, new_def)?)
1163            }
1164            "AWS::ApiGatewayV2::RouteResponse" => {
1165                Some(self.update_apigwv2_route_response(existing, new_def)?)
1166            }
1167            "AWS::ApiGatewayV2::Stage" => Some(self.update_apigwv2_stage(existing, new_def)?),
1168            "AWS::ApiGatewayV2::Deployment" => {
1169                Some(self.update_apigwv2_deployment(existing, new_def)?)
1170            }
1171            "AWS::ApiGatewayV2::Authorizer" => {
1172                Some(self.update_apigwv2_authorizer(existing, new_def)?)
1173            }
1174            "AWS::ApiGatewayV2::DomainName" => {
1175                Some(self.update_apigwv2_domain_name(existing, new_def)?)
1176            }
1177            "AWS::ApiGatewayV2::ApiMapping" => {
1178                Some(self.update_apigwv2_api_mapping(existing, new_def)?)
1179            }
1180            "AWS::ApiGatewayV2::VpcLink" => Some(self.update_apigwv2_vpc_link(existing, new_def)?),
1181            "AWS::ApiGatewayV2::Model" => Some(self.update_apigwv2_model(existing, new_def)?),
1182            "AWS::ECS::Cluster" => Some(self.update_ecs_cluster(existing, new_def)?),
1183            "AWS::ECS::Service" => Some(self.update_ecs_service(existing, new_def)?),
1184            "AWS::ECS::TaskDefinition" => Some(self.update_ecs_task_definition(existing, new_def)?),
1185            "AWS::ECS::CapacityProvider" => {
1186                Some(self.update_ecs_capacity_provider(existing, new_def)?)
1187            }
1188            "AWS::ECR::Repository" => Some(self.update_ecr_repository(existing, new_def)?),
1189            "AWS::ECR::RepositoryPolicy" => {
1190                Some(self.update_ecr_repository_policy(existing, new_def)?)
1191            }
1192            "AWS::ECR::LifecyclePolicy" => {
1193                Some(self.update_ecr_lifecycle_policy(existing, new_def)?)
1194            }
1195            "AWS::ECR::RegistryPolicy" => Some(self.update_ecr_registry_policy(existing, new_def)?),
1196            "AWS::ECR::ReplicationConfiguration" => {
1197                Some(self.update_ecr_replication_configuration(existing, new_def)?)
1198            }
1199            "AWS::ECR::RegistryScanningConfiguration" => {
1200                Some(self.update_ecr_registry_scanning_configuration(existing, new_def)?)
1201            }
1202            "AWS::ECR::PullThroughCacheRule" => {
1203                Some(self.update_ecr_pull_through_cache_rule(existing, new_def)?)
1204            }
1205            "AWS::KMS::Key" => Some(self.update_kms_key(existing, new_def)?),
1206            "AWS::KMS::ReplicaKey" => Some(self.update_kms_replica_key(existing, new_def)?),
1207            "AWS::KMS::Alias" => Some(self.update_kms_alias(existing, new_def)?),
1208            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
1209                Some(self.update_elbv2_load_balancer(existing, new_def)?)
1210            }
1211            "AWS::ElasticLoadBalancingV2::TargetGroup" => {
1212                Some(self.update_elbv2_target_group(existing, new_def)?)
1213            }
1214            "AWS::ElasticLoadBalancingV2::Listener" => {
1215                Some(self.update_elbv2_listener(existing, new_def)?)
1216            }
1217            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
1218                Some(self.update_elbv2_listener_rule(existing, new_def)?)
1219            }
1220            "AWS::ElasticLoadBalancingV2::ListenerCertificate" => {
1221                Some(self.update_elbv2_listener_certificate(existing, new_def)?)
1222            }
1223            "AWS::ElasticLoadBalancingV2::TrustStore" => {
1224                Some(self.update_elbv2_trust_store(existing, new_def)?)
1225            }
1226            "AWS::CloudWatch::Alarm" => Some(self.update_cloudwatch_alarm(existing, new_def)?),
1227            "AWS::CloudWatch::Dashboard" => {
1228                Some(self.update_cloudwatch_dashboard(existing, new_def)?)
1229            }
1230            _ => None,
1231        };
1232
1233        Ok(result.map(|res| StackResource {
1234            logical_id: existing.logical_id.clone(),
1235            physical_id: res.physical_id,
1236            resource_type: existing.resource_type.clone(),
1237            status: "UPDATE_COMPLETE".to_string(),
1238            service_token: existing.service_token.clone(),
1239            attributes: res.attributes,
1240        }))
1241    }
1242
1243    /// Resolve a `Fn::GetAtt` against a previously provisioned resource.
1244    /// Returns the attribute value as a string, or `None` if the resource
1245    /// type doesn't expose that attribute (caller falls back to a placeholder
1246    /// so multi-pass provisioning can retry).
1247    ///
1248    /// The lookup first checks attributes captured at create time on the
1249    /// `StackResource`, then falls back to live service-state queries for
1250    /// the well-known attribute names of each resource type. This means
1251    /// attributes that change after creation (e.g. Lambda `FunctionUrl`)
1252    /// resolve correctly even when the URL was added in a separate pass.
1253    pub fn get_att(&self, resource: &StackResource, attribute: &str) -> Option<String> {
1254        // Captured attributes are the source of truth — they were computed
1255        // at create time and never go stale for the resources we ship today.
1256        if let Some(v) = resource.attributes.get(attribute) {
1257            return Some(v.clone());
1258        }
1259        // Live-state fallback for attributes that aren't pre-captured. This
1260        // is the extension point for future provisioners.
1261        match resource.resource_type.as_str() {
1262            "AWS::S3::Bucket" => self.get_att_s3_bucket(&resource.physical_id, attribute),
1263            "AWS::Lambda::Function" => {
1264                self.get_att_lambda_function(&resource.physical_id, attribute)
1265            }
1266            "AWS::IAM::Role" => self.get_att_iam_role(&resource.physical_id, attribute),
1267            "AWS::SQS::Queue" => self.get_att_sqs_queue(&resource.physical_id, attribute),
1268            "AWS::SNS::Topic" => self.get_att_sns_topic(&resource.physical_id, attribute),
1269            "AWS::DynamoDB::Table" => self.get_att_dynamodb_table(&resource.physical_id, attribute),
1270            "AWS::KMS::Key" => self.get_att_kms_key(&resource.physical_id, attribute),
1271            "AWS::SecretsManager::Secret" => {
1272                self.get_att_secrets_manager_secret(&resource.physical_id, attribute)
1273            }
1274            "AWS::CloudFront::Distribution" => {
1275                self.get_att_cf_distribution(&resource.physical_id, attribute)
1276            }
1277            "AWS::ECS::Cluster" => self.get_att_ecs_cluster(&resource.physical_id, attribute),
1278            "AWS::ECS::Service" => self.get_att_ecs_service(&resource.physical_id, attribute),
1279            "AWS::ECS::CapacityProvider" => {
1280                self.get_att_ecs_capacity_provider(&resource.physical_id, attribute)
1281            }
1282            "AWS::ECR::Repository" => self.get_att_ecr_repository(&resource.physical_id, attribute),
1283            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
1284                self.get_att_elbv2_load_balancer(&resource.physical_id, attribute)
1285            }
1286            "AWS::ElasticLoadBalancingV2::TargetGroup" => {
1287                self.get_att_elbv2_target_group(&resource.physical_id, attribute)
1288            }
1289            "AWS::ElasticLoadBalancingV2::Listener" => {
1290                self.get_att_elbv2_listener(&resource.physical_id, attribute)
1291            }
1292            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
1293                self.get_att_elbv2_listener_rule(&resource.physical_id, attribute)
1294            }
1295            "AWS::ElasticLoadBalancingV2::TrustStore" => {
1296                self.get_att_elbv2_trust_store(&resource.physical_id, attribute)
1297            }
1298            "AWS::WAFv2::WebACL" => self.get_att_wafv2_web_acl(&resource.physical_id, attribute),
1299            "AWS::WAFv2::IPSet" => self.get_att_wafv2_ip_set(&resource.physical_id, attribute),
1300            "AWS::WAFv2::RegexPatternSet" => {
1301                self.get_att_wafv2_regex_pattern_set(&resource.physical_id, attribute)
1302            }
1303            "AWS::WAFv2::RuleGroup" => {
1304                self.get_att_wafv2_rule_group(&resource.physical_id, attribute)
1305            }
1306            "AWS::SES::ConfigurationSet" => {
1307                self.get_att_ses_configuration_set(&resource.physical_id, attribute)
1308            }
1309            "AWS::SES::EmailIdentity" => {
1310                self.get_att_ses_email_identity(&resource.physical_id, attribute)
1311            }
1312            "AWS::SES::Template" => self.get_att_ses_template(&resource.physical_id, attribute),
1313            "AWS::SES::ContactList" => {
1314                self.get_att_ses_contact_list(&resource.physical_id, attribute)
1315            }
1316            "AWS::SES::DedicatedIpPool" => {
1317                self.get_att_ses_dedicated_ip_pool(&resource.physical_id, attribute)
1318            }
1319            "AWS::SES::ReceiptRuleSet" => {
1320                self.get_att_ses_receipt_rule_set(&resource.physical_id, attribute)
1321            }
1322            "AWS::Athena::DataCatalog" => {
1323                self.get_att_athena_data_catalog(&resource.physical_id, attribute)
1324            }
1325            "AWS::Athena::NamedQuery" => {
1326                self.get_att_athena_named_query(&resource.physical_id, attribute)
1327            }
1328            "AWS::Athena::WorkGroup" => {
1329                self.get_att_athena_work_group(&resource.physical_id, attribute)
1330            }
1331            "AWS::Athena::PreparedStatement" => {
1332                self.get_att_athena_prepared_statement(&resource.physical_id, attribute)
1333            }
1334            "AWS::CloudFormation::Stack" => {
1335                self.get_att_cloudformation_stack(&resource.physical_id, attribute)
1336            }
1337            _ => None,
1338        }
1339    }
1340
1341    fn get_att_ses_configuration_set(&self, physical_id: &str, attribute: &str) -> Option<String> {
1342        let mut accounts = self.ses_state.write();
1343        let state = accounts.get_or_create(&self.account_id);
1344        let cs = state.configuration_sets.get(physical_id)?;
1345        match attribute {
1346            "Name" => Some(cs.name.clone()),
1347            _ => None,
1348        }
1349    }
1350
1351    fn get_att_ses_email_identity(&self, physical_id: &str, attribute: &str) -> Option<String> {
1352        let mut accounts = self.ses_state.write();
1353        let state = accounts.get_or_create(&self.account_id);
1354        let id = state.identities.get(physical_id)?;
1355        match attribute {
1356            "IdentityName" => Some(id.identity_name.clone()),
1357            _ => None,
1358        }
1359    }
1360
1361    fn get_att_ses_template(&self, physical_id: &str, attribute: &str) -> Option<String> {
1362        let mut accounts = self.ses_state.write();
1363        let state = accounts.get_or_create(&self.account_id);
1364        let tpl = state.templates.get(physical_id)?;
1365        match attribute {
1366            "TemplateName" => Some(tpl.template_name.clone()),
1367            _ => None,
1368        }
1369    }
1370
1371    fn get_att_ses_contact_list(&self, physical_id: &str, attribute: &str) -> Option<String> {
1372        let mut accounts = self.ses_state.write();
1373        let state = accounts.get_or_create(&self.account_id);
1374        let cl = state.contact_lists.get(physical_id)?;
1375        match attribute {
1376            "ContactListName" => Some(cl.contact_list_name.clone()),
1377            _ => None,
1378        }
1379    }
1380
1381    fn get_att_ses_dedicated_ip_pool(&self, physical_id: &str, attribute: &str) -> Option<String> {
1382        let mut accounts = self.ses_state.write();
1383        let state = accounts.get_or_create(&self.account_id);
1384        let pool = state.dedicated_ip_pools.get(physical_id)?;
1385        match attribute {
1386            "PoolName" => Some(pool.pool_name.clone()),
1387            _ => None,
1388        }
1389    }
1390
1391    fn get_att_ses_receipt_rule_set(&self, physical_id: &str, attribute: &str) -> Option<String> {
1392        let mut accounts = self.ses_state.write();
1393        let state = accounts.get_or_create(&self.account_id);
1394        let rs = state.receipt_rule_sets.get(physical_id)?;
1395        match attribute {
1396            "RuleSetName" => Some(rs.name.clone()),
1397            _ => None,
1398        }
1399    }
1400
1401    fn get_att_wafv2_web_acl(&self, physical_id: &str, attribute: &str) -> Option<String> {
1402        let mut accounts = self.wafv2_state.write();
1403        let state = accounts
1404            .accounts
1405            .entry(self.account_id.clone())
1406            .or_default();
1407        let acl = state.web_acls.values().find(|a| a.arn == physical_id)?;
1408        match attribute {
1409            "Arn" => Some(acl.arn.clone()),
1410            "Id" => Some(acl.id.clone()),
1411            "Name" => Some(acl.name.clone()),
1412            "LabelNamespace" => Some(acl.label_namespace.clone()),
1413            "Capacity" => Some(acl.capacity.to_string()),
1414            _ => None,
1415        }
1416    }
1417
1418    fn get_att_wafv2_ip_set(&self, physical_id: &str, attribute: &str) -> Option<String> {
1419        let mut accounts = self.wafv2_state.write();
1420        let state = accounts
1421            .accounts
1422            .entry(self.account_id.clone())
1423            .or_default();
1424        let ip_set = state.ip_sets.values().find(|i| i.arn == physical_id)?;
1425        match attribute {
1426            "Arn" => Some(ip_set.arn.clone()),
1427            "Id" => Some(ip_set.id.clone()),
1428            "Name" => Some(ip_set.name.clone()),
1429            _ => None,
1430        }
1431    }
1432
1433    fn get_att_wafv2_regex_pattern_set(
1434        &self,
1435        physical_id: &str,
1436        attribute: &str,
1437    ) -> Option<String> {
1438        let mut accounts = self.wafv2_state.write();
1439        let state = accounts
1440            .accounts
1441            .entry(self.account_id.clone())
1442            .or_default();
1443        let set = state
1444            .regex_pattern_sets
1445            .values()
1446            .find(|r| r.arn == physical_id)?;
1447        match attribute {
1448            "Arn" => Some(set.arn.clone()),
1449            "Id" => Some(set.id.clone()),
1450            "Name" => Some(set.name.clone()),
1451            _ => None,
1452        }
1453    }
1454
1455    fn get_att_wafv2_rule_group(&self, physical_id: &str, attribute: &str) -> Option<String> {
1456        let mut accounts = self.wafv2_state.write();
1457        let state = accounts
1458            .accounts
1459            .entry(self.account_id.clone())
1460            .or_default();
1461        let rg = state.rule_groups.values().find(|r| r.arn == physical_id)?;
1462        match attribute {
1463            "Arn" => Some(rg.arn.clone()),
1464            "Id" => Some(rg.id.clone()),
1465            "Name" => Some(rg.name.clone()),
1466            _ => None,
1467        }
1468    }
1469
1470    fn get_att_s3_bucket(&self, physical_id: &str, attribute: &str) -> Option<String> {
1471        let mut accounts = self.s3_state.write();
1472        let state = accounts.get_or_create(&self.account_id);
1473        let bucket = state.buckets.get(physical_id)?;
1474        match attribute {
1475            "Arn" => Some(format!("arn:aws:s3:::{}", bucket.name)),
1476            "DomainName" => Some(format!("{}.s3.amazonaws.com", bucket.name)),
1477            "RegionalDomainName" => {
1478                Some(format!("{}.s3.{}.amazonaws.com", bucket.name, self.region))
1479            }
1480            "DualStackDomainName" => Some(format!(
1481                "{}.s3.dualstack.{}.amazonaws.com",
1482                bucket.name, self.region
1483            )),
1484            "WebsiteURL" => Some(format!(
1485                "http://{}.s3-website-{}.amazonaws.com",
1486                bucket.name, self.region
1487            )),
1488            _ => None,
1489        }
1490    }
1491
1492    fn get_att_lambda_function(&self, physical_id: &str, attribute: &str) -> Option<String> {
1493        let function_name = parse_lambda_function_name(physical_id);
1494        let mut accounts = self.lambda_state.write();
1495        let state = accounts.get_or_create(&self.account_id);
1496        match attribute {
1497            "Arn" => state
1498                .functions
1499                .get(&function_name)
1500                .map(|f| f.function_arn.clone()),
1501            "FunctionUrl" => state
1502                .function_url_configs
1503                .get(&function_name)
1504                .map(|u| u.function_url.clone()),
1505            "Version" => state
1506                .functions
1507                .get(&function_name)
1508                .map(|f| f.version.clone()),
1509            _ => None,
1510        }
1511    }
1512
1513    fn get_att_iam_role(&self, physical_id: &str, attribute: &str) -> Option<String> {
1514        let mut accounts = self.iam_state.write();
1515        let state = accounts.get_or_create(&self.account_id);
1516        // physical_id may be the role ARN or the role name depending on how
1517        // provisioning resolved Ref. Try both shapes.
1518        let role = state
1519            .roles
1520            .values()
1521            .find(|r| r.arn == physical_id || r.role_name == physical_id)?;
1522        match attribute {
1523            "Arn" => Some(role.arn.clone()),
1524            "RoleId" => Some(role.role_id.clone()),
1525            _ => None,
1526        }
1527    }
1528
1529    fn get_att_sqs_queue(&self, physical_id: &str, attribute: &str) -> Option<String> {
1530        let mut accounts = self.sqs_state.write();
1531        let state = accounts.get_or_create(&self.account_id);
1532        let queue = state.queues.get(physical_id)?;
1533        match attribute {
1534            "Arn" => Some(queue.arn.clone()),
1535            "QueueName" => Some(queue.queue_name.clone()),
1536            "QueueUrl" => Some(queue.queue_url.clone()),
1537            _ => None,
1538        }
1539    }
1540
1541    fn get_att_sns_topic(&self, physical_id: &str, attribute: &str) -> Option<String> {
1542        let mut accounts = self.sns_state.write();
1543        let state = accounts.get_or_create(&self.account_id);
1544        let topic = state.topics.get(physical_id)?;
1545        match attribute {
1546            "TopicArn" => Some(topic.topic_arn.clone()),
1547            "TopicName" => Some(topic.name.clone()),
1548            _ => None,
1549        }
1550    }
1551
1552    fn get_att_dynamodb_table(&self, physical_id: &str, attribute: &str) -> Option<String> {
1553        let mut accounts = self.dynamodb_state.write();
1554        let state = accounts.get_or_create(&self.account_id);
1555        let table = state.tables.get(physical_id)?;
1556        match attribute {
1557            "Arn" => Some(table.arn.clone()),
1558            "StreamArn" => table.stream_arn.clone(),
1559            _ => None,
1560        }
1561    }
1562
1563    fn get_att_kms_key(&self, physical_id: &str, attribute: &str) -> Option<String> {
1564        let mut accounts = self.kms_state.write();
1565        let state = accounts.get_or_create(&self.account_id);
1566        let key = state.keys.get(physical_id)?;
1567        match attribute {
1568            "Arn" => Some(key.arn.clone()),
1569            "KeyId" => Some(key.key_id.clone()),
1570            _ => None,
1571        }
1572    }
1573
1574    fn get_att_secrets_manager_secret(&self, physical_id: &str, attribute: &str) -> Option<String> {
1575        let mut accounts = self.secretsmanager_state.write();
1576        let state = accounts.get_or_create(&self.account_id);
1577        let secret = state.secrets.get(physical_id)?;
1578        match attribute {
1579            // Secrets Manager's CFN doc treats Id and Arn interchangeably —
1580            // both resolve to the secret ARN.
1581            "Arn" | "Id" => Some(secret.arn.clone()),
1582            _ => None,
1583        }
1584    }
1585
1586    fn get_att_cf_distribution(&self, physical_id: &str, attribute: &str) -> Option<String> {
1587        // CloudFront state is keyed under a fixed "000000000000" account in the
1588        // fakecloud_cloudfront crate; matching create_cf_distribution above.
1589        let accounts = self.cloudfront_state.read();
1590        let state = accounts.get("000000000000")?;
1591        let dist = state.distributions.get(physical_id)?;
1592        match attribute {
1593            "DomainName" => Some(dist.domain_name.clone()),
1594            "Id" => Some(dist.id.clone()),
1595            _ => None,
1596        }
1597    }
1598
1599    /// Delete a previously created resource.
1600    pub fn delete_resource(&self, resource: &StackResource) -> Result<(), String> {
1601        match resource.resource_type.as_str() {
1602            "AWS::SQS::Queue" => self.delete_sqs_queue(&resource.physical_id),
1603            "AWS::SNS::Topic" => self.delete_sns_topic(&resource.physical_id),
1604            "AWS::SNS::Subscription" => self.delete_sns_subscription(&resource.physical_id),
1605            "AWS::SSM::Parameter" => self.delete_ssm_parameter(&resource.physical_id),
1606            "AWS::IAM::Role" => self.delete_iam_role(&resource.physical_id),
1607            "AWS::IAM::Policy" => self.delete_iam_policy(&resource.physical_id),
1608            "AWS::IAM::User" => self.delete_iam_user(&resource.physical_id),
1609            "AWS::IAM::Group" => self.delete_iam_group(&resource.physical_id),
1610            "AWS::IAM::ManagedPolicy" => self.delete_iam_managed_policy(&resource.physical_id),
1611            "AWS::IAM::UserToGroupAddition" => {
1612                self.delete_iam_user_to_group_addition(&resource.physical_id)
1613            }
1614            "AWS::IAM::AccessKey" => self.delete_iam_access_key(&resource.physical_id),
1615            "AWS::IAM::InstanceProfile" => self.delete_iam_instance_profile(&resource.physical_id),
1616            "AWS::IAM::OIDCProvider" => self.delete_iam_oidc_provider(&resource.physical_id),
1617            "AWS::IAM::SAMLProvider" => self.delete_iam_saml_provider(&resource.physical_id),
1618            "AWS::IAM::ServiceLinkedRole" => {
1619                self.delete_iam_service_linked_role(&resource.physical_id)
1620            }
1621            "AWS::IAM::VirtualMFADevice" => {
1622                self.delete_iam_virtual_mfa_device(&resource.physical_id)
1623            }
1624            "AWS::S3::Bucket" => self.delete_s3_bucket(&resource.physical_id),
1625            "AWS::Events::Rule" => self.delete_eventbridge_rule(&resource.physical_id),
1626            "AWS::Events::Connection" => self.delete_eventbridge_connection(&resource.physical_id),
1627            "AWS::Events::EventBus" => self.delete_eventbridge_event_bus(&resource.physical_id),
1628            "AWS::Events::EventBusPolicy" => {
1629                self.delete_eventbridge_event_bus_policy(&resource.physical_id)
1630            }
1631            "AWS::Events::Endpoint" => self.delete_eventbridge_endpoint(&resource.physical_id),
1632            "AWS::Events::ApiDestination" => {
1633                self.delete_eventbridge_api_destination(&resource.physical_id)
1634            }
1635            "AWS::Events::Archive" => self.delete_eventbridge_archive(&resource.physical_id),
1636            "AWS::DynamoDB::Table" => self.delete_dynamodb_table(&resource.physical_id),
1637            "AWS::Logs::LogGroup" => self.delete_log_group(&resource.physical_id),
1638            "AWS::Logs::LogStream" => self.delete_log_stream(&resource.physical_id),
1639            "AWS::Logs::MetricFilter" => self.delete_metric_filter(&resource.physical_id),
1640            "AWS::Logs::SubscriptionFilter" => {
1641                self.delete_subscription_filter(&resource.physical_id)
1642            }
1643            "AWS::Logs::Destination" => self.delete_logs_destination(&resource.physical_id),
1644            "AWS::Logs::ResourcePolicy" => self.delete_logs_resource_policy(&resource.physical_id),
1645            "AWS::Logs::QueryDefinition" => {
1646                self.delete_logs_query_definition(&resource.physical_id)
1647            }
1648            "AWS::Logs::Delivery" => self.delete_logs_delivery(&resource.physical_id),
1649            "AWS::Logs::DeliveryDestination" => {
1650                self.delete_logs_delivery_destination(&resource.physical_id)
1651            }
1652            "AWS::Logs::DeliverySource" => self.delete_logs_delivery_source(&resource.physical_id),
1653            "AWS::Lambda::Function" => self.delete_lambda_function(&resource.physical_id),
1654            "AWS::Lambda::Permission" => self.delete_lambda_permission(&resource.physical_id),
1655            "AWS::Lambda::EventSourceMapping" => {
1656                self.delete_lambda_event_source_mapping(&resource.physical_id)
1657            }
1658            "AWS::Lambda::LayerVersion" => self.delete_lambda_layer_version(&resource.physical_id),
1659            "AWS::Lambda::Url" => self.delete_lambda_url(&resource.physical_id),
1660            "AWS::Lambda::Alias" => self.delete_lambda_alias(&resource.physical_id),
1661            "AWS::Lambda::Version" => self.delete_lambda_version(&resource.physical_id),
1662            "AWS::SecretsManager::Secret" => {
1663                self.delete_secrets_manager_secret(&resource.physical_id)
1664            }
1665            "AWS::Kinesis::Stream" => self.delete_kinesis_stream(&resource.physical_id),
1666            "AWS::Kinesis::StreamConsumer" => {
1667                self.delete_kinesis_stream_consumer(&resource.physical_id)
1668            }
1669            "AWS::KMS::Key" => self.delete_kms_key(&resource.physical_id),
1670            "AWS::KMS::ReplicaKey" => self.delete_kms_replica_key(&resource.physical_id),
1671            "AWS::KMS::Alias" => self.delete_kms_alias(&resource.physical_id),
1672            "AWS::ECR::Repository" => self.delete_ecr_repository(&resource.physical_id),
1673            "AWS::ECR::RepositoryPolicy" => {
1674                self.delete_ecr_repository_policy(&resource.physical_id)
1675            }
1676            "AWS::ECR::LifecyclePolicy" => self.delete_ecr_lifecycle_policy(&resource.physical_id),
1677            "AWS::ECR::RegistryPolicy" => self.delete_ecr_registry_policy(),
1678            "AWS::ECR::ReplicationConfiguration" => self.delete_ecr_replication_configuration(),
1679            "AWS::ECR::RegistryScanningConfiguration" => {
1680                self.delete_ecr_registry_scanning_configuration()
1681            }
1682            "AWS::ECR::PullThroughCacheRule" => {
1683                self.delete_ecr_pull_through_cache_rule(&resource.physical_id)
1684            }
1685            "AWS::CloudWatch::Alarm" => self.delete_cloudwatch_alarm(&resource.physical_id),
1686            "AWS::CloudWatch::Dashboard" => self.delete_cloudwatch_dashboard(&resource.physical_id),
1687            "AWS::ElasticLoadBalancingV2::LoadBalancer" => {
1688                self.delete_elbv2_load_balancer(&resource.physical_id)
1689            }
1690            "AWS::ElasticLoadBalancingV2::TargetGroup" => {
1691                self.delete_elbv2_target_group(&resource.physical_id)
1692            }
1693            "AWS::ElasticLoadBalancingV2::Listener" => {
1694                self.delete_elbv2_listener(&resource.physical_id)
1695            }
1696            "AWS::ElasticLoadBalancingV2::ListenerRule" => {
1697                self.delete_elbv2_listener_rule(&resource.physical_id)
1698            }
1699            "AWS::ElasticLoadBalancingV2::ListenerCertificate" => {
1700                self.delete_elbv2_listener_certificate(&resource.physical_id)
1701            }
1702            "AWS::ElasticLoadBalancingV2::TrustStore" => {
1703                self.delete_elbv2_trust_store(&resource.physical_id)
1704            }
1705            "AWS::Organizations::Organization" => self.delete_organization(&resource.physical_id),
1706            "AWS::Organizations::OrganizationalUnit" => {
1707                self.delete_organization_unit(&resource.physical_id)
1708            }
1709            "AWS::Organizations::Account" => {
1710                self.delete_organization_account(&resource.physical_id)
1711            }
1712            "AWS::Organizations::Policy" => self.delete_organization_policy(&resource.physical_id),
1713            "AWS::Organizations::ResourcePolicy" => {
1714                self.delete_organization_resource_policy(&resource.physical_id)
1715            }
1716            "AWS::Cognito::UserPool" => self.delete_cognito_user_pool(&resource.physical_id),
1717            "AWS::Cognito::UserPoolClient" => {
1718                self.delete_cognito_user_pool_client(&resource.physical_id)
1719            }
1720            "AWS::Cognito::UserPoolDomain" => {
1721                self.delete_cognito_user_pool_domain(&resource.physical_id)
1722            }
1723            "AWS::Cognito::IdentityPool" => {
1724                self.delete_cognito_identity_pool(&resource.physical_id)
1725            }
1726            "AWS::Cognito::IdentityPoolRoleAttachment" => {
1727                self.delete_cognito_identity_pool_role_attachment(&resource.physical_id)
1728            }
1729            "AWS::RDS::DBSubnetGroup" => self.delete_rds_subnet_group(&resource.physical_id),
1730            "AWS::RDS::DBParameterGroup" => self.delete_rds_parameter_group(&resource.physical_id),
1731            "AWS::RDS::DBClusterParameterGroup" => {
1732                self.delete_rds_cluster_parameter_group(&resource.physical_id)
1733            }
1734            "AWS::RDS::OptionGroup" => self.delete_rds_option_group(&resource.physical_id),
1735            "AWS::RDS::EventSubscription" => {
1736                self.delete_rds_event_subscription(&resource.physical_id)
1737            }
1738            "AWS::RDS::DBSecurityGroup" => self.delete_rds_security_group(&resource.physical_id),
1739            "AWS::RDS::DBProxy" => self.delete_rds_db_proxy(&resource.physical_id),
1740            "AWS::RDS::DBInstance" => self.delete_rds_db_instance(&resource.physical_id),
1741            "AWS::RDS::DBCluster" => self.delete_rds_db_cluster(&resource.physical_id),
1742            "AWS::ECS::Cluster" => self.delete_ecs_cluster(&resource.physical_id),
1743            "AWS::ECS::TaskDefinition" => self.delete_ecs_task_definition(&resource.physical_id),
1744            "AWS::ECS::Service" => self.delete_ecs_service(&resource.physical_id),
1745            "AWS::ECS::CapacityProvider" => {
1746                self.delete_ecs_capacity_provider(&resource.physical_id)
1747            }
1748            "AWS::CertificateManager::Certificate" => {
1749                self.delete_acm_certificate(&resource.physical_id)
1750            }
1751            "AWS::CertificateManager::Account" => self.delete_acm_account(),
1752            "AWS::ElastiCache::ParameterGroup" => {
1753                self.delete_ec_parameter_group(&resource.physical_id)
1754            }
1755            "AWS::ElastiCache::SubnetGroup" => self.delete_ec_subnet_group(&resource.physical_id),
1756            "AWS::ElastiCache::SecurityGroup" => {
1757                self.delete_ec_security_group(&resource.physical_id)
1758            }
1759            "AWS::ElastiCache::User" => self.delete_ec_user(&resource.physical_id),
1760            "AWS::ElastiCache::UserGroup" => self.delete_ec_user_group(&resource.physical_id),
1761            "AWS::ElastiCache::CacheCluster" => self.delete_ec_cache_cluster(&resource.physical_id),
1762            "AWS::ElastiCache::ReplicationGroup" => {
1763                self.delete_ec_replication_group(&resource.physical_id)
1764            }
1765            "AWS::Route53::HostedZone" => self.delete_route53_hosted_zone(&resource.physical_id),
1766            "AWS::Route53::RecordSet" => {
1767                self.delete_route53_record_set(&resource.physical_id, &resource.attributes)
1768            }
1769            "AWS::Route53::HealthCheck" => self.delete_route53_health_check(&resource.physical_id),
1770            "AWS::Route53::DNSSEC" => self.delete_route53_dnssec(&resource.physical_id),
1771            "AWS::Route53::KeySigningKey" => {
1772                self.delete_route53_key_signing_key(&resource.physical_id)
1773            }
1774            "AWS::CloudFront::CloudFrontOriginAccessIdentity" => {
1775                self.delete_cf_origin_access_identity(&resource.physical_id)
1776            }
1777            "AWS::CloudFront::Distribution" => self.delete_cf_distribution(&resource.physical_id),
1778            "AWS::CloudFront::OriginAccessControl" => {
1779                self.delete_cf_origin_access_control(&resource.physical_id)
1780            }
1781            "AWS::CloudFront::PublicKey" => self.delete_cf_public_key(&resource.physical_id),
1782            "AWS::CloudFront::KeyGroup" => self.delete_cf_key_group(&resource.physical_id),
1783            "AWS::CloudFront::Function" => self.delete_cf_function(&resource.physical_id),
1784            "AWS::CloudFront::CachePolicy" => self.delete_cf_cache_policy(&resource.physical_id),
1785            "AWS::CloudFront::OriginRequestPolicy" => {
1786                self.delete_cf_origin_request_policy(&resource.physical_id)
1787            }
1788            "AWS::CloudFront::ResponseHeadersPolicy" => {
1789                self.delete_cf_response_headers_policy(&resource.physical_id)
1790            }
1791            "AWS::StepFunctions::StateMachine" => {
1792                self.delete_sfn_state_machine(&resource.physical_id)
1793            }
1794            "AWS::StepFunctions::Activity" => self.delete_sfn_activity(&resource.physical_id),
1795            "AWS::StepFunctions::StateMachineVersion" => {
1796                self.delete_sfn_version(&resource.physical_id)
1797            }
1798            "AWS::StepFunctions::StateMachineAlias" => self.delete_sfn_alias(&resource.physical_id),
1799            "AWS::WAFv2::WebACL" => self.delete_wafv2_web_acl(&resource.physical_id),
1800            "AWS::WAFv2::IPSet" => self.delete_wafv2_ip_set(&resource.physical_id),
1801            "AWS::WAFv2::RegexPatternSet" => {
1802                self.delete_wafv2_regex_pattern_set(&resource.physical_id)
1803            }
1804            "AWS::WAFv2::RuleGroup" => self.delete_wafv2_rule_group(&resource.physical_id),
1805            "AWS::WAFv2::LoggingConfiguration" => {
1806                self.delete_wafv2_logging_configuration(&resource.physical_id)
1807            }
1808            "AWS::WAFv2::WebACLAssociation" => {
1809                self.delete_wafv2_web_acl_association(&resource.physical_id)
1810            }
1811            "AWS::ApiGateway::RestApi" => self.delete_apigw_rest_api(&resource.physical_id),
1812            "AWS::ApiGateway::Resource" => {
1813                self.delete_apigw_resource(&resource.physical_id, &resource.attributes)
1814            }
1815            "AWS::ApiGateway::Method" => self.delete_apigw_method(&resource.physical_id),
1816            "AWS::ApiGateway::Deployment" => {
1817                self.delete_apigw_deployment(&resource.physical_id, &resource.attributes)
1818            }
1819            "AWS::ApiGateway::Stage" => {
1820                self.delete_apigw_stage(&resource.physical_id, &resource.attributes)
1821            }
1822            "AWS::ApiGateway::Authorizer" => {
1823                self.delete_apigw_authorizer(&resource.physical_id, &resource.attributes)
1824            }
1825            "AWS::ApiGateway::RequestValidator" => {
1826                self.delete_apigw_request_validator(&resource.physical_id, &resource.attributes)
1827            }
1828            "AWS::ApiGateway::Model" => {
1829                self.delete_apigw_model(&resource.physical_id, &resource.attributes)
1830            }
1831            "AWS::ApiGateway::GatewayResponse" => {
1832                self.delete_apigw_gateway_response(&resource.physical_id, &resource.attributes)
1833            }
1834            "AWS::ApiGateway::UsagePlan" => self.delete_apigw_usage_plan(&resource.physical_id),
1835            "AWS::ApiGateway::ApiKey" => self.delete_apigw_api_key(&resource.physical_id),
1836            "AWS::ApiGateway::UsagePlanKey" => {
1837                self.delete_apigw_usage_plan_key(&resource.physical_id, &resource.attributes)
1838            }
1839            "AWS::ApiGateway::DomainName" => self.delete_apigw_domain_name(&resource.physical_id),
1840            "AWS::ApiGateway::BasePathMapping" => {
1841                self.delete_apigw_base_path_mapping(&resource.physical_id, &resource.attributes)
1842            }
1843            "AWS::ApiGatewayV2::Api" => self.delete_apigwv2_api(&resource.physical_id),
1844            "AWS::ApiGatewayV2::Route" => {
1845                self.delete_apigwv2_route(&resource.physical_id, &resource.attributes)
1846            }
1847            "AWS::ApiGatewayV2::Integration" => {
1848                self.delete_apigwv2_integration(&resource.physical_id, &resource.attributes)
1849            }
1850            "AWS::ApiGatewayV2::IntegrationResponse" => self
1851                .delete_apigwv2_integration_response(&resource.physical_id, &resource.attributes),
1852            "AWS::ApiGatewayV2::RouteResponse" => {
1853                self.delete_apigwv2_route_response(&resource.physical_id, &resource.attributes)
1854            }
1855            "AWS::ApiGatewayV2::Stage" => {
1856                self.delete_apigwv2_stage(&resource.physical_id, &resource.attributes)
1857            }
1858            "AWS::ApiGatewayV2::Deployment" => {
1859                self.delete_apigwv2_deployment(&resource.physical_id, &resource.attributes)
1860            }
1861            "AWS::ApiGatewayV2::Authorizer" => {
1862                self.delete_apigwv2_authorizer(&resource.physical_id, &resource.attributes)
1863            }
1864            "AWS::ApiGatewayV2::DomainName" => {
1865                self.delete_apigwv2_domain_name(&resource.physical_id)
1866            }
1867            "AWS::ApiGatewayV2::ApiMapping" => {
1868                self.delete_apigwv2_api_mapping(&resource.physical_id, &resource.attributes)
1869            }
1870            "AWS::ApiGatewayV2::VpcLink" => self.delete_apigwv2_vpc_link(&resource.physical_id),
1871            "AWS::ApiGatewayV2::Model" => {
1872                self.delete_apigwv2_model(&resource.physical_id, &resource.attributes)
1873            }
1874            "AWS::SES::ConfigurationSet" => {
1875                self.delete_ses_configuration_set(&resource.physical_id)
1876            }
1877            "AWS::SES::ConfigurationSetEventDestination" => {
1878                self.delete_ses_event_destination(&resource.physical_id, &resource.attributes)
1879            }
1880            "AWS::SES::EmailIdentity" => self.delete_ses_email_identity(&resource.physical_id),
1881            "AWS::SES::Template" => self.delete_ses_template(&resource.physical_id),
1882            "AWS::SES::ContactList" => self.delete_ses_contact_list(&resource.physical_id),
1883            "AWS::SES::DedicatedIpPool" => self.delete_ses_dedicated_ip_pool(&resource.physical_id),
1884            "AWS::SES::ReceiptRule" => {
1885                self.delete_ses_receipt_rule(&resource.physical_id, &resource.attributes)
1886            }
1887            "AWS::SES::ReceiptRuleSet" => self.delete_ses_receipt_rule_set(&resource.physical_id),
1888            "AWS::SES::ReceiptFilter" => self.delete_ses_receipt_filter(&resource.physical_id),
1889            "AWS::SES::VdmAttributes" => Ok(()),
1890            "AWS::SecretsManager::RotationSchedule" => {
1891                self.delete_secrets_manager_rotation_schedule(&resource.physical_id)
1892            }
1893            "AWS::SecretsManager::ResourcePolicy" => {
1894                self.delete_secrets_manager_resource_policy(&resource.physical_id)
1895            }
1896            "AWS::SecretsManager::SecretTargetAttachment" => Ok(()),
1897            "AWS::ApplicationAutoScaling::ScalableTarget" => self
1898                .delete_application_autoscaling_scalable_target(
1899                    &resource.physical_id,
1900                    &resource.attributes,
1901                ),
1902            "AWS::ApplicationAutoScaling::ScalingPolicy" => self
1903                .delete_application_autoscaling_scaling_policy(
1904                    &resource.physical_id,
1905                    &resource.attributes,
1906                ),
1907            "AWS::Athena::DataCatalog" => self.delete_athena_data_catalog(&resource.physical_id),
1908            "AWS::Athena::NamedQuery" => self.delete_athena_named_query(&resource.physical_id),
1909            "AWS::Athena::WorkGroup" => self.delete_athena_work_group(&resource.physical_id),
1910            "AWS::Athena::PreparedStatement" => {
1911                self.delete_athena_prepared_statement(&resource.physical_id, &resource.attributes)
1912            }
1913            "AWS::KinesisFirehose::DeliveryStream" => {
1914                self.delete_firehose_delivery_stream(&resource.physical_id)
1915            }
1916            "AWS::Glue::Database" => self.delete_glue_database(&resource.physical_id),
1917            "AWS::CloudFormation::Stack" => self.delete_cloudformation_stack(&resource.physical_id),
1918            "AWS::Glue::Table" => self.delete_glue_table(&resource.physical_id),
1919            "AWS::Glue::Partition" => {
1920                self.delete_glue_partition(&resource.physical_id, &resource.attributes)
1921            }
1922            t if t.starts_with("Custom::") || t == "AWS::CloudFormation::CustomResource" => {
1923                self.delete_custom_resource(resource)
1924            }
1925            other => Err(format!("Unsupported resource type: {other}")),
1926        }
1927    }
1928
1929    // --- SQS ---
1930
1931    fn create_sqs_queue(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
1932        let props = &resource.properties;
1933        let queue_name = props
1934            .get("QueueName")
1935            .and_then(|v| v.as_str())
1936            .unwrap_or(&resource.logical_id);
1937
1938        let mut __sqs_mas = self.sqs_state.write();
1939        let state = __sqs_mas.get_or_create(&self.account_id);
1940        let queue_url = format!("{}/{}/{}", state.endpoint, state.account_id, queue_name);
1941        let arn = format!(
1942            "arn:aws:sqs:{}:{}:{}",
1943            state.region, state.account_id, queue_name
1944        );
1945
1946        let is_fifo = queue_name.ends_with(".fifo");
1947        let mut attributes = std::collections::BTreeMap::new();
1948        if let Some(obj) = props.as_object() {
1949            for (k, v) in obj {
1950                if k != "QueueName" {
1951                    if let Some(s) = v.as_str() {
1952                        attributes.insert(k.clone(), s.to_string());
1953                    } else if let Some(n) = v.as_i64() {
1954                        attributes.insert(k.clone(), n.to_string());
1955                    }
1956                }
1957            }
1958        }
1959
1960        let queue = SqsQueue {
1961            queue_name: queue_name.to_string(),
1962            queue_url: queue_url.clone(),
1963            arn: arn.clone(),
1964            created_at: Utc::now(),
1965            messages: std::collections::VecDeque::new(),
1966            inflight: Vec::new(),
1967            attributes,
1968            is_fifo,
1969            dedup_cache: std::collections::BTreeMap::new(),
1970            redrive_policy: None,
1971            tags: std::collections::BTreeMap::new(),
1972            next_sequence_number: 0,
1973            permission_labels: Vec::new(),
1974            receipt_handle_map: std::collections::BTreeMap::new(),
1975        };
1976
1977        state
1978            .name_to_url
1979            .insert(queue_name.to_string(), queue_url.clone());
1980        state.queues.insert(queue_url.clone(), queue);
1981
1982        Ok(ProvisionResult::new(queue_url.clone())
1983            .with("Arn", arn)
1984            .with("QueueName", queue_name)
1985            .with("QueueUrl", queue_url))
1986    }
1987
1988    fn delete_sqs_queue(&self, physical_id: &str) -> Result<(), String> {
1989        let mut __sqs_mas = self.sqs_state.write();
1990        let state = __sqs_mas.get_or_create(&self.account_id);
1991        if let Some(queue) = state.queues.remove(physical_id) {
1992            state.name_to_url.remove(&queue.queue_name);
1993        }
1994        Ok(())
1995    }
1996
1997    // --- SNS ---
1998
1999    fn create_sns_topic(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
2000        let props = &resource.properties;
2001        let topic_name = props
2002            .get("TopicName")
2003            .and_then(|v| v.as_str())
2004            .unwrap_or(&resource.logical_id);
2005
2006        let mut __sns_mas = self.sns_state.write();
2007        let state = __sns_mas.get_or_create(&self.account_id);
2008        let topic_arn = format!(
2009            "arn:aws:sns:{}:{}:{}",
2010            state.region, state.account_id, topic_name
2011        );
2012
2013        let topic = SnsTopic {
2014            topic_arn: topic_arn.clone(),
2015            name: topic_name.to_string(),
2016            attributes: BTreeMap::new(),
2017            tags: Vec::new(),
2018            is_fifo: topic_name.ends_with(".fifo"),
2019            created_at: Utc::now(),
2020            subscriptions_deleted: 0,
2021        };
2022
2023        state.topics.insert(topic_arn.clone(), topic);
2024        Ok(ProvisionResult::new(topic_arn.clone())
2025            .with("TopicArn", topic_arn)
2026            .with("TopicName", topic_name))
2027    }
2028
2029    fn delete_sns_topic(&self, physical_id: &str) -> Result<(), String> {
2030        let mut __sns_mas = self.sns_state.write();
2031        let state = __sns_mas.get_or_create(&self.account_id);
2032        state.topics.remove(physical_id);
2033        // Also remove subscriptions for this topic
2034        state
2035            .subscriptions
2036            .retain(|_, sub| sub.topic_arn != physical_id);
2037        Ok(())
2038    }
2039
2040    // --- SNS Subscription ---
2041
2042    fn create_sns_subscription(
2043        &self,
2044        resource: &ResourceDefinition,
2045    ) -> Result<ProvisionResult, String> {
2046        let props = &resource.properties;
2047        let topic_arn = props
2048            .get("TopicArn")
2049            .and_then(|v| v.as_str())
2050            .ok_or("SNS Subscription requires TopicArn")?;
2051        let protocol = props
2052            .get("Protocol")
2053            .and_then(|v| v.as_str())
2054            .ok_or("SNS Subscription requires Protocol")?;
2055        let endpoint = props
2056            .get("Endpoint")
2057            .and_then(|v| v.as_str())
2058            .ok_or("SNS Subscription requires Endpoint")?;
2059
2060        let mut __sns_mas = self.sns_state.write();
2061        let state = __sns_mas.get_or_create(&self.account_id);
2062
2063        // Validate that the topic exists
2064        if !state.topics.contains_key(topic_arn) {
2065            return Err(format!("Topic ARN does not exist: {topic_arn}"));
2066        }
2067
2068        let sub_arn = format!("{}:{}", topic_arn, Uuid::new_v4());
2069
2070        let subscription = SnsSubscription {
2071            subscription_arn: sub_arn.clone(),
2072            topic_arn: topic_arn.to_string(),
2073            protocol: protocol.to_string(),
2074            endpoint: endpoint.to_string(),
2075            owner: state.account_id.clone(),
2076            attributes: BTreeMap::new(),
2077            confirmed: true,
2078            confirmation_token: None,
2079        };
2080
2081        state.subscriptions.insert(sub_arn.clone(), subscription);
2082        Ok(ProvisionResult::new(sub_arn.clone()).with("Arn", sub_arn))
2083    }
2084
2085    fn delete_sns_subscription(&self, physical_id: &str) -> Result<(), String> {
2086        let mut __sns_mas = self.sns_state.write();
2087        let state = __sns_mas.get_or_create(&self.account_id);
2088        state.subscriptions.remove(physical_id);
2089        Ok(())
2090    }
2091
2092    // --- SSM ---
2093
2094    fn create_ssm_parameter(
2095        &self,
2096        resource: &ResourceDefinition,
2097    ) -> Result<ProvisionResult, String> {
2098        let props = &resource.properties;
2099        let name = props
2100            .get("Name")
2101            .and_then(|v| v.as_str())
2102            .ok_or("SSM Parameter requires Name")?;
2103        let value = props
2104            .get("Value")
2105            .and_then(|v| v.as_str())
2106            .ok_or("SSM Parameter requires Value")?;
2107        let param_type = props
2108            .get("Type")
2109            .and_then(|v| v.as_str())
2110            .unwrap_or("String");
2111
2112        let mut accounts = self.ssm_state.write();
2113        let state = accounts.get_or_create(&self.account_id);
2114        let arn = format!(
2115            "arn:aws:ssm:{}:{}:parameter{}",
2116            self.region,
2117            self.account_id,
2118            if name.starts_with('/') {
2119                name.to_string()
2120            } else {
2121                format!("/{name}")
2122            }
2123        );
2124
2125        let parameter = SsmParameter {
2126            name: name.to_string(),
2127            value: value.to_string(),
2128            param_type: param_type.to_string(),
2129            version: 1,
2130            arn: arn.clone(),
2131            last_modified: Utc::now(),
2132            history: Vec::new(),
2133            tags: BTreeMap::new(),
2134            labels: BTreeMap::new(),
2135            description: props
2136                .get("Description")
2137                .and_then(|v| v.as_str())
2138                .map(|s| s.to_string()),
2139            allowed_pattern: None,
2140            key_id: None,
2141            data_type: "text".to_string(),
2142            tier: "Standard".to_string(),
2143            policies: None,
2144            expiration_notified: false,
2145            no_change_notified: false,
2146        };
2147
2148        state.parameters.insert(name.to_string(), parameter);
2149        Ok(ProvisionResult::new(name)
2150            .with("Type", param_type)
2151            .with("Value", value))
2152    }
2153
2154    fn delete_ssm_parameter(&self, physical_id: &str) -> Result<(), String> {
2155        let mut accounts = self.ssm_state.write();
2156        let state = accounts.get_or_create(&self.account_id);
2157        state.parameters.remove(physical_id);
2158        Ok(())
2159    }
2160
2161    // --- IAM Role ---
2162
2163    fn create_iam_role(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
2164        let props = &resource.properties;
2165        let role_name = props
2166            .get("RoleName")
2167            .and_then(|v| v.as_str())
2168            .unwrap_or(&resource.logical_id);
2169
2170        let assume_role_policy = props
2171            .get("AssumeRolePolicyDocument")
2172            .map(|v| {
2173                if v.is_string() {
2174                    v.as_str().unwrap().to_string()
2175                } else {
2176                    serde_json::to_string(v).unwrap_or_default()
2177                }
2178            })
2179            .unwrap_or_default();
2180
2181        let path = props.get("Path").and_then(|v| v.as_str()).unwrap_or("/");
2182
2183        let mut accounts = self.iam_state.write();
2184        let state = accounts.get_or_create(&self.account_id);
2185        let role_id = format!(
2186            "FKIA{}",
2187            &Uuid::new_v4().to_string().replace('-', "").to_uppercase()[..16]
2188        );
2189        let arn = format!(
2190            "arn:aws:iam::{}:role{}{}",
2191            state.account_id,
2192            if path == "/" { "/" } else { path },
2193            role_name
2194        );
2195
2196        let role = IamRole {
2197            role_name: role_name.to_string(),
2198            role_id: role_id.clone(),
2199            arn: arn.clone(),
2200            path: path.to_string(),
2201            assume_role_policy_document: assume_role_policy,
2202            created_at: Utc::now(),
2203            description: props
2204                .get("Description")
2205                .and_then(|v| v.as_str())
2206                .map(|s| s.to_string()),
2207            max_session_duration: 3600,
2208            tags: Vec::new(),
2209            permissions_boundary: None,
2210        };
2211
2212        state.roles.insert(role_name.to_string(), role);
2213        Ok(ProvisionResult::new(arn.clone())
2214            .with("Arn", arn)
2215            .with("RoleId", role_id))
2216    }
2217
2218    fn delete_iam_role(&self, physical_id: &str) -> Result<(), String> {
2219        let mut accounts = self.iam_state.write();
2220        let state = accounts.get_or_create(&self.account_id);
2221        // physical_id is the ARN; find the role name
2222        let role_name = state
2223            .roles
2224            .iter()
2225            .find(|(_, r)| r.arn == physical_id)
2226            .map(|(name, _)| name.clone());
2227        if let Some(name) = role_name {
2228            state.roles.remove(&name);
2229            state.role_policies.remove(&name);
2230            state.role_inline_policies.remove(&name);
2231        }
2232        Ok(())
2233    }
2234
2235    // --- IAM Policy ---
2236
2237    fn create_iam_policy(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
2238        let props = &resource.properties;
2239        let policy_name = props
2240            .get("PolicyName")
2241            .and_then(|v| v.as_str())
2242            .unwrap_or(&resource.logical_id);
2243
2244        let policy_document = props
2245            .get("PolicyDocument")
2246            .map(|v| {
2247                if v.is_string() {
2248                    v.as_str().unwrap().to_string()
2249                } else {
2250                    serde_json::to_string(v).unwrap_or_default()
2251                }
2252            })
2253            .unwrap_or_default();
2254
2255        let path = props.get("Path").and_then(|v| v.as_str()).unwrap_or("/");
2256
2257        let mut accounts = self.iam_state.write();
2258        let state = accounts.get_or_create(&self.account_id);
2259        let policy_id = format!(
2260            "FSIA{}",
2261            &Uuid::new_v4().to_string().replace('-', "").to_uppercase()[..16]
2262        );
2263        let arn = format!(
2264            "arn:aws:iam::{}:policy{}{}",
2265            state.account_id,
2266            if path == "/" { "/" } else { path },
2267            policy_name
2268        );
2269
2270        let now = Utc::now();
2271        let policy = IamPolicy {
2272            policy_name: policy_name.to_string(),
2273            policy_id,
2274            arn: arn.clone(),
2275            path: path.to_string(),
2276            description: props
2277                .get("Description")
2278                .and_then(|v| v.as_str())
2279                .unwrap_or("")
2280                .to_string(),
2281            created_at: now,
2282            tags: Vec::new(),
2283            default_version_id: "v1".to_string(),
2284            versions: vec![PolicyVersion {
2285                version_id: "v1".to_string(),
2286                document: policy_document,
2287                is_default: true,
2288                created_at: now,
2289            }],
2290            next_version_num: 2,
2291            attachment_count: 0,
2292        };
2293
2294        state.policies.insert(arn.clone(), policy);
2295        Ok(ProvisionResult::new(arn.clone()).with("Arn", arn))
2296    }
2297
2298    fn delete_iam_policy(&self, physical_id: &str) -> Result<(), String> {
2299        let mut accounts = self.iam_state.write();
2300        let state = accounts.get_or_create(&self.account_id);
2301        state.policies.remove(physical_id);
2302        Ok(())
2303    }
2304
2305    fn create_iam_user(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
2306        let props = &resource.properties;
2307        let user_name = props
2308            .get("UserName")
2309            .and_then(|v| v.as_str())
2310            .unwrap_or(&resource.logical_id)
2311            .to_string();
2312        let path = props
2313            .get("Path")
2314            .and_then(|v| v.as_str())
2315            .unwrap_or("/")
2316            .to_string();
2317        let permissions_boundary = props
2318            .get("PermissionsBoundary")
2319            .and_then(|v| v.as_str())
2320            .map(|s| s.to_string());
2321        let tags = parse_iam_tags(props.get("Tags"));
2322
2323        let mut accounts = self.iam_state.write();
2324        let state = accounts.get_or_create(&self.account_id);
2325        if state.users.contains_key(&user_name) {
2326            return Err(format!("User {user_name} already exists"));
2327        }
2328        let arn = format!(
2329            "arn:aws:iam::{}:user{}{}",
2330            state.account_id, path, user_name
2331        );
2332        let user_id = format!(
2333            "AIDA{}",
2334            &Uuid::new_v4().to_string().replace('-', "").to_uppercase()[..16]
2335        );
2336        let user = IamUser {
2337            user_name: user_name.clone(),
2338            user_id: user_id.clone(),
2339            arn: arn.clone(),
2340            path,
2341            created_at: Utc::now(),
2342            tags,
2343            permissions_boundary,
2344        };
2345        state.users.insert(user_name.clone(), user);
2346
2347        // Inline + managed policies declared inline on the user.
2348        if let Some(policies) = props.get("Policies").and_then(|v| v.as_array()) {
2349            let inline = state
2350                .user_inline_policies
2351                .entry(user_name.clone())
2352                .or_default();
2353            for p in policies {
2354                if let (Some(n), Some(doc)) = (
2355                    p.get("PolicyName").and_then(|v| v.as_str()),
2356                    p.get("PolicyDocument"),
2357                ) {
2358                    let document = if doc.is_string() {
2359                        doc.as_str().unwrap_or("").to_string()
2360                    } else {
2361                        serde_json::to_string(doc).unwrap_or_default()
2362                    };
2363                    inline.insert(n.to_string(), document);
2364                }
2365            }
2366        }
2367        if let Some(arns) = props.get("ManagedPolicyArns").and_then(|v| v.as_array()) {
2368            let attached = state.user_policies.entry(user_name.clone()).or_default();
2369            for a in arns {
2370                if let Some(s) = a.as_str() {
2371                    if !attached.contains(&s.to_string()) {
2372                        attached.push(s.to_string());
2373                    }
2374                }
2375            }
2376        }
2377        if let Some(groups) = props.get("Groups").and_then(|v| v.as_array()) {
2378            for g in groups {
2379                if let Some(g_name) = g.as_str() {
2380                    if let Some(group) = state.groups.get_mut(g_name) {
2381                        if !group.members.iter().any(|m| m == &user_name) {
2382                            group.members.push(user_name.clone());
2383                        }
2384                    }
2385                }
2386            }
2387        }
2388
2389        Ok(ProvisionResult::new(user_name).with("Arn", arn))
2390    }
2391
2392    fn delete_iam_user(&self, physical_id: &str) -> Result<(), String> {
2393        let mut accounts = self.iam_state.write();
2394        let state = accounts.get_or_create(&self.account_id);
2395        state.users.remove(physical_id);
2396        state.user_inline_policies.remove(physical_id);
2397        state.user_policies.remove(physical_id);
2398        state.access_keys.remove(physical_id);
2399        for group in state.groups.values_mut() {
2400            group.members.retain(|m| m != physical_id);
2401        }
2402        Ok(())
2403    }
2404
2405    fn create_iam_group(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
2406        let props = &resource.properties;
2407        let group_name = props
2408            .get("GroupName")
2409            .and_then(|v| v.as_str())
2410            .unwrap_or(&resource.logical_id)
2411            .to_string();
2412        let path = props
2413            .get("Path")
2414            .and_then(|v| v.as_str())
2415            .unwrap_or("/")
2416            .to_string();
2417
2418        let mut accounts = self.iam_state.write();
2419        let state = accounts.get_or_create(&self.account_id);
2420        if state.groups.contains_key(&group_name) {
2421            return Err(format!("Group {group_name} already exists"));
2422        }
2423        let arn = format!(
2424            "arn:aws:iam::{}:group{}{}",
2425            state.account_id, path, group_name
2426        );
2427        let group_id = format!(
2428            "AGPA{}",
2429            &Uuid::new_v4().to_string().replace('-', "").to_uppercase()[..16]
2430        );
2431        let mut inline_policies: BTreeMap<String, String> = BTreeMap::new();
2432        if let Some(policies) = props.get("Policies").and_then(|v| v.as_array()) {
2433            for p in policies {
2434                if let (Some(n), Some(doc)) = (
2435                    p.get("PolicyName").and_then(|v| v.as_str()),
2436                    p.get("PolicyDocument"),
2437                ) {
2438                    let document = if doc.is_string() {
2439                        doc.as_str().unwrap_or("").to_string()
2440                    } else {
2441                        serde_json::to_string(doc).unwrap_or_default()
2442                    };
2443                    inline_policies.insert(n.to_string(), document);
2444                }
2445            }
2446        }
2447        let mut attached_policies: Vec<String> = Vec::new();
2448        if let Some(arns) = props.get("ManagedPolicyArns").and_then(|v| v.as_array()) {
2449            for a in arns {
2450                if let Some(s) = a.as_str() {
2451                    attached_policies.push(s.to_string());
2452                }
2453            }
2454        }
2455        state.groups.insert(
2456            group_name.clone(),
2457            IamGroup {
2458                group_name: group_name.clone(),
2459                group_id,
2460                arn: arn.clone(),
2461                path,
2462                created_at: Utc::now(),
2463                members: Vec::new(),
2464                inline_policies,
2465                attached_policies,
2466            },
2467        );
2468
2469        Ok(ProvisionResult::new(group_name).with("Arn", arn))
2470    }
2471
2472    fn delete_iam_group(&self, physical_id: &str) -> Result<(), String> {
2473        let mut accounts = self.iam_state.write();
2474        let state = accounts.get_or_create(&self.account_id);
2475        state.groups.remove(physical_id);
2476        Ok(())
2477    }
2478
2479    fn create_iam_managed_policy(
2480        &self,
2481        resource: &ResourceDefinition,
2482    ) -> Result<ProvisionResult, String> {
2483        // Same shape as AWS::IAM::Policy minus the inline-attach knobs;
2484        // ManagedPolicy is a standalone policy, attached separately.
2485        let props = &resource.properties;
2486        let policy_name = props
2487            .get("ManagedPolicyName")
2488            .and_then(|v| v.as_str())
2489            .unwrap_or(&resource.logical_id)
2490            .to_string();
2491        let policy_document = props
2492            .get("PolicyDocument")
2493            .map(|v| {
2494                if v.is_string() {
2495                    v.as_str().unwrap_or("").to_string()
2496                } else {
2497                    serde_json::to_string(v).unwrap_or_default()
2498                }
2499            })
2500            .unwrap_or_default();
2501        let path = props
2502            .get("Path")
2503            .and_then(|v| v.as_str())
2504            .unwrap_or("/")
2505            .to_string();
2506        let description = props
2507            .get("Description")
2508            .and_then(|v| v.as_str())
2509            .unwrap_or("")
2510            .to_string();
2511
2512        let mut accounts = self.iam_state.write();
2513        let state = accounts.get_or_create(&self.account_id);
2514        let arn = format!(
2515            "arn:aws:iam::{}:policy{}{}",
2516            state.account_id,
2517            if path == "/" { "/" } else { path.as_str() },
2518            policy_name
2519        );
2520        if state.policies.contains_key(&arn) {
2521            return Err(format!("Managed policy {policy_name} already exists"));
2522        }
2523        let policy_id = format!(
2524            "ANPA{}",
2525            &Uuid::new_v4().to_string().replace('-', "").to_uppercase()[..16]
2526        );
2527        let now = Utc::now();
2528        state.policies.insert(
2529            arn.clone(),
2530            IamPolicy {
2531                policy_name,
2532                policy_id,
2533                arn: arn.clone(),
2534                path,
2535                description,
2536                created_at: now,
2537                tags: Vec::new(),
2538                default_version_id: "v1".to_string(),
2539                versions: vec![PolicyVersion {
2540                    version_id: "v1".to_string(),
2541                    document: policy_document,
2542                    is_default: true,
2543                    created_at: now,
2544                }],
2545                next_version_num: 2,
2546                attachment_count: 0,
2547            },
2548        );
2549
2550        // Attach to declared users/groups/roles.
2551        if let Some(users) = props.get("Users").and_then(|v| v.as_array()) {
2552            for u in users {
2553                if let Some(name) = u.as_str() {
2554                    let attached = state.user_policies.entry(name.to_string()).or_default();
2555                    if !attached.contains(&arn) {
2556                        attached.push(arn.clone());
2557                    }
2558                }
2559            }
2560        }
2561        if let Some(groups) = props.get("Groups").and_then(|v| v.as_array()) {
2562            for g in groups {
2563                if let Some(name) = g.as_str() {
2564                    if let Some(group) = state.groups.get_mut(name) {
2565                        if !group.attached_policies.contains(&arn) {
2566                            group.attached_policies.push(arn.clone());
2567                        }
2568                    }
2569                }
2570            }
2571        }
2572        if let Some(roles) = props.get("Roles").and_then(|v| v.as_array()) {
2573            for r in roles {
2574                if let Some(name) = r.as_str() {
2575                    let attached = state.role_policies.entry(name.to_string()).or_default();
2576                    if !attached.contains(&arn) {
2577                        attached.push(arn.clone());
2578                    }
2579                }
2580            }
2581        }
2582
2583        Ok(ProvisionResult::new(arn.clone()).with("Arn", arn))
2584    }
2585
2586    fn delete_iam_managed_policy(&self, physical_id: &str) -> Result<(), String> {
2587        let mut accounts = self.iam_state.write();
2588        let state = accounts.get_or_create(&self.account_id);
2589        state.policies.remove(physical_id);
2590        for arns in state.user_policies.values_mut() {
2591            arns.retain(|a| a != physical_id);
2592        }
2593        for arns in state.role_policies.values_mut() {
2594            arns.retain(|a| a != physical_id);
2595        }
2596        for group in state.groups.values_mut() {
2597            group.attached_policies.retain(|a| a != physical_id);
2598        }
2599        Ok(())
2600    }
2601
2602    fn create_iam_user_to_group_addition(
2603        &self,
2604        resource: &ResourceDefinition,
2605    ) -> Result<ProvisionResult, String> {
2606        let props = &resource.properties;
2607        let group_name = props
2608            .get("GroupName")
2609            .and_then(|v| v.as_str())
2610            .ok_or_else(|| "GroupName is required".to_string())?
2611            .to_string();
2612        let users: Vec<String> = props
2613            .get("Users")
2614            .and_then(|v| v.as_array())
2615            .map(|arr| {
2616                arr.iter()
2617                    .filter_map(|u| u.as_str().map(|s| s.to_string()))
2618                    .collect()
2619            })
2620            .unwrap_or_default();
2621
2622        let mut accounts = self.iam_state.write();
2623        let state = accounts.get_or_create(&self.account_id);
2624        let group = state
2625            .groups
2626            .get_mut(&group_name)
2627            .ok_or_else(|| format!("Group {group_name} does not exist"))?;
2628        for u in &users {
2629            if !group.members.iter().any(|m| m == u) {
2630                group.members.push(u.clone());
2631            }
2632        }
2633
2634        // Encode group + users so delete can revert exactly this addition.
2635        let physical_id = format!("{group_name}|{}", users.join(","));
2636        Ok(ProvisionResult::new(physical_id))
2637    }
2638
2639    fn delete_iam_user_to_group_addition(&self, physical_id: &str) -> Result<(), String> {
2640        let mut accounts = self.iam_state.write();
2641        let state = accounts.get_or_create(&self.account_id);
2642        if let Some((group_name, users)) = physical_id.split_once('|') {
2643            if let Some(group) = state.groups.get_mut(group_name) {
2644                let to_remove: Vec<&str> = users.split(',').filter(|s| !s.is_empty()).collect();
2645                group.members.retain(|m| !to_remove.iter().any(|u| u == m));
2646            }
2647        }
2648        Ok(())
2649    }
2650
2651    fn create_iam_access_key(
2652        &self,
2653        resource: &ResourceDefinition,
2654    ) -> Result<ProvisionResult, String> {
2655        let props = &resource.properties;
2656        let user_name = props
2657            .get("UserName")
2658            .and_then(|v| v.as_str())
2659            .ok_or_else(|| "UserName is required".to_string())?
2660            .to_string();
2661        let status = props
2662            .get("Status")
2663            .and_then(|v| v.as_str())
2664            .unwrap_or("Active")
2665            .to_string();
2666
2667        let mut accounts = self.iam_state.write();
2668        let state = accounts.get_or_create(&self.account_id);
2669        if !state.users.contains_key(&user_name) {
2670            return Err(format!("User {user_name} does not exist"));
2671        }
2672        let access_key_id = format!(
2673            "AKIA{}",
2674            &Uuid::new_v4().to_string().replace('-', "").to_uppercase()[..16]
2675        );
2676        let secret_access_key: String = Uuid::new_v4()
2677            .to_string()
2678            .replace('-', "")
2679            .chars()
2680            .take(40)
2681            .collect();
2682        state
2683            .access_keys
2684            .entry(user_name.clone())
2685            .or_default()
2686            .push(IamAccessKey {
2687                access_key_id: access_key_id.clone(),
2688                secret_access_key: secret_access_key.clone(),
2689                user_name: user_name.clone(),
2690                status,
2691                created_at: Utc::now(),
2692            });
2693
2694        Ok(ProvisionResult::new(access_key_id.clone()).with("SecretAccessKey", secret_access_key))
2695    }
2696
2697    fn delete_iam_access_key(&self, physical_id: &str) -> Result<(), String> {
2698        let mut accounts = self.iam_state.write();
2699        let state = accounts.get_or_create(&self.account_id);
2700        for keys in state.access_keys.values_mut() {
2701            keys.retain(|k| k.access_key_id != physical_id);
2702        }
2703        Ok(())
2704    }
2705
2706    fn create_iam_instance_profile(
2707        &self,
2708        resource: &ResourceDefinition,
2709    ) -> Result<ProvisionResult, String> {
2710        let props = &resource.properties;
2711        let name = props
2712            .get("InstanceProfileName")
2713            .and_then(|v| v.as_str())
2714            .unwrap_or(&resource.logical_id)
2715            .to_string();
2716        let path = props
2717            .get("Path")
2718            .and_then(|v| v.as_str())
2719            .unwrap_or("/")
2720            .to_string();
2721        // Roles[] entries can be plain role names or `Ref`-resolved role
2722        // ARNs (which the IAM Role provisioner emits as physical_id);
2723        // extract the trailing name segment so DescribeInstanceProfile
2724        // round-trips a name list.
2725        let roles: Vec<String> = props
2726            .get("Roles")
2727            .and_then(|v| v.as_array())
2728            .map(|arr| {
2729                arr.iter()
2730                    .filter_map(|r| r.as_str())
2731                    .map(|s| {
2732                        if let Some(rest) = s.strip_prefix("arn:aws:iam::") {
2733                            rest.split(":role/")
2734                                .nth(1)
2735                                .map(|name| name.to_string())
2736                                .unwrap_or_else(|| s.to_string())
2737                        } else {
2738                            s.to_string()
2739                        }
2740                    })
2741                    .collect()
2742            })
2743            .unwrap_or_default();
2744
2745        let mut accounts = self.iam_state.write();
2746        let state = accounts.get_or_create(&self.account_id);
2747        if state.instance_profiles.contains_key(&name) {
2748            return Err(format!("InstanceProfile {name} already exists"));
2749        }
2750        // Force a retry pass when role refs haven't been resolved yet: a
2751        // logical-id placeholder won't match any real role, and silently
2752        // storing it would leave DescribeInstanceProfile returning an
2753        // empty Roles array.
2754        for role_name in &roles {
2755            if !state.roles.contains_key(role_name) {
2756                return Err(format!(
2757                    "InstanceProfile {name}: referenced role {role_name} not yet provisioned"
2758                ));
2759            }
2760        }
2761        let arn = format!(
2762            "arn:aws:iam::{}:instance-profile{}{}",
2763            state.account_id, path, name
2764        );
2765        let id = format!(
2766            "AIPA{}",
2767            &Uuid::new_v4().to_string().replace('-', "").to_uppercase()[..16]
2768        );
2769        state.instance_profiles.insert(
2770            name.clone(),
2771            IamInstanceProfile {
2772                instance_profile_name: name.clone(),
2773                instance_profile_id: id,
2774                arn: arn.clone(),
2775                path,
2776                created_at: Utc::now(),
2777                roles,
2778                tags: Vec::new(),
2779            },
2780        );
2781
2782        Ok(ProvisionResult::new(name).with("Arn", arn))
2783    }
2784
2785    fn delete_iam_instance_profile(&self, physical_id: &str) -> Result<(), String> {
2786        let mut accounts = self.iam_state.write();
2787        let state = accounts.get_or_create(&self.account_id);
2788        state.instance_profiles.remove(physical_id);
2789        Ok(())
2790    }
2791
2792    fn create_iam_oidc_provider(
2793        &self,
2794        resource: &ResourceDefinition,
2795    ) -> Result<ProvisionResult, String> {
2796        let props = &resource.properties;
2797        let url = props
2798            .get("Url")
2799            .and_then(|v| v.as_str())
2800            .ok_or("Url is required")?
2801            .to_string();
2802        let client_id_list: Vec<String> = props
2803            .get("ClientIdList")
2804            .and_then(|v| v.as_array())
2805            .map(|arr| {
2806                arr.iter()
2807                    .filter_map(|v| v.as_str().map(String::from))
2808                    .collect()
2809            })
2810            .unwrap_or_default();
2811        let thumbprint_list: Vec<String> = props
2812            .get("ThumbprintList")
2813            .and_then(|v| v.as_array())
2814            .map(|arr| {
2815                arr.iter()
2816                    .filter_map(|v| v.as_str().map(String::from))
2817                    .collect()
2818            })
2819            .unwrap_or_default();
2820        // Real AWS strips the scheme to form the resource path component.
2821        let url_path = url
2822            .trim_start_matches("https://")
2823            .trim_start_matches("http://")
2824            .to_string();
2825        let arn = format!(
2826            "arn:aws:iam::{}:oidc-provider/{}",
2827            self.account_id, url_path
2828        );
2829        let provider = OidcProvider {
2830            arn: arn.clone(),
2831            url,
2832            client_id_list,
2833            thumbprint_list,
2834            created_at: Utc::now(),
2835            tags: Vec::new(),
2836        };
2837        let mut accounts = self.iam_state.write();
2838        let state = accounts.get_or_create(&self.account_id);
2839        state.oidc_providers.insert(arn.clone(), provider);
2840        Ok(ProvisionResult::new(arn.clone()).with("Arn", arn))
2841    }
2842
2843    fn delete_iam_oidc_provider(&self, physical_id: &str) -> Result<(), String> {
2844        let mut accounts = self.iam_state.write();
2845        let state = accounts.get_or_create(&self.account_id);
2846        state.oidc_providers.remove(physical_id);
2847        Ok(())
2848    }
2849
2850    fn create_iam_saml_provider(
2851        &self,
2852        resource: &ResourceDefinition,
2853    ) -> Result<ProvisionResult, String> {
2854        let props = &resource.properties;
2855        let name = props
2856            .get("Name")
2857            .and_then(|v| v.as_str())
2858            .map(String::from)
2859            .unwrap_or_else(|| {
2860                let suffix = Uuid::new_v4().simple().to_string();
2861                format!("{}-{}", resource.logical_id, &suffix[..8])
2862            });
2863        let saml_metadata_document = props
2864            .get("SamlMetadataDocument")
2865            .and_then(|v| v.as_str())
2866            .ok_or("SamlMetadataDocument is required")?
2867            .to_string();
2868        let arn = format!("arn:aws:iam::{}:saml-provider/{name}", self.account_id);
2869        let now = Utc::now();
2870        let valid_until = now + chrono::Duration::days(365 * 10);
2871        let provider = SamlProvider {
2872            arn: arn.clone(),
2873            name,
2874            saml_metadata_document,
2875            created_at: now,
2876            valid_until,
2877            tags: Vec::new(),
2878        };
2879        let mut accounts = self.iam_state.write();
2880        let state = accounts.get_or_create(&self.account_id);
2881        state.saml_providers.insert(arn.clone(), provider);
2882        Ok(ProvisionResult::new(arn.clone()).with("Arn", arn))
2883    }
2884
2885    fn delete_iam_saml_provider(&self, physical_id: &str) -> Result<(), String> {
2886        let mut accounts = self.iam_state.write();
2887        let state = accounts.get_or_create(&self.account_id);
2888        state.saml_providers.remove(physical_id);
2889        Ok(())
2890    }
2891
2892    fn create_iam_service_linked_role(
2893        &self,
2894        resource: &ResourceDefinition,
2895    ) -> Result<ProvisionResult, String> {
2896        let props = &resource.properties;
2897        let aws_service_name = props
2898            .get("AWSServiceName")
2899            .and_then(|v| v.as_str())
2900            .ok_or("AWSServiceName is required")?
2901            .to_string();
2902        let custom_suffix = props
2903            .get("CustomSuffix")
2904            .and_then(|v| v.as_str())
2905            .map(String::from);
2906        let description = props
2907            .get("Description")
2908            .and_then(|v| v.as_str())
2909            .unwrap_or("")
2910            .to_string();
2911        // AWS service-linked role naming convention.
2912        let service_short = aws_service_name.split('.').next().unwrap_or("Service");
2913        let role_name = match &custom_suffix {
2914            Some(s) => format!("AWSServiceRoleFor{service_short}_{s}"),
2915            None => format!("AWSServiceRoleFor{service_short}"),
2916        };
2917        let path = format!("/aws-service-role/{aws_service_name}/");
2918        let arn = format!("arn:aws:iam::{}:role{path}{role_name}", self.account_id);
2919        // Service-linked roles get a trust policy specific to the service.
2920        let assume_role_policy_document = serde_json::json!({
2921            "Version": "2012-10-17",
2922            "Statement": [{
2923                "Effect": "Allow",
2924                "Principal": {"Service": aws_service_name.clone()},
2925                "Action": "sts:AssumeRole"
2926            }]
2927        })
2928        .to_string();
2929        let role_id_suffix = Uuid::new_v4().simple().to_string();
2930        let role = IamRole {
2931            role_name: role_name.clone(),
2932            role_id: format!("AROA{}", role_id_suffix[..16].to_uppercase()),
2933            arn: arn.clone(),
2934            path,
2935            assume_role_policy_document,
2936            created_at: Utc::now(),
2937            description: Some(description),
2938            max_session_duration: 3600,
2939            tags: Vec::new(),
2940            permissions_boundary: None,
2941        };
2942        let mut accounts = self.iam_state.write();
2943        let state = accounts.get_or_create(&self.account_id);
2944        state.roles.insert(role_name.clone(), role);
2945        Ok(ProvisionResult::new(role_name)
2946            .with("Arn", arn)
2947            .with("RoleId", String::new()))
2948    }
2949
2950    fn delete_iam_service_linked_role(&self, physical_id: &str) -> Result<(), String> {
2951        let mut accounts = self.iam_state.write();
2952        let state = accounts.get_or_create(&self.account_id);
2953        state.roles.remove(physical_id);
2954        Ok(())
2955    }
2956
2957    fn create_iam_virtual_mfa_device(
2958        &self,
2959        resource: &ResourceDefinition,
2960    ) -> Result<ProvisionResult, String> {
2961        let props = &resource.properties;
2962        let name = props
2963            .get("VirtualMfaDeviceName")
2964            .and_then(|v| v.as_str())
2965            .ok_or("VirtualMfaDeviceName is required")?
2966            .to_string();
2967        let path = props
2968            .get("Path")
2969            .and_then(|v| v.as_str())
2970            .unwrap_or("/")
2971            .to_string();
2972        let serial_number = format!("arn:aws:iam::{}:mfa{}{name}", self.account_id, path);
2973        // Real AWS returns a base32 seed + a PNG QR code; we synthesize
2974        // deterministic placeholders so callers can read them back.
2975        let seed = format!("BASE32SEED{}", Uuid::new_v4().simple());
2976        let user = props
2977            .get("Users")
2978            .and_then(|v| v.as_array())
2979            .and_then(|arr| arr.first())
2980            .and_then(|v| v.as_str())
2981            .map(String::from);
2982        let device = VirtualMfaDevice {
2983            serial_number: serial_number.clone(),
2984            base32_string_seed: seed,
2985            qr_code_png: String::new(),
2986            enable_date: user.as_ref().map(|_| Utc::now()),
2987            user,
2988            tags: Vec::new(),
2989        };
2990        let mut accounts = self.iam_state.write();
2991        let state = accounts.get_or_create(&self.account_id);
2992        state
2993            .virtual_mfa_devices
2994            .insert(serial_number.clone(), device);
2995        Ok(ProvisionResult::new(serial_number.clone()).with("SerialNumber", serial_number))
2996    }
2997
2998    fn delete_iam_virtual_mfa_device(&self, physical_id: &str) -> Result<(), String> {
2999        let mut accounts = self.iam_state.write();
3000        let state = accounts.get_or_create(&self.account_id);
3001        state.virtual_mfa_devices.remove(physical_id);
3002        Ok(())
3003    }
3004
3005    // --- S3 ---
3006
3007    fn create_s3_bucket(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
3008        let props = &resource.properties;
3009        let bucket_name = props
3010            .get("BucketName")
3011            .and_then(|v| v.as_str())
3012            .unwrap_or(&resource.logical_id);
3013
3014        let mut __s3_mas = self.s3_state.write();
3015        let state = __s3_mas.get_or_create(&self.account_id);
3016        let region = state.region.clone();
3017        let bucket = S3Bucket::new(bucket_name, &state.region, &state.account_id);
3018        state.buckets.insert(bucket_name.to_string(), bucket);
3019
3020        let arn = format!("arn:aws:s3:::{bucket_name}");
3021        let domain_name = format!("{bucket_name}.s3.amazonaws.com");
3022        let regional_domain_name = format!("{bucket_name}.s3.{region}.amazonaws.com");
3023        let dual_stack_domain_name = format!("{bucket_name}.s3.dualstack.{region}.amazonaws.com");
3024        let website_url = format!("http://{bucket_name}.s3-website-{region}.amazonaws.com");
3025        Ok(ProvisionResult::new(bucket_name)
3026            .with("Arn", arn)
3027            .with("DomainName", domain_name)
3028            .with("RegionalDomainName", regional_domain_name)
3029            .with("DualStackDomainName", dual_stack_domain_name)
3030            .with("WebsiteURL", website_url))
3031    }
3032
3033    fn delete_s3_bucket(&self, physical_id: &str) -> Result<(), String> {
3034        let mut __s3_mas = self.s3_state.write();
3035        let state = __s3_mas.get_or_create(&self.account_id);
3036        state.buckets.remove(physical_id);
3037        Ok(())
3038    }
3039
3040    // --- EventBridge ---
3041
3042    fn create_eventbridge_rule(
3043        &self,
3044        resource: &ResourceDefinition,
3045    ) -> Result<ProvisionResult, String> {
3046        let props = &resource.properties;
3047        let rule_name = props
3048            .get("Name")
3049            .and_then(|v| v.as_str())
3050            .unwrap_or(&resource.logical_id);
3051        let event_bus_name = props
3052            .get("EventBusName")
3053            .and_then(|v| v.as_str())
3054            .unwrap_or("default");
3055
3056        let mut eb_accounts = self.eventbridge_state.write();
3057        let state = eb_accounts.get_or_create(&self.account_id);
3058
3059        // Validate that the event bus exists
3060        if !state.buses.contains_key(event_bus_name) {
3061            return Err(format!("Event bus does not exist: {event_bus_name}"));
3062        }
3063
3064        let arn = if event_bus_name == "default" {
3065            format!(
3066                "arn:aws:events:{}:{}:rule/{}",
3067                state.region, state.account_id, rule_name
3068            )
3069        } else {
3070            format!(
3071                "arn:aws:events:{}:{}:rule/{}/{}",
3072                state.region, state.account_id, event_bus_name, rule_name
3073            )
3074        };
3075
3076        let rule = EventRule {
3077            name: rule_name.to_string(),
3078            arn: arn.clone(),
3079            event_bus_name: event_bus_name.to_string(),
3080            event_pattern: props.get("EventPattern").map(|v| {
3081                if v.is_string() {
3082                    v.as_str().unwrap().to_string()
3083                } else {
3084                    serde_json::to_string(v).unwrap_or_default()
3085                }
3086            }),
3087            schedule_expression: props
3088                .get("ScheduleExpression")
3089                .and_then(|v| v.as_str())
3090                .map(|s| s.to_string()),
3091            state: props
3092                .get("State")
3093                .and_then(|v| v.as_str())
3094                .unwrap_or("ENABLED")
3095                .to_string(),
3096            description: props
3097                .get("Description")
3098                .and_then(|v| v.as_str())
3099                .map(|s| s.to_string()),
3100            role_arn: props
3101                .get("RoleArn")
3102                .and_then(|v| v.as_str())
3103                .map(|s| s.to_string()),
3104            managed_by: None,
3105            created_by: None,
3106            targets: Vec::new(),
3107            tags: std::collections::BTreeMap::new(),
3108            last_fired: None,
3109        };
3110
3111        state
3112            .rules
3113            .insert((event_bus_name.to_string(), rule_name.to_string()), rule);
3114        Ok(ProvisionResult::new(arn.clone()).with("Arn", arn))
3115    }
3116
3117    fn delete_eventbridge_rule(&self, physical_id: &str) -> Result<(), String> {
3118        let mut eb_accounts = self.eventbridge_state.write();
3119        let state = eb_accounts.default_mut();
3120        // physical_id is the ARN; find the rule key
3121        let key = state
3122            .rules
3123            .iter()
3124            .find(|(_, r)| r.arn == physical_id)
3125            .map(|(k, _)| k.clone());
3126        if let Some(k) = key {
3127            state.rules.remove(&k);
3128        }
3129        Ok(())
3130    }
3131
3132    fn create_eventbridge_connection(
3133        &self,
3134        resource: &ResourceDefinition,
3135    ) -> Result<ProvisionResult, String> {
3136        let props = &resource.properties;
3137        let name = props
3138            .get("Name")
3139            .and_then(|v| v.as_str())
3140            .unwrap_or(&resource.logical_id)
3141            .to_string();
3142        let description = props
3143            .get("Description")
3144            .and_then(|v| v.as_str())
3145            .map(|s| s.to_string());
3146        let authorization_type = props
3147            .get("AuthorizationType")
3148            .and_then(|v| v.as_str())
3149            .unwrap_or("API_KEY")
3150            .to_string();
3151        let auth_parameters = props
3152            .get("AuthParameters")
3153            .cloned()
3154            .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
3155
3156        let mut eb_accounts = self.eventbridge_state.write();
3157        let state = eb_accounts.get_or_create(&self.account_id);
3158        if state.connections.contains_key(&name) {
3159            return Err(format!("Connection {name} already exists"));
3160        }
3161        let now = Utc::now();
3162        let arn = format!(
3163            "arn:aws:events:{}:{}:connection/{}/{}",
3164            state.region,
3165            state.account_id,
3166            name,
3167            Uuid::new_v4().as_simple()
3168        );
3169        let secret_arn = format!(
3170            "arn:aws:secretsmanager:{}:{}:secret:events!connection/{}-{}",
3171            state.region,
3172            state.account_id,
3173            name,
3174            Uuid::new_v4().as_simple()
3175        );
3176        let connection = Connection {
3177            name: name.clone(),
3178            arn: arn.clone(),
3179            description,
3180            authorization_type,
3181            auth_parameters,
3182            connection_state: "AUTHORIZED".to_string(),
3183            secret_arn: secret_arn.clone(),
3184            creation_time: now,
3185            last_modified_time: now,
3186            last_authorized_time: now,
3187        };
3188        state.connections.insert(name.clone(), connection);
3189
3190        Ok(ProvisionResult::new(name)
3191            .with("Arn", arn)
3192            .with("SecretArn", secret_arn))
3193    }
3194
3195    fn delete_eventbridge_connection(&self, physical_id: &str) -> Result<(), String> {
3196        let mut eb_accounts = self.eventbridge_state.write();
3197        let state = eb_accounts.get_or_create(&self.account_id);
3198        state.connections.remove(physical_id);
3199        Ok(())
3200    }
3201
3202    fn create_eventbridge_api_destination(
3203        &self,
3204        resource: &ResourceDefinition,
3205    ) -> Result<ProvisionResult, String> {
3206        let props = &resource.properties;
3207        let name = props
3208            .get("Name")
3209            .and_then(|v| v.as_str())
3210            .unwrap_or(&resource.logical_id)
3211            .to_string();
3212        let description = props
3213            .get("Description")
3214            .and_then(|v| v.as_str())
3215            .map(|s| s.to_string());
3216        let connection_arn = props
3217            .get("ConnectionArn")
3218            .and_then(|v| v.as_str())
3219            .ok_or_else(|| "ConnectionArn is required".to_string())?
3220            .to_string();
3221        let invocation_endpoint = props
3222            .get("InvocationEndpoint")
3223            .and_then(|v| v.as_str())
3224            .ok_or_else(|| "InvocationEndpoint is required".to_string())?
3225            .to_string();
3226        let http_method = props
3227            .get("HttpMethod")
3228            .and_then(|v| v.as_str())
3229            .unwrap_or("POST")
3230            .to_string();
3231        let invocation_rate_limit_per_second = props
3232            .get("InvocationRateLimitPerSecond")
3233            .and_then(|v| v.as_i64());
3234
3235        let mut eb_accounts = self.eventbridge_state.write();
3236        let state = eb_accounts.get_or_create(&self.account_id);
3237        if state.api_destinations.contains_key(&name) {
3238            return Err(format!("ApiDestination {name} already exists"));
3239        }
3240        let now = Utc::now();
3241        let arn = format!(
3242            "arn:aws:events:{}:{}:api-destination/{}/{}",
3243            state.region,
3244            state.account_id,
3245            name,
3246            Uuid::new_v4().as_simple()
3247        );
3248        state.api_destinations.insert(
3249            name.clone(),
3250            ApiDestination {
3251                name: name.clone(),
3252                arn: arn.clone(),
3253                description,
3254                connection_arn,
3255                invocation_endpoint,
3256                http_method,
3257                invocation_rate_limit_per_second,
3258                state: "ACTIVE".to_string(),
3259                creation_time: now,
3260                last_modified_time: now,
3261            },
3262        );
3263
3264        Ok(ProvisionResult::new(name).with("Arn", arn))
3265    }
3266
3267    fn delete_eventbridge_api_destination(&self, physical_id: &str) -> Result<(), String> {
3268        let mut eb_accounts = self.eventbridge_state.write();
3269        let state = eb_accounts.get_or_create(&self.account_id);
3270        state.api_destinations.remove(physical_id);
3271        Ok(())
3272    }
3273
3274    fn create_eventbridge_archive(
3275        &self,
3276        resource: &ResourceDefinition,
3277    ) -> Result<ProvisionResult, String> {
3278        let props = &resource.properties;
3279        let name = props
3280            .get("ArchiveName")
3281            .and_then(|v| v.as_str())
3282            .unwrap_or(&resource.logical_id)
3283            .to_string();
3284        let event_source_arn = props
3285            .get("SourceArn")
3286            .and_then(|v| v.as_str())
3287            .ok_or_else(|| "SourceArn is required".to_string())?
3288            .to_string();
3289        let description = props
3290            .get("Description")
3291            .and_then(|v| v.as_str())
3292            .map(|s| s.to_string());
3293        let event_pattern = props.get("EventPattern").map(|v| {
3294            if v.is_string() {
3295                v.as_str().unwrap_or("").to_string()
3296            } else {
3297                serde_json::to_string(v).unwrap_or_default()
3298            }
3299        });
3300        let retention_days = props
3301            .get("RetentionDays")
3302            .and_then(|v| v.as_i64())
3303            .unwrap_or(0);
3304
3305        let mut eb_accounts = self.eventbridge_state.write();
3306        let state = eb_accounts.get_or_create(&self.account_id);
3307        if state.archives.contains_key(&name) {
3308            return Err(format!("Archive {name} already exists"));
3309        }
3310        let arn = format!(
3311            "arn:aws:events:{}:{}:archive/{}",
3312            state.region, state.account_id, name
3313        );
3314        state.archives.insert(
3315            name.clone(),
3316            Archive {
3317                name: name.clone(),
3318                arn: arn.clone(),
3319                event_source_arn,
3320                description,
3321                event_pattern,
3322                retention_days,
3323                state: "ENABLED".to_string(),
3324                creation_time: Utc::now(),
3325                event_count: 0,
3326                size_bytes: 0,
3327                events: Vec::new(),
3328            },
3329        );
3330
3331        Ok(ProvisionResult::new(name).with("Arn", arn))
3332    }
3333
3334    fn delete_eventbridge_archive(&self, physical_id: &str) -> Result<(), String> {
3335        let mut eb_accounts = self.eventbridge_state.write();
3336        let state = eb_accounts.get_or_create(&self.account_id);
3337        state.archives.remove(physical_id);
3338        Ok(())
3339    }
3340
3341    fn create_eventbridge_event_bus(
3342        &self,
3343        resource: &ResourceDefinition,
3344    ) -> Result<ProvisionResult, String> {
3345        let props = &resource.properties;
3346        let name = props
3347            .get("Name")
3348            .and_then(|v| v.as_str())
3349            .ok_or("Name is required")?
3350            .to_string();
3351        let description = props
3352            .get("Description")
3353            .and_then(|v| v.as_str())
3354            .map(String::from);
3355        let kms_key_identifier = props
3356            .get("KmsKeyIdentifier")
3357            .and_then(|v| v.as_str())
3358            .map(String::from);
3359        let dead_letter_config = props.get("DeadLetterConfig").cloned();
3360        let policy = props.get("Policy").cloned();
3361        let arn = format!(
3362            "arn:aws:events:{}:{}:event-bus/{name}",
3363            self.region, self.account_id
3364        );
3365        let now = Utc::now();
3366        let bus = EventBus {
3367            name: name.clone(),
3368            arn: arn.clone(),
3369            tags: BTreeMap::new(),
3370            policy,
3371            description,
3372            kms_key_identifier,
3373            dead_letter_config,
3374            creation_time: now,
3375            last_modified_time: now,
3376        };
3377
3378        let mut eb_accounts = self.eventbridge_state.write();
3379        let state = eb_accounts.get_or_create(&self.account_id);
3380        state.buses.insert(name.clone(), bus);
3381
3382        Ok(ProvisionResult::new(name).with("Arn", arn))
3383    }
3384
3385    fn delete_eventbridge_event_bus(&self, physical_id: &str) -> Result<(), String> {
3386        let mut eb_accounts = self.eventbridge_state.write();
3387        let state = eb_accounts.get_or_create(&self.account_id);
3388        // The default bus is reserved; refuse to delete it.
3389        if physical_id == "default" {
3390            return Ok(());
3391        }
3392        state.buses.remove(physical_id);
3393        Ok(())
3394    }
3395
3396    fn create_eventbridge_event_bus_policy(
3397        &self,
3398        resource: &ResourceDefinition,
3399    ) -> Result<ProvisionResult, String> {
3400        let props = &resource.properties;
3401        let bus_name = props
3402            .get("EventBusName")
3403            .and_then(|v| v.as_str())
3404            .unwrap_or("default")
3405            .to_string();
3406        // Statement is the v2 shape; the older v1 shape has Action+Principal+
3407        // StatementId; both ultimately end up as a single statement on the bus
3408        // policy. We accept either form.
3409        let statement = if let Some(s) = props.get("Statement") {
3410            s.clone()
3411        } else {
3412            let sid = props
3413                .get("Sid")
3414                .or_else(|| props.get("StatementId"))
3415                .and_then(|v| v.as_str())
3416                .map(String::from);
3417            let action = props
3418                .get("Action")
3419                .and_then(|v| v.as_str())
3420                .map(String::from);
3421            let principal = props.get("Principal").cloned();
3422            let condition = props.get("Condition").cloned();
3423            let mut obj = serde_json::json!({
3424                "Effect": "Allow",
3425                "Resource": format!(
3426                    "arn:aws:events:{}:{}:event-bus/{bus_name}",
3427                    self.region, self.account_id
3428                ),
3429            });
3430            if let (Some(sid), Some(obj)) = (sid, obj.as_object_mut()) {
3431                obj.insert("Sid".to_string(), serde_json::Value::String(sid));
3432            }
3433            if let (Some(action), Some(obj)) = (action, obj.as_object_mut()) {
3434                obj.insert("Action".to_string(), serde_json::Value::String(action));
3435            }
3436            if let (Some(principal), Some(obj)) = (principal, obj.as_object_mut()) {
3437                obj.insert("Principal".to_string(), principal);
3438            }
3439            if let (Some(condition), Some(obj)) = (condition, obj.as_object_mut()) {
3440                obj.insert("Condition".to_string(), condition);
3441            }
3442            obj
3443        };
3444
3445        let mut eb_accounts = self.eventbridge_state.write();
3446        let state = eb_accounts.get_or_create(&self.account_id);
3447        let bus = state
3448            .buses
3449            .get_mut(&bus_name)
3450            .ok_or_else(|| format!("EventBus {bus_name} not yet provisioned"))?;
3451        // Append to the existing policy's Statement array, or create one.
3452        match bus.policy.as_mut() {
3453            Some(serde_json::Value::Object(obj)) => {
3454                if let Some(serde_json::Value::Array(arr)) = obj.get_mut("Statement") {
3455                    arr.push(statement);
3456                } else {
3457                    obj.insert(
3458                        "Statement".to_string(),
3459                        serde_json::Value::Array(vec![statement]),
3460                    );
3461                }
3462            }
3463            _ => {
3464                bus.policy = Some(serde_json::json!({
3465                    "Version": "2012-10-17",
3466                    "Statement": [statement],
3467                }));
3468            }
3469        }
3470
3471        // Physical id encodes the bus name + a synthetic id so delete locates the
3472        // entry even when the user never sets Sid/StatementId.
3473        let pid = format!("{bus_name}|{}", Uuid::new_v4().simple());
3474        Ok(ProvisionResult::new(pid))
3475    }
3476
3477    fn delete_eventbridge_event_bus_policy(&self, physical_id: &str) -> Result<(), String> {
3478        let bus_name = physical_id
3479            .split_once('|')
3480            .map(|(b, _)| b.to_string())
3481            .unwrap_or_else(|| "default".to_string());
3482        let mut eb_accounts = self.eventbridge_state.write();
3483        let state = eb_accounts.get_or_create(&self.account_id);
3484        if let Some(bus) = state.buses.get_mut(&bus_name) {
3485            bus.policy = None;
3486        }
3487        Ok(())
3488    }
3489
3490    fn create_eventbridge_endpoint(
3491        &self,
3492        resource: &ResourceDefinition,
3493    ) -> Result<ProvisionResult, String> {
3494        let props = &resource.properties;
3495        let name = props
3496            .get("Name")
3497            .and_then(|v| v.as_str())
3498            .ok_or("Name is required")?
3499            .to_string();
3500        let description = props
3501            .get("Description")
3502            .and_then(|v| v.as_str())
3503            .map(String::from);
3504        let routing_config = props
3505            .get("RoutingConfig")
3506            .cloned()
3507            .ok_or("RoutingConfig is required")?;
3508        let replication_config = props.get("ReplicationConfig").cloned();
3509        let event_buses = props
3510            .get("EventBuses")
3511            .and_then(|v| v.as_array())
3512            .cloned()
3513            .unwrap_or_default();
3514        let role_arn = props
3515            .get("RoleArn")
3516            .and_then(|v| v.as_str())
3517            .map(String::from);
3518
3519        let endpoint_id = Uuid::new_v4().simple().to_string()[..16].to_string();
3520        let arn = format!(
3521            "arn:aws:events:{}:{}:endpoint/{name}",
3522            self.region, self.account_id
3523        );
3524        let endpoint_url = format!("https://{endpoint_id}.endpoint.events.amazonaws.com");
3525        let now = Utc::now();
3526        let endpoint = Endpoint {
3527            name: name.clone(),
3528            arn: arn.clone(),
3529            endpoint_id: endpoint_id.clone(),
3530            endpoint_url: Some(endpoint_url.clone()),
3531            description,
3532            routing_config,
3533            replication_config,
3534            event_buses,
3535            role_arn,
3536            state: "ACTIVE".to_string(),
3537            creation_time: now,
3538            last_modified_time: now,
3539        };
3540
3541        let mut eb_accounts = self.eventbridge_state.write();
3542        let state = eb_accounts.get_or_create(&self.account_id);
3543        state.endpoints.insert(name.clone(), endpoint);
3544
3545        Ok(ProvisionResult::new(name)
3546            .with("Arn", arn)
3547            .with("EndpointId", endpoint_id)
3548            .with("EndpointUrl", endpoint_url)
3549            .with("State", "ACTIVE"))
3550    }
3551
3552    fn delete_eventbridge_endpoint(&self, physical_id: &str) -> Result<(), String> {
3553        let mut eb_accounts = self.eventbridge_state.write();
3554        let state = eb_accounts.get_or_create(&self.account_id);
3555        state.endpoints.remove(physical_id);
3556        Ok(())
3557    }
3558
3559    // --- DynamoDB ---
3560
3561    fn create_dynamodb_table(
3562        &self,
3563        resource: &ResourceDefinition,
3564    ) -> Result<ProvisionResult, String> {
3565        let props = &resource.properties;
3566        let table_name = props
3567            .get("TableName")
3568            .and_then(|v| v.as_str())
3569            .unwrap_or(&resource.logical_id);
3570
3571        let mut key_schema = Vec::new();
3572        if let Some(ks) = props.get("KeySchema").and_then(|v| v.as_array()) {
3573            for item in ks {
3574                let attr_name = item
3575                    .get("AttributeName")
3576                    .and_then(|v| v.as_str())
3577                    .unwrap_or("")
3578                    .to_string();
3579                let key_type = item
3580                    .get("KeyType")
3581                    .and_then(|v| v.as_str())
3582                    .unwrap_or("HASH")
3583                    .to_string();
3584                key_schema.push(KeySchemaElement {
3585                    attribute_name: attr_name,
3586                    key_type,
3587                });
3588            }
3589        }
3590
3591        let mut attribute_definitions = Vec::new();
3592        if let Some(defs) = props.get("AttributeDefinitions").and_then(|v| v.as_array()) {
3593            for item in defs {
3594                let attr_name = item
3595                    .get("AttributeName")
3596                    .and_then(|v| v.as_str())
3597                    .unwrap_or("")
3598                    .to_string();
3599                let attr_type = item
3600                    .get("AttributeType")
3601                    .and_then(|v| v.as_str())
3602                    .unwrap_or("S")
3603                    .to_string();
3604                attribute_definitions.push(AttributeDefinition {
3605                    attribute_name: attr_name,
3606                    attribute_type: attr_type,
3607                });
3608            }
3609        }
3610
3611        let billing_mode = props
3612            .get("BillingMode")
3613            .and_then(|v| v.as_str())
3614            .unwrap_or("PAY_PER_REQUEST")
3615            .to_string();
3616
3617        let provisioned_throughput = if billing_mode == "PROVISIONED" {
3618            if let Some(pt) = props.get("ProvisionedThroughput") {
3619                ProvisionedThroughput {
3620                    read_capacity_units: pt
3621                        .get("ReadCapacityUnits")
3622                        .and_then(|v| v.as_i64())
3623                        .unwrap_or(5),
3624                    write_capacity_units: pt
3625                        .get("WriteCapacityUnits")
3626                        .and_then(|v| v.as_i64())
3627                        .unwrap_or(5),
3628                }
3629            } else {
3630                ProvisionedThroughput {
3631                    read_capacity_units: 5,
3632                    write_capacity_units: 5,
3633                }
3634            }
3635        } else {
3636            ProvisionedThroughput {
3637                read_capacity_units: 0,
3638                write_capacity_units: 0,
3639            }
3640        };
3641
3642        // Parse StreamSpecification from CloudFormation properties
3643        let (stream_enabled, stream_view_type) =
3644            if let Some(stream_spec) = props.get("StreamSpecification") {
3645                let view_type = stream_spec
3646                    .get("StreamViewType")
3647                    .and_then(|v| v.as_str())
3648                    .map(|s| s.to_string());
3649                let enabled = stream_spec
3650                    .get("StreamEnabled")
3651                    .and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true")))
3652                    // If StreamViewType is set, treat streams as enabled even if StreamEnabled is missing
3653                    .unwrap_or(view_type.is_some());
3654                (enabled, view_type)
3655            } else {
3656                (false, None)
3657            };
3658
3659        let deletion_protection_enabled = props
3660            .get("DeletionProtectionEnabled")
3661            .and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true")))
3662            .unwrap_or(false);
3663
3664        let on_demand_throughput = props
3665            .get("OnDemandThroughput")
3666            .map(|odt| OnDemandThroughput {
3667                max_read_request_units: odt
3668                    .get("MaxReadRequestUnits")
3669                    .and_then(|v| v.as_i64())
3670                    .unwrap_or(-1),
3671                max_write_request_units: odt
3672                    .get("MaxWriteRequestUnits")
3673                    .and_then(|v| v.as_i64())
3674                    .unwrap_or(-1),
3675            });
3676
3677        let mut __ddb_mas = self.dynamodb_state.write();
3678        let state = __ddb_mas.get_or_create(&self.account_id);
3679        let arn = format!(
3680            "arn:aws:dynamodb:{}:{}:table/{}",
3681            state.region, state.account_id, table_name
3682        );
3683
3684        let stream_arn = if stream_enabled {
3685            Some(format!(
3686                "{}/stream/{}",
3687                arn,
3688                Utc::now().format("%Y-%m-%dT%H:%M:%S.%3f")
3689            ))
3690        } else {
3691            None
3692        };
3693        let stream_arn_attr = stream_arn.clone();
3694
3695        let table = DynamoTable {
3696            name: table_name.to_string(),
3697            arn: arn.clone(),
3698            table_id: Uuid::new_v4().to_string().replace('-', ""),
3699            key_schema,
3700            attribute_definitions,
3701            provisioned_throughput,
3702            items: Vec::new(),
3703            gsi: Vec::new(),
3704            lsi: Vec::new(),
3705            tags: BTreeMap::new(),
3706            created_at: Utc::now(),
3707            status: "ACTIVE".to_string(),
3708            item_count: 0,
3709            size_bytes: 0,
3710            billing_mode,
3711            ttl_attribute: None,
3712            ttl_enabled: false,
3713            resource_policy: None,
3714            pitr_enabled: false,
3715            kinesis_destinations: Vec::new(),
3716            contributor_insights_status: "DISABLED".to_string(),
3717            contributor_insights_counters: BTreeMap::new(),
3718            stream_enabled,
3719            stream_view_type,
3720            stream_arn,
3721            stream_records: Arc::new(RwLock::new(Vec::new())),
3722            sse_type: None,
3723            sse_kms_key_arn: None,
3724            deletion_protection_enabled,
3725            on_demand_throughput,
3726        };
3727
3728        state.tables.insert(table_name.to_string(), table);
3729        let mut result = ProvisionResult::new(arn.clone()).with("Arn", arn);
3730        if let Some(stream_arn_value) = stream_arn_attr {
3731            result = result.with("StreamArn", stream_arn_value);
3732        }
3733        Ok(result)
3734    }
3735
3736    fn delete_dynamodb_table(&self, physical_id: &str) -> Result<(), String> {
3737        let mut __ddb_mas = self.dynamodb_state.write();
3738        let state = __ddb_mas.get_or_create(&self.account_id);
3739        // physical_id is the ARN; find the table name
3740        let table_name = state
3741            .tables
3742            .iter()
3743            .find(|(_, t)| t.arn == physical_id)
3744            .map(|(name, _)| name.clone());
3745        if let Some(name) = table_name {
3746            state.tables.remove(&name);
3747        }
3748        Ok(())
3749    }
3750
3751    // --- CloudWatch Logs ---
3752
3753    fn create_log_group(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
3754        let props = &resource.properties;
3755        let log_group_name = props
3756            .get("LogGroupName")
3757            .and_then(|v| v.as_str())
3758            .unwrap_or(&resource.logical_id);
3759
3760        let retention_in_days = props
3761            .get("RetentionInDays")
3762            .and_then(|v| v.as_i64())
3763            .map(|v| v as i32);
3764
3765        let mut logs_accounts = self.logs_state.write();
3766        let state = logs_accounts.get_or_create(&self.account_id);
3767        let arn = format!(
3768            "arn:aws:logs:{}:{}:log-group:{}:*",
3769            state.region, state.account_id, log_group_name
3770        );
3771
3772        let log_group = fakecloud_logs::LogGroup {
3773            name: log_group_name.to_string(),
3774            arn: arn.clone(),
3775            creation_time: Utc::now().timestamp_millis(),
3776            retention_in_days,
3777            kms_key_id: None,
3778            stored_bytes: 0,
3779            log_streams: std::collections::BTreeMap::new(),
3780            tags: std::collections::BTreeMap::new(),
3781            subscription_filters: Vec::new(),
3782            data_protection_policy: None,
3783            index_policies: Vec::new(),
3784            transformer: None,
3785            deletion_protection: false,
3786            log_group_class: Some("STANDARD".to_string()),
3787        };
3788
3789        state
3790            .log_groups
3791            .insert(log_group_name.to_string(), log_group);
3792        Ok(ProvisionResult::new(arn.clone()).with("Arn", arn))
3793    }
3794
3795    fn create_lambda_function(
3796        &self,
3797        resource: &ResourceDefinition,
3798    ) -> Result<ProvisionResult, String> {
3799        let props = &resource.properties;
3800        let function_name = props
3801            .get("FunctionName")
3802            .and_then(|v| v.as_str())
3803            .map(|s| s.to_string())
3804            .unwrap_or_else(|| {
3805                format!(
3806                    "{}-{}-{}",
3807                    self.stack_id
3808                        .rsplit('/')
3809                        .nth(1)
3810                        .unwrap_or(&resource.logical_id),
3811                    resource.logical_id,
3812                    Uuid::new_v4()
3813                        .to_string()
3814                        .split('-')
3815                        .next()
3816                        .unwrap_or("rand")
3817                )
3818            });
3819
3820        let cfg = parse_lambda_function_props(props)?;
3821        let function_arn = format!(
3822            "arn:aws:lambda:{}:{}:function:{}",
3823            self.region, self.account_id, function_name
3824        );
3825
3826        // Resolve `Code.S3Bucket` + `Code.S3Key` against the in-process S3 state
3827        // so a stack that uploads code via `AWS::S3::Bucket` + `AWS::S3::Object`
3828        // (or any S3 PutObject before CreateStack) hydrates `code_zip` from the
3829        // real bytes. ZipFile (already decoded by parse_lambda_function_props)
3830        // wins over S3 if both are present, mirroring AWS validation order.
3831        let code_zip = if cfg.code_zip.is_some() {
3832            cfg.code_zip.clone()
3833        } else if let (Some(bucket_name), Some(key)) = (&cfg.s3_bucket, &cfg.s3_key) {
3834            Some(self.read_s3_object_bytes(bucket_name, key).map_err(|e| {
3835                format!("Failed to read Code.S3Bucket={bucket_name} Code.S3Key={key}: {e}")
3836            })?)
3837        } else {
3838            None
3839        };
3840
3841        // Hash the actual ZIP bytes when available; image-based functions
3842        // leave the sha empty (matching the lambda CreateFunction code path
3843        // when neither ZipFile nor S3 source is supplied).
3844        let (code_sha256, code_size) = match &code_zip {
3845            Some(bytes) => (sha256_b64(bytes), bytes.len() as i64),
3846            None => (String::new(), 0),
3847        };
3848
3849        // Resolve attached layer ARNs to their current code_size so
3850        // GetFunctionConfiguration echoes the right size without a
3851        // second state lookup.
3852        let layers: Vec<AttachedLayer> = {
3853            let accounts = self.lambda_state.read();
3854            cfg.layers
3855                .iter()
3856                .map(|arn| AttachedLayer {
3857                    arn: arn.clone(),
3858                    code_size: layer_code_size(&accounts, arn),
3859                })
3860                .collect()
3861        };
3862
3863        let func = fakecloud_lambda::LambdaFunction {
3864            function_name: function_name.clone(),
3865            function_arn: function_arn.clone(),
3866            runtime: cfg.runtime,
3867            role: cfg.role,
3868            handler: cfg.handler,
3869            description: cfg.description,
3870            timeout: cfg.timeout,
3871            memory_size: cfg.memory_size,
3872            code_sha256,
3873            code_size,
3874            version: "$LATEST".to_string(),
3875            last_modified: Utc::now(),
3876            tags: cfg.tags,
3877            environment: cfg.environment,
3878            architectures: cfg.architectures,
3879            package_type: cfg.package_type,
3880            code_zip,
3881            image_uri: cfg.image_uri,
3882            policy: None,
3883            layers,
3884            revision_id: Uuid::new_v4().to_string(),
3885            tracing_mode: cfg.tracing_mode,
3886            kms_key_arn: cfg.kms_key_arn,
3887            ephemeral_storage_size: cfg.ephemeral_storage_size,
3888            vpc_config: cfg.vpc_config,
3889            snap_start: cfg.snap_start,
3890            dead_letter_config_arn: cfg.dead_letter_config_arn,
3891            file_system_configs: cfg.file_system_configs,
3892            logging_config: cfg.logging_config,
3893            image_config: None,
3894            signing_profile_version_arn: None,
3895            signing_job_arn: None,
3896            runtime_version_config: None,
3897            master_arn: None,
3898            state_reason: None,
3899            state_reason_code: None,
3900            last_update_status_reason: None,
3901            last_update_status_reason_code: None,
3902        };
3903
3904        let mut accounts = self.lambda_state.write();
3905        let state = accounts.get_or_create(&self.account_id);
3906        state.functions.insert(function_name.clone(), func);
3907
3908        // Capture every GetAtt-resolvable attribute eagerly. Output
3909        // resolution happens after `provision_stack_resources` returns
3910        // and only sees `StackResource.attributes`, so any attribute the
3911        // template's `Outputs` need must be in this map.
3912        Ok(ProvisionResult::new(function_name.clone())
3913            .with("Arn", function_arn)
3914            .with("FunctionName", function_name)
3915            .with("Version", "$LATEST"))
3916    }
3917
3918    /// Apply a CFN template-driven update to an existing Lambda function.
3919    /// Mirrors `UpdateFunctionConfiguration` + `UpdateFunctionCode`:
3920    /// rewrite mutable configuration fields from the new template, re-hash
3921    /// any new code bytes, bump `revision_id` and `last_modified`. The
3922    /// function's ARN, name, and `$LATEST` version stay put — those are
3923    /// the immutable identity of the resource as far as CloudFormation
3924    /// is concerned.
3925    fn update_lambda_function(
3926        &self,
3927        existing: &StackResource,
3928        resource: &ResourceDefinition,
3929    ) -> Result<ProvisionResult, String> {
3930        let props = &resource.properties;
3931        let function_name = existing.physical_id.clone();
3932        let cfg = parse_lambda_function_props(props)?;
3933
3934        let new_code_zip = if cfg.code_zip.is_some() {
3935            cfg.code_zip.clone()
3936        } else if let (Some(bucket_name), Some(key)) = (&cfg.s3_bucket, &cfg.s3_key) {
3937            Some(self.read_s3_object_bytes(bucket_name, key).map_err(|e| {
3938                format!("Failed to read Code.S3Bucket={bucket_name} Code.S3Key={key}: {e}")
3939            })?)
3940        } else {
3941            None
3942        };
3943
3944        let resolved_layers: Vec<AttachedLayer> = {
3945            let accounts = self.lambda_state.read();
3946            cfg.layers
3947                .iter()
3948                .map(|arn| AttachedLayer {
3949                    arn: arn.clone(),
3950                    code_size: layer_code_size(&accounts, arn),
3951                })
3952                .collect()
3953        };
3954
3955        let mut accounts = self.lambda_state.write();
3956        let state = accounts.get_or_create(&self.account_id);
3957        let func = state.functions.get_mut(&function_name).ok_or_else(|| {
3958            format!("Cannot update {function_name}: function does not exist in lambda state")
3959        })?;
3960
3961        func.runtime = cfg.runtime;
3962        func.role = cfg.role;
3963        func.handler = cfg.handler;
3964        func.description = cfg.description;
3965        func.timeout = cfg.timeout;
3966        func.memory_size = cfg.memory_size;
3967        func.environment = cfg.environment;
3968        func.architectures = cfg.architectures;
3969        func.package_type = cfg.package_type;
3970        func.tracing_mode = cfg.tracing_mode;
3971        func.kms_key_arn = cfg.kms_key_arn;
3972        func.ephemeral_storage_size = cfg.ephemeral_storage_size;
3973        func.vpc_config = cfg.vpc_config;
3974        func.snap_start = cfg.snap_start;
3975        func.dead_letter_config_arn = cfg.dead_letter_config_arn;
3976        func.file_system_configs = cfg.file_system_configs;
3977        func.logging_config = cfg.logging_config;
3978        func.layers = resolved_layers;
3979        if cfg.image_uri.is_some() {
3980            func.image_uri = cfg.image_uri;
3981        }
3982        if !cfg.tags.is_empty() {
3983            func.tags = cfg.tags;
3984        }
3985        if let Some(bytes) = new_code_zip {
3986            func.code_sha256 = sha256_b64(&bytes);
3987            func.code_size = bytes.len() as i64;
3988            func.code_zip = Some(bytes);
3989        }
3990        func.last_modified = Utc::now();
3991        func.revision_id = Uuid::new_v4().to_string();
3992
3993        let function_arn = func.function_arn.clone();
3994        Ok(ProvisionResult::new(function_name.clone())
3995            .with("Arn", function_arn)
3996            .with("FunctionName", function_name)
3997            .with("Version", "$LATEST"))
3998    }
3999
4000    fn delete_lambda_function(&self, physical_id: &str) -> Result<(), String> {
4001        let mut accounts = self.lambda_state.write();
4002        let state = accounts.default_mut();
4003        state.functions.remove(physical_id);
4004        Ok(())
4005    }
4006
4007    /// Look up an S3 object's bytes from the in-process S3 state. Used by
4008    /// the Lambda function provisioner to hydrate `Code.S3Bucket` /
4009    /// `Code.S3Key` references into real ZIP content. Returns an error
4010    /// string when the bucket or key is missing so the CFN error
4011    /// surfaces back to the caller.
4012    fn read_s3_object_bytes(&self, bucket: &str, key: &str) -> Result<Vec<u8>, String> {
4013        let mut accounts = self.s3_state.write();
4014        let state = accounts.get_or_create(&self.account_id);
4015        let body_ref = {
4016            let b = state
4017                .buckets
4018                .get(bucket)
4019                .ok_or_else(|| format!("S3 bucket {bucket} does not exist"))?;
4020            let object = b
4021                .objects
4022                .get(key)
4023                .ok_or_else(|| format!("S3 object s3://{bucket}/{key} does not exist"))?;
4024            object.body.clone()
4025        };
4026        // `read_body` consults the body cache (which is owned by the state),
4027        // so re-borrow `state` after dropping the bucket borrow above.
4028        state
4029            .read_body(&body_ref)
4030            .map(|b| b.to_vec())
4031            .map_err(|e| format!("S3 read failed: {e}"))
4032    }
4033
4034    fn create_lambda_permission(
4035        &self,
4036        resource: &ResourceDefinition,
4037    ) -> Result<ProvisionResult, String> {
4038        let props = &resource.properties;
4039        let function_name = parse_lambda_function_name(
4040            props
4041                .get("FunctionName")
4042                .and_then(|v| v.as_str())
4043                .ok_or_else(|| "FunctionName is required".to_string())?,
4044        );
4045        // CFN does not surface a StatementId knob; synthesize one from
4046        // the logical id so subsequent updates / deletes can find the
4047        // statement again.
4048        let statement_id = format!(
4049            "cfn-{}-{}",
4050            resource.logical_id,
4051            &Uuid::new_v4().simple().to_string()[..8]
4052        );
4053        self.append_lambda_permission_statement(&function_name, &statement_id, props)?;
4054
4055        // Encode `{function}|{sid}` so delete can target a single statement.
4056        let physical_id = format!("{function_name}|{statement_id}");
4057        Ok(ProvisionResult::new(physical_id).with("Id", statement_id))
4058    }
4059
4060    /// Build a canonical Lambda resource-policy statement from a CFN
4061    /// `AWS::Lambda::Permission` `Properties` map and append it to the
4062    /// function's stored policy. Shared by create + update so the same
4063    /// property→condition mapping applies on both paths. Returns the
4064    /// function ARN of the target so callers can echo it back if needed.
4065    fn append_lambda_permission_statement(
4066        &self,
4067        function_name: &str,
4068        statement_id: &str,
4069        props: &serde_json::Value,
4070    ) -> Result<String, String> {
4071        let action = props
4072            .get("Action")
4073            .and_then(|v| v.as_str())
4074            .ok_or_else(|| "Action is required".to_string())?
4075            .to_string();
4076        let principal = props
4077            .get("Principal")
4078            .and_then(|v| v.as_str())
4079            .ok_or_else(|| "Principal is required".to_string())?
4080            .to_string();
4081        let source_arn = props
4082            .get("SourceArn")
4083            .and_then(|v| v.as_str())
4084            .map(|s| s.to_string());
4085        let source_account = props
4086            .get("SourceAccount")
4087            .and_then(|v| v.as_str())
4088            .map(|s| s.to_string());
4089        let event_source_token = props
4090            .get("EventSourceToken")
4091            .and_then(|v| v.as_str())
4092            .map(|s| s.to_string());
4093        let function_url_auth_type = props
4094            .get("FunctionUrlAuthType")
4095            .and_then(|v| v.as_str())
4096            .map(|s| s.to_string());
4097        let principal_org_id = props
4098            .get("PrincipalOrgID")
4099            .and_then(|v| v.as_str())
4100            .map(|s| s.to_string());
4101
4102        let mut accounts = self.lambda_state.write();
4103        let state = accounts.get_or_create(&self.account_id);
4104        let func = state.functions.get_mut(function_name).ok_or_else(|| {
4105            format!(
4106                "Function {function_name} does not exist yet — retry once it has been provisioned"
4107            )
4108        })?;
4109
4110        let mut doc: serde_json::Value = func
4111            .policy
4112            .as_deref()
4113            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
4114            .filter(|v| v.is_object())
4115            .unwrap_or_else(|| serde_json::json!({"Version": "2012-10-17", "Statement": []}));
4116        if !doc.get("Statement").map(|s| s.is_array()).unwrap_or(false) {
4117            doc["Statement"] = serde_json::json!([]);
4118        }
4119        let principal_value =
4120            if principal.ends_with(".amazonaws.com") || principal.contains(".amazon") {
4121                serde_json::json!({ "Service": principal })
4122            } else {
4123                serde_json::json!({ "AWS": principal })
4124            };
4125        let mut arn_like = serde_json::Map::new();
4126        let mut string_equals = serde_json::Map::new();
4127        if let Some(src) = source_arn {
4128            arn_like.insert("AWS:SourceArn".to_string(), serde_json::Value::String(src));
4129        }
4130        if let Some(acct) = source_account {
4131            string_equals.insert(
4132                "AWS:SourceAccount".to_string(),
4133                serde_json::Value::String(acct),
4134            );
4135        }
4136        if let Some(token) = event_source_token {
4137            string_equals.insert(
4138                "lambda:EventSourceToken".to_string(),
4139                serde_json::Value::String(token),
4140            );
4141        }
4142        if let Some(auth) = function_url_auth_type {
4143            string_equals.insert(
4144                "lambda:FunctionUrlAuthType".to_string(),
4145                serde_json::Value::String(auth),
4146            );
4147        }
4148        if let Some(org) = principal_org_id {
4149            string_equals.insert(
4150                "aws:PrincipalOrgID".to_string(),
4151                serde_json::Value::String(org),
4152            );
4153        }
4154        let mut conditions = serde_json::Map::new();
4155        if !arn_like.is_empty() {
4156            conditions.insert("ArnLike".to_string(), serde_json::Value::Object(arn_like));
4157        }
4158        if !string_equals.is_empty() {
4159            conditions.insert(
4160                "StringEquals".to_string(),
4161                serde_json::Value::Object(string_equals),
4162            );
4163        }
4164
4165        let mut statement = serde_json::Map::new();
4166        statement.insert(
4167            "Sid".to_string(),
4168            serde_json::Value::String(statement_id.to_string()),
4169        );
4170        statement.insert(
4171            "Effect".to_string(),
4172            serde_json::Value::String("Allow".to_string()),
4173        );
4174        statement.insert("Principal".to_string(), principal_value);
4175        statement.insert("Action".to_string(), serde_json::Value::String(action));
4176        statement.insert(
4177            "Resource".to_string(),
4178            serde_json::Value::String(func.function_arn.clone()),
4179        );
4180        if !conditions.is_empty() {
4181            statement.insert(
4182                "Condition".to_string(),
4183                serde_json::Value::Object(conditions),
4184            );
4185        }
4186        doc["Statement"]
4187            .as_array_mut()
4188            .unwrap()
4189            .push(serde_json::Value::Object(statement));
4190        func.policy = Some(doc.to_string());
4191        Ok(func.function_arn.clone())
4192    }
4193
4194    /// Replace the policy statement for an existing CFN-managed
4195    /// `AWS::Lambda::Permission`. CFN treats most property changes as
4196    /// in-place updates: the statement id stays put, the body is
4197    /// rewritten to reflect the new properties.
4198    fn update_lambda_permission(
4199        &self,
4200        existing: &StackResource,
4201        resource: &ResourceDefinition,
4202    ) -> Result<ProvisionResult, String> {
4203        let Some((function_name, statement_id)) = existing.physical_id.split_once('|') else {
4204            return Err(format!(
4205                "Permission physical id `{}` is malformed; expected `{{function}}|{{sid}}`",
4206                existing.physical_id
4207            ));
4208        };
4209        // First strip the prior statement so we don't end up with a duplicate
4210        // sid in the policy doc.
4211        {
4212            let mut accounts = self.lambda_state.write();
4213            let state = accounts.get_or_create(&self.account_id);
4214            if let Some(func) = state.functions.get_mut(function_name) {
4215                if let Some(policy_str) = func.policy.as_deref() {
4216                    if let Ok(mut doc) = serde_json::from_str::<serde_json::Value>(policy_str) {
4217                        if let Some(arr) = doc.get_mut("Statement").and_then(|v| v.as_array_mut()) {
4218                            arr.retain(|s| {
4219                                s.get("Sid").and_then(|v| v.as_str()) != Some(statement_id)
4220                            });
4221                            func.policy = Some(doc.to_string());
4222                        }
4223                    }
4224                }
4225            }
4226        }
4227        self.append_lambda_permission_statement(function_name, statement_id, &resource.properties)?;
4228        Ok(ProvisionResult::new(existing.physical_id.clone()).with("Id", statement_id.to_string()))
4229    }
4230
4231    fn delete_lambda_permission(&self, physical_id: &str) -> Result<(), String> {
4232        let Some((function_name, sid)) = physical_id.split_once('|') else {
4233            return Ok(());
4234        };
4235        let mut accounts = self.lambda_state.write();
4236        let state = accounts.get_or_create(&self.account_id);
4237        if let Some(func) = state.functions.get_mut(function_name) {
4238            if let Some(policy_str) = func.policy.as_deref() {
4239                if let Ok(mut doc) = serde_json::from_str::<serde_json::Value>(policy_str) {
4240                    if let Some(arr) = doc.get_mut("Statement").and_then(|v| v.as_array_mut()) {
4241                        arr.retain(|s| s.get("Sid").and_then(|v| v.as_str()) != Some(sid));
4242                        func.policy = Some(doc.to_string());
4243                    }
4244                }
4245            }
4246        }
4247        Ok(())
4248    }
4249
4250    fn create_lambda_event_source_mapping(
4251        &self,
4252        resource: &ResourceDefinition,
4253    ) -> Result<ProvisionResult, String> {
4254        let props = &resource.properties;
4255        let function_name = parse_lambda_function_name(
4256            props
4257                .get("FunctionName")
4258                .and_then(|v| v.as_str())
4259                .ok_or_else(|| "FunctionName is required".to_string())?,
4260        );
4261        let cfg = parse_lambda_event_source_mapping_props(props)?;
4262
4263        let mut accounts = self.lambda_state.write();
4264        let state = accounts.get_or_create(&self.account_id);
4265        if !state.functions.contains_key(&function_name) {
4266            return Err(format!(
4267                "Function {function_name} does not exist yet — retry once it has been provisioned"
4268            ));
4269        }
4270        let function_arn = format!(
4271            "arn:aws:lambda:{}:{}:function:{}",
4272            self.region, self.account_id, function_name
4273        );
4274        let uuid = Uuid::new_v4().to_string();
4275        let esm = EventSourceMapping {
4276            uuid: uuid.clone(),
4277            function_arn,
4278            event_source_arn: cfg.event_source_arn,
4279            batch_size: cfg.batch_size,
4280            enabled: cfg.enabled,
4281            state: if cfg.enabled {
4282                "Enabled".to_string()
4283            } else {
4284                "Disabled".to_string()
4285            },
4286            last_modified: Utc::now(),
4287            filter_patterns: cfg.filter_patterns,
4288            maximum_batching_window_in_seconds: cfg.maximum_batching_window_in_seconds,
4289            starting_position: cfg.starting_position,
4290            starting_position_timestamp: cfg.starting_position_timestamp,
4291            parallelization_factor: cfg.parallelization_factor,
4292            function_response_types: cfg.function_response_types,
4293            kms_key_arn: cfg.kms_key_arn,
4294            metrics_config: cfg.metrics_config,
4295            destination_config: cfg.destination_config,
4296            maximum_retry_attempts: cfg.maximum_retry_attempts,
4297            maximum_record_age_in_seconds: cfg.maximum_record_age_in_seconds,
4298            bisect_batch_on_function_error: cfg.bisect_batch_on_function_error,
4299            tumbling_window_in_seconds: cfg.tumbling_window_in_seconds,
4300            topics: cfg.topics,
4301            queues: cfg.queues,
4302        };
4303        state.event_source_mappings.insert(uuid.clone(), esm);
4304        Ok(ProvisionResult::new(uuid.clone()).with("Id", uuid))
4305    }
4306
4307    /// In-place update of an existing EventSourceMapping. CFN treats
4308    /// every mutable property as in-place; the EventSourceArn /
4309    /// FunctionName pair stays put (those are immutable identity).
4310    fn update_lambda_event_source_mapping(
4311        &self,
4312        existing: &StackResource,
4313        resource: &ResourceDefinition,
4314    ) -> Result<ProvisionResult, String> {
4315        let cfg = parse_lambda_event_source_mapping_props(&resource.properties)?;
4316        let mut accounts = self.lambda_state.write();
4317        let state = accounts.get_or_create(&self.account_id);
4318        let esm = state
4319            .event_source_mappings
4320            .get_mut(&existing.physical_id)
4321            .ok_or_else(|| {
4322                format!(
4323                    "EventSourceMapping {} does not exist in lambda state",
4324                    existing.physical_id
4325                )
4326            })?;
4327        esm.batch_size = cfg.batch_size;
4328        esm.enabled = cfg.enabled;
4329        esm.state = if cfg.enabled {
4330            "Enabled".to_string()
4331        } else {
4332            "Disabled".to_string()
4333        };
4334        esm.last_modified = Utc::now();
4335        esm.filter_patterns = cfg.filter_patterns;
4336        esm.maximum_batching_window_in_seconds = cfg.maximum_batching_window_in_seconds;
4337        esm.parallelization_factor = cfg.parallelization_factor;
4338        esm.function_response_types = cfg.function_response_types;
4339        esm.kms_key_arn = cfg.kms_key_arn;
4340        esm.metrics_config = cfg.metrics_config;
4341        esm.destination_config = cfg.destination_config;
4342        esm.maximum_retry_attempts = cfg.maximum_retry_attempts;
4343        esm.maximum_record_age_in_seconds = cfg.maximum_record_age_in_seconds;
4344        esm.bisect_batch_on_function_error = cfg.bisect_batch_on_function_error;
4345        esm.tumbling_window_in_seconds = cfg.tumbling_window_in_seconds;
4346        Ok(ProvisionResult::new(existing.physical_id.clone())
4347            .with("Id", existing.physical_id.clone()))
4348    }
4349
4350    fn delete_lambda_event_source_mapping(&self, physical_id: &str) -> Result<(), String> {
4351        let mut accounts = self.lambda_state.write();
4352        let state = accounts.get_or_create(&self.account_id);
4353        state.event_source_mappings.remove(physical_id);
4354        Ok(())
4355    }
4356
4357    fn create_lambda_layer_version(
4358        &self,
4359        resource: &ResourceDefinition,
4360    ) -> Result<ProvisionResult, String> {
4361        let props = &resource.properties;
4362        let layer_name = props
4363            .get("LayerName")
4364            .and_then(|v| v.as_str())
4365            .unwrap_or(&resource.logical_id)
4366            .to_string();
4367        let description = props
4368            .get("Description")
4369            .and_then(|v| v.as_str())
4370            .unwrap_or("")
4371            .to_string();
4372        let license_info = props
4373            .get("LicenseInfo")
4374            .and_then(|v| v.as_str())
4375            .unwrap_or("")
4376            .to_string();
4377        let compatible_runtimes: Vec<String> = props
4378            .get("CompatibleRuntimes")
4379            .and_then(|v| v.as_array())
4380            .map(|arr| {
4381                arr.iter()
4382                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
4383                    .collect()
4384            })
4385            .unwrap_or_default();
4386        let compatible_architectures: Vec<String> = props
4387            .get("CompatibleArchitectures")
4388            .and_then(|v| v.as_array())
4389            .map(|arr| {
4390                arr.iter()
4391                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
4392                    .collect()
4393            })
4394            .unwrap_or_default();
4395        // CFN's `Content.ZipFile` rides as base64 (per the user guide).
4396        // `Content.S3Bucket` + `Content.S3Key` hydrate the same way the
4397        // Lambda function provisioner pulls Code.S3Bucket. Either path
4398        // is optional — fakecloud accepts a layer with zero-byte content
4399        // so templates that reference an upstream-managed layer ARN
4400        // still work.
4401        let content = props.get("Content");
4402        let zip_bytes = if let Some(b64) = content
4403            .and_then(|v| v.get("ZipFile"))
4404            .and_then(|v| v.as_str())
4405        {
4406            use base64::Engine;
4407            Some(
4408                base64::engine::general_purpose::STANDARD
4409                    .decode(b64)
4410                    .map_err(|e| format!("Content.ZipFile is not valid base64: {e}"))?,
4411            )
4412        } else if let (Some(bucket), Some(key)) = (
4413            content
4414                .and_then(|c| c.get("S3Bucket"))
4415                .and_then(|v| v.as_str()),
4416            content
4417                .and_then(|c| c.get("S3Key"))
4418                .and_then(|v| v.as_str()),
4419        ) {
4420            Some(self.read_s3_object_bytes(bucket, key).map_err(|e| {
4421                format!("Failed to read Content.S3Bucket={bucket} Content.S3Key={key}: {e}")
4422            })?)
4423        } else {
4424            None
4425        };
4426
4427        let (code_sha256, code_size) = match zip_bytes.as_deref() {
4428            Some(bytes) => (sha256_b64(bytes), bytes.len() as i64),
4429            None => (String::new(), 0),
4430        };
4431
4432        let mut accounts = self.lambda_state.write();
4433        let state = accounts.get_or_create(&self.account_id);
4434        let layer_arn = format!(
4435            "arn:aws:lambda:{}:{}:layer:{}",
4436            self.region, self.account_id, layer_name
4437        );
4438        let layer = state
4439            .layers
4440            .entry(layer_name.clone())
4441            .or_insert_with(|| Layer {
4442                layer_name: layer_name.clone(),
4443                layer_arn: layer_arn.clone(),
4444                versions: Vec::new(),
4445            });
4446        let next_version = (layer.versions.len() as i64) + 1;
4447        let version_arn = format!("{}:{}", layer.layer_arn, next_version);
4448        layer.versions.push(LayerVersion {
4449            version: next_version,
4450            layer_version_arn: version_arn.clone(),
4451            description: description.clone(),
4452            created_date: Utc::now(),
4453            compatible_runtimes,
4454            license_info,
4455            policy: None,
4456            code_zip: zip_bytes,
4457            code_sha256,
4458            code_size,
4459            compatible_architectures,
4460        });
4461        Ok(ProvisionResult::new(version_arn.clone())
4462            .with("LayerVersionArn", version_arn)
4463            .with("LayerArn", layer_arn))
4464    }
4465
4466    fn delete_lambda_layer_version(&self, physical_id: &str) -> Result<(), String> {
4467        // physical_id = `{layer_arn}:{version}` — strip trailing version.
4468        let Some(idx) = physical_id.rfind(':') else {
4469            return Ok(());
4470        };
4471        let (layer_arn, version_part) = physical_id.split_at(idx);
4472        let version_part = &version_part[1..];
4473        let Ok(version) = version_part.parse::<i64>() else {
4474            return Ok(());
4475        };
4476        // ARN form: arn:aws:lambda:<region>:<account>:layer:<name>
4477        let layer_name = layer_arn.rsplit(':').next().unwrap_or("").to_string();
4478        let mut accounts = self.lambda_state.write();
4479        let state = accounts.get_or_create(&self.account_id);
4480        if let Some(layer) = state.layers.get_mut(&layer_name) {
4481            layer.versions.retain(|v| v.version != version);
4482            if layer.versions.is_empty() {
4483                state.layers.remove(&layer_name);
4484            }
4485        }
4486        Ok(())
4487    }
4488
4489    fn create_lambda_url(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
4490        let props = &resource.properties;
4491        let function_name = parse_lambda_function_name(
4492            props
4493                .get("TargetFunctionArn")
4494                .and_then(|v| v.as_str())
4495                .ok_or_else(|| "TargetFunctionArn is required".to_string())?,
4496        );
4497        // Optional qualifier — when set the URL points at an alias
4498        // (`{function_name}:{qualifier}`) rather than `$LATEST`. AWS
4499        // refuses to mint a URL for a numeric version, but the CFN
4500        // schema allows the property so we accept and round-trip it.
4501        let qualifier = props
4502            .get("Qualifier")
4503            .and_then(|v| v.as_str())
4504            .filter(|s| !s.is_empty())
4505            .map(|s| s.to_string());
4506        let auth_type = props
4507            .get("AuthType")
4508            .and_then(|v| v.as_str())
4509            .unwrap_or("NONE")
4510            .to_string();
4511        let invoke_mode = props
4512            .get("InvokeMode")
4513            .and_then(|v| v.as_str())
4514            .unwrap_or("BUFFERED")
4515            .to_string();
4516        let cors = props.get("Cors").cloned();
4517
4518        let mut accounts = self.lambda_state.write();
4519        let state = accounts.get_or_create(&self.account_id);
4520        if !state.functions.contains_key(&function_name) {
4521            return Err(format!(
4522                "Function {function_name} does not exist yet — retry once it has been provisioned"
4523            ));
4524        }
4525        let function_arn = match &qualifier {
4526            Some(q) => format!(
4527                "arn:aws:lambda:{}:{}:function:{}:{}",
4528                self.region, self.account_id, function_name, q
4529            ),
4530            None => format!(
4531                "arn:aws:lambda:{}:{}:function:{}",
4532                self.region, self.account_id, function_name
4533            ),
4534        };
4535        let function_url = format!("https://{function_name}.lambda-url.{}.on.aws/", self.region);
4536        let now = Utc::now();
4537        let cfg = FunctionUrlConfig {
4538            function_arn: function_arn.clone(),
4539            function_url: function_url.clone(),
4540            auth_type,
4541            cors,
4542            creation_time: now,
4543            last_modified_time: now,
4544            invoke_mode,
4545        };
4546        // Key by `{function}:{qualifier}` when qualifier is set so
4547        // delete and update can round-trip the same shape, while leaving
4548        // the bare-function-name URL alone for $LATEST.
4549        let key = match &qualifier {
4550            Some(q) => format!("{function_name}:{q}"),
4551            None => function_name.clone(),
4552        };
4553        state.function_url_configs.insert(key.clone(), cfg);
4554
4555        Ok(ProvisionResult::new(key)
4556            .with("FunctionArn", function_arn)
4557            .with("FunctionUrl", function_url))
4558    }
4559
4560    /// Update an existing Lambda Url config in place. AuthType, Cors,
4561    /// and InvokeMode are mutable; the target function and qualifier
4562    /// (which collectively form the key) are not — CFN replaces the
4563    /// resource if those change, so we only touch the body here.
4564    fn update_lambda_url(
4565        &self,
4566        existing: &StackResource,
4567        resource: &ResourceDefinition,
4568    ) -> Result<ProvisionResult, String> {
4569        let props = &resource.properties;
4570        let auth_type = props
4571            .get("AuthType")
4572            .and_then(|v| v.as_str())
4573            .unwrap_or("NONE")
4574            .to_string();
4575        let invoke_mode = props
4576            .get("InvokeMode")
4577            .and_then(|v| v.as_str())
4578            .unwrap_or("BUFFERED")
4579            .to_string();
4580        let cors = props.get("Cors").cloned();
4581
4582        let mut accounts = self.lambda_state.write();
4583        let state = accounts.get_or_create(&self.account_id);
4584        let cfg = state
4585            .function_url_configs
4586            .get_mut(&existing.physical_id)
4587            .ok_or_else(|| {
4588                format!(
4589                    "FunctionUrlConfig {} does not exist in lambda state",
4590                    existing.physical_id
4591                )
4592            })?;
4593        cfg.auth_type = auth_type;
4594        cfg.invoke_mode = invoke_mode;
4595        cfg.cors = cors;
4596        cfg.last_modified_time = Utc::now();
4597        let function_arn = cfg.function_arn.clone();
4598        let function_url = cfg.function_url.clone();
4599        Ok(ProvisionResult::new(existing.physical_id.clone())
4600            .with("FunctionArn", function_arn)
4601            .with("FunctionUrl", function_url))
4602    }
4603
4604    fn delete_lambda_url(&self, physical_id: &str) -> Result<(), String> {
4605        let mut accounts = self.lambda_state.write();
4606        let state = accounts.get_or_create(&self.account_id);
4607        state.function_url_configs.remove(physical_id);
4608        Ok(())
4609    }
4610
4611    fn create_lambda_alias(
4612        &self,
4613        resource: &ResourceDefinition,
4614    ) -> Result<ProvisionResult, String> {
4615        let props = &resource.properties;
4616        let function_name = parse_lambda_function_name(
4617            props
4618                .get("FunctionName")
4619                .and_then(|v| v.as_str())
4620                .ok_or_else(|| "FunctionName is required".to_string())?,
4621        );
4622        let alias_name = props
4623            .get("Name")
4624            .and_then(|v| v.as_str())
4625            .ok_or_else(|| "Name is required".to_string())?
4626            .to_string();
4627        let function_version = props
4628            .get("FunctionVersion")
4629            .and_then(|v| v.as_str())
4630            .unwrap_or("$LATEST")
4631            .to_string();
4632        let description = props
4633            .get("Description")
4634            .and_then(|v| v.as_str())
4635            .unwrap_or("")
4636            .to_string();
4637        let routing_config = props.get("RoutingConfig").cloned();
4638
4639        let mut accounts = self.lambda_state.write();
4640        let state = accounts.get_or_create(&self.account_id);
4641        if !state.functions.contains_key(&function_name) {
4642            return Err(format!(
4643                "Function {function_name} does not exist yet — retry once it has been provisioned"
4644            ));
4645        }
4646        let alias_arn = format!(
4647            "arn:aws:lambda:{}:{}:function:{}:{}",
4648            self.region, self.account_id, function_name, alias_name
4649        );
4650        let key = format!("{function_name}:{alias_name}");
4651        state.aliases.insert(
4652            key.clone(),
4653            FunctionAlias {
4654                alias_arn: alias_arn.clone(),
4655                name: alias_name,
4656                function_version,
4657                description,
4658                revision_id: Uuid::new_v4().to_string(),
4659                routing_config,
4660            },
4661        );
4662        // ProvisionedConcurrencyConfig — CFN allows attaching a target
4663        // concurrency at the same time as the alias is created. Mirror
4664        // the runtime PutProvisionedConcurrencyConfig path: stamp a
4665        // `ProvisionedConcurrencyConfig` entry keyed by `{function}:{alias}`
4666        // with a `READY` status and the requested count fully allocated.
4667        if let Some(cnt) = props
4668            .get("ProvisionedConcurrencyConfig")
4669            .and_then(|v| v.get("ProvisionedConcurrentExecutions"))
4670            .and_then(|v| v.as_i64())
4671        {
4672            state.provisioned_concurrency.insert(
4673                key.clone(),
4674                fakecloud_lambda::ProvisionedConcurrencyConfig {
4675                    requested: cnt,
4676                    allocated: cnt,
4677                    status: "READY".to_string(),
4678                    last_modified: Utc::now(),
4679                },
4680            );
4681        }
4682        Ok(ProvisionResult::new(key).with("AliasArn", alias_arn))
4683    }
4684
4685    /// Update an existing Lambda Alias in place. FunctionVersion,
4686    /// Description, RoutingConfig, and ProvisionedConcurrencyConfig are
4687    /// the mutable fields — Name and FunctionName are immutable, so a
4688    /// change there is a replacement (the parent CFN engine handles
4689    /// that path; this fn only sees the in-place case).
4690    fn update_lambda_alias(
4691        &self,
4692        existing: &StackResource,
4693        resource: &ResourceDefinition,
4694    ) -> Result<ProvisionResult, String> {
4695        let props = &resource.properties;
4696        let function_version = props
4697            .get("FunctionVersion")
4698            .and_then(|v| v.as_str())
4699            .unwrap_or("$LATEST")
4700            .to_string();
4701        let description = props
4702            .get("Description")
4703            .and_then(|v| v.as_str())
4704            .unwrap_or("")
4705            .to_string();
4706        let routing_config = props.get("RoutingConfig").cloned();
4707
4708        let mut accounts = self.lambda_state.write();
4709        let state = accounts.get_or_create(&self.account_id);
4710        let alias = state
4711            .aliases
4712            .get_mut(&existing.physical_id)
4713            .ok_or_else(|| {
4714                format!(
4715                    "Alias {} does not exist in lambda state",
4716                    existing.physical_id
4717                )
4718            })?;
4719        alias.function_version = function_version;
4720        alias.description = description;
4721        alias.routing_config = routing_config;
4722        alias.revision_id = Uuid::new_v4().to_string();
4723        let alias_arn = alias.alias_arn.clone();
4724
4725        // Re-stamp provisioned concurrency to match the new shape; remove
4726        // when the property is dropped from the template.
4727        match props
4728            .get("ProvisionedConcurrencyConfig")
4729            .and_then(|v| v.get("ProvisionedConcurrentExecutions"))
4730            .and_then(|v| v.as_i64())
4731        {
4732            Some(cnt) => {
4733                state.provisioned_concurrency.insert(
4734                    existing.physical_id.clone(),
4735                    fakecloud_lambda::ProvisionedConcurrencyConfig {
4736                        requested: cnt,
4737                        allocated: cnt,
4738                        status: "READY".to_string(),
4739                        last_modified: Utc::now(),
4740                    },
4741                );
4742            }
4743            None => {
4744                state.provisioned_concurrency.remove(&existing.physical_id);
4745            }
4746        }
4747        Ok(ProvisionResult::new(existing.physical_id.clone()).with("AliasArn", alias_arn))
4748    }
4749
4750    fn delete_lambda_alias(&self, physical_id: &str) -> Result<(), String> {
4751        let mut accounts = self.lambda_state.write();
4752        let state = accounts.get_or_create(&self.account_id);
4753        state.aliases.remove(physical_id);
4754        Ok(())
4755    }
4756
4757    fn create_lambda_version(
4758        &self,
4759        resource: &ResourceDefinition,
4760    ) -> Result<ProvisionResult, String> {
4761        let props = &resource.properties;
4762        let function_name = parse_lambda_function_name(
4763            props
4764                .get("FunctionName")
4765                .and_then(|v| v.as_str())
4766                .ok_or_else(|| "FunctionName is required".to_string())?,
4767        );
4768        // CFN's `Description` overrides the parent function's
4769        // description on this immutable snapshot; AWS does the same.
4770        // `CodeSha256`, when set, gates publish — AWS rejects publish if
4771        // the current `$LATEST` sha doesn't match the supplied value.
4772        let description_override = props
4773            .get("Description")
4774            .and_then(|v| v.as_str())
4775            .map(|s| s.to_string());
4776        let expected_sha = props
4777            .get("CodeSha256")
4778            .and_then(|v| v.as_str())
4779            .map(|s| s.to_string());
4780
4781        let mut accounts = self.lambda_state.write();
4782        let state = accounts.get_or_create(&self.account_id);
4783        let func = state
4784            .functions
4785            .get(&function_name)
4786            .ok_or_else(|| format!("Function {function_name} does not exist yet — retry once it has been provisioned"))?
4787            .clone();
4788        if let Some(expected) = &expected_sha {
4789            if !expected.is_empty() && expected != &func.code_sha256 {
4790                return Err(format!(
4791                    "PreconditionFailed: CodeSha256 mismatch on {function_name} — expected {expected}, $LATEST is {actual}",
4792                    actual = func.code_sha256,
4793                ));
4794            }
4795        }
4796        let versions = state
4797            .function_versions
4798            .entry(function_name.clone())
4799            .or_default();
4800        let next_version = (versions.len() as i64 + 1).to_string();
4801        versions.push(next_version.clone());
4802        // Snapshot current function config under this version with the
4803        // CFN-supplied description override (if any).
4804        let mut snapshot = func.clone();
4805        snapshot.version = next_version.clone();
4806        if let Some(desc) = description_override {
4807            snapshot.description = desc;
4808        }
4809        state
4810            .function_version_snapshots
4811            .entry(function_name.clone())
4812            .or_default()
4813            .insert(next_version.clone(), snapshot);
4814        let version_arn = format!(
4815            "arn:aws:lambda:{}:{}:function:{}:{}",
4816            self.region, self.account_id, function_name, next_version
4817        );
4818        let physical_id = format!("{function_name}:{next_version}");
4819        Ok(ProvisionResult::new(physical_id)
4820            .with("Version", next_version)
4821            .with("FunctionArn", version_arn))
4822    }
4823
4824    /// Update an existing Lambda Version. CFN treats numbered versions
4825    /// as immutable — any property change forces replacement (a new
4826    /// version number) at the engine level, so this fn just no-ops and
4827    /// echoes the existing physical id back. Keeping it wired keeps the
4828    /// update dispatch table consistent with the other 5 aux types.
4829    fn update_lambda_version(
4830        &self,
4831        existing: &StackResource,
4832        _resource: &ResourceDefinition,
4833    ) -> Result<ProvisionResult, String> {
4834        let mut accounts = self.lambda_state.write();
4835        let state = accounts.get_or_create(&self.account_id);
4836        let Some((function_name, version)) = existing.physical_id.split_once(':') else {
4837            return Err(format!(
4838                "Version physical id `{}` is malformed; expected `{{function}}:{{version}}`",
4839                existing.physical_id
4840            ));
4841        };
4842        let exists = state
4843            .function_version_snapshots
4844            .get(function_name)
4845            .map(|m| m.contains_key(version))
4846            .unwrap_or(false);
4847        if !exists {
4848            return Err(format!(
4849                "Version {version} for function {function_name} no longer exists in lambda state"
4850            ));
4851        }
4852        let version_arn = format!(
4853            "arn:aws:lambda:{}:{}:function:{}:{}",
4854            self.region, self.account_id, function_name, version
4855        );
4856        Ok(ProvisionResult::new(existing.physical_id.clone())
4857            .with("Version", version.to_string())
4858            .with("FunctionArn", version_arn))
4859    }
4860
4861    /// Update an existing LayerVersion. CFN treats LayerVersion as
4862    /// immutable (Content / CompatibleRuntimes / etc are all fixed once
4863    /// published), so a property change forces a new version. This fn
4864    /// no-ops to keep the dispatch table symmetric with the other aux
4865    /// types.
4866    fn update_lambda_layer_version(
4867        &self,
4868        existing: &StackResource,
4869        _resource: &ResourceDefinition,
4870    ) -> Result<ProvisionResult, String> {
4871        let arn = existing.physical_id.clone();
4872        // ARN form: arn:aws:lambda:<region>:<account>:layer:<name>:<version>
4873        let layer_arn_only = arn
4874            .rsplit_once(':')
4875            .map(|(prefix, _)| prefix.to_string())
4876            .unwrap_or_else(|| arn.clone());
4877        Ok(ProvisionResult::new(existing.physical_id.clone())
4878            .with("LayerVersionArn", arn)
4879            .with("LayerArn", layer_arn_only))
4880    }
4881
4882    fn delete_lambda_version(&self, physical_id: &str) -> Result<(), String> {
4883        let Some((function_name, version)) = physical_id.split_once(':') else {
4884            return Ok(());
4885        };
4886        let mut accounts = self.lambda_state.write();
4887        let state = accounts.get_or_create(&self.account_id);
4888        if let Some(versions) = state.function_versions.get_mut(function_name) {
4889            versions.retain(|v| v != version);
4890        }
4891        if let Some(snapshots) = state.function_version_snapshots.get_mut(function_name) {
4892            snapshots.remove(version);
4893        }
4894        Ok(())
4895    }
4896
4897    // --- SecretsManager ---
4898
4899    fn create_secrets_manager_secret(
4900        &self,
4901        resource: &ResourceDefinition,
4902    ) -> Result<ProvisionResult, String> {
4903        let props = &resource.properties;
4904        let name = props
4905            .get("Name")
4906            .and_then(|v| v.as_str())
4907            .unwrap_or(&resource.logical_id)
4908            .to_string();
4909        let description = props
4910            .get("Description")
4911            .and_then(|v| v.as_str())
4912            .map(|s| s.to_string());
4913        let kms_key_id = props
4914            .get("KmsKeyId")
4915            .and_then(|v| v.as_str())
4916            .map(|s| s.to_string());
4917
4918        let mut accounts = self.secretsmanager_state.write();
4919        let state = accounts.get_or_create(&self.account_id);
4920        let arn = format!(
4921            "arn:aws:secretsmanager:{}:{}:secret:{}",
4922            state.region, state.account_id, name
4923        );
4924
4925        if state.secrets.contains_key(&arn) {
4926            return Err(format!("Secret {name} already exists"));
4927        }
4928
4929        let now = Utc::now();
4930        let mut versions = BTreeMap::new();
4931        let mut current_version_id: Option<String> = None;
4932        let initial_string: Option<String> =
4933            if let Some(secret_string) = props.get("SecretString").and_then(|v| v.as_str()) {
4934                Some(secret_string.to_string())
4935            } else if let Some(gen) = props.get("GenerateSecretString") {
4936                Some(generate_secret_string_payload(gen)?)
4937            } else {
4938                None
4939            };
4940        if let Some(secret_string) = initial_string {
4941            let version_id = Uuid::new_v4().to_string();
4942            versions.insert(
4943                version_id.clone(),
4944                SecretVersion {
4945                    version_id: version_id.clone(),
4946                    secret_string: Some(secret_string),
4947                    secret_binary: None,
4948                    stages: vec!["AWSCURRENT".to_string()],
4949                    created_at: now,
4950                },
4951            );
4952            current_version_id = Some(version_id);
4953        }
4954
4955        let mut tags: Vec<(String, String)> = Vec::new();
4956        if let Some(arr) = props.get("Tags").and_then(|v| v.as_array()) {
4957            for t in arr {
4958                if let (Some(k), Some(v)) = (
4959                    t.get("Key").and_then(|x| x.as_str()),
4960                    t.get("Value").and_then(|x| x.as_str()),
4961                ) {
4962                    tags.push((k.to_string(), v.to_string()));
4963                }
4964            }
4965        }
4966        let tags_set = !tags.is_empty();
4967
4968        let secret = Secret {
4969            name: name.clone(),
4970            arn: arn.clone(),
4971            description,
4972            kms_key_id,
4973            versions,
4974            current_version_id,
4975            tags,
4976            tags_ever_set: tags_set,
4977            deleted: false,
4978            deletion_date: None,
4979            created_at: now,
4980            last_changed_at: now,
4981            last_accessed_at: None,
4982            rotation_enabled: None,
4983            rotation_lambda_arn: None,
4984            rotation_rules: None,
4985            last_rotated_at: None,
4986            resource_policy: None,
4987        };
4988        state.secrets.insert(arn.clone(), secret);
4989
4990        Ok(ProvisionResult::new(arn.clone())
4991            .with("Id", arn.clone())
4992            .with("Name", name))
4993    }
4994
4995    fn delete_secrets_manager_secret(&self, physical_id: &str) -> Result<(), String> {
4996        let mut accounts = self.secretsmanager_state.write();
4997        let state = accounts.get_or_create(&self.account_id);
4998        state.secrets.remove(physical_id);
4999        Ok(())
5000    }
5001
5002    // --- Kinesis ---
5003
5004    fn create_kinesis_stream(
5005        &self,
5006        resource: &ResourceDefinition,
5007    ) -> Result<ProvisionResult, String> {
5008        let props = &resource.properties;
5009        let stream_name = props
5010            .get("Name")
5011            .and_then(|v| v.as_str())
5012            .unwrap_or(&resource.logical_id)
5013            .to_string();
5014        let shard_count = props
5015            .get("ShardCount")
5016            .and_then(|v| v.as_i64())
5017            .unwrap_or(1) as i32;
5018        if shard_count <= 0 {
5019            return Err("ShardCount must be greater than zero".to_string());
5020        }
5021        let stream_mode = props
5022            .get("StreamModeDetails")
5023            .and_then(|v| v.get("StreamMode"))
5024            .and_then(|v| v.as_str())
5025            .unwrap_or("PROVISIONED")
5026            .to_string();
5027        let retention_period_hours = props
5028            .get("RetentionPeriodHours")
5029            .and_then(|v| v.as_i64())
5030            .unwrap_or(24) as i32;
5031
5032        let mut accounts = self.kinesis_state.write();
5033        let state = accounts.get_or_create(&self.account_id);
5034        if state.streams.contains_key(&stream_name) {
5035            return Err(format!("Stream {stream_name} already exists"));
5036        }
5037        let stream_arn = format!(
5038            "arn:aws:kinesis:{}:{}:stream/{}",
5039            state.region, state.account_id, stream_name
5040        );
5041        let stream = KinesisStream {
5042            stream_name: stream_name.clone(),
5043            stream_arn: stream_arn.clone(),
5044            stream_status: "ACTIVE".to_string(),
5045            stream_creation_timestamp: Utc::now(),
5046            retention_period_hours,
5047            stream_mode,
5048            encryption_type: "NONE".to_string(),
5049            key_id: None,
5050            shard_count,
5051            open_shard_count: shard_count,
5052            tags: BTreeMap::new(),
5053            shards: build_stream_shards(shard_count),
5054            next_shard_index: shard_count,
5055            enhanced_metrics: Vec::new(),
5056            warm_throughput_mibps: None,
5057            max_record_size_kib: None,
5058        };
5059        state.streams.insert(stream_name.clone(), stream);
5060
5061        Ok(ProvisionResult::new(stream_name).with("Arn", stream_arn))
5062    }
5063
5064    fn delete_kinesis_stream(&self, physical_id: &str) -> Result<(), String> {
5065        let mut accounts = self.kinesis_state.write();
5066        let state = accounts.get_or_create(&self.account_id);
5067        state.streams.remove(physical_id);
5068        Ok(())
5069    }
5070
5071    fn create_kinesis_stream_consumer(
5072        &self,
5073        resource: &ResourceDefinition,
5074    ) -> Result<ProvisionResult, String> {
5075        let props = &resource.properties;
5076        let stream_arn = props
5077            .get("StreamARN")
5078            .and_then(|v| v.as_str())
5079            .ok_or_else(|| "StreamARN is required".to_string())?
5080            .to_string();
5081        let consumer_name = props
5082            .get("ConsumerName")
5083            .and_then(|v| v.as_str())
5084            .ok_or_else(|| "ConsumerName is required".to_string())?
5085            .to_string();
5086
5087        let mut accounts = self.kinesis_state.write();
5088        let state = accounts.get_or_create(&self.account_id);
5089        if state
5090            .consumers
5091            .values()
5092            .any(|c| c.stream_arn == stream_arn && c.consumer_name == consumer_name)
5093        {
5094            return Err(format!(
5095                "Consumer {consumer_name} already exists on stream {stream_arn}"
5096            ));
5097        }
5098        let now = Utc::now();
5099        let consumer_arn = format!(
5100            "{}/consumer/{}:{}",
5101            stream_arn,
5102            consumer_name,
5103            now.timestamp()
5104        );
5105        let consumer = KinesisConsumer {
5106            consumer_name: consumer_name.clone(),
5107            consumer_arn: consumer_arn.clone(),
5108            consumer_status: "ACTIVE".to_string(),
5109            consumer_creation_timestamp: now,
5110            stream_arn: stream_arn.clone(),
5111        };
5112        state.consumers.insert(consumer_arn.clone(), consumer);
5113
5114        Ok(ProvisionResult::new(consumer_arn.clone())
5115            .with("ConsumerARN", consumer_arn)
5116            .with("ConsumerName", consumer_name)
5117            .with("ConsumerStatus", "ACTIVE")
5118            .with("ConsumerCreationTimestamp", now.timestamp().to_string())
5119            .with("StreamARN", stream_arn))
5120    }
5121
5122    fn delete_kinesis_stream_consumer(&self, physical_id: &str) -> Result<(), String> {
5123        let mut accounts = self.kinesis_state.write();
5124        let state = accounts.get_or_create(&self.account_id);
5125        state.consumers.remove(physical_id);
5126        Ok(())
5127    }
5128
5129    // --- KMS ---
5130
5131    fn create_kms_key(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
5132        let input = parse_kms_key_input(&resource.properties);
5133        let (key_id, arn) =
5134            kms_provisioner::provision_key(&self.kms_state, &self.account_id, &input)?;
5135        Ok(ProvisionResult::new(key_id.clone())
5136            .with("Arn", arn)
5137            .with("KeyId", key_id))
5138    }
5139
5140    /// Update an `AWS::KMS::Key` in place. Properties that AWS treats
5141    /// as immutable (`KeySpec`, `KeyUsage`, `Origin`, `MultiRegion`)
5142    /// trigger replacement: when the diff carries one of those, this
5143    /// returns an error and the upstream stack engine recreates the
5144    /// resource.
5145    fn update_kms_key(
5146        &self,
5147        existing: &StackResource,
5148        new_def: &ResourceDefinition,
5149    ) -> Result<ProvisionResult, String> {
5150        let new_input = parse_kms_key_input(&new_def.properties);
5151        let arn = {
5152            let mut accounts = self.kms_state.write();
5153            let state = accounts.get_or_create(&self.account_id);
5154            let key = state
5155                .keys
5156                .get(&existing.physical_id)
5157                .ok_or_else(|| format!("Key '{}' does not exist", existing.physical_id))?;
5158            if key.key_spec != new_input.key_spec
5159                || key.key_usage != new_input.key_usage
5160                || key.origin != new_input.origin
5161                || key.multi_region != new_input.multi_region
5162            {
5163                return Err(
5164                    "AWS::KMS::Key updates that change KeySpec, KeyUsage, Origin, or MultiRegion require replacement"
5165                        .to_string(),
5166                );
5167            }
5168            key.arn.clone()
5169        };
5170        kms_provisioner::update_key_properties(
5171            &self.kms_state,
5172            &self.account_id,
5173            &existing.physical_id,
5174            kms_provisioner::KeyUpdate {
5175                description: Some(new_input.description.clone()),
5176                enabled: Some(new_input.enabled),
5177                key_rotation_enabled: Some(new_input.key_rotation_enabled),
5178                policy: new_input.policy.clone(),
5179                tags: Some(new_input.tags.clone()),
5180            },
5181        )?;
5182        Ok(ProvisionResult::new(existing.physical_id.clone())
5183            .with("Arn", arn)
5184            .with("KeyId", existing.physical_id.clone()))
5185    }
5186
5187    fn delete_kms_key(&self, physical_id: &str) -> Result<(), String> {
5188        let mut accounts = self.kms_state.write();
5189        let state = accounts.get_or_create(&self.account_id);
5190        state.keys.remove(physical_id);
5191        state.aliases.retain(|_, a| a.target_key_id != physical_id);
5192        Ok(())
5193    }
5194
5195    /// Provision an `AWS::KMS::ReplicaKey`. Looks up the primary key by
5196    /// arn and inserts a region-keyed replica into the same account
5197    /// state, mirroring the ReplicateKey API contract.
5198    fn create_kms_replica_key(
5199        &self,
5200        resource: &ResourceDefinition,
5201    ) -> Result<ProvisionResult, String> {
5202        let props = &resource.properties;
5203        let primary_arn = props
5204            .get("PrimaryKeyArn")
5205            .and_then(|v| v.as_str())
5206            .ok_or_else(|| "PrimaryKeyArn is required".to_string())?
5207            .to_string();
5208        let description = props
5209            .get("Description")
5210            .and_then(|v| v.as_str())
5211            .map(str::to_string);
5212        let enabled = props
5213            .get("Enabled")
5214            .and_then(|v| v.as_bool())
5215            .unwrap_or(true);
5216        let policy = parse_key_policy(props);
5217        let tags = parse_tag_list(props);
5218
5219        let (replica_key_id, replica_arn) = kms_provisioner::provision_replica_key(
5220            &self.kms_state,
5221            &self.account_id,
5222            &primary_arn,
5223            description,
5224            enabled,
5225            policy,
5226            tags,
5227        )?;
5228        Ok(ProvisionResult::new(replica_key_id.clone())
5229            .with("KeyId", replica_key_id)
5230            .with("Arn", replica_arn))
5231    }
5232
5233    /// Update an `AWS::KMS::ReplicaKey` in place. `PrimaryKeyArn`
5234    /// changes are replacement-only, like the AWS contract — we'd
5235    /// have to rebuild the replica from a different source. Other
5236    /// fields (description, enabled, policy, tags) flow through
5237    /// [`update_key_properties`].
5238    fn update_kms_replica_key(
5239        &self,
5240        existing: &StackResource,
5241        new_def: &ResourceDefinition,
5242    ) -> Result<ProvisionResult, String> {
5243        let props = &new_def.properties;
5244        let new_primary = props
5245            .get("PrimaryKeyArn")
5246            .and_then(|v| v.as_str())
5247            .ok_or_else(|| "PrimaryKeyArn is required".to_string())?;
5248        // Compare primary region from existing replica's primary_region
5249        // field. Replacement triggers when the primary key changes.
5250        {
5251            let mut accounts = self.kms_state.write();
5252            let state = accounts.get_or_create(&self.account_id);
5253            let key = state
5254                .keys
5255                .get(&existing.physical_id)
5256                .ok_or_else(|| format!("ReplicaKey '{}' does not exist", existing.physical_id))?;
5257            if let Some(existing_region) = key.primary_region.as_deref() {
5258                let parts: Vec<&str> = new_primary.split(':').collect();
5259                if parts.len() < 4 || parts[3] != existing_region {
5260                    return Err(
5261                        "AWS::KMS::ReplicaKey updates that change PrimaryKeyArn require replacement"
5262                            .to_string(),
5263                    );
5264                }
5265            }
5266        }
5267        let description = props
5268            .get("Description")
5269            .and_then(|v| v.as_str())
5270            .map(str::to_string);
5271        let enabled = props
5272            .get("Enabled")
5273            .and_then(|v| v.as_bool())
5274            .unwrap_or(true);
5275        let policy = parse_key_policy(props);
5276        let tags = parse_tag_list(props);
5277        kms_provisioner::update_key_properties(
5278            &self.kms_state,
5279            &self.account_id,
5280            &existing.physical_id,
5281            kms_provisioner::KeyUpdate {
5282                description,
5283                enabled: Some(enabled),
5284                key_rotation_enabled: None,
5285                policy,
5286                tags: Some(tags),
5287            },
5288        )?;
5289        let arn = {
5290            let mut accounts = self.kms_state.write();
5291            let state = accounts.get_or_create(&self.account_id);
5292            state
5293                .keys
5294                .get(&existing.physical_id)
5295                .map(|k| k.arn.clone())
5296                .unwrap_or_default()
5297        };
5298        Ok(ProvisionResult::new(existing.physical_id.clone())
5299            .with("KeyId", existing.physical_id.clone())
5300            .with("Arn", arn))
5301    }
5302
5303    fn delete_kms_replica_key(&self, physical_id: &str) -> Result<(), String> {
5304        let mut accounts = self.kms_state.write();
5305        let state = accounts.get_or_create(&self.account_id);
5306        state.keys.remove(physical_id);
5307        Ok(())
5308    }
5309
5310    fn create_kms_alias(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
5311        let props = &resource.properties;
5312        let alias_name = props
5313            .get("AliasName")
5314            .and_then(|v| v.as_str())
5315            .ok_or_else(|| "AliasName is required".to_string())?
5316            .to_string();
5317        let target_input = props
5318            .get("TargetKeyId")
5319            .and_then(|v| v.as_str())
5320            .ok_or_else(|| "TargetKeyId is required".to_string())?
5321            .to_string();
5322        let alias = kms_provisioner::provision_alias(
5323            &self.kms_state,
5324            &self.account_id,
5325            &alias_name,
5326            &target_input,
5327        )?;
5328        Ok(ProvisionResult::new(alias))
5329    }
5330
5331    /// Update an `AWS::KMS::Alias` in place. Changing `AliasName`
5332    /// triggers replacement (the alias is the resource's identity);
5333    /// only `TargetKeyId` is updatable here.
5334    fn update_kms_alias(
5335        &self,
5336        existing: &StackResource,
5337        new_def: &ResourceDefinition,
5338    ) -> Result<ProvisionResult, String> {
5339        let props = &new_def.properties;
5340        let new_alias_name = props
5341            .get("AliasName")
5342            .and_then(|v| v.as_str())
5343            .ok_or_else(|| "AliasName is required".to_string())?;
5344        if new_alias_name != existing.physical_id {
5345            return Err(
5346                "AWS::KMS::Alias updates that change AliasName require replacement".to_string(),
5347            );
5348        }
5349        let target_input = props
5350            .get("TargetKeyId")
5351            .and_then(|v| v.as_str())
5352            .ok_or_else(|| "TargetKeyId is required".to_string())?;
5353        kms_provisioner::update_alias_target(
5354            &self.kms_state,
5355            &self.account_id,
5356            &existing.physical_id,
5357            target_input,
5358        )?;
5359        Ok(ProvisionResult::new(existing.physical_id.clone()))
5360    }
5361
5362    fn delete_kms_alias(&self, physical_id: &str) -> Result<(), String> {
5363        let mut accounts = self.kms_state.write();
5364        let state = accounts.get_or_create(&self.account_id);
5365        state.aliases.remove(physical_id);
5366        Ok(())
5367    }
5368
5369    // --- ECR ---
5370
5371    fn create_ecr_repository(
5372        &self,
5373        resource: &ResourceDefinition,
5374    ) -> Result<ProvisionResult, String> {
5375        let props = &resource.properties;
5376        let repository_name = props
5377            .get("RepositoryName")
5378            .and_then(|v| v.as_str())
5379            .unwrap_or(&resource.logical_id)
5380            .to_string();
5381        let image_tag_mutability = props
5382            .get("ImageTagMutability")
5383            .and_then(|v| v.as_str())
5384            .unwrap_or("MUTABLE")
5385            .to_string();
5386        let scan_on_push = props
5387            .get("ImageScanningConfiguration")
5388            .and_then(|v| v.get("ScanOnPush"))
5389            .and_then(|v| v.as_bool())
5390            .unwrap_or(false);
5391        let encryption_type = props
5392            .get("EncryptionConfiguration")
5393            .and_then(|v| v.get("EncryptionType"))
5394            .and_then(|v| v.as_str())
5395            .unwrap_or("AES256")
5396            .to_string();
5397        let kms_key = props
5398            .get("EncryptionConfiguration")
5399            .and_then(|v| v.get("KmsKey"))
5400            .and_then(|v| v.as_str())
5401            .map(|s| s.to_string());
5402        let policy_text = props
5403            .get("RepositoryPolicyText")
5404            .map(|v| {
5405                if v.is_string() {
5406                    v.as_str().unwrap_or("").to_string()
5407                } else {
5408                    serde_json::to_string(v).unwrap_or_default()
5409                }
5410            })
5411            .filter(|s| !s.is_empty());
5412        let lifecycle_policy = props
5413            .get("LifecyclePolicy")
5414            .and_then(|v| v.get("LifecyclePolicyText"))
5415            .and_then(|v| v.as_str())
5416            .map(|s| s.to_string());
5417        let mut tags: BTreeMap<String, String> = BTreeMap::new();
5418        if let Some(arr) = props.get("Tags").and_then(|v| v.as_array()) {
5419            for t in arr {
5420                if let (Some(k), Some(v)) = (
5421                    t.get("Key").and_then(|x| x.as_str()),
5422                    t.get("Value").and_then(|x| x.as_str()),
5423                ) {
5424                    tags.insert(k.to_string(), v.to_string());
5425                }
5426            }
5427        }
5428
5429        let empty_on_delete = props
5430            .get("EmptyOnDelete")
5431            .and_then(|v| v.as_bool())
5432            .unwrap_or(false);
5433
5434        let mut accounts = self.ecr_state.write();
5435        let state = accounts.get_or_create(&self.account_id);
5436        if state.repositories.contains_key(&repository_name) {
5437            return Err(format!("Repository {repository_name} already exists"));
5438        }
5439        let arn = state.repository_arn(&repository_name);
5440        let registry_id = state.account_id.clone();
5441        let endpoint = format!(
5442            "{}.dkr.ecr.{}.amazonaws.com",
5443            state.account_id, state.region
5444        );
5445        let mut repo = Repository::new(&repository_name, arn.clone(), &registry_id, &endpoint);
5446        repo.image_tag_mutability = image_tag_mutability;
5447        repo.image_scanning_configuration.scan_on_push = scan_on_push;
5448        repo.encryption_configuration.encryption_type = encryption_type;
5449        repo.encryption_configuration.kms_key = kms_key;
5450        repo.policy = policy_text;
5451        // Apply lifecycle policy synchronously so the store reflects the
5452        // initial prune, matching the PutLifecyclePolicy data path.
5453        if let Some(policy) = lifecycle_policy.as_ref() {
5454            let prune = fakecloud_ecr::evaluate_lifecycle_policy(&repo, policy);
5455            for digest in &prune {
5456                repo.images.remove(digest);
5457                repo.image_tags.retain(|_, d| d != digest);
5458            }
5459            repo.lifecycle_policy_last_evaluated_at = Some(Utc::now());
5460        }
5461        repo.lifecycle_policy = lifecycle_policy;
5462        repo.tags = tags;
5463        let uri = repo.repository_uri.clone();
5464        state.repositories.insert(repository_name.clone(), repo);
5465
5466        Ok(ProvisionResult::new(repository_name)
5467            .with("Arn", arn)
5468            .with("RepositoryUri", uri)
5469            .with("RegistryId", registry_id)
5470            .with("EmptyOnDelete", empty_on_delete.to_string()))
5471    }
5472
5473    fn delete_ecr_repository(&self, physical_id: &str) -> Result<(), String> {
5474        let mut accounts = self.ecr_state.write();
5475        let state = accounts.get_or_create(&self.account_id);
5476        state.repositories.remove(physical_id);
5477        Ok(())
5478    }
5479
5480    /// Provision a standalone `AWS::ECR::RepositoryPolicy` against an
5481    /// existing repository. Physical id encodes `account/repo` so the
5482    /// delete path can find the right repository.
5483    fn create_ecr_repository_policy(
5484        &self,
5485        resource: &ResourceDefinition,
5486    ) -> Result<ProvisionResult, String> {
5487        let props = &resource.properties;
5488        let repository_name = props
5489            .get("RepositoryName")
5490            .and_then(|v| v.as_str())
5491            .ok_or_else(|| "RepositoryName is required".to_string())?
5492            .to_string();
5493        let policy_text = props
5494            .get("PolicyText")
5495            .map(|v| {
5496                if v.is_string() {
5497                    v.as_str().unwrap_or("").to_string()
5498                } else {
5499                    serde_json::to_string(v).unwrap_or_default()
5500                }
5501            })
5502            .ok_or_else(|| "PolicyText is required".to_string())?;
5503        let mut accounts = self.ecr_state.write();
5504        let state = accounts.get_or_create(&self.account_id);
5505        let repo = state
5506            .repositories
5507            .get_mut(&repository_name)
5508            .ok_or_else(|| format!("Repository {repository_name} does not exist"))?;
5509        repo.policy = Some(policy_text);
5510        Ok(ProvisionResult::new(format!(
5511            "{}/{}",
5512            self.account_id, repository_name
5513        )))
5514    }
5515
5516    fn delete_ecr_repository_policy(&self, physical_id: &str) -> Result<(), String> {
5517        let repository_name = physical_id
5518            .split_once('/')
5519            .map(|(_, n)| n.to_string())
5520            .unwrap_or_else(|| physical_id.to_string());
5521        let mut accounts = self.ecr_state.write();
5522        let state = accounts.get_or_create(&self.account_id);
5523        if let Some(repo) = state.repositories.get_mut(&repository_name) {
5524            repo.policy = None;
5525        }
5526        Ok(())
5527    }
5528
5529    /// Set the registry-wide policy for the active account. Physical id
5530    /// is just the account id since the registry is a singleton.
5531    fn create_ecr_registry_policy(
5532        &self,
5533        resource: &ResourceDefinition,
5534    ) -> Result<ProvisionResult, String> {
5535        let props = &resource.properties;
5536        let policy_text = props
5537            .get("PolicyText")
5538            .map(|v| {
5539                if v.is_string() {
5540                    v.as_str().unwrap_or("").to_string()
5541                } else {
5542                    serde_json::to_string(v).unwrap_or_default()
5543                }
5544            })
5545            .ok_or_else(|| "PolicyText is required".to_string())?;
5546        let mut accounts = self.ecr_state.write();
5547        let state = accounts.get_or_create(&self.account_id);
5548        state.registry_policy = Some(policy_text);
5549        Ok(ProvisionResult::new(self.account_id.clone())
5550            .with("RegistryId", self.account_id.clone()))
5551    }
5552
5553    fn delete_ecr_registry_policy(&self) -> Result<(), String> {
5554        let mut accounts = self.ecr_state.write();
5555        let state = accounts.get_or_create(&self.account_id);
5556        state.registry_policy = None;
5557        Ok(())
5558    }
5559
5560    /// Provision the singleton `AWS::ECR::ReplicationConfiguration`.
5561    fn create_ecr_replication_configuration(
5562        &self,
5563        resource: &ResourceDefinition,
5564    ) -> Result<ProvisionResult, String> {
5565        use fakecloud_ecr::state::{
5566            ReplicationConfiguration, ReplicationDestination, ReplicationRule, RepositoryFilter,
5567        };
5568        let cfg = resource
5569            .properties
5570            .get("ReplicationConfiguration")
5571            .ok_or_else(|| "ReplicationConfiguration is required".to_string())?;
5572        let rules: Vec<ReplicationRule> = cfg
5573            .get("Rules")
5574            .and_then(|v| v.as_array())
5575            .map(|arr| {
5576                arr.iter()
5577                    .map(|r| {
5578                        let destinations: Vec<ReplicationDestination> = r
5579                            .get("Destinations")
5580                            .and_then(|v| v.as_array())
5581                            .map(|d| {
5582                                d.iter()
5583                                    .map(|dest| ReplicationDestination {
5584                                        region: dest
5585                                            .get("Region")
5586                                            .and_then(|v| v.as_str())
5587                                            .unwrap_or_default()
5588                                            .to_string(),
5589                                        registry_id: dest
5590                                            .get("RegistryId")
5591                                            .and_then(|v| v.as_str())
5592                                            .unwrap_or_default()
5593                                            .to_string(),
5594                                    })
5595                                    .collect()
5596                            })
5597                            .unwrap_or_default();
5598                        let repository_filters: Vec<RepositoryFilter> = r
5599                            .get("RepositoryFilters")
5600                            .and_then(|v| v.as_array())
5601                            .map(|f| {
5602                                f.iter()
5603                                    .map(|f| RepositoryFilter {
5604                                        filter: f
5605                                            .get("Filter")
5606                                            .and_then(|v| v.as_str())
5607                                            .unwrap_or_default()
5608                                            .to_string(),
5609                                        filter_type: f
5610                                            .get("FilterType")
5611                                            .and_then(|v| v.as_str())
5612                                            .unwrap_or_default()
5613                                            .to_string(),
5614                                    })
5615                                    .collect()
5616                            })
5617                            .unwrap_or_default();
5618                        ReplicationRule {
5619                            destinations,
5620                            repository_filters,
5621                        }
5622                    })
5623                    .collect()
5624            })
5625            .unwrap_or_default();
5626        let mut accounts = self.ecr_state.write();
5627        let state = accounts.get_or_create(&self.account_id);
5628        state.replication_configuration = Some(ReplicationConfiguration { rules });
5629        Ok(ProvisionResult::new(self.account_id.clone()))
5630    }
5631
5632    fn delete_ecr_replication_configuration(&self) -> Result<(), String> {
5633        let mut accounts = self.ecr_state.write();
5634        let state = accounts.get_or_create(&self.account_id);
5635        state.replication_configuration = None;
5636        Ok(())
5637    }
5638
5639    fn create_ecr_pull_through_cache_rule(
5640        &self,
5641        resource: &ResourceDefinition,
5642    ) -> Result<ProvisionResult, String> {
5643        use fakecloud_ecr::state::PullThroughCacheRule;
5644        let props = &resource.properties;
5645        let prefix = props
5646            .get("EcrRepositoryPrefix")
5647            .and_then(|v| v.as_str())
5648            .ok_or_else(|| "EcrRepositoryPrefix is required".to_string())?
5649            .to_string();
5650        let upstream_url = props
5651            .get("UpstreamRegistryUrl")
5652            .and_then(|v| v.as_str())
5653            .ok_or_else(|| "UpstreamRegistryUrl is required".to_string())?
5654            .to_string();
5655        let upstream_registry = props
5656            .get("UpstreamRegistry")
5657            .and_then(|v| v.as_str())
5658            .map(|s| s.to_string());
5659        let credential_arn = props
5660            .get("CredentialArn")
5661            .and_then(|v| v.as_str())
5662            .map(|s| s.to_string());
5663        let custom_role_arn = props
5664            .get("CustomRoleArn")
5665            .and_then(|v| v.as_str())
5666            .map(|s| s.to_string());
5667        let now = Utc::now();
5668        let rule = PullThroughCacheRule {
5669            ecr_repository_prefix: prefix.clone(),
5670            upstream_registry_url: upstream_url,
5671            upstream_registry,
5672            credential_arn,
5673            created_at: now,
5674            updated_at: now,
5675            custom_role_arn,
5676        };
5677        let mut accounts = self.ecr_state.write();
5678        let state = accounts.get_or_create(&self.account_id);
5679        state.pull_through_cache_rules.insert(prefix.clone(), rule);
5680        Ok(ProvisionResult::new(prefix))
5681    }
5682
5683    fn delete_ecr_pull_through_cache_rule(&self, physical_id: &str) -> Result<(), String> {
5684        let mut accounts = self.ecr_state.write();
5685        let state = accounts.get_or_create(&self.account_id);
5686        state.pull_through_cache_rules.remove(physical_id);
5687        Ok(())
5688    }
5689
5690    /// Provision a standalone `AWS::ECR::LifecyclePolicy` against an
5691    /// existing repository. Mirrors the `PutLifecyclePolicy` data path:
5692    /// the policy text is stored on the repo and applied synchronously
5693    /// so the store reflects any prune set immediately. Physical id
5694    /// encodes `account/repo` so delete can find the right repository.
5695    fn create_ecr_lifecycle_policy(
5696        &self,
5697        resource: &ResourceDefinition,
5698    ) -> Result<ProvisionResult, String> {
5699        let props = &resource.properties;
5700        let repository_name = props
5701            .get("RepositoryName")
5702            .and_then(|v| v.as_str())
5703            .ok_or_else(|| "RepositoryName is required".to_string())?
5704            .to_string();
5705        let policy_text = props
5706            .get("LifecyclePolicyText")
5707            .map(|v| {
5708                if v.is_string() {
5709                    v.as_str().unwrap_or("").to_string()
5710                } else {
5711                    serde_json::to_string(v).unwrap_or_default()
5712                }
5713            })
5714            .ok_or_else(|| "LifecyclePolicyText is required".to_string())?;
5715        // RegistryId is informational on AWS — accepted but the registry
5716        // lookup is always the active account in fakecloud.
5717        let _registry_id = props
5718            .get("RegistryId")
5719            .and_then(|v| v.as_str())
5720            .map(|s| s.to_string());
5721        let mut accounts = self.ecr_state.write();
5722        let state = accounts.get_or_create(&self.account_id);
5723        let repo = state
5724            .repositories
5725            .get_mut(&repository_name)
5726            .ok_or_else(|| format!("Repository {repository_name} does not exist"))?;
5727        // Run the policy once now so callers see the same prune-on-write
5728        // behaviour they get from the JSON `PutLifecyclePolicy` endpoint.
5729        let prune = fakecloud_ecr::evaluate_lifecycle_policy(repo, &policy_text);
5730        for digest in &prune {
5731            repo.images.remove(digest);
5732            repo.image_tags.retain(|_, d| d != digest);
5733        }
5734        repo.lifecycle_policy = Some(policy_text);
5735        repo.lifecycle_policy_last_evaluated_at = Some(Utc::now());
5736        let registry_id = repo.registry_id.clone();
5737        Ok(
5738            ProvisionResult::new(format!("{}/{}", self.account_id, repository_name))
5739                .with("RepositoryName", repository_name)
5740                .with("RegistryId", registry_id),
5741        )
5742    }
5743
5744    fn delete_ecr_lifecycle_policy(&self, physical_id: &str) -> Result<(), String> {
5745        let repository_name = physical_id
5746            .split_once('/')
5747            .map(|(_, n)| n.to_string())
5748            .unwrap_or_else(|| physical_id.to_string());
5749        let mut accounts = self.ecr_state.write();
5750        let state = accounts.get_or_create(&self.account_id);
5751        if let Some(repo) = state.repositories.get_mut(&repository_name) {
5752            repo.lifecycle_policy = None;
5753            repo.lifecycle_policy_last_evaluated_at = None;
5754        }
5755        Ok(())
5756    }
5757
5758    /// Provision the singleton `AWS::ECR::RegistryScanningConfiguration`.
5759    /// One per account; later applies overwrite the prior config —
5760    /// matching `PutRegistryScanningConfiguration` semantics.
5761    fn create_ecr_registry_scanning_configuration(
5762        &self,
5763        resource: &ResourceDefinition,
5764    ) -> Result<ProvisionResult, String> {
5765        use fakecloud_ecr::state::{
5766            RegistryScanningConfiguration, RegistryScanningRule, RepositoryFilter,
5767        };
5768        let props = &resource.properties;
5769        let scan_type = props
5770            .get("ScanType")
5771            .and_then(|v| v.as_str())
5772            .unwrap_or("BASIC")
5773            .to_string();
5774        let rules: Vec<RegistryScanningRule> = props
5775            .get("Rules")
5776            .and_then(|v| v.as_array())
5777            .map(|arr| {
5778                arr.iter()
5779                    .map(|r| {
5780                        let scan_frequency = r
5781                            .get("ScanFrequency")
5782                            .and_then(|v| v.as_str())
5783                            .unwrap_or("MANUAL")
5784                            .to_string();
5785                        let repository_filters: Vec<RepositoryFilter> = r
5786                            .get("RepositoryFilters")
5787                            .and_then(|v| v.as_array())
5788                            .map(|f| {
5789                                f.iter()
5790                                    .map(|f| RepositoryFilter {
5791                                        filter: f
5792                                            .get("Filter")
5793                                            .and_then(|v| v.as_str())
5794                                            .unwrap_or_default()
5795                                            .to_string(),
5796                                        filter_type: f
5797                                            .get("FilterType")
5798                                            .and_then(|v| v.as_str())
5799                                            .unwrap_or_default()
5800                                            .to_string(),
5801                                    })
5802                                    .collect()
5803                            })
5804                            .unwrap_or_default();
5805                        RegistryScanningRule {
5806                            scan_frequency,
5807                            repository_filters,
5808                        }
5809                    })
5810                    .collect()
5811            })
5812            .unwrap_or_default();
5813        let mut accounts = self.ecr_state.write();
5814        let state = accounts.get_or_create(&self.account_id);
5815        state.registry_scanning_configuration = RegistryScanningConfiguration { scan_type, rules };
5816        Ok(ProvisionResult::new(self.account_id.clone()))
5817    }
5818
5819    fn delete_ecr_registry_scanning_configuration(&self) -> Result<(), String> {
5820        use fakecloud_ecr::state::RegistryScanningConfiguration;
5821        let mut accounts = self.ecr_state.write();
5822        let state = accounts.get_or_create(&self.account_id);
5823        // CFN delete reverts to the AWS default (BASIC, no rules).
5824        state.registry_scanning_configuration = RegistryScanningConfiguration::default();
5825        Ok(())
5826    }
5827
5828    // ECR update_resource handlers. RepositoryName / EcrRepositoryPrefix
5829    // are immutable on AWS; CFN replaces the resource if they change.
5830    // These handlers refresh the mutable fields and keep the physical id
5831    // stable.
5832
5833    fn update_ecr_repository(
5834        &self,
5835        existing: &StackResource,
5836        resource: &ResourceDefinition,
5837    ) -> Result<ProvisionResult, String> {
5838        let props = &resource.properties;
5839        let repository_name = existing.physical_id.clone();
5840        let mut accounts = self.ecr_state.write();
5841        let state = accounts.get_or_create(&self.account_id);
5842        let repo = state
5843            .repositories
5844            .get_mut(&repository_name)
5845            .ok_or_else(|| format!("Repository {repository_name} no longer exists"))?;
5846        if let Some(s) = props.get("ImageTagMutability").and_then(|v| v.as_str()) {
5847            repo.image_tag_mutability = s.to_string();
5848        }
5849        if let Some(b) = props
5850            .get("ImageScanningConfiguration")
5851            .and_then(|v| v.get("ScanOnPush"))
5852            .and_then(|v| v.as_bool())
5853        {
5854            repo.image_scanning_configuration.scan_on_push = b;
5855        }
5856        if let Some(cfg) = props.get("EncryptionConfiguration") {
5857            if let Some(s) = cfg.get("EncryptionType").and_then(|v| v.as_str()) {
5858                repo.encryption_configuration.encryption_type = s.to_string();
5859            }
5860            if let Some(s) = cfg.get("KmsKey").and_then(|v| v.as_str()) {
5861                repo.encryption_configuration.kms_key = Some(s.to_string());
5862            }
5863        }
5864        if let Some(v) = props.get("RepositoryPolicyText") {
5865            let text = if v.is_string() {
5866                v.as_str().unwrap_or("").to_string()
5867            } else {
5868                serde_json::to_string(v).unwrap_or_default()
5869            };
5870            repo.policy = if text.is_empty() { None } else { Some(text) };
5871        }
5872        if let Some(text) = props
5873            .get("LifecyclePolicy")
5874            .and_then(|v| v.get("LifecyclePolicyText"))
5875            .and_then(|v| v.as_str())
5876        {
5877            let prune = fakecloud_ecr::evaluate_lifecycle_policy(repo, text);
5878            for digest in &prune {
5879                repo.images.remove(digest);
5880                repo.image_tags.retain(|_, d| d != digest);
5881            }
5882            repo.lifecycle_policy = Some(text.to_string());
5883            repo.lifecycle_policy_last_evaluated_at = Some(Utc::now());
5884        }
5885        if let Some(arr) = props.get("Tags").and_then(|v| v.as_array()) {
5886            let mut tags: BTreeMap<String, String> = BTreeMap::new();
5887            for t in arr {
5888                if let (Some(k), Some(v)) = (
5889                    t.get("Key").and_then(|x| x.as_str()),
5890                    t.get("Value").and_then(|x| x.as_str()),
5891                ) {
5892                    tags.insert(k.to_string(), v.to_string());
5893                }
5894            }
5895            repo.tags = tags;
5896        }
5897        let arn = repo.repository_arn.clone();
5898        let uri = repo.repository_uri.clone();
5899        let registry_id = repo.registry_id.clone();
5900        Ok(ProvisionResult::new(repository_name)
5901            .with("Arn", arn)
5902            .with("RepositoryUri", uri)
5903            .with("RegistryId", registry_id))
5904    }
5905
5906    fn update_ecr_repository_policy(
5907        &self,
5908        existing: &StackResource,
5909        resource: &ResourceDefinition,
5910    ) -> Result<ProvisionResult, String> {
5911        let props = &resource.properties;
5912        let physical_id = existing.physical_id.clone();
5913        let repository_name = physical_id
5914            .split_once('/')
5915            .map(|(_, n)| n.to_string())
5916            .unwrap_or_else(|| physical_id.clone());
5917        let policy_text = props
5918            .get("PolicyText")
5919            .map(|v| {
5920                if v.is_string() {
5921                    v.as_str().unwrap_or("").to_string()
5922                } else {
5923                    serde_json::to_string(v).unwrap_or_default()
5924                }
5925            })
5926            .ok_or_else(|| "PolicyText is required".to_string())?;
5927        let mut accounts = self.ecr_state.write();
5928        let state = accounts.get_or_create(&self.account_id);
5929        let repo = state
5930            .repositories
5931            .get_mut(&repository_name)
5932            .ok_or_else(|| format!("Repository {repository_name} does not exist"))?;
5933        repo.policy = Some(policy_text);
5934        Ok(ProvisionResult::new(physical_id))
5935    }
5936
5937    fn update_ecr_lifecycle_policy(
5938        &self,
5939        existing: &StackResource,
5940        resource: &ResourceDefinition,
5941    ) -> Result<ProvisionResult, String> {
5942        let props = &resource.properties;
5943        let physical_id = existing.physical_id.clone();
5944        let repository_name = physical_id
5945            .split_once('/')
5946            .map(|(_, n)| n.to_string())
5947            .unwrap_or_else(|| physical_id.clone());
5948        let policy_text = props
5949            .get("LifecyclePolicyText")
5950            .map(|v| {
5951                if v.is_string() {
5952                    v.as_str().unwrap_or("").to_string()
5953                } else {
5954                    serde_json::to_string(v).unwrap_or_default()
5955                }
5956            })
5957            .ok_or_else(|| "LifecyclePolicyText is required".to_string())?;
5958        let mut accounts = self.ecr_state.write();
5959        let state = accounts.get_or_create(&self.account_id);
5960        let repo = state
5961            .repositories
5962            .get_mut(&repository_name)
5963            .ok_or_else(|| format!("Repository {repository_name} does not exist"))?;
5964        let prune = fakecloud_ecr::evaluate_lifecycle_policy(repo, &policy_text);
5965        for digest in &prune {
5966            repo.images.remove(digest);
5967            repo.image_tags.retain(|_, d| d != digest);
5968        }
5969        repo.lifecycle_policy = Some(policy_text);
5970        repo.lifecycle_policy_last_evaluated_at = Some(Utc::now());
5971        let registry_id = repo.registry_id.clone();
5972        Ok(ProvisionResult::new(physical_id)
5973            .with("RepositoryName", repository_name)
5974            .with("RegistryId", registry_id))
5975    }
5976
5977    fn update_ecr_registry_policy(
5978        &self,
5979        existing: &StackResource,
5980        resource: &ResourceDefinition,
5981    ) -> Result<ProvisionResult, String> {
5982        let props = &resource.properties;
5983        let policy_text = props
5984            .get("PolicyText")
5985            .map(|v| {
5986                if v.is_string() {
5987                    v.as_str().unwrap_or("").to_string()
5988                } else {
5989                    serde_json::to_string(v).unwrap_or_default()
5990                }
5991            })
5992            .ok_or_else(|| "PolicyText is required".to_string())?;
5993        let mut accounts = self.ecr_state.write();
5994        let state = accounts.get_or_create(&self.account_id);
5995        state.registry_policy = Some(policy_text);
5996        Ok(ProvisionResult::new(existing.physical_id.clone())
5997            .with("RegistryId", self.account_id.clone()))
5998    }
5999
6000    fn update_ecr_replication_configuration(
6001        &self,
6002        existing: &StackResource,
6003        resource: &ResourceDefinition,
6004    ) -> Result<ProvisionResult, String> {
6005        // Updates fully replace the prior config — same as create.
6006        let result = self.create_ecr_replication_configuration(resource)?;
6007        Ok(ProvisionResult::new(existing.physical_id.clone()).merge_attributes(result.attributes))
6008    }
6009
6010    fn update_ecr_registry_scanning_configuration(
6011        &self,
6012        existing: &StackResource,
6013        resource: &ResourceDefinition,
6014    ) -> Result<ProvisionResult, String> {
6015        let result = self.create_ecr_registry_scanning_configuration(resource)?;
6016        Ok(ProvisionResult::new(existing.physical_id.clone()).merge_attributes(result.attributes))
6017    }
6018
6019    fn update_ecr_pull_through_cache_rule(
6020        &self,
6021        existing: &StackResource,
6022        resource: &ResourceDefinition,
6023    ) -> Result<ProvisionResult, String> {
6024        let props = &resource.properties;
6025        let prefix = existing.physical_id.clone();
6026        let mut accounts = self.ecr_state.write();
6027        let state = accounts.get_or_create(&self.account_id);
6028        let rule = state
6029            .pull_through_cache_rules
6030            .get_mut(&prefix)
6031            .ok_or_else(|| format!("PullThroughCacheRule {prefix} no longer exists"))?;
6032        if let Some(s) = props.get("UpstreamRegistryUrl").and_then(|v| v.as_str()) {
6033            rule.upstream_registry_url = s.to_string();
6034        }
6035        if let Some(s) = props.get("UpstreamRegistry").and_then(|v| v.as_str()) {
6036            rule.upstream_registry = Some(s.to_string());
6037        }
6038        if let Some(s) = props.get("CredentialArn").and_then(|v| v.as_str()) {
6039            rule.credential_arn = Some(s.to_string());
6040        }
6041        if let Some(s) = props.get("CustomRoleArn").and_then(|v| v.as_str()) {
6042            rule.custom_role_arn = Some(s.to_string());
6043        }
6044        rule.updated_at = Utc::now();
6045        Ok(ProvisionResult::new(prefix))
6046    }
6047
6048    fn get_att_ecr_repository(&self, physical_id: &str, attribute: &str) -> Option<String> {
6049        let mut accounts = self.ecr_state.write();
6050        let state = accounts.get_or_create(&self.account_id);
6051        let repo = state.repositories.get(physical_id)?;
6052        match attribute {
6053            "Arn" => Some(repo.repository_arn.clone()),
6054            "RepositoryUri" => Some(repo.repository_uri.clone()),
6055            "RegistryId" => Some(repo.registry_id.clone()),
6056            _ => None,
6057        }
6058    }
6059
6060    // --- CloudWatch ---
6061
6062    fn create_cloudwatch_alarm(
6063        &self,
6064        resource: &ResourceDefinition,
6065    ) -> Result<ProvisionResult, String> {
6066        let props = &resource.properties;
6067        let alarm_name = props
6068            .get("AlarmName")
6069            .and_then(|v| v.as_str())
6070            .unwrap_or(&resource.logical_id)
6071            .to_string();
6072        let alarm_description = props
6073            .get("AlarmDescription")
6074            .and_then(|v| v.as_str())
6075            .map(|s| s.to_string());
6076        let actions_enabled = props
6077            .get("ActionsEnabled")
6078            .and_then(|v| v.as_bool())
6079            .unwrap_or(true);
6080        let str_array = |key: &str| -> Vec<String> {
6081            props
6082                .get(key)
6083                .and_then(|v| v.as_array())
6084                .map(|arr| {
6085                    arr.iter()
6086                        .filter_map(|x| x.as_str().map(|s| s.to_string()))
6087                        .collect()
6088                })
6089                .unwrap_or_default()
6090        };
6091        let alarm_actions = str_array("AlarmActions");
6092        let ok_actions = str_array("OKActions");
6093        let insufficient_data_actions = str_array("InsufficientDataActions");
6094
6095        let metric_name = props
6096            .get("MetricName")
6097            .and_then(|v| v.as_str())
6098            .map(|s| s.to_string());
6099        let namespace = props
6100            .get("Namespace")
6101            .and_then(|v| v.as_str())
6102            .map(|s| s.to_string());
6103        let statistic = props
6104            .get("Statistic")
6105            .and_then(|v| v.as_str())
6106            .map(|s| s.to_string());
6107        let extended_statistic = props
6108            .get("ExtendedStatistic")
6109            .and_then(|v| v.as_str())
6110            .map(|s| s.to_string());
6111        let unit = props
6112            .get("Unit")
6113            .and_then(|v| v.as_str())
6114            .map(|s| s.to_string());
6115        let period = props.get("Period").and_then(|v| v.as_i64());
6116        let evaluation_periods = props
6117            .get("EvaluationPeriods")
6118            .and_then(|v| v.as_i64())
6119            .unwrap_or(1);
6120        let datapoints_to_alarm = props.get("DatapointsToAlarm").and_then(|v| v.as_i64());
6121        let threshold = props.get("Threshold").and_then(|v| v.as_f64());
6122        let comparison_operator = props
6123            .get("ComparisonOperator")
6124            .and_then(|v| v.as_str())
6125            .unwrap_or("GreaterThanThreshold")
6126            .to_string();
6127        let treat_missing_data = props
6128            .get("TreatMissingData")
6129            .and_then(|v| v.as_str())
6130            .map(|s| s.to_string());
6131        let evaluate_low_sample_count_percentile = props
6132            .get("EvaluateLowSampleCountPercentile")
6133            .and_then(|v| v.as_str())
6134            .map(|s| s.to_string());
6135
6136        let mut dimensions: BTreeMap<String, String> = BTreeMap::new();
6137        if let Some(arr) = props.get("Dimensions").and_then(|v| v.as_array()) {
6138            for d in arr {
6139                if let (Some(k), Some(v)) = (
6140                    d.get("Name").and_then(|x| x.as_str()),
6141                    d.get("Value").and_then(|x| x.as_str()),
6142                ) {
6143                    dimensions.insert(k.to_string(), v.to_string());
6144                }
6145            }
6146        }
6147
6148        let mut accounts = self.cloudwatch_state.write();
6149        let state = accounts.get_or_create(&self.account_id);
6150        let alarm_arn = format!(
6151            "arn:aws:cloudwatch:{}:{}:alarm:{}",
6152            self.region, self.account_id, alarm_name
6153        );
6154        let now = Utc::now();
6155        let alarm = MetricAlarm {
6156            alarm_name: alarm_name.clone(),
6157            alarm_arn: alarm_arn.clone(),
6158            alarm_description,
6159            actions_enabled,
6160            ok_actions,
6161            alarm_actions,
6162            insufficient_data_actions,
6163            state_value: AlarmState::InsufficientData,
6164            state_reason: "Unchecked: Initial alarm creation".to_string(),
6165            state_updated_timestamp: now,
6166            metric_name,
6167            namespace,
6168            statistic,
6169            extended_statistic,
6170            dimensions,
6171            period,
6172            unit,
6173            evaluation_periods,
6174            datapoints_to_alarm,
6175            threshold,
6176            comparison_operator,
6177            treat_missing_data,
6178            evaluate_low_sample_count_percentile,
6179            configuration_updated_timestamp: now,
6180            alarm_configuration_updated_timestamp: now,
6181        };
6182        let region_alarms = state.alarms_in_mut(&self.region);
6183        if region_alarms.contains_key(&alarm_name) {
6184            return Err(format!("Alarm {alarm_name} already exists"));
6185        }
6186        region_alarms.insert(alarm_name.clone(), alarm);
6187
6188        Ok(ProvisionResult::new(alarm_name).with("Arn", alarm_arn))
6189    }
6190
6191    fn delete_cloudwatch_alarm(&self, physical_id: &str) -> Result<(), String> {
6192        let mut accounts = self.cloudwatch_state.write();
6193        let state = accounts.get_or_create(&self.account_id);
6194        state.alarms_in_mut(&self.region).remove(physical_id);
6195        Ok(())
6196    }
6197
6198    fn create_cloudwatch_dashboard(
6199        &self,
6200        resource: &ResourceDefinition,
6201    ) -> Result<ProvisionResult, String> {
6202        let props = &resource.properties;
6203        let dashboard_name = props
6204            .get("DashboardName")
6205            .and_then(|v| v.as_str())
6206            .map(String::from)
6207            .unwrap_or_else(|| {
6208                let suffix = Uuid::new_v4().simple().to_string();
6209                format!("{}-{}", resource.logical_id, &suffix[..8])
6210            });
6211        // CFN passes DashboardBody as a JSON string (Fn::Sub friendly).
6212        let body = props
6213            .get("DashboardBody")
6214            .ok_or("DashboardBody is required")?;
6215        let body_str = if let Some(s) = body.as_str() {
6216            s.to_string()
6217        } else {
6218            serde_json::to_string(body).map_err(|e| format!("invalid DashboardBody: {e}"))?
6219        };
6220        // Validate JSON syntax to mirror real PutDashboard behavior.
6221        serde_json::from_str::<serde_json::Value>(&body_str)
6222            .map_err(|e| format!("DashboardBody must be valid JSON: {e}"))?;
6223
6224        let arn = format!(
6225            "arn:aws:cloudwatch::{}:dashboard/{dashboard_name}",
6226            self.account_id
6227        );
6228        let dashboard = Dashboard {
6229            name: dashboard_name.clone(),
6230            arn: arn.clone(),
6231            size_bytes: body_str.len() as i64,
6232            body: body_str,
6233            last_modified: Utc::now(),
6234        };
6235
6236        let mut accounts = self.cloudwatch_state.write();
6237        let state = accounts.get_or_create(&self.account_id);
6238        state.dashboards.insert(dashboard_name.clone(), dashboard);
6239
6240        Ok(ProvisionResult::new(dashboard_name).with("Arn", arn))
6241    }
6242
6243    fn delete_cloudwatch_dashboard(&self, physical_id: &str) -> Result<(), String> {
6244        let mut accounts = self.cloudwatch_state.write();
6245        let state = accounts.get_or_create(&self.account_id);
6246        state.dashboards.remove(physical_id);
6247        Ok(())
6248    }
6249
6250    fn update_cloudwatch_alarm(
6251        &self,
6252        existing: &StackResource,
6253        new_def: &ResourceDefinition,
6254    ) -> Result<ProvisionResult, String> {
6255        let props = &new_def.properties;
6256        // Renaming AlarmName forces replacement in real CFN; mirror that.
6257        let new_alarm_name = props
6258            .get("AlarmName")
6259            .and_then(|v| v.as_str())
6260            .unwrap_or(&new_def.logical_id);
6261        if new_alarm_name != existing.physical_id {
6262            return Err(
6263                "AWS::CloudWatch::Alarm updates that change AlarmName require replacement"
6264                    .to_string(),
6265            );
6266        }
6267
6268        let str_array = |key: &str| -> Vec<String> {
6269            props
6270                .get(key)
6271                .and_then(|v| v.as_array())
6272                .map(|arr| {
6273                    arr.iter()
6274                        .filter_map(|x| x.as_str().map(|s| s.to_string()))
6275                        .collect()
6276                })
6277                .unwrap_or_default()
6278        };
6279        let alarm_description = props
6280            .get("AlarmDescription")
6281            .and_then(|v| v.as_str())
6282            .map(|s| s.to_string());
6283        let actions_enabled = props
6284            .get("ActionsEnabled")
6285            .and_then(|v| v.as_bool())
6286            .unwrap_or(true);
6287        let alarm_actions = str_array("AlarmActions");
6288        let ok_actions = str_array("OKActions");
6289        let insufficient_data_actions = str_array("InsufficientDataActions");
6290        let metric_name = props
6291            .get("MetricName")
6292            .and_then(|v| v.as_str())
6293            .map(|s| s.to_string());
6294        let namespace = props
6295            .get("Namespace")
6296            .and_then(|v| v.as_str())
6297            .map(|s| s.to_string());
6298        let statistic = props
6299            .get("Statistic")
6300            .and_then(|v| v.as_str())
6301            .map(|s| s.to_string());
6302        let extended_statistic = props
6303            .get("ExtendedStatistic")
6304            .and_then(|v| v.as_str())
6305            .map(|s| s.to_string());
6306        let unit = props
6307            .get("Unit")
6308            .and_then(|v| v.as_str())
6309            .map(|s| s.to_string());
6310        let period = props.get("Period").and_then(|v| v.as_i64());
6311        let evaluation_periods = props
6312            .get("EvaluationPeriods")
6313            .and_then(|v| v.as_i64())
6314            .unwrap_or(1);
6315        let datapoints_to_alarm = props.get("DatapointsToAlarm").and_then(|v| v.as_i64());
6316        let threshold = props.get("Threshold").and_then(|v| v.as_f64());
6317        let comparison_operator = props
6318            .get("ComparisonOperator")
6319            .and_then(|v| v.as_str())
6320            .unwrap_or("GreaterThanThreshold")
6321            .to_string();
6322        let treat_missing_data = props
6323            .get("TreatMissingData")
6324            .and_then(|v| v.as_str())
6325            .map(|s| s.to_string());
6326        let evaluate_low_sample_count_percentile = props
6327            .get("EvaluateLowSampleCountPercentile")
6328            .and_then(|v| v.as_str())
6329            .map(|s| s.to_string());
6330
6331        let mut dimensions: BTreeMap<String, String> = BTreeMap::new();
6332        if let Some(arr) = props.get("Dimensions").and_then(|v| v.as_array()) {
6333            for d in arr {
6334                if let (Some(k), Some(v)) = (
6335                    d.get("Name").and_then(|x| x.as_str()),
6336                    d.get("Value").and_then(|x| x.as_str()),
6337                ) {
6338                    dimensions.insert(k.to_string(), v.to_string());
6339                }
6340            }
6341        }
6342
6343        let mut accounts = self.cloudwatch_state.write();
6344        let state = accounts.get_or_create(&self.account_id);
6345        let region_alarms = state.alarms_in_mut(&self.region);
6346        let alarm = region_alarms
6347            .get_mut(&existing.physical_id)
6348            .ok_or_else(|| format!("Alarm {} not found", existing.physical_id))?;
6349        let now = Utc::now();
6350        alarm.alarm_description = alarm_description;
6351        alarm.actions_enabled = actions_enabled;
6352        alarm.ok_actions = ok_actions;
6353        alarm.alarm_actions = alarm_actions;
6354        alarm.insufficient_data_actions = insufficient_data_actions;
6355        alarm.metric_name = metric_name;
6356        alarm.namespace = namespace;
6357        alarm.statistic = statistic;
6358        alarm.extended_statistic = extended_statistic;
6359        alarm.dimensions = dimensions;
6360        alarm.period = period;
6361        alarm.unit = unit;
6362        alarm.evaluation_periods = evaluation_periods;
6363        alarm.datapoints_to_alarm = datapoints_to_alarm;
6364        alarm.threshold = threshold;
6365        alarm.comparison_operator = comparison_operator;
6366        alarm.treat_missing_data = treat_missing_data;
6367        alarm.evaluate_low_sample_count_percentile = evaluate_low_sample_count_percentile;
6368        alarm.configuration_updated_timestamp = now;
6369        alarm.alarm_configuration_updated_timestamp = now;
6370
6371        let alarm_arn = alarm.alarm_arn.clone();
6372        Ok(ProvisionResult::new(existing.physical_id.clone()).with("Arn", alarm_arn))
6373    }
6374
6375    fn update_cloudwatch_dashboard(
6376        &self,
6377        existing: &StackResource,
6378        new_def: &ResourceDefinition,
6379    ) -> Result<ProvisionResult, String> {
6380        let props = &new_def.properties;
6381        // Renaming DashboardName forces replacement.
6382        if let Some(new_name) = props.get("DashboardName").and_then(|v| v.as_str()) {
6383            if new_name != existing.physical_id {
6384                return Err(
6385                    "AWS::CloudWatch::Dashboard updates that change DashboardName require replacement"
6386                        .to_string(),
6387                );
6388            }
6389        }
6390        let body = props
6391            .get("DashboardBody")
6392            .ok_or("DashboardBody is required")?;
6393        let body_str = if let Some(s) = body.as_str() {
6394            s.to_string()
6395        } else {
6396            serde_json::to_string(body).map_err(|e| format!("invalid DashboardBody: {e}"))?
6397        };
6398        serde_json::from_str::<serde_json::Value>(&body_str)
6399            .map_err(|e| format!("DashboardBody must be valid JSON: {e}"))?;
6400
6401        let mut accounts = self.cloudwatch_state.write();
6402        let state = accounts.get_or_create(&self.account_id);
6403        let dashboard = state
6404            .dashboards
6405            .get_mut(&existing.physical_id)
6406            .ok_or_else(|| format!("Dashboard {} not found", existing.physical_id))?;
6407        dashboard.size_bytes = body_str.len() as i64;
6408        dashboard.body = body_str;
6409        dashboard.last_modified = Utc::now();
6410        let arn = dashboard.arn.clone();
6411        Ok(ProvisionResult::new(existing.physical_id.clone()).with("Arn", arn))
6412    }
6413
6414    // --- ELBv2 ---
6415
6416    fn create_elbv2_load_balancer(
6417        &self,
6418        resource: &ResourceDefinition,
6419    ) -> Result<ProvisionResult, String> {
6420        let props = &resource.properties;
6421        let name = props
6422            .get("Name")
6423            .and_then(|v| v.as_str())
6424            .unwrap_or(&resource.logical_id)
6425            .to_string();
6426        let scheme = props
6427            .get("Scheme")
6428            .and_then(|v| v.as_str())
6429            .unwrap_or("internet-facing")
6430            .to_string();
6431        let lb_type = props
6432            .get("Type")
6433            .and_then(|v| v.as_str())
6434            .unwrap_or("application")
6435            .to_string();
6436        let ip_address_type = props
6437            .get("IpAddressType")
6438            .and_then(|v| v.as_str())
6439            .unwrap_or("ipv4")
6440            .to_string();
6441        let security_groups: Vec<String> = props
6442            .get("SecurityGroups")
6443            .and_then(|v| v.as_array())
6444            .map(|arr| {
6445                arr.iter()
6446                    .filter_map(|s| s.as_str().map(|s| s.to_string()))
6447                    .collect()
6448            })
6449            .unwrap_or_default();
6450        let tags = parse_elb_tags(props.get("Tags"));
6451
6452        let mut accounts = self.elbv2_state.write();
6453        let state = accounts.get_or_create(&self.account_id);
6454        let lb_id = Uuid::new_v4().simple().to_string();
6455        let arn = format!(
6456            "arn:aws:elasticloadbalancing:{}:{}:loadbalancer/{}/{}/{}",
6457            self.region,
6458            self.account_id,
6459            if lb_type == "network" { "net" } else { "app" },
6460            name,
6461            &lb_id[..16]
6462        );
6463        let dns_name = format!(
6464            "{}-{}.{}.elb.{}.amazonaws.com",
6465            name,
6466            &lb_id[..16],
6467            self.region,
6468            self.region
6469        );
6470
6471        let mut availability_zones: Vec<fakecloud_elbv2::AvailabilityZone> = Vec::new();
6472        if let Some(arr) = props.get("Subnets").and_then(|v| v.as_array()) {
6473            for s in arr {
6474                if let Some(subnet_id) = s.as_str() {
6475                    availability_zones.push(fakecloud_elbv2::AvailabilityZone {
6476                        zone_name: format!("{}a", self.region),
6477                        subnet_id: subnet_id.to_string(),
6478                        outpost_id: None,
6479                        load_balancer_addresses: Vec::new(),
6480                        source_nat_ipv6_prefixes: Vec::new(),
6481                    });
6482                }
6483            }
6484        }
6485
6486        state.load_balancers.insert(
6487            arn.clone(),
6488            LoadBalancer {
6489                arn: arn.clone(),
6490                name: name.clone(),
6491                dns_name: dns_name.clone(),
6492                canonical_hosted_zone_id: "Z2P70J7EXAMPLE".to_string(),
6493                created_time: Utc::now(),
6494                scheme,
6495                vpc_id: String::new(),
6496                state_code: "active".to_string(),
6497                state_reason: None,
6498                lb_type,
6499                availability_zones,
6500                security_groups,
6501                ip_address_type,
6502                customer_owned_ipv4_pool: None,
6503                enforce_security_group_inbound_rules_on_private_link_traffic: None,
6504                enable_prefix_for_ipv6_source_nat: None,
6505                ipv4_ipam_pool_id: None,
6506                tags,
6507                attributes: BTreeMap::new(),
6508                minimum_capacity_units: None,
6509                bound_port: None,
6510            },
6511        );
6512
6513        Ok(ProvisionResult::new(arn.clone())
6514            .with("LoadBalancerArn", arn)
6515            .with(
6516                "LoadBalancerFullName",
6517                format!("app/{name}/{}", &lb_id[..16]),
6518            )
6519            .with("LoadBalancerName", name)
6520            .with("DNSName", dns_name)
6521            .with("CanonicalHostedZoneID", "Z2P70J7EXAMPLE"))
6522    }
6523
6524    fn delete_elbv2_load_balancer(&self, physical_id: &str) -> Result<(), String> {
6525        let mut accounts = self.elbv2_state.write();
6526        let state = accounts.get_or_create(&self.account_id);
6527        state.load_balancers.remove(physical_id);
6528        // Cascade-delete listeners and rules attached to this LB.
6529        let listeners: Vec<String> = state
6530            .listeners
6531            .iter()
6532            .filter(|(_, l)| l.load_balancer_arn == physical_id)
6533            .map(|(arn, _)| arn.clone())
6534            .collect();
6535        for arn in &listeners {
6536            state.listeners.remove(arn);
6537            let rules: Vec<String> = state
6538                .rules
6539                .iter()
6540                .filter(|(_, r)| r.listener_arn == *arn)
6541                .map(|(a, _)| a.clone())
6542                .collect();
6543            for r in rules {
6544                state.rules.remove(&r);
6545            }
6546        }
6547        for tg in state.target_groups.values_mut() {
6548            tg.load_balancer_arns.retain(|a| a != physical_id);
6549        }
6550        Ok(())
6551    }
6552
6553    fn create_elbv2_target_group(
6554        &self,
6555        resource: &ResourceDefinition,
6556    ) -> Result<ProvisionResult, String> {
6557        let props = &resource.properties;
6558        let name = props
6559            .get("Name")
6560            .and_then(|v| v.as_str())
6561            .unwrap_or(&resource.logical_id)
6562            .to_string();
6563        let protocol = props
6564            .get("Protocol")
6565            .and_then(|v| v.as_str())
6566            .map(|s| s.to_string());
6567        let port = props.get("Port").and_then(|v| v.as_i64()).map(|n| n as i32);
6568        let vpc_id = props
6569            .get("VpcId")
6570            .and_then(|v| v.as_str())
6571            .map(|s| s.to_string());
6572        let target_type = props
6573            .get("TargetType")
6574            .and_then(|v| v.as_str())
6575            .unwrap_or("instance")
6576            .to_string();
6577        let ip_address_type = props
6578            .get("IpAddressType")
6579            .and_then(|v| v.as_str())
6580            .unwrap_or("ipv4")
6581            .to_string();
6582        let protocol_version = props
6583            .get("ProtocolVersion")
6584            .and_then(|v| v.as_str())
6585            .map(|s| s.to_string());
6586        let tags = parse_elb_tags(props.get("Tags"));
6587
6588        let mut accounts = self.elbv2_state.write();
6589        let state = accounts.get_or_create(&self.account_id);
6590        let id = Uuid::new_v4().simple().to_string();
6591        let arn = format!(
6592            "arn:aws:elasticloadbalancing:{}:{}:targetgroup/{}/{}",
6593            self.region,
6594            self.account_id,
6595            name,
6596            &id[..16]
6597        );
6598
6599        state.target_groups.insert(
6600            arn.clone(),
6601            TargetGroup {
6602                arn: arn.clone(),
6603                name: name.clone(),
6604                protocol,
6605                port,
6606                vpc_id,
6607                target_type,
6608                ip_address_type,
6609                protocol_version,
6610                health_check_protocol: props
6611                    .get("HealthCheckProtocol")
6612                    .and_then(|v| v.as_str())
6613                    .map(|s| s.to_string()),
6614                health_check_port: props
6615                    .get("HealthCheckPort")
6616                    .and_then(|v| v.as_str())
6617                    .map(|s| s.to_string()),
6618                health_check_enabled: props
6619                    .get("HealthCheckEnabled")
6620                    .and_then(|v| v.as_bool())
6621                    .unwrap_or(true),
6622                health_check_path: props
6623                    .get("HealthCheckPath")
6624                    .and_then(|v| v.as_str())
6625                    .map(|s| s.to_string()),
6626                health_check_interval_seconds: props
6627                    .get("HealthCheckIntervalSeconds")
6628                    .and_then(|v| v.as_i64())
6629                    .unwrap_or(30) as i32,
6630                health_check_timeout_seconds: props
6631                    .get("HealthCheckTimeoutSeconds")
6632                    .and_then(|v| v.as_i64())
6633                    .unwrap_or(5) as i32,
6634                healthy_threshold_count: props
6635                    .get("HealthyThresholdCount")
6636                    .and_then(|v| v.as_i64())
6637                    .unwrap_or(5) as i32,
6638                unhealthy_threshold_count: props
6639                    .get("UnhealthyThresholdCount")
6640                    .and_then(|v| v.as_i64())
6641                    .unwrap_or(2) as i32,
6642                matcher_http_code: props
6643                    .get("Matcher")
6644                    .and_then(|v| v.get("HttpCode"))
6645                    .and_then(|v| v.as_str())
6646                    .map(|s| s.to_string()),
6647                matcher_grpc_code: props
6648                    .get("Matcher")
6649                    .and_then(|v| v.get("GrpcCode"))
6650                    .and_then(|v| v.as_str())
6651                    .map(|s| s.to_string()),
6652                load_balancer_arns: Vec::new(),
6653                targets: Vec::new(),
6654                tags,
6655                attributes: BTreeMap::new(),
6656                created_time: Utc::now(),
6657            },
6658        );
6659
6660        Ok(ProvisionResult::new(arn.clone())
6661            .with("TargetGroupArn", arn)
6662            .with("TargetGroupName", name)
6663            .with("TargetGroupFullName", format!("targetgroup/{}", &id[..16])))
6664    }
6665
6666    fn delete_elbv2_target_group(&self, physical_id: &str) -> Result<(), String> {
6667        let mut accounts = self.elbv2_state.write();
6668        let state = accounts.get_or_create(&self.account_id);
6669        state.target_groups.remove(physical_id);
6670        Ok(())
6671    }
6672
6673    fn create_elbv2_listener(
6674        &self,
6675        resource: &ResourceDefinition,
6676    ) -> Result<ProvisionResult, String> {
6677        let props = &resource.properties;
6678        let load_balancer_arn = props
6679            .get("LoadBalancerArn")
6680            .and_then(|v| v.as_str())
6681            .ok_or_else(|| "LoadBalancerArn is required".to_string())?
6682            .to_string();
6683        let port = props.get("Port").and_then(|v| v.as_i64()).map(|n| n as i32);
6684        let protocol = props
6685            .get("Protocol")
6686            .and_then(|v| v.as_str())
6687            .map(|s| s.to_string());
6688        let default_actions = parse_elb_actions(props.get("DefaultActions"));
6689
6690        let mut accounts = self.elbv2_state.write();
6691        let state = accounts.get_or_create(&self.account_id);
6692        if !state.load_balancers.contains_key(&load_balancer_arn) {
6693            return Err(format!(
6694                "LoadBalancer {load_balancer_arn} not yet provisioned"
6695            ));
6696        }
6697
6698        let lb_full = load_balancer_arn
6699            .rsplit("loadbalancer/")
6700            .next()
6701            .unwrap_or("")
6702            .to_string();
6703        let listener_id = Uuid::new_v4().simple().to_string();
6704        let arn = format!(
6705            "arn:aws:elasticloadbalancing:{}:{}:listener/{}/{}",
6706            self.region,
6707            self.account_id,
6708            lb_full,
6709            &listener_id[..16]
6710        );
6711
6712        // Wire forward target groups -> LB association so dataplane probing
6713        // and DescribeTargetGroups round-trip the relationship.
6714        for action in &default_actions {
6715            if let Some(tg_arn) = &action.target_group_arn {
6716                if let Some(tg) = state.target_groups.get_mut(tg_arn) {
6717                    if !tg.load_balancer_arns.contains(&load_balancer_arn) {
6718                        tg.load_balancer_arns.push(load_balancer_arn.clone());
6719                    }
6720                }
6721            }
6722            if let Some(forward) = &action.forward {
6723                for tgt in &forward.target_groups {
6724                    if let Some(tg) = state.target_groups.get_mut(&tgt.target_group_arn) {
6725                        if !tg.load_balancer_arns.contains(&load_balancer_arn) {
6726                            tg.load_balancer_arns.push(load_balancer_arn.clone());
6727                        }
6728                    }
6729                }
6730            }
6731        }
6732
6733        state.listeners.insert(
6734            arn.clone(),
6735            Listener {
6736                arn: arn.clone(),
6737                load_balancer_arn,
6738                port,
6739                protocol,
6740                certificates: Vec::new(),
6741                ssl_policy: props
6742                    .get("SslPolicy")
6743                    .and_then(|v| v.as_str())
6744                    .map(|s| s.to_string()),
6745                default_actions,
6746                alpn_policy: Vec::new(),
6747                mutual_authentication: None,
6748                tags: parse_elb_tags(props.get("Tags")),
6749                attributes: BTreeMap::new(),
6750            },
6751        );
6752
6753        Ok(ProvisionResult::new(arn.clone()).with("ListenerArn", arn))
6754    }
6755
6756    fn delete_elbv2_listener(&self, physical_id: &str) -> Result<(), String> {
6757        let mut accounts = self.elbv2_state.write();
6758        let state = accounts.get_or_create(&self.account_id);
6759        state.listeners.remove(physical_id);
6760        let rules: Vec<String> = state
6761            .rules
6762            .iter()
6763            .filter(|(_, r)| r.listener_arn == physical_id)
6764            .map(|(arn, _)| arn.clone())
6765            .collect();
6766        for r in rules {
6767            state.rules.remove(&r);
6768        }
6769        Ok(())
6770    }
6771
6772    fn create_elbv2_listener_rule(
6773        &self,
6774        resource: &ResourceDefinition,
6775    ) -> Result<ProvisionResult, String> {
6776        let props = &resource.properties;
6777        let listener_arn = props
6778            .get("ListenerArn")
6779            .and_then(|v| v.as_str())
6780            .ok_or_else(|| "ListenerArn is required".to_string())?
6781            .to_string();
6782        let priority = props
6783            .get("Priority")
6784            .map(|v| {
6785                if let Some(s) = v.as_str() {
6786                    s.to_string()
6787                } else if let Some(n) = v.as_i64() {
6788                    n.to_string()
6789                } else {
6790                    "1".to_string()
6791                }
6792            })
6793            .unwrap_or_else(|| "1".to_string());
6794        let actions = parse_elb_actions(props.get("Actions"));
6795        let conditions = parse_elb_rule_conditions(props.get("Conditions"));
6796
6797        let mut accounts = self.elbv2_state.write();
6798        let state = accounts.get_or_create(&self.account_id);
6799        if !state.listeners.contains_key(&listener_arn) {
6800            return Err(format!("Listener {listener_arn} not yet provisioned"));
6801        }
6802        let listener_full = listener_arn
6803            .rsplit("listener/")
6804            .next()
6805            .unwrap_or("")
6806            .to_string();
6807        let rule_id = Uuid::new_v4().simple().to_string();
6808        let arn = format!(
6809            "arn:aws:elasticloadbalancing:{}:{}:listener-rule/{}/{}",
6810            self.region,
6811            self.account_id,
6812            listener_full,
6813            &rule_id[..16]
6814        );
6815
6816        state.rules.insert(
6817            arn.clone(),
6818            ElbRule {
6819                arn: arn.clone(),
6820                listener_arn,
6821                priority,
6822                conditions,
6823                actions,
6824                is_default: false,
6825                tags: parse_elb_tags(props.get("Tags")),
6826            },
6827        );
6828
6829        Ok(ProvisionResult::new(arn.clone()).with("RuleArn", arn))
6830    }
6831
6832    fn delete_elbv2_listener_rule(&self, physical_id: &str) -> Result<(), String> {
6833        let mut accounts = self.elbv2_state.write();
6834        let state = accounts.get_or_create(&self.account_id);
6835        state.rules.remove(physical_id);
6836        Ok(())
6837    }
6838
6839    /// Provision an `AWS::ElasticLoadBalancingV2::ListenerCertificate`.
6840    /// Appends each non-default certificate from `Certificates` to the
6841    /// target listener (the default listener cert is set on Listener
6842    /// creation, so this resource only manages SNI extras).
6843    fn create_elbv2_listener_certificate(
6844        &self,
6845        resource: &ResourceDefinition,
6846    ) -> Result<ProvisionResult, String> {
6847        let props = &resource.properties;
6848        let listener_arn = props
6849            .get("ListenerArn")
6850            .and_then(|v| v.as_str())
6851            .ok_or_else(|| "ListenerArn is required".to_string())?
6852            .to_string();
6853        let certs: Vec<String> = props
6854            .get("Certificates")
6855            .and_then(|v| v.as_array())
6856            .map(|arr| {
6857                arr.iter()
6858                    .filter_map(|c| c.get("CertificateArn").and_then(|v| v.as_str()))
6859                    .map(|s| s.to_string())
6860                    .collect()
6861            })
6862            .unwrap_or_default();
6863        if certs.is_empty() {
6864            return Err("Certificates must contain at least one CertificateArn".to_string());
6865        }
6866        let mut accounts = self.elbv2_state.write();
6867        let state = accounts.get_or_create(&self.account_id);
6868        let listener = state
6869            .listeners
6870            .get_mut(&listener_arn)
6871            .ok_or_else(|| format!("Listener {listener_arn} does not exist"))?;
6872        for arn in &certs {
6873            listener.certificates.retain(|c| &c.certificate_arn != arn);
6874            listener.certificates.push(fakecloud_elbv2::Certificate {
6875                certificate_arn: arn.clone(),
6876                is_default: false,
6877            });
6878        }
6879        Ok(ProvisionResult::new(format!(
6880            "{}#{}",
6881            listener_arn,
6882            certs.join(",")
6883        )))
6884    }
6885
6886    fn delete_elbv2_listener_certificate(&self, physical_id: &str) -> Result<(), String> {
6887        let (listener_arn, cert_list) = match physical_id.split_once('#') {
6888            Some(parts) => parts,
6889            None => return Ok(()),
6890        };
6891        let cert_arns: Vec<&str> = cert_list.split(',').collect();
6892        let mut accounts = self.elbv2_state.write();
6893        let state = accounts.get_or_create(&self.account_id);
6894        if let Some(listener) = state.listeners.get_mut(listener_arn) {
6895            listener
6896                .certificates
6897                .retain(|c| !cert_arns.iter().any(|a| *a == c.certificate_arn));
6898        }
6899        Ok(())
6900    }
6901
6902    /// Provision an `AWS::ElasticLoadBalancingV2::TrustStore`.
6903    fn create_elbv2_trust_store(
6904        &self,
6905        resource: &ResourceDefinition,
6906    ) -> Result<ProvisionResult, String> {
6907        let props = &resource.properties;
6908        let name = props
6909            .get("Name")
6910            .and_then(|v| v.as_str())
6911            .unwrap_or(&resource.logical_id)
6912            .to_string();
6913        let bucket = props
6914            .get("CaCertificatesBundleS3Bucket")
6915            .and_then(|v| v.as_str())
6916            .ok_or_else(|| "CaCertificatesBundleS3Bucket is required".to_string())?;
6917        let key = props
6918            .get("CaCertificatesBundleS3Key")
6919            .and_then(|v| v.as_str())
6920            .ok_or_else(|| "CaCertificatesBundleS3Key is required".to_string())?;
6921        let tags: Vec<fakecloud_elbv2::Tag> = props
6922            .get("Tags")
6923            .and_then(|v| v.as_array())
6924            .map(|arr| {
6925                arr.iter()
6926                    .filter_map(|t| {
6927                        let k = t.get("Key").and_then(|v| v.as_str())?;
6928                        let val = t.get("Value").and_then(|v| v.as_str()).unwrap_or("");
6929                        Some(fakecloud_elbv2::Tag {
6930                            key: k.to_string(),
6931                            value: val.to_string(),
6932                        })
6933                    })
6934                    .collect()
6935            })
6936            .unwrap_or_default();
6937
6938        let mut accounts = self.elbv2_state.write();
6939        let state = accounts.get_or_create(&self.account_id);
6940        if state.trust_stores.values().any(|t| t.name == name) {
6941            return Err(format!("Trust store {name} already exists"));
6942        }
6943        let suffix: String = Uuid::new_v4()
6944            .simple()
6945            .to_string()
6946            .chars()
6947            .take(16)
6948            .collect();
6949        let arn = format!(
6950            "arn:aws:elasticloadbalancing:{}:{}:truststore/{}/{}",
6951            self.region, self.account_id, name, suffix
6952        );
6953        let ts = fakecloud_elbv2::TrustStore {
6954            arn: arn.clone(),
6955            name: name.clone(),
6956            status: "ACTIVE".to_string(),
6957            number_of_ca_certificates: 1,
6958            total_revoked_entries: 0,
6959            created_time: Utc::now(),
6960            ca_certificates_bundle: Some(format!("s3://{bucket}/{key}").into_bytes()),
6961            revocations: BTreeMap::new(),
6962            next_revocation_id: 1,
6963            tags,
6964        };
6965        state.trust_stores.insert(arn.clone(), ts);
6966        Ok(ProvisionResult::new(arn.clone())
6967            .with("TrustStoreArn", arn)
6968            .with("Name", name)
6969            .with("Status", "ACTIVE".to_string()))
6970    }
6971
6972    fn delete_elbv2_trust_store(&self, physical_id: &str) -> Result<(), String> {
6973        let mut accounts = self.elbv2_state.write();
6974        let state = accounts.get_or_create(&self.account_id);
6975        state.trust_stores.remove(physical_id);
6976        Ok(())
6977    }
6978
6979    /// In-place update for AWS::ElasticLoadBalancingV2::LoadBalancer. Name,
6980    /// scheme, type and subnet topology are immutable in real AWS — CFN
6981    /// would replace the resource. We only mutate fields the SetSubnets /
6982    /// SetSecurityGroups / SetIpAddressType APIs would touch.
6983    fn update_elbv2_load_balancer(
6984        &self,
6985        existing: &StackResource,
6986        resource: &ResourceDefinition,
6987    ) -> Result<ProvisionResult, String> {
6988        let props = &resource.properties;
6989        let arn = existing.physical_id.clone();
6990        let mut accounts = self.elbv2_state.write();
6991        let state = accounts.get_or_create(&self.account_id);
6992        let lb = state
6993            .load_balancers
6994            .get_mut(&arn)
6995            .ok_or_else(|| format!("LoadBalancer {arn} no longer exists"))?;
6996        if let Some(arr) = props.get("SecurityGroups").and_then(|v| v.as_array()) {
6997            lb.security_groups = arr
6998                .iter()
6999                .filter_map(|s| s.as_str().map(|s| s.to_string()))
7000                .collect();
7001        }
7002        if let Some(s) = props.get("IpAddressType").and_then(|v| v.as_str()) {
7003            lb.ip_address_type = s.to_string();
7004        }
7005        if let Some(arr) = props.get("Subnets").and_then(|v| v.as_array()) {
7006            let mut zones: Vec<fakecloud_elbv2::AvailabilityZone> = Vec::new();
7007            for s in arr {
7008                if let Some(subnet_id) = s.as_str() {
7009                    zones.push(fakecloud_elbv2::AvailabilityZone {
7010                        zone_name: format!("{}a", self.region),
7011                        subnet_id: subnet_id.to_string(),
7012                        outpost_id: None,
7013                        load_balancer_addresses: Vec::new(),
7014                        source_nat_ipv6_prefixes: Vec::new(),
7015                    });
7016                }
7017            }
7018            lb.availability_zones = zones;
7019        }
7020        if props.get("Tags").is_some() {
7021            lb.tags = parse_elb_tags(props.get("Tags"));
7022        }
7023        let name = lb.name.clone();
7024        let dns_name = lb.dns_name.clone();
7025        let canonical = lb.canonical_hosted_zone_id.clone();
7026        let lb_full = arn.rsplit("loadbalancer/").next().unwrap_or("").to_string();
7027        Ok(ProvisionResult::new(arn.clone())
7028            .with("LoadBalancerArn", arn)
7029            .with("LoadBalancerFullName", lb_full)
7030            .with("LoadBalancerName", name)
7031            .with("DNSName", dns_name)
7032            .with("CanonicalHostedZoneID", canonical))
7033    }
7034
7035    /// In-place update for AWS::ElasticLoadBalancingV2::TargetGroup. Mirrors
7036    /// ModifyTargetGroup: only health-check fields and matcher are mutable.
7037    fn update_elbv2_target_group(
7038        &self,
7039        existing: &StackResource,
7040        resource: &ResourceDefinition,
7041    ) -> Result<ProvisionResult, String> {
7042        let props = &resource.properties;
7043        let arn = existing.physical_id.clone();
7044        let mut accounts = self.elbv2_state.write();
7045        let state = accounts.get_or_create(&self.account_id);
7046        let tg = state
7047            .target_groups
7048            .get_mut(&arn)
7049            .ok_or_else(|| format!("TargetGroup {arn} no longer exists"))?;
7050        if let Some(s) = props.get("HealthCheckProtocol").and_then(|v| v.as_str()) {
7051            tg.health_check_protocol = Some(s.to_string());
7052        }
7053        if let Some(s) = props.get("HealthCheckPort").and_then(|v| v.as_str()) {
7054            tg.health_check_port = Some(s.to_string());
7055        }
7056        if let Some(b) = props.get("HealthCheckEnabled").and_then(|v| v.as_bool()) {
7057            tg.health_check_enabled = b;
7058        }
7059        if let Some(s) = props.get("HealthCheckPath").and_then(|v| v.as_str()) {
7060            tg.health_check_path = Some(s.to_string());
7061        }
7062        if let Some(n) = props.get("HealthCheckIntervalSeconds").and_then(cfn_as_i64) {
7063            tg.health_check_interval_seconds = n as i32;
7064        }
7065        if let Some(n) = props.get("HealthCheckTimeoutSeconds").and_then(cfn_as_i64) {
7066            tg.health_check_timeout_seconds = n as i32;
7067        }
7068        if let Some(n) = props.get("HealthyThresholdCount").and_then(cfn_as_i64) {
7069            tg.healthy_threshold_count = n as i32;
7070        }
7071        if let Some(n) = props.get("UnhealthyThresholdCount").and_then(cfn_as_i64) {
7072            tg.unhealthy_threshold_count = n as i32;
7073        }
7074        if let Some(matcher) = props.get("Matcher") {
7075            tg.matcher_http_code = matcher
7076                .get("HttpCode")
7077                .and_then(|v| v.as_str())
7078                .map(|s| s.to_string());
7079            tg.matcher_grpc_code = matcher
7080                .get("GrpcCode")
7081                .and_then(|v| v.as_str())
7082                .map(|s| s.to_string());
7083        }
7084        if props.get("Tags").is_some() {
7085            tg.tags = parse_elb_tags(props.get("Tags"));
7086        }
7087        let name = tg.name.clone();
7088        let tg_full = arn
7089            .rsplit("targetgroup/")
7090            .next()
7091            .map(|s| format!("targetgroup/{s}"))
7092            .unwrap_or_default();
7093        Ok(ProvisionResult::new(arn.clone())
7094            .with("TargetGroupArn", arn)
7095            .with("TargetGroupName", name)
7096            .with("TargetGroupFullName", tg_full))
7097    }
7098
7099    /// In-place update for AWS::ElasticLoadBalancingV2::Listener. Mirrors
7100    /// ModifyListener: port, protocol, default actions, certs, ssl policy.
7101    fn update_elbv2_listener(
7102        &self,
7103        existing: &StackResource,
7104        resource: &ResourceDefinition,
7105    ) -> Result<ProvisionResult, String> {
7106        let props = &resource.properties;
7107        let arn = existing.physical_id.clone();
7108        let new_default_actions = props
7109            .get("DefaultActions")
7110            .map(|v| parse_elb_actions(Some(v)));
7111        let mut accounts = self.elbv2_state.write();
7112        let state = accounts.get_or_create(&self.account_id);
7113        let listener = state
7114            .listeners
7115            .get_mut(&arn)
7116            .ok_or_else(|| format!("Listener {arn} no longer exists"))?;
7117        if let Some(n) = props.get("Port").and_then(cfn_as_i64) {
7118            listener.port = Some(n as i32);
7119        }
7120        if let Some(s) = props.get("Protocol").and_then(|v| v.as_str()) {
7121            listener.protocol = Some(s.to_string());
7122        }
7123        if let Some(s) = props.get("SslPolicy").and_then(|v| v.as_str()) {
7124            listener.ssl_policy = Some(s.to_string());
7125        }
7126        if let Some(actions) = new_default_actions {
7127            listener.default_actions = actions;
7128        }
7129        if props.get("Tags").is_some() {
7130            listener.tags = parse_elb_tags(props.get("Tags"));
7131        }
7132        Ok(ProvisionResult::new(arn.clone()).with("ListenerArn", arn))
7133    }
7134
7135    /// In-place update for AWS::ElasticLoadBalancingV2::ListenerRule. Mirrors
7136    /// ModifyRule + SetRulePriorities.
7137    fn update_elbv2_listener_rule(
7138        &self,
7139        existing: &StackResource,
7140        resource: &ResourceDefinition,
7141    ) -> Result<ProvisionResult, String> {
7142        let props = &resource.properties;
7143        let arn = existing.physical_id.clone();
7144        let new_actions = props.get("Actions").map(|v| parse_elb_actions(Some(v)));
7145        let new_conditions = props
7146            .get("Conditions")
7147            .map(|v| parse_elb_rule_conditions(Some(v)));
7148        let mut accounts = self.elbv2_state.write();
7149        let state = accounts.get_or_create(&self.account_id);
7150        let rule = state
7151            .rules
7152            .get_mut(&arn)
7153            .ok_or_else(|| format!("ListenerRule {arn} no longer exists"))?;
7154        if let Some(v) = props.get("Priority") {
7155            rule.priority = if let Some(s) = v.as_str() {
7156                s.to_string()
7157            } else if let Some(n) = v.as_i64() {
7158                n.to_string()
7159            } else {
7160                rule.priority.clone()
7161            };
7162        }
7163        if let Some(actions) = new_actions {
7164            rule.actions = actions;
7165        }
7166        if let Some(conditions) = new_conditions {
7167            rule.conditions = conditions;
7168        }
7169        if props.get("Tags").is_some() {
7170            rule.tags = parse_elb_tags(props.get("Tags"));
7171        }
7172        Ok(ProvisionResult::new(arn.clone()).with("RuleArn", arn))
7173    }
7174
7175    /// In-place update for AWS::ElasticLoadBalancingV2::ListenerCertificate.
7176    /// CFN treats this as replace-on-cert-list-change in real AWS, but we
7177    /// can rebuild the SNI cert set against the same physical id without
7178    /// disrupting the listener.
7179    fn update_elbv2_listener_certificate(
7180        &self,
7181        existing: &StackResource,
7182        resource: &ResourceDefinition,
7183    ) -> Result<ProvisionResult, String> {
7184        let props = &resource.properties;
7185        let physical_id = existing.physical_id.clone();
7186        let listener_arn = props
7187            .get("ListenerArn")
7188            .and_then(|v| v.as_str())
7189            .map(|s| s.to_string())
7190            .or_else(|| physical_id.split_once('#').map(|(l, _)| l.to_string()))
7191            .ok_or_else(|| "ListenerArn is required".to_string())?;
7192        let new_certs: Vec<String> = props
7193            .get("Certificates")
7194            .and_then(|v| v.as_array())
7195            .map(|arr| {
7196                arr.iter()
7197                    .filter_map(|c| c.get("CertificateArn").and_then(|v| v.as_str()))
7198                    .map(|s| s.to_string())
7199                    .collect()
7200            })
7201            .unwrap_or_default();
7202        if new_certs.is_empty() {
7203            return Err("Certificates must contain at least one CertificateArn".to_string());
7204        }
7205
7206        // Strip the previously-managed certs, then attach the new set.
7207        let prev_certs: Vec<String> = physical_id
7208            .split_once('#')
7209            .map(|(_, list)| list.split(',').map(|s| s.to_string()).collect())
7210            .unwrap_or_default();
7211
7212        let mut accounts = self.elbv2_state.write();
7213        let state = accounts.get_or_create(&self.account_id);
7214        let listener = state
7215            .listeners
7216            .get_mut(&listener_arn)
7217            .ok_or_else(|| format!("Listener {listener_arn} does not exist"))?;
7218        listener
7219            .certificates
7220            .retain(|c| !prev_certs.iter().any(|p| p == &c.certificate_arn));
7221        for arn in &new_certs {
7222            listener.certificates.retain(|c| &c.certificate_arn != arn);
7223            listener.certificates.push(fakecloud_elbv2::Certificate {
7224                certificate_arn: arn.clone(),
7225                is_default: false,
7226            });
7227        }
7228        Ok(ProvisionResult::new(format!(
7229            "{}#{}",
7230            listener_arn,
7231            new_certs.join(",")
7232        )))
7233    }
7234
7235    /// In-place update for AWS::ElasticLoadBalancingV2::TrustStore. Only the
7236    /// CA bundle and tags are mutable; name is immutable in real AWS.
7237    fn update_elbv2_trust_store(
7238        &self,
7239        existing: &StackResource,
7240        resource: &ResourceDefinition,
7241    ) -> Result<ProvisionResult, String> {
7242        let props = &resource.properties;
7243        let arn = existing.physical_id.clone();
7244        let mut accounts = self.elbv2_state.write();
7245        let state = accounts.get_or_create(&self.account_id);
7246        let ts = state
7247            .trust_stores
7248            .get_mut(&arn)
7249            .ok_or_else(|| format!("TrustStore {arn} no longer exists"))?;
7250        let new_bucket = props
7251            .get("CaCertificatesBundleS3Bucket")
7252            .and_then(|v| v.as_str());
7253        let new_key = props
7254            .get("CaCertificatesBundleS3Key")
7255            .and_then(|v| v.as_str());
7256        if let (Some(b), Some(k)) = (new_bucket, new_key) {
7257            ts.ca_certificates_bundle = Some(format!("s3://{b}/{k}").into_bytes());
7258        }
7259        if let Some(arr) = props.get("Tags").and_then(|v| v.as_array()) {
7260            ts.tags = arr
7261                .iter()
7262                .filter_map(|t| {
7263                    let k = t.get("Key").and_then(|v| v.as_str())?;
7264                    let v = t.get("Value").and_then(|v| v.as_str()).unwrap_or("");
7265                    Some(fakecloud_elbv2::Tag {
7266                        key: k.to_string(),
7267                        value: v.to_string(),
7268                    })
7269                })
7270                .collect();
7271        }
7272        let name = ts.name.clone();
7273        let status = ts.status.clone();
7274        Ok(ProvisionResult::new(arn.clone())
7275            .with("TrustStoreArn", arn)
7276            .with("Name", name)
7277            .with("Status", status))
7278    }
7279
7280    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::LoadBalancer.
7281    fn get_att_elbv2_load_balancer(&self, physical_id: &str, attribute: &str) -> Option<String> {
7282        let mut accounts = self.elbv2_state.write();
7283        let state = accounts.get_or_create(&self.account_id);
7284        let lb = state.load_balancers.get(physical_id)?;
7285        let lb_full = lb
7286            .arn
7287            .rsplit("loadbalancer/")
7288            .next()
7289            .unwrap_or("")
7290            .to_string();
7291        match attribute {
7292            "Arn" | "LoadBalancerArn" => Some(lb.arn.clone()),
7293            "DNSName" => Some(lb.dns_name.clone()),
7294            "CanonicalHostedZoneID" => Some(lb.canonical_hosted_zone_id.clone()),
7295            "LoadBalancerFullName" => Some(lb_full),
7296            "LoadBalancerName" => Some(lb.name.clone()),
7297            "SecurityGroups" => Some(lb.security_groups.join(",")),
7298            _ => None,
7299        }
7300    }
7301
7302    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::TargetGroup.
7303    fn get_att_elbv2_target_group(&self, physical_id: &str, attribute: &str) -> Option<String> {
7304        let mut accounts = self.elbv2_state.write();
7305        let state = accounts.get_or_create(&self.account_id);
7306        let tg = state.target_groups.get(physical_id)?;
7307        let tg_full = tg
7308            .arn
7309            .rsplit("targetgroup/")
7310            .next()
7311            .map(|s| format!("targetgroup/{s}"))
7312            .unwrap_or_default();
7313        match attribute {
7314            "TargetGroupArn" => Some(tg.arn.clone()),
7315            "TargetGroupName" => Some(tg.name.clone()),
7316            "TargetGroupFullName" => Some(tg_full),
7317            "LoadBalancerArns" => Some(tg.load_balancer_arns.join(",")),
7318            _ => None,
7319        }
7320    }
7321
7322    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::Listener.
7323    fn get_att_elbv2_listener(&self, physical_id: &str, attribute: &str) -> Option<String> {
7324        let mut accounts = self.elbv2_state.write();
7325        let state = accounts.get_or_create(&self.account_id);
7326        let listener = state.listeners.get(physical_id)?;
7327        match attribute {
7328            "Arn" | "ListenerArn" => Some(listener.arn.clone()),
7329            _ => None,
7330        }
7331    }
7332
7333    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::ListenerRule.
7334    fn get_att_elbv2_listener_rule(&self, physical_id: &str, attribute: &str) -> Option<String> {
7335        let mut accounts = self.elbv2_state.write();
7336        let state = accounts.get_or_create(&self.account_id);
7337        let rule = state.rules.get(physical_id)?;
7338        match attribute {
7339            "RuleArn" => Some(rule.arn.clone()),
7340            "IsDefault" => Some(rule.is_default.to_string()),
7341            _ => None,
7342        }
7343    }
7344
7345    /// Live-state GetAtt fallback for AWS::ElasticLoadBalancingV2::TrustStore.
7346    fn get_att_elbv2_trust_store(&self, physical_id: &str, attribute: &str) -> Option<String> {
7347        let mut accounts = self.elbv2_state.write();
7348        let state = accounts.get_or_create(&self.account_id);
7349        let ts = state.trust_stores.get(physical_id)?;
7350        match attribute {
7351            "TrustStoreArn" => Some(ts.arn.clone()),
7352            "Name" => Some(ts.name.clone()),
7353            "Status" => Some(ts.status.clone()),
7354            "NumberOfCaCertificates" => Some(ts.number_of_ca_certificates.to_string()),
7355            "TotalRevokedEntries" => Some(ts.total_revoked_entries.to_string()),
7356            _ => None,
7357        }
7358    }
7359
7360    // --- Organizations ---
7361
7362    fn create_organization(
7363        &self,
7364        resource: &ResourceDefinition,
7365    ) -> Result<ProvisionResult, String> {
7366        let props = &resource.properties;
7367        let feature_set = props
7368            .get("FeatureSet")
7369            .and_then(|v| v.as_str())
7370            .unwrap_or("ALL")
7371            .to_string();
7372
7373        let mut org = self.organizations_state.write();
7374        if org.is_some() {
7375            return Err("Organization already exists; only one per fakecloud process".to_string());
7376        }
7377        let mut state = OrganizationState::bootstrap(&self.account_id);
7378        state.feature_set = feature_set;
7379        let org_id = state.org_id.clone();
7380        let org_arn = state.org_arn.clone();
7381        let mgmt_arn = state.management_account_arn.clone();
7382        let root_id = state.root_id.clone();
7383        *org = Some(state);
7384
7385        Ok(ProvisionResult::new(org_id.clone())
7386            .with("Id", org_id)
7387            .with("Arn", org_arn)
7388            .with("ManagementAccountArn", mgmt_arn)
7389            .with("RootId", root_id))
7390    }
7391
7392    fn delete_organization(&self, _physical_id: &str) -> Result<(), String> {
7393        let mut org = self.organizations_state.write();
7394        *org = None;
7395        Ok(())
7396    }
7397
7398    fn create_organization_unit(
7399        &self,
7400        resource: &ResourceDefinition,
7401    ) -> Result<ProvisionResult, String> {
7402        let props = &resource.properties;
7403        let name = props
7404            .get("Name")
7405            .and_then(|v| v.as_str())
7406            .unwrap_or(&resource.logical_id)
7407            .to_string();
7408        let parent_id = props
7409            .get("ParentId")
7410            .and_then(|v| v.as_str())
7411            .ok_or_else(|| "ParentId is required".to_string())?
7412            .to_string();
7413
7414        let mut org_lock = self.organizations_state.write();
7415        let org = org_lock
7416            .as_mut()
7417            .ok_or_else(|| "Organization not yet created".to_string())?;
7418        // Accept root id, OU id, or `Ref`-resolved logical id (we map to root).
7419        let resolved_parent_id = if parent_id == org.root_id || org.ous.contains_key(&parent_id) {
7420            parent_id
7421        } else {
7422            return Err(format!("Parent {parent_id} does not exist"));
7423        };
7424        let id_suffix: String = Uuid::new_v4()
7425            .simple()
7426            .to_string()
7427            .chars()
7428            .take(8)
7429            .collect();
7430        let id = format!("ou-{}-{}", &org.root_id[2..], id_suffix);
7431        let arn = format!(
7432            "arn:aws:organizations::{}:ou/{}/{}",
7433            org.management_account_id, org.org_id, id
7434        );
7435        org.ous.insert(
7436            id.clone(),
7437            OrganizationalUnit {
7438                id: id.clone(),
7439                arn: arn.clone(),
7440                name: name.clone(),
7441                parent_id: resolved_parent_id,
7442            },
7443        );
7444        Ok(ProvisionResult::new(id.clone())
7445            .with("Id", id)
7446            .with("Arn", arn)
7447            .with("Name", name))
7448    }
7449
7450    fn delete_organization_unit(&self, physical_id: &str) -> Result<(), String> {
7451        let mut org_lock = self.organizations_state.write();
7452        if let Some(org) = org_lock.as_mut() {
7453            org.ous.remove(physical_id);
7454            org.attachments.remove(physical_id);
7455        }
7456        Ok(())
7457    }
7458
7459    /// Provision an `AWS::Organizations::Account`. Mints a new member
7460    /// account synchronously (via Organizations state), optionally moves
7461    /// it under the first ParentId when supplied, and persists tags.
7462    fn create_organization_account(
7463        &self,
7464        resource: &ResourceDefinition,
7465    ) -> Result<ProvisionResult, String> {
7466        let props = &resource.properties;
7467        let email = props
7468            .get("Email")
7469            .and_then(|v| v.as_str())
7470            .ok_or_else(|| "Email is required".to_string())?
7471            .to_string();
7472        let name = props
7473            .get("AccountName")
7474            .and_then(|v| v.as_str())
7475            .ok_or_else(|| "AccountName is required".to_string())?
7476            .to_string();
7477        let parent_ids: Vec<String> = props
7478            .get("ParentIds")
7479            .and_then(|v| v.as_array())
7480            .map(|arr| {
7481                arr.iter()
7482                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
7483                    .collect()
7484            })
7485            .unwrap_or_default();
7486        let tags: Vec<(String, String)> = props
7487            .get("Tags")
7488            .and_then(|v| v.as_array())
7489            .map(|arr| {
7490                arr.iter()
7491                    .filter_map(|t| {
7492                        let k = t.get("Key").and_then(|v| v.as_str())?;
7493                        let val = t.get("Value").and_then(|v| v.as_str()).unwrap_or("");
7494                        Some((k.to_string(), val.to_string()))
7495                    })
7496                    .collect()
7497            })
7498            .unwrap_or_default();
7499
7500        let mut org_lock = self.organizations_state.write();
7501        let org = org_lock
7502            .as_mut()
7503            .ok_or_else(|| "Organization not yet created".to_string())?;
7504        // CFN provisioning is its own asynchronous flow; we don't need
7505        // a second layer of poll-for-completion on top. Begin the
7506        // request and immediately drive it to SUCCEEDED so the rest of
7507        // this provisioner sees a fully enrolled account.
7508        let pending = org.begin_create_account(&email, &name, None);
7509        let status = org.complete_create_account(&pending.id).unwrap_or(pending);
7510        let account_id = status
7511            .account_id
7512            .clone()
7513            .ok_or_else(|| "create_account did not return an account id".to_string())?;
7514        let account_arn = org
7515            .accounts
7516            .get(&account_id)
7517            .map(|a| a.arn.clone())
7518            .unwrap_or_default();
7519        let joined_method = org
7520            .accounts
7521            .get(&account_id)
7522            .map(|a| a.joined_method.clone())
7523            .unwrap_or_else(|| "CREATED".to_string());
7524        let joined_timestamp = org
7525            .accounts
7526            .get(&account_id)
7527            .map(|a| a.joined_timestamp.to_rfc3339())
7528            .unwrap_or_default();
7529        let acct_status = org
7530            .accounts
7531            .get(&account_id)
7532            .map(|a| a.status.clone())
7533            .unwrap_or_else(|| "ACTIVE".to_string());
7534
7535        if let Some(parent) = parent_ids.first() {
7536            let source = org
7537                .accounts
7538                .get(&account_id)
7539                .map(|a| a.parent_id.clone())
7540                .unwrap_or_else(|| org.root_id.clone());
7541            if parent != &source {
7542                org.move_account(&account_id, &source, parent)
7543                    .map_err(|e| format!("Failed to move account to parent {parent}: {e:?}"))?;
7544            }
7545        }
7546
7547        if !tags.is_empty() {
7548            org.set_resource_tags(&account_id, &tags);
7549        }
7550
7551        Ok(ProvisionResult::new(account_id.clone())
7552            .with("AccountId", account_id)
7553            .with("AccountName", name)
7554            .with("Email", email)
7555            .with("Arn", account_arn)
7556            .with("JoinedMethod", joined_method)
7557            .with("JoinedTimestamp", joined_timestamp)
7558            .with("Status", acct_status))
7559    }
7560
7561    /// Close the member account on stack delete. Real AWS leaves the
7562    /// account in `SUSPENDED` for 90 days; we just flip it via
7563    /// `close_account` so subsequent reads see it as suspended.
7564    fn delete_organization_account(&self, physical_id: &str) -> Result<(), String> {
7565        let mut org_lock = self.organizations_state.write();
7566        if let Some(org) = org_lock.as_mut() {
7567            let _ = org.close_account(physical_id);
7568        }
7569        Ok(())
7570    }
7571
7572    fn create_organization_policy(
7573        &self,
7574        resource: &ResourceDefinition,
7575    ) -> Result<ProvisionResult, String> {
7576        let props = &resource.properties;
7577        let name = props
7578            .get("Name")
7579            .and_then(|v| v.as_str())
7580            .unwrap_or(&resource.logical_id)
7581            .to_string();
7582        let description = props
7583            .get("Description")
7584            .and_then(|v| v.as_str())
7585            .unwrap_or("")
7586            .to_string();
7587        let policy_type = props
7588            .get("Type")
7589            .and_then(|v| v.as_str())
7590            .unwrap_or(POLICY_TYPE_SCP)
7591            .to_string();
7592        let content = props
7593            .get("Content")
7594            .map(|v| {
7595                if v.is_string() {
7596                    v.as_str().unwrap_or("").to_string()
7597                } else {
7598                    serde_json::to_string(v).unwrap_or_default()
7599                }
7600            })
7601            .unwrap_or_default();
7602        let target_ids: Vec<String> = props
7603            .get("TargetIds")
7604            .and_then(|v| v.as_array())
7605            .map(|arr| {
7606                arr.iter()
7607                    .filter_map(|t| t.as_str().map(|s| s.to_string()))
7608                    .collect()
7609            })
7610            .unwrap_or_default();
7611
7612        let mut org_lock = self.organizations_state.write();
7613        let org = org_lock
7614            .as_mut()
7615            .ok_or_else(|| "Organization not yet created".to_string())?;
7616        let id_suffix: String = Uuid::new_v4()
7617            .simple()
7618            .to_string()
7619            .chars()
7620            .take(8)
7621            .collect();
7622        let id = format!("p-{}", id_suffix);
7623        let arn = format!(
7624            "arn:aws:organizations::{}:policy/{}/{}/{}",
7625            org.management_account_id,
7626            org.org_id,
7627            policy_type.to_lowercase(),
7628            id
7629        );
7630        org.policies.insert(
7631            id.clone(),
7632            OrgPolicy {
7633                id: id.clone(),
7634                arn: arn.clone(),
7635                name: name.clone(),
7636                description,
7637                policy_type,
7638                aws_managed: false,
7639                content,
7640            },
7641        );
7642        for target in target_ids {
7643            org.attachments
7644                .entry(target)
7645                .or_default()
7646                .insert(id.clone());
7647        }
7648        Ok(ProvisionResult::new(id.clone())
7649            .with("Id", id)
7650            .with("Arn", arn)
7651            .with("Name", name))
7652    }
7653
7654    fn delete_organization_policy(&self, physical_id: &str) -> Result<(), String> {
7655        let mut org_lock = self.organizations_state.write();
7656        if let Some(org) = org_lock.as_mut() {
7657            org.policies.remove(physical_id);
7658            for attachments in org.attachments.values_mut() {
7659                attachments.remove(physical_id);
7660            }
7661        }
7662        Ok(())
7663    }
7664
7665    fn create_organization_resource_policy(
7666        &self,
7667        resource: &ResourceDefinition,
7668    ) -> Result<ProvisionResult, String> {
7669        let props = &resource.properties;
7670        let content = props
7671            .get("Content")
7672            .map(|v| {
7673                if v.is_string() {
7674                    v.as_str().unwrap_or("").to_string()
7675                } else {
7676                    serde_json::to_string(v).unwrap_or_default()
7677                }
7678            })
7679            .ok_or_else(|| "Content is required".to_string())?;
7680
7681        let mut org_lock = self.organizations_state.write();
7682        let org = org_lock
7683            .as_mut()
7684            .ok_or_else(|| "Organization not yet created".to_string())?;
7685        org.resource_policy = Some(content);
7686        let arn = format!(
7687            "arn:aws:organizations::{}:resourcepolicy/{}/rp",
7688            org.management_account_id, org.org_id
7689        );
7690        Ok(ProvisionResult::new(arn.clone()).with("Arn", arn))
7691    }
7692
7693    fn delete_organization_resource_policy(&self, _physical_id: &str) -> Result<(), String> {
7694        let mut org_lock = self.organizations_state.write();
7695        if let Some(org) = org_lock.as_mut() {
7696            org.resource_policy = None;
7697        }
7698        Ok(())
7699    }
7700
7701    fn delete_log_group(&self, physical_id: &str) -> Result<(), String> {
7702        let mut logs_accounts = self.logs_state.write();
7703        let state = logs_accounts.default_mut();
7704        // physical_id is the ARN; find the log group name
7705        let name = state
7706            .log_groups
7707            .iter()
7708            .find(|(_, g)| g.arn == physical_id)
7709            .map(|(name, _)| name.clone());
7710        if let Some(name) = name {
7711            state.log_groups.remove(&name);
7712        }
7713        Ok(())
7714    }
7715
7716    fn create_log_stream(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
7717        let props = &resource.properties;
7718        let log_group_name = props
7719            .get("LogGroupName")
7720            .and_then(|v| v.as_str())
7721            .map(parse_log_group_name)
7722            .ok_or_else(|| "LogGroupName is required".to_string())?;
7723        let log_stream_name = props
7724            .get("LogStreamName")
7725            .and_then(|v| v.as_str())
7726            .unwrap_or(&resource.logical_id)
7727            .to_string();
7728
7729        let mut logs_accounts = self.logs_state.write();
7730        let state = logs_accounts.get_or_create(&self.account_id);
7731        let group = state
7732            .log_groups
7733            .get_mut(&log_group_name)
7734            .ok_or_else(|| format!("Log group {log_group_name} does not exist"))?;
7735        let arn = format!(
7736            "arn:aws:logs:{}:{}:log-group:{}:log-stream:{}",
7737            self.region, self.account_id, log_group_name, log_stream_name
7738        );
7739        if group.log_streams.contains_key(&log_stream_name) {
7740            return Err(format!(
7741                "Log stream {log_stream_name} already exists in {log_group_name}"
7742            ));
7743        }
7744        group.log_streams.insert(
7745            log_stream_name.clone(),
7746            LogStream {
7747                name: log_stream_name.clone(),
7748                arn,
7749                creation_time: Utc::now().timestamp_millis(),
7750                first_event_timestamp: None,
7751                last_event_timestamp: None,
7752                last_ingestion_time: None,
7753                upload_sequence_token: String::new(),
7754                events: Vec::new(),
7755            },
7756        );
7757
7758        // Encode group + stream into the physical id so deletion can target both.
7759        let physical_id = format!("{log_group_name}|{log_stream_name}");
7760        Ok(ProvisionResult::new(physical_id))
7761    }
7762
7763    fn delete_log_stream(&self, physical_id: &str) -> Result<(), String> {
7764        let mut logs_accounts = self.logs_state.write();
7765        let state = logs_accounts.get_or_create(&self.account_id);
7766        if let Some((group_name, stream_name)) = physical_id.split_once('|') {
7767            if let Some(group) = state.log_groups.get_mut(group_name) {
7768                group.log_streams.remove(stream_name);
7769            }
7770        }
7771        Ok(())
7772    }
7773
7774    fn create_metric_filter(
7775        &self,
7776        resource: &ResourceDefinition,
7777    ) -> Result<ProvisionResult, String> {
7778        let props = &resource.properties;
7779        let log_group_name = props
7780            .get("LogGroupName")
7781            .and_then(|v| v.as_str())
7782            .map(parse_log_group_name)
7783            .ok_or_else(|| "LogGroupName is required".to_string())?;
7784        let filter_name = props
7785            .get("FilterName")
7786            .and_then(|v| v.as_str())
7787            .unwrap_or(&resource.logical_id)
7788            .to_string();
7789        let filter_pattern = props
7790            .get("FilterPattern")
7791            .and_then(|v| v.as_str())
7792            .unwrap_or("")
7793            .to_string();
7794
7795        let mut transformations: Vec<MetricTransformation> = Vec::new();
7796        if let Some(arr) = props
7797            .get("MetricTransformations")
7798            .and_then(|v| v.as_array())
7799        {
7800            for t in arr {
7801                let metric_name = t
7802                    .get("MetricName")
7803                    .and_then(|v| v.as_str())
7804                    .unwrap_or("")
7805                    .to_string();
7806                let metric_namespace = t
7807                    .get("MetricNamespace")
7808                    .and_then(|v| v.as_str())
7809                    .unwrap_or("")
7810                    .to_string();
7811                let metric_value = t
7812                    .get("MetricValue")
7813                    .and_then(|v| v.as_str())
7814                    .unwrap_or("1")
7815                    .to_string();
7816                let default_value = t.get("DefaultValue").and_then(|v| v.as_f64());
7817                transformations.push(MetricTransformation {
7818                    metric_name,
7819                    metric_namespace,
7820                    metric_value,
7821                    default_value,
7822                });
7823            }
7824        }
7825
7826        let mut logs_accounts = self.logs_state.write();
7827        let state = logs_accounts.get_or_create(&self.account_id);
7828        if !state.log_groups.contains_key(&log_group_name) {
7829            return Err(format!("Log group {log_group_name} does not exist"));
7830        }
7831        state
7832            .metric_filters
7833            .retain(|f| !(f.log_group_name == log_group_name && f.filter_name == filter_name));
7834        state.metric_filters.push(MetricFilter {
7835            filter_name: filter_name.clone(),
7836            filter_pattern,
7837            log_group_name: log_group_name.clone(),
7838            metric_transformations: transformations,
7839            creation_time: Utc::now().timestamp_millis(),
7840        });
7841
7842        Ok(ProvisionResult::new(format!(
7843            "{log_group_name}|{filter_name}"
7844        )))
7845    }
7846
7847    fn delete_metric_filter(&self, physical_id: &str) -> Result<(), String> {
7848        let mut logs_accounts = self.logs_state.write();
7849        let state = logs_accounts.get_or_create(&self.account_id);
7850        if let Some((group_name, filter_name)) = physical_id.split_once('|') {
7851            state
7852                .metric_filters
7853                .retain(|f| !(f.log_group_name == group_name && f.filter_name == filter_name));
7854        }
7855        Ok(())
7856    }
7857
7858    fn create_subscription_filter(
7859        &self,
7860        resource: &ResourceDefinition,
7861    ) -> Result<ProvisionResult, String> {
7862        let props = &resource.properties;
7863        let log_group_name = props
7864            .get("LogGroupName")
7865            .and_then(|v| v.as_str())
7866            .map(parse_log_group_name)
7867            .ok_or_else(|| "LogGroupName is required".to_string())?;
7868        let filter_name = props
7869            .get("FilterName")
7870            .and_then(|v| v.as_str())
7871            .unwrap_or(&resource.logical_id)
7872            .to_string();
7873        let filter_pattern = props
7874            .get("FilterPattern")
7875            .and_then(|v| v.as_str())
7876            .unwrap_or("")
7877            .to_string();
7878        let destination_arn = props
7879            .get("DestinationArn")
7880            .and_then(|v| v.as_str())
7881            .ok_or_else(|| "DestinationArn is required".to_string())?
7882            .to_string();
7883        let role_arn = props
7884            .get("RoleArn")
7885            .and_then(|v| v.as_str())
7886            .map(|s| s.to_string());
7887        let distribution = props
7888            .get("Distribution")
7889            .and_then(|v| v.as_str())
7890            .unwrap_or("ByLogStream")
7891            .to_string();
7892
7893        let mut logs_accounts = self.logs_state.write();
7894        let state = logs_accounts.get_or_create(&self.account_id);
7895        let group = state
7896            .log_groups
7897            .get_mut(&log_group_name)
7898            .ok_or_else(|| format!("Log group {log_group_name} does not exist"))?;
7899        group
7900            .subscription_filters
7901            .retain(|f| f.filter_name != filter_name);
7902        group.subscription_filters.push(SubscriptionFilter {
7903            filter_name: filter_name.clone(),
7904            log_group_name: log_group_name.clone(),
7905            filter_pattern,
7906            destination_arn,
7907            role_arn,
7908            distribution,
7909            creation_time: Utc::now().timestamp_millis(),
7910        });
7911
7912        Ok(ProvisionResult::new(format!(
7913            "{log_group_name}|{filter_name}"
7914        )))
7915    }
7916
7917    fn delete_subscription_filter(&self, physical_id: &str) -> Result<(), String> {
7918        let mut logs_accounts = self.logs_state.write();
7919        let state = logs_accounts.get_or_create(&self.account_id);
7920        if let Some((group_name, filter_name)) = physical_id.split_once('|') {
7921            if let Some(group) = state.log_groups.get_mut(group_name) {
7922                group
7923                    .subscription_filters
7924                    .retain(|f| f.filter_name != filter_name);
7925            }
7926        }
7927        Ok(())
7928    }
7929
7930    // --- Logs: Destination / ResourcePolicy / QueryDefinition / Delivery* ---
7931
7932    fn create_logs_destination(
7933        &self,
7934        resource: &ResourceDefinition,
7935    ) -> Result<ProvisionResult, String> {
7936        let props = &resource.properties;
7937        let destination_name = props
7938            .get("DestinationName")
7939            .and_then(|v| v.as_str())
7940            .ok_or("DestinationName is required")?
7941            .to_string();
7942        let target_arn = props
7943            .get("TargetArn")
7944            .and_then(|v| v.as_str())
7945            .ok_or("TargetArn is required")?
7946            .to_string();
7947        let role_arn = props
7948            .get("RoleArn")
7949            .and_then(|v| v.as_str())
7950            .ok_or("RoleArn is required")?
7951            .to_string();
7952        let access_policy = props
7953            .get("DestinationPolicy")
7954            .and_then(|v| v.as_str())
7955            .map(String::from);
7956
7957        let arn = format!(
7958            "arn:aws:logs:{}:{}:destination:{destination_name}",
7959            self.region, self.account_id
7960        );
7961        let dest = Destination {
7962            destination_name: destination_name.clone(),
7963            target_arn,
7964            role_arn,
7965            arn: arn.clone(),
7966            access_policy,
7967            creation_time: Utc::now().timestamp_millis(),
7968            tags: BTreeMap::new(),
7969        };
7970
7971        let mut logs_accounts = self.logs_state.write();
7972        let state = logs_accounts.get_or_create(&self.account_id);
7973        state.destinations.insert(destination_name.clone(), dest);
7974
7975        Ok(ProvisionResult::new(destination_name).with("Arn", arn))
7976    }
7977
7978    fn delete_logs_destination(&self, physical_id: &str) -> Result<(), String> {
7979        let mut logs_accounts = self.logs_state.write();
7980        let state = logs_accounts.get_or_create(&self.account_id);
7981        state.destinations.remove(physical_id);
7982        Ok(())
7983    }
7984
7985    fn create_logs_resource_policy(
7986        &self,
7987        resource: &ResourceDefinition,
7988    ) -> Result<ProvisionResult, String> {
7989        let props = &resource.properties;
7990        let policy_name = props
7991            .get("PolicyName")
7992            .and_then(|v| v.as_str())
7993            .ok_or("PolicyName is required")?
7994            .to_string();
7995        let policy_document = props
7996            .get("PolicyDocument")
7997            .map(|v| {
7998                if let Some(s) = v.as_str() {
7999                    s.to_string()
8000                } else {
8001                    serde_json::to_string(v).unwrap_or_default()
8002                }
8003            })
8004            .ok_or("PolicyDocument is required")?;
8005
8006        let policy = ResourcePolicy {
8007            policy_name: policy_name.clone(),
8008            policy_document,
8009            last_updated_time: Utc::now().timestamp_millis(),
8010        };
8011
8012        let mut logs_accounts = self.logs_state.write();
8013        let state = logs_accounts.get_or_create(&self.account_id);
8014        state.resource_policies.insert(policy_name.clone(), policy);
8015
8016        Ok(ProvisionResult::new(policy_name))
8017    }
8018
8019    fn delete_logs_resource_policy(&self, physical_id: &str) -> Result<(), String> {
8020        let mut logs_accounts = self.logs_state.write();
8021        let state = logs_accounts.get_or_create(&self.account_id);
8022        state.resource_policies.remove(physical_id);
8023        Ok(())
8024    }
8025
8026    fn create_logs_query_definition(
8027        &self,
8028        resource: &ResourceDefinition,
8029    ) -> Result<ProvisionResult, String> {
8030        let props = &resource.properties;
8031        let name = props
8032            .get("Name")
8033            .and_then(|v| v.as_str())
8034            .ok_or("Name is required")?
8035            .to_string();
8036        let query_string = props
8037            .get("QueryString")
8038            .and_then(|v| v.as_str())
8039            .ok_or("QueryString is required")?
8040            .to_string();
8041        let log_group_names: Vec<String> = props
8042            .get("LogGroupNames")
8043            .and_then(|v| v.as_array())
8044            .map(|arr| {
8045                arr.iter()
8046                    .filter_map(|v| v.as_str().map(String::from))
8047                    .collect()
8048            })
8049            .unwrap_or_default();
8050
8051        let id = Uuid::new_v4().to_string();
8052        let qd = QueryDefinition {
8053            query_definition_id: id.clone(),
8054            name,
8055            query_string,
8056            log_group_names,
8057            last_modified: Utc::now().timestamp_millis(),
8058        };
8059
8060        let mut logs_accounts = self.logs_state.write();
8061        let state = logs_accounts.get_or_create(&self.account_id);
8062        state.query_definitions.insert(id.clone(), qd);
8063
8064        Ok(ProvisionResult::new(id.clone()).with("QueryDefinitionId", id))
8065    }
8066
8067    fn delete_logs_query_definition(&self, physical_id: &str) -> Result<(), String> {
8068        let mut logs_accounts = self.logs_state.write();
8069        let state = logs_accounts.get_or_create(&self.account_id);
8070        state.query_definitions.remove(physical_id);
8071        Ok(())
8072    }
8073
8074    fn create_logs_delivery_destination(
8075        &self,
8076        resource: &ResourceDefinition,
8077    ) -> Result<ProvisionResult, String> {
8078        let props = &resource.properties;
8079        let name = props
8080            .get("Name")
8081            .and_then(|v| v.as_str())
8082            .ok_or("Name is required")?
8083            .to_string();
8084        let output_format = props
8085            .get("OutputFormat")
8086            .and_then(|v| v.as_str())
8087            .map(String::from);
8088        let mut configuration: BTreeMap<String, String> = BTreeMap::new();
8089        if let Some(arn) = props.get("DestinationResourceArn").and_then(|v| v.as_str()) {
8090            configuration.insert("destinationResourceArn".to_string(), arn.to_string());
8091        }
8092        if let Some(cfg) = props
8093            .get("DeliveryDestinationConfiguration")
8094            .and_then(|v| v.as_object())
8095        {
8096            for (k, v) in cfg {
8097                if let Some(s) = v.as_str() {
8098                    configuration.insert(k.clone(), s.to_string());
8099                }
8100            }
8101        }
8102        let policy = props.get("DeliveryDestinationPolicy").map(|v| {
8103            if let Some(s) = v.as_str() {
8104                s.to_string()
8105            } else {
8106                serde_json::to_string(v).unwrap_or_default()
8107            }
8108        });
8109
8110        let arn = format!(
8111            "arn:aws:logs:{}:{}:delivery-destination:{name}",
8112            self.region, self.account_id
8113        );
8114        let dd = DeliveryDestination {
8115            name: name.clone(),
8116            arn: arn.clone(),
8117            output_format,
8118            delivery_destination_configuration: configuration,
8119            tags: BTreeMap::new(),
8120            delivery_destination_policy: policy,
8121        };
8122
8123        let mut logs_accounts = self.logs_state.write();
8124        let state = logs_accounts.get_or_create(&self.account_id);
8125        state.delivery_destinations.insert(name.clone(), dd);
8126
8127        Ok(ProvisionResult::new(name).with("Arn", arn))
8128    }
8129
8130    fn delete_logs_delivery_destination(&self, physical_id: &str) -> Result<(), String> {
8131        let mut logs_accounts = self.logs_state.write();
8132        let state = logs_accounts.get_or_create(&self.account_id);
8133        state.delivery_destinations.remove(physical_id);
8134        Ok(())
8135    }
8136
8137    fn create_logs_delivery_source(
8138        &self,
8139        resource: &ResourceDefinition,
8140    ) -> Result<ProvisionResult, String> {
8141        let props = &resource.properties;
8142        let name = props
8143            .get("Name")
8144            .and_then(|v| v.as_str())
8145            .ok_or("Name is required")?
8146            .to_string();
8147        let resource_arns: Vec<String> = props
8148            .get("ResourceArn")
8149            .and_then(|v| v.as_str())
8150            .map(|s| vec![s.to_string()])
8151            .or_else(|| {
8152                props
8153                    .get("ResourceArns")
8154                    .and_then(|v| v.as_array())
8155                    .map(|arr| {
8156                        arr.iter()
8157                            .filter_map(|v| v.as_str().map(String::from))
8158                            .collect()
8159                    })
8160            })
8161            .unwrap_or_default();
8162        let log_type = props
8163            .get("LogType")
8164            .and_then(|v| v.as_str())
8165            .ok_or("LogType is required")?
8166            .to_string();
8167        let service = props
8168            .get("Service")
8169            .and_then(|v| v.as_str())
8170            .unwrap_or("")
8171            .to_string();
8172
8173        let arn = format!(
8174            "arn:aws:logs:{}:{}:delivery-source:{name}",
8175            self.region, self.account_id
8176        );
8177        let ds = DeliverySource {
8178            name: name.clone(),
8179            arn: arn.clone(),
8180            resource_arns,
8181            service,
8182            log_type,
8183            tags: BTreeMap::new(),
8184            created_at: chrono::Utc::now().timestamp_millis(),
8185        };
8186
8187        let mut logs_accounts = self.logs_state.write();
8188        let state = logs_accounts.get_or_create(&self.account_id);
8189        state.delivery_sources.insert(name.clone(), ds);
8190
8191        Ok(ProvisionResult::new(name).with("Arn", arn))
8192    }
8193
8194    fn delete_logs_delivery_source(&self, physical_id: &str) -> Result<(), String> {
8195        let mut logs_accounts = self.logs_state.write();
8196        let state = logs_accounts.get_or_create(&self.account_id);
8197        state.delivery_sources.remove(physical_id);
8198        Ok(())
8199    }
8200
8201    fn create_logs_delivery(
8202        &self,
8203        resource: &ResourceDefinition,
8204    ) -> Result<ProvisionResult, String> {
8205        let props = &resource.properties;
8206        let delivery_source_name = props
8207            .get("DeliverySourceName")
8208            .and_then(|v| v.as_str())
8209            .ok_or("DeliverySourceName is required")?
8210            .to_string();
8211        let delivery_destination_arn = props
8212            .get("DeliveryDestinationArn")
8213            .and_then(|v| v.as_str())
8214            .ok_or("DeliveryDestinationArn is required")?
8215            .to_string();
8216        // Infer destination type from the destination ARN service segment.
8217        let delivery_destination_type = if delivery_destination_arn.contains(":s3:") {
8218            "S3".to_string()
8219        } else if delivery_destination_arn.contains(":firehose:") {
8220            "FH".to_string()
8221        } else {
8222            "CWL".to_string()
8223        };
8224
8225        let id = Uuid::new_v4().simple().to_string();
8226        let arn = format!(
8227            "arn:aws:logs:{}:{}:delivery:{id}",
8228            self.region, self.account_id
8229        );
8230        let delivery = Delivery {
8231            id: id.clone(),
8232            delivery_source_name,
8233            delivery_destination_arn,
8234            delivery_destination_type,
8235            arn: arn.clone(),
8236            tags: BTreeMap::new(),
8237            field_delimiter: None,
8238            record_fields: Vec::new(),
8239            s3_delivery_configuration: None,
8240            created_at: chrono::Utc::now().timestamp_millis(),
8241        };
8242
8243        let mut logs_accounts = self.logs_state.write();
8244        let state = logs_accounts.get_or_create(&self.account_id);
8245        state.deliveries.insert(id.clone(), delivery);
8246
8247        Ok(ProvisionResult::new(id.clone())
8248            .with("DeliveryId", id)
8249            .with("Arn", arn))
8250    }
8251
8252    fn delete_logs_delivery(&self, physical_id: &str) -> Result<(), String> {
8253        let mut logs_accounts = self.logs_state.write();
8254        let state = logs_accounts.get_or_create(&self.account_id);
8255        state.deliveries.remove(physical_id);
8256        Ok(())
8257    }
8258
8259    // --- Custom Resources ---
8260
8261    /// Invoke a Lambda function synchronously via the delivery bus.
8262    fn invoke_lambda_sync(&self, function_arn: &str, payload: &str) -> Result<(), String> {
8263        let delivery = self.delivery.clone();
8264        let function_arn = function_arn.to_string();
8265        let payload = payload.to_string();
8266        std::thread::scope(|s| {
8267            s.spawn(|| {
8268                let rt = tokio::runtime::Builder::new_current_thread()
8269                    .enable_all()
8270                    .build()
8271                    .map_err(|e| format!("Failed to create runtime: {e}"))?;
8272                rt.block_on(async {
8273                    match delivery.invoke_lambda(&function_arn, &payload).await {
8274                        Some(Ok(_)) => {
8275                            tracing::info!(
8276                                "Custom resource Lambda {} invoked successfully",
8277                                function_arn
8278                            );
8279                            Ok(())
8280                        }
8281                        Some(Err(e)) => {
8282                            tracing::warn!(
8283                                "Custom resource Lambda {} invocation failed: {e}",
8284                                function_arn
8285                            );
8286                            Err(format!("Lambda invocation failed: {e}"))
8287                        }
8288                        None => {
8289                            tracing::warn!(
8290                                "No Lambda delivery configured; skipping custom resource invocation for {}",
8291                                function_arn
8292                            );
8293                            Ok(())
8294                        }
8295                    }
8296                })
8297            })
8298            .join()
8299            .map_err(|_| "Lambda invocation thread panicked".to_string())?
8300        })
8301    }
8302
8303    fn create_custom_resource(&self, resource: &ResourceDefinition) -> Result<String, String> {
8304        let props = &resource.properties;
8305        let service_token = props
8306            .get("ServiceToken")
8307            .and_then(|v| v.as_str())
8308            .ok_or("Custom resource requires ServiceToken property")?;
8309
8310        let request_id = Uuid::new_v4().to_string();
8311
8312        // Build the CloudFormation custom resource event
8313        let event = serde_json::json!({
8314            "RequestType": "Create",
8315            "ServiceToken": service_token,
8316            "StackId": self.stack_id,
8317            "RequestId": request_id,
8318            "ResourceType": resource.resource_type,
8319            "LogicalResourceId": resource.logical_id,
8320            "ResourceProperties": props,
8321        });
8322
8323        let payload = serde_json::to_string(&event).map_err(|e| e.to_string())?;
8324        self.invoke_lambda_sync(service_token, &payload)?;
8325
8326        // Physical resource ID: use a generated ID (the Lambda could return one,
8327        // but for simplicity we generate one here).
8328        let physical_id = format!("{}-{}", resource.logical_id, &request_id[..8]);
8329        Ok(physical_id)
8330    }
8331
8332    fn delete_custom_resource(&self, resource: &StackResource) -> Result<(), String> {
8333        let service_token = match &resource.service_token {
8334            Some(token) => token.clone(),
8335            None => {
8336                // No ServiceToken stored — nothing to invoke
8337                return Ok(());
8338            }
8339        };
8340
8341        let request_id = Uuid::new_v4().to_string();
8342
8343        let event = serde_json::json!({
8344            "RequestType": "Delete",
8345            "ServiceToken": service_token,
8346            "StackId": self.stack_id,
8347            "RequestId": request_id,
8348            "ResourceType": resource.resource_type,
8349            "LogicalResourceId": resource.logical_id,
8350            "PhysicalResourceId": resource.physical_id,
8351        });
8352
8353        let payload = serde_json::to_string(&event).map_err(|e| e.to_string())?;
8354
8355        // Best-effort: don't fail stack deletion if Lambda invocation fails
8356        if let Err(e) = self.invoke_lambda_sync(&service_token, &payload) {
8357            tracing::warn!(
8358                "Custom resource delete Lambda invocation failed for {}: {e}",
8359                resource.logical_id
8360            );
8361        }
8362        Ok(())
8363    }
8364
8365    // --- Application Auto Scaling ---
8366
8367    fn create_application_autoscaling_scalable_target(
8368        &self,
8369        resource: &ResourceDefinition,
8370    ) -> Result<ProvisionResult, String> {
8371        let props = &resource.properties;
8372        let service_namespace = props
8373            .get("ServiceNamespace")
8374            .and_then(|v| v.as_str())
8375            .ok_or_else(|| "ServiceNamespace is required".to_string())?
8376            .to_string();
8377        let resource_id = props
8378            .get("ResourceId")
8379            .and_then(|v| v.as_str())
8380            .ok_or_else(|| "ResourceId is required".to_string())?
8381            .to_string();
8382        let scalable_dimension = props
8383            .get("ScalableDimension")
8384            .and_then(|v| v.as_str())
8385            .ok_or_else(|| "ScalableDimension is required".to_string())?
8386            .to_string();
8387        let min_capacity = props
8388            .get("MinCapacity")
8389            .and_then(|v| v.as_i64())
8390            .map(|n| n as i32)
8391            .ok_or_else(|| "MinCapacity is required".to_string())?;
8392        let max_capacity = props
8393            .get("MaxCapacity")
8394            .and_then(|v| v.as_i64())
8395            .map(|n| n as i32)
8396            .ok_or_else(|| "MaxCapacity is required".to_string())?;
8397        if min_capacity > max_capacity {
8398            return Err("MinCapacity must be <= MaxCapacity".to_string());
8399        }
8400        let role_arn = props
8401            .get("RoleARN")
8402            .and_then(|v| v.as_str())
8403            .map(|s| s.to_string());
8404        let suspended_state = props.get("SuspendedState").map(|v| AppasSuspendedState {
8405            dynamic_scaling_in_suspended: v
8406                .get("DynamicScalingInSuspended")
8407                .and_then(|x| x.as_bool()),
8408            dynamic_scaling_out_suspended: v
8409                .get("DynamicScalingOutSuspended")
8410                .and_then(|x| x.as_bool()),
8411            scheduled_scaling_suspended: v
8412                .get("ScheduledScalingSuspended")
8413                .and_then(|x| x.as_bool()),
8414        });
8415
8416        let arn = format!(
8417            "arn:aws:application-autoscaling:{}:{}:scalable-target/{}",
8418            self.region,
8419            self.account_id,
8420            &Uuid::new_v4().simple().to_string()[..10]
8421        );
8422        let role = role_arn.unwrap_or_else(|| {
8423            let suffix = match service_namespace.as_str() {
8424                "ecs" => "ECSService",
8425                "elasticmapreduce" => "EMRContainerService",
8426                "ec2" => "EC2SpotFleetRequest",
8427                "appstream" => "ApplicationAutoScaling_AppStreamFleet",
8428                "dynamodb" => "DynamoDBTable",
8429                "rds" => "RDSCluster",
8430                "sagemaker" => "SageMakerEndpoint",
8431                "lambda" => "LambdaConcurrency",
8432                "elasticache" => "ElastiCacheRG",
8433                "cassandra" => "CassandraTable",
8434                "kafka" => "KafkaCluster",
8435                _ => "ApplicationAutoScaling_Default",
8436            };
8437            format!(
8438                "arn:aws:iam::{}:role/aws-service-role/applicationautoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_{}",
8439                self.account_id, suffix
8440            )
8441        });
8442
8443        let mut state = self.app_autoscaling_state.write();
8444        let account = state.accounts.entry(self.account_id.clone()).or_default();
8445        let key = (
8446            service_namespace.clone(),
8447            resource_id.clone(),
8448            scalable_dimension.clone(),
8449        );
8450        let target = AppasScalableTarget {
8451            arn: arn.clone(),
8452            service_namespace: service_namespace.clone(),
8453            resource_id: resource_id.clone(),
8454            scalable_dimension: scalable_dimension.clone(),
8455            min_capacity,
8456            max_capacity,
8457            role_arn: role,
8458            creation_time: Utc::now(),
8459            suspended_state,
8460            predicted_capacity: None,
8461        };
8462        account.scalable_targets.insert(key, target);
8463
8464        Ok(ProvisionResult::new(resource_id.clone())
8465            .with("ScalableTargetARN", arn)
8466            .with("ServiceNamespace", service_namespace)
8467            .with("ScalableDimension", scalable_dimension))
8468    }
8469
8470    fn create_application_autoscaling_scaling_policy(
8471        &self,
8472        resource: &ResourceDefinition,
8473    ) -> Result<ProvisionResult, String> {
8474        let props = &resource.properties;
8475        let policy_name = props
8476            .get("PolicyName")
8477            .and_then(|v| v.as_str())
8478            .ok_or_else(|| "PolicyName is required".to_string())?
8479            .to_string();
8480        let service_namespace = props
8481            .get("ServiceNamespace")
8482            .and_then(|v| v.as_str())
8483            .ok_or_else(|| "ServiceNamespace is required".to_string())?
8484            .to_string();
8485        let resource_id = props
8486            .get("ResourceId")
8487            .and_then(|v| v.as_str())
8488            .ok_or_else(|| "ResourceId is required".to_string())?
8489            .to_string();
8490        let scalable_dimension = props
8491            .get("ScalableDimension")
8492            .and_then(|v| v.as_str())
8493            .ok_or_else(|| "ScalableDimension is required".to_string())?
8494            .to_string();
8495        let policy_type = props
8496            .get("PolicyType")
8497            .and_then(|v| v.as_str())
8498            .unwrap_or("StepScaling")
8499            .to_string();
8500        let step_cfg = props.get("StepScalingPolicyConfiguration").cloned();
8501        let tt_cfg = props
8502            .get("TargetTrackingScalingPolicyConfiguration")
8503            .cloned();
8504        let pred_cfg = props.get("PredictiveScalingPolicyConfiguration").cloned();
8505
8506        let target_key = (
8507            service_namespace.clone(),
8508            resource_id.clone(),
8509            scalable_dimension.clone(),
8510        );
8511        let policy_key = (
8512            service_namespace.clone(),
8513            resource_id.clone(),
8514            scalable_dimension.clone(),
8515            policy_name.clone(),
8516        );
8517
8518        let mut state = self.app_autoscaling_state.write();
8519        let account = state.accounts.entry(self.account_id.clone()).or_default();
8520        if !account.scalable_targets.contains_key(&target_key) {
8521            return Err(format!(
8522                "No scalable target registered for ServiceNamespace={} ResourceId={} ScalableDimension={}",
8523                service_namespace, resource_id, scalable_dimension
8524            ));
8525        }
8526        let arn = format!(
8527            "arn:aws:autoscaling:{}:{}:scalingPolicy:{}:resource/{}/{}:policyName/{}",
8528            self.region,
8529            self.account_id,
8530            Uuid::new_v4(),
8531            service_namespace,
8532            resource_id,
8533            policy_name
8534        );
8535        let policy = AppasScalingPolicy {
8536            arn: arn.clone(),
8537            policy_name: policy_name.clone(),
8538            service_namespace: service_namespace.clone(),
8539            resource_id: resource_id.clone(),
8540            scalable_dimension: scalable_dimension.clone(),
8541            policy_type: policy_type.clone(),
8542            creation_time: Utc::now(),
8543            step_scaling_policy_configuration: step_cfg,
8544            target_tracking_scaling_policy_configuration: tt_cfg,
8545            predictive_scaling_policy_configuration: pred_cfg,
8546            alarms: Vec::new(),
8547            last_applied_at: None,
8548        };
8549        account.scaling_policies.insert(policy_key, policy);
8550
8551        Ok(ProvisionResult::new(arn.clone())
8552            .with("PolicyName", policy_name)
8553            .with("ServiceNamespace", service_namespace)
8554            .with("ResourceId", resource_id)
8555            .with("ScalableDimension", scalable_dimension))
8556    }
8557
8558    fn delete_application_autoscaling_scalable_target(
8559        &self,
8560        physical_id: &str,
8561        attributes: &BTreeMap<String, String>,
8562    ) -> Result<(), String> {
8563        let namespace = attributes
8564            .get("ServiceNamespace")
8565            .cloned()
8566            .ok_or_else(|| "ServiceNamespace missing in attributes".to_string())?;
8567        let resource_id = physical_id.to_string();
8568        let dimension = attributes
8569            .get("ScalableDimension")
8570            .cloned()
8571            .ok_or_else(|| "ScalableDimension missing in attributes".to_string())?;
8572        let key = (namespace, resource_id.clone(), dimension);
8573
8574        let mut state = self.app_autoscaling_state.write();
8575        let account = state.accounts.entry(self.account_id.clone()).or_default();
8576        account.scalable_targets.remove(&key);
8577        account
8578            .scaling_policies
8579            .retain(|k, _| !(k.0 == key.0 && k.1 == key.1 && k.2 == key.2));
8580        account
8581            .scheduled_actions
8582            .retain(|k, _| !(k.0 == key.0 && k.1 == key.1 && k.2 == key.2));
8583        Ok(())
8584    }
8585
8586    fn delete_application_autoscaling_scaling_policy(
8587        &self,
8588        _physical_id: &str,
8589        attributes: &BTreeMap<String, String>,
8590    ) -> Result<(), String> {
8591        let policy_name = attributes
8592            .get("PolicyName")
8593            .cloned()
8594            .ok_or_else(|| "PolicyName missing in attributes".to_string())?;
8595        let namespace = attributes
8596            .get("ServiceNamespace")
8597            .cloned()
8598            .ok_or_else(|| "ServiceNamespace missing in attributes".to_string())?;
8599        let resource_id = attributes
8600            .get("ResourceId")
8601            .cloned()
8602            .ok_or_else(|| "ResourceId missing in attributes".to_string())?;
8603        let dimension = attributes
8604            .get("ScalableDimension")
8605            .cloned()
8606            .ok_or_else(|| "ScalableDimension missing in attributes".to_string())?;
8607        let key = (namespace, resource_id, dimension, policy_name);
8608
8609        let mut state = self.app_autoscaling_state.write();
8610        let account = state.accounts.entry(self.account_id.clone()).or_default();
8611        account.scaling_policies.remove(&key);
8612        Ok(())
8613    }
8614
8615    // --- Firehose ---
8616
8617    fn create_firehose_delivery_stream(
8618        &self,
8619        resource: &ResourceDefinition,
8620    ) -> Result<ProvisionResult, String> {
8621        let props = &resource.properties;
8622        let name = props
8623            .get("DeliveryStreamName")
8624            .and_then(|v| v.as_str())
8625            .unwrap_or(&resource.logical_id)
8626            .to_string();
8627
8628        let arn = format!(
8629            "arn:aws:firehose:{}:{}:deliverystream/{}",
8630            self.region, self.account_id, name
8631        );
8632        let stream_type = props
8633            .get("DeliveryStreamType")
8634            .and_then(|v| v.as_str())
8635            .unwrap_or("DirectPut")
8636            .to_string();
8637
8638        let has_s3 = props.get("S3DestinationConfiguration").is_some();
8639        let has_extended_s3 = props.get("ExtendedS3DestinationConfiguration").is_some();
8640        if has_s3 && has_extended_s3 {
8641            return Err("Only one of S3DestinationConfiguration or ExtendedS3DestinationConfiguration may be set".to_string());
8642        }
8643        let destination = Some(if let Some(s3) = props.get("S3DestinationConfiguration") {
8644            parse_firehose_s3_destination(s3)?
8645        } else if let Some(s3) = props.get("ExtendedS3DestinationConfiguration") {
8646            parse_firehose_s3_destination(s3)?
8647        } else {
8648            return Err("Delivery stream requires a destination configuration".to_string());
8649        });
8650
8651        let mut tags = BTreeMap::new();
8652        if let Some(arr) = props.get("Tags").and_then(|v| v.as_array()) {
8653            for tag in arr {
8654                if let (Some(k), Some(v)) = (
8655                    tag.get("Key").and_then(|v| v.as_str()),
8656                    tag.get("Value").and_then(|v| v.as_str()),
8657                ) {
8658                    tags.insert(k.to_string(), v.to_string());
8659                }
8660            }
8661        }
8662
8663        let stream = DeliveryStream {
8664            name: name.clone(),
8665            arn: arn.clone(),
8666            status: "ACTIVE".to_string(),
8667            stream_type: stream_type.clone(),
8668            created_at: Utc::now(),
8669            last_update: Utc::now(),
8670            version_id: "1".to_string(),
8671            destination,
8672            tags,
8673        };
8674
8675        let mut state = self.firehose_state.write();
8676        let account = state.get_or_create(&self.account_id, &self.region);
8677        account
8678            .streams_mut(&self.region)
8679            .insert(name.clone(), stream);
8680
8681        let mut attributes = BTreeMap::new();
8682        attributes.insert("Arn".to_string(), arn.clone());
8683        attributes.insert("DeliveryStreamName".to_string(), name.clone());
8684
8685        Ok(ProvisionResult {
8686            physical_id: name,
8687            attributes,
8688        })
8689    }
8690
8691    fn delete_firehose_delivery_stream(&self, physical_id: &str) -> Result<(), String> {
8692        let mut state = self.firehose_state.write();
8693        let account = state.get_or_create(&self.account_id, &self.region);
8694        account.streams_mut(&self.region).remove(physical_id);
8695        Ok(())
8696    }
8697
8698    // --- Cognito ---
8699
8700    fn create_cognito_user_pool(
8701        &self,
8702        resource: &ResourceDefinition,
8703    ) -> Result<ProvisionResult, String> {
8704        let props = &resource.properties;
8705        let pool_name = props
8706            .get("PoolName")
8707            .and_then(|v| v.as_str())
8708            .unwrap_or(&resource.logical_id)
8709            .to_string();
8710
8711        let pool_id = format!(
8712            "{}_{}",
8713            self.region,
8714            Uuid::new_v4()
8715                .simple()
8716                .to_string()
8717                .chars()
8718                .take(9)
8719                .collect::<String>()
8720        );
8721        let arn = format!(
8722            "arn:aws:cognito-idp:{}:{}:userpool/{}",
8723            self.region, self.account_id, pool_id
8724        );
8725        let now = Utc::now();
8726
8727        let password_policy = parse_cognito_password_policy(props.get("Policies"));
8728        let auto_verified = parse_cognito_string_array(props.get("AutoVerifiedAttributes"));
8729        let username_attributes = props
8730            .get("UsernameAttributes")
8731            .and_then(|v| v.as_array())
8732            .map(|_| parse_cognito_string_array(props.get("UsernameAttributes")));
8733        let alias_attributes = props
8734            .get("AliasAttributes")
8735            .and_then(|v| v.as_array())
8736            .map(|_| parse_cognito_string_array(props.get("AliasAttributes")));
8737        let mut schema_attributes = default_schema_attributes();
8738        if let Some(arr) = props.get("Schema").and_then(|v| v.as_array()) {
8739            for attr in arr {
8740                if let Some(parsed) = parse_cognito_schema_attribute(attr) {
8741                    if !schema_attributes.iter().any(|a| a.name == parsed.name) {
8742                        schema_attributes.push(parsed);
8743                    }
8744                }
8745            }
8746        }
8747        let mfa_configuration = props
8748            .get("MfaConfiguration")
8749            .and_then(|v| v.as_str())
8750            .unwrap_or("OFF")
8751            .to_string();
8752        let user_pool_tier = props
8753            .get("UserPoolTier")
8754            .and_then(|v| v.as_str())
8755            .unwrap_or("ESSENTIALS")
8756            .to_string();
8757        let deletion_protection = props
8758            .get("DeletionProtection")
8759            .and_then(|v| v.as_str())
8760            .map(|s| s.to_string());
8761        let user_pool_tags = parse_cognito_tags(props.get("UserPoolTags"));
8762        let email_configuration =
8763            parse_cognito_email_configuration(props.get("EmailConfiguration"));
8764        let sms_configuration = parse_cognito_sms_configuration(props.get("SmsConfiguration"));
8765        let admin_create_user_config =
8766            parse_cognito_admin_create_user_config(props.get("AdminCreateUserConfig"));
8767        let account_recovery_setting =
8768            parse_cognito_account_recovery(props.get("AccountRecoverySetting"));
8769
8770        // Generate the RSA-2048 keypair eagerly. The kid is derived
8771        // from a SHA-256 of the public SPKI DER so it stays stable
8772        // across snapshots and matches the JWKS document.
8773        let signing = fakecloud_cognito::jwt::generate_pool_signing_key();
8774        let signing_key_pem = signing.private_key_pem;
8775        let signing_kid = signing.kid;
8776        let pool = UserPool {
8777            id: pool_id.clone(),
8778            name: pool_name,
8779            arn: arn.clone(),
8780            status: "ACTIVE".to_string(),
8781            creation_date: now,
8782            last_modified_date: now,
8783            policies: PoolPolicies {
8784                password_policy,
8785                sign_in_policy: SignInPolicy {
8786                    allowed_first_auth_factors: vec!["PASSWORD".to_string()],
8787                },
8788            },
8789            auto_verified_attributes: auto_verified,
8790            username_attributes,
8791            alias_attributes,
8792            schema_attributes,
8793            lambda_config: None,
8794            mfa_configuration,
8795            email_configuration,
8796            sms_configuration,
8797            admin_create_user_config,
8798            user_pool_tags,
8799            account_recovery_setting,
8800            deletion_protection,
8801            estimated_number_of_users: 0,
8802            software_token_mfa_configuration: None,
8803            sms_mfa_configuration: None,
8804            user_pool_tier,
8805            verification_message_template: None,
8806            signing_key_pem: Some(signing_key_pem),
8807            signing_kid: Some(signing_kid),
8808        };
8809
8810        let mut accounts = self.cognito_state.write();
8811        let state = accounts.get_or_create(&self.account_id);
8812        state.user_pools.insert(pool_id.clone(), pool);
8813
8814        let provider_name = format!("cognito-idp.{}.amazonaws.com/{}", self.region, pool_id);
8815        let provider_url = format!("https://{provider_name}");
8816
8817        Ok(ProvisionResult::new(pool_id.clone())
8818            .with("Arn", arn)
8819            .with("ProviderName", provider_name)
8820            .with("ProviderURL", provider_url)
8821            .with("UserPoolId", pool_id))
8822    }
8823
8824    fn delete_cognito_user_pool(&self, physical_id: &str) -> Result<(), String> {
8825        let mut accounts = self.cognito_state.write();
8826        let state = accounts.get_or_create(&self.account_id);
8827        state.user_pools.remove(physical_id);
8828        // Cascade: drop clients tied to this pool, plus per-pool side maps.
8829        state
8830            .user_pool_clients
8831            .retain(|_, c| c.user_pool_id != physical_id);
8832        state.users.remove(physical_id);
8833        state.groups.remove(physical_id);
8834        state.user_groups.remove(physical_id);
8835        state.identity_providers.remove(physical_id);
8836        state.resource_servers.remove(physical_id);
8837        state.import_jobs.remove(physical_id);
8838        state.domains.retain(|_, d| d.user_pool_id != physical_id);
8839        Ok(())
8840    }
8841
8842    fn create_cognito_user_pool_client(
8843        &self,
8844        resource: &ResourceDefinition,
8845    ) -> Result<ProvisionResult, String> {
8846        let props = &resource.properties;
8847        let pool_id = props
8848            .get("UserPoolId")
8849            .and_then(|v| v.as_str())
8850            .ok_or_else(|| "UserPoolId is required".to_string())?
8851            .to_string();
8852        let client_name = props
8853            .get("ClientName")
8854            .and_then(|v| v.as_str())
8855            .unwrap_or(&resource.logical_id)
8856            .to_string();
8857
8858        let mut accounts = self.cognito_state.write();
8859        let state = accounts.get_or_create(&self.account_id);
8860        if !state.user_pools.contains_key(&pool_id) {
8861            // Force CFN to retry once UserPool resource provisions.
8862            return Err(format!(
8863                "User pool {pool_id} does not exist yet — retry once it has been provisioned"
8864            ));
8865        }
8866
8867        let client_id: String = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
8868            .chars()
8869            .filter(|c| c.is_ascii_alphanumeric())
8870            .take(26)
8871            .collect::<String>()
8872            .to_lowercase();
8873        let generate_secret = props
8874            .get("GenerateSecret")
8875            .and_then(|v| v.as_bool())
8876            .unwrap_or(false);
8877        let client_secret = if generate_secret {
8878            use base64::Engine;
8879            let mut bytes = Vec::with_capacity(48);
8880            for _ in 0..3 {
8881                bytes.extend_from_slice(Uuid::new_v4().as_bytes());
8882            }
8883            Some(
8884                base64::engine::general_purpose::STANDARD
8885                    .encode(&bytes)
8886                    .chars()
8887                    .take(51)
8888                    .collect(),
8889            )
8890        } else {
8891            None
8892        };
8893
8894        let now = Utc::now();
8895        let client = UserPoolClient {
8896            client_id: client_id.clone(),
8897            client_name,
8898            user_pool_id: pool_id.clone(),
8899            client_secret: client_secret.clone(),
8900            explicit_auth_flows: parse_cognito_string_array(props.get("ExplicitAuthFlows")),
8901            token_validity_units: None,
8902            access_token_validity: props.get("AccessTokenValidity").and_then(|v| v.as_i64()),
8903            id_token_validity: props.get("IdTokenValidity").and_then(|v| v.as_i64()),
8904            refresh_token_validity: props.get("RefreshTokenValidity").and_then(|v| v.as_i64()),
8905            callback_urls: parse_cognito_string_array(props.get("CallbackURLs")),
8906            logout_urls: parse_cognito_string_array(props.get("LogoutURLs")),
8907            supported_identity_providers: parse_cognito_string_array(
8908                props.get("SupportedIdentityProviders"),
8909            ),
8910            allowed_o_auth_flows: parse_cognito_string_array(props.get("AllowedOAuthFlows")),
8911            allowed_o_auth_scopes: parse_cognito_string_array(props.get("AllowedOAuthScopes")),
8912            allowed_o_auth_flows_user_pool_client: props
8913                .get("AllowedOAuthFlowsUserPoolClient")
8914                .and_then(|v| v.as_bool())
8915                .unwrap_or(false),
8916            prevent_user_existence_errors: props
8917                .get("PreventUserExistenceErrors")
8918                .and_then(|v| v.as_str())
8919                .map(|s| s.to_string()),
8920            read_attributes: parse_cognito_string_array(props.get("ReadAttributes")),
8921            write_attributes: parse_cognito_string_array(props.get("WriteAttributes")),
8922            creation_date: now,
8923            last_modified_date: now,
8924            enable_token_revocation: props
8925                .get("EnableTokenRevocation")
8926                .and_then(|v| v.as_bool())
8927                .unwrap_or(true),
8928            auth_session_validity: props.get("AuthSessionValidity").and_then(|v| v.as_i64()),
8929            client_secrets: Vec::new(),
8930            refresh_token_rotation: None,
8931        };
8932
8933        state.user_pool_clients.insert(client_id.clone(), client);
8934
8935        let mut result = ProvisionResult::new(client_id.clone())
8936            .with("ClientId", client_id.clone())
8937            .with("Name", client_id);
8938        if let Some(secret) = client_secret {
8939            result = result.with("ClientSecret", secret);
8940        }
8941        Ok(result)
8942    }
8943
8944    fn delete_cognito_user_pool_client(&self, physical_id: &str) -> Result<(), String> {
8945        let mut accounts = self.cognito_state.write();
8946        let state = accounts.get_or_create(&self.account_id);
8947        state.user_pool_clients.remove(physical_id);
8948        Ok(())
8949    }
8950
8951    fn create_cognito_user_pool_domain(
8952        &self,
8953        resource: &ResourceDefinition,
8954    ) -> Result<ProvisionResult, String> {
8955        let props = &resource.properties;
8956        let domain = props
8957            .get("Domain")
8958            .and_then(|v| v.as_str())
8959            .ok_or_else(|| "Domain is required".to_string())?
8960            .to_string();
8961        let pool_id = props
8962            .get("UserPoolId")
8963            .and_then(|v| v.as_str())
8964            .ok_or_else(|| "UserPoolId is required".to_string())?
8965            .to_string();
8966        let custom_domain_config = props
8967            .get("CustomDomainConfig")
8968            .and_then(|v| v.as_object())
8969            .and_then(|m| {
8970                m.get("CertificateArn")
8971                    .and_then(|v| v.as_str())
8972                    .map(|s| CustomDomainConfig {
8973                        certificate_arn: s.to_string(),
8974                    })
8975            });
8976
8977        let mut accounts = self.cognito_state.write();
8978        let state = accounts.get_or_create(&self.account_id);
8979        if !state.user_pools.contains_key(&pool_id) {
8980            return Err(format!(
8981                "User pool {pool_id} does not exist yet — retry once it has been provisioned"
8982            ));
8983        }
8984        if state.domains.contains_key(&domain) {
8985            return Err(format!("Domain {domain} already exists"));
8986        }
8987        state.domains.insert(
8988            domain.clone(),
8989            UserPoolDomain {
8990                user_pool_id: pool_id,
8991                domain: domain.clone(),
8992                status: "ACTIVE".to_string(),
8993                custom_domain_config: custom_domain_config.clone(),
8994                creation_date: Utc::now(),
8995            },
8996        );
8997
8998        let cloudfront_distribution = if custom_domain_config.is_some() {
8999            format!("{domain}.cloudfront.net")
9000        } else {
9001            format!("{domain}.auth.{}.amazoncognito.com", self.region)
9002        };
9003
9004        Ok(ProvisionResult::new(domain.clone())
9005            .with("Domain", domain)
9006            .with("CloudFrontDistribution", cloudfront_distribution))
9007    }
9008
9009    fn delete_cognito_user_pool_domain(&self, physical_id: &str) -> Result<(), String> {
9010        let mut accounts = self.cognito_state.write();
9011        let state = accounts.get_or_create(&self.account_id);
9012        state.domains.remove(physical_id);
9013        Ok(())
9014    }
9015
9016    fn create_cognito_identity_pool(
9017        &self,
9018        resource: &ResourceDefinition,
9019    ) -> Result<ProvisionResult, String> {
9020        let props = &resource.properties;
9021        let identity_pool_name = props
9022            .get("IdentityPoolName")
9023            .and_then(|v| v.as_str())
9024            .unwrap_or(&resource.logical_id)
9025            .to_string();
9026        let allow_unauth = props
9027            .get("AllowUnauthenticatedIdentities")
9028            .and_then(|v| v.as_bool())
9029            .unwrap_or(false);
9030        let allow_classic = props
9031            .get("AllowClassicFlow")
9032            .and_then(|v| v.as_bool())
9033            .unwrap_or(false);
9034        let developer_provider_name = props
9035            .get("DeveloperProviderName")
9036            .and_then(|v| v.as_str())
9037            .map(|s| s.to_string());
9038        let cognito_identity_providers = props
9039            .get("CognitoIdentityProviders")
9040            .and_then(|v| v.as_array())
9041            .map(|arr| {
9042                arr.iter()
9043                    .filter_map(|p| {
9044                        let obj = p.as_object()?;
9045                        let provider_name = obj
9046                            .get("ProviderName")
9047                            .and_then(|v| v.as_str())?
9048                            .to_string();
9049                        let client_id = obj.get("ClientId").and_then(|v| v.as_str())?.to_string();
9050                        let server_side_token_check = obj
9051                            .get("ServerSideTokenCheck")
9052                            .and_then(|v| v.as_bool())
9053                            .unwrap_or(false);
9054                        Some(CognitoIdentityProvider {
9055                            provider_name,
9056                            client_id,
9057                            server_side_token_check,
9058                        })
9059                    })
9060                    .collect::<Vec<_>>()
9061            })
9062            .unwrap_or_default();
9063        let open_id_connect_provider_arns =
9064            parse_cognito_string_array(props.get("OpenIdConnectProviderARNs"));
9065        let saml_provider_arns = parse_cognito_string_array(props.get("SamlProviderARNs"));
9066        let supported_login_providers = props
9067            .get("SupportedLoginProviders")
9068            .and_then(|v| v.as_object())
9069            .map(|m| {
9070                m.iter()
9071                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
9072                    .collect()
9073            })
9074            .unwrap_or_default();
9075        let identity_pool_tags = parse_cognito_tags(props.get("IdentityPoolTags"));
9076        let cognito_streams = props.get("CognitoStreams").cloned();
9077        let push_sync = props.get("PushSync").cloned();
9078
9079        // Real Cognito identity pool ids look like `<region>:<uuid>`. Match
9080        // that shape so SDKs that parse it don't choke.
9081        let identity_pool_id = format!("{}:{}", self.region, Uuid::new_v4());
9082
9083        let pool = IdentityPool {
9084            identity_pool_id: identity_pool_id.clone(),
9085            identity_pool_name,
9086            allow_unauthenticated_identities: allow_unauth,
9087            allow_classic_flow: allow_classic,
9088            developer_provider_name,
9089            cognito_identity_providers,
9090            open_id_connect_provider_arns,
9091            saml_provider_arns,
9092            supported_login_providers,
9093            cognito_streams,
9094            push_sync,
9095            identity_pool_tags,
9096            creation_date: Utc::now(),
9097        };
9098
9099        let mut accounts = self.cognito_state.write();
9100        let state = accounts.get_or_create(&self.account_id);
9101        state.identity_pools.insert(identity_pool_id.clone(), pool);
9102
9103        Ok(ProvisionResult::new(identity_pool_id.clone()).with("Name", identity_pool_id))
9104    }
9105
9106    fn delete_cognito_identity_pool(&self, physical_id: &str) -> Result<(), String> {
9107        let mut accounts = self.cognito_state.write();
9108        let state = accounts.get_or_create(&self.account_id);
9109        state.identity_pools.remove(physical_id);
9110        // Cascade: drop role attachments tied to this pool.
9111        state
9112            .identity_pool_role_attachments
9113            .retain(|_, a| a.identity_pool_id != physical_id);
9114        Ok(())
9115    }
9116
9117    fn create_cognito_identity_pool_role_attachment(
9118        &self,
9119        resource: &ResourceDefinition,
9120    ) -> Result<ProvisionResult, String> {
9121        let props = &resource.properties;
9122        let identity_pool_id = props
9123            .get("IdentityPoolId")
9124            .and_then(|v| v.as_str())
9125            .ok_or_else(|| "IdentityPoolId is required".to_string())?
9126            .to_string();
9127
9128        let mut accounts = self.cognito_state.write();
9129        let state = accounts.get_or_create(&self.account_id);
9130        if !state.identity_pools.contains_key(&identity_pool_id) {
9131            return Err(format!(
9132                "Identity pool {identity_pool_id} does not exist yet — retry once it has been provisioned"
9133            ));
9134        }
9135
9136        let roles = props
9137            .get("Roles")
9138            .and_then(|v| v.as_object())
9139            .map(|m| {
9140                m.iter()
9141                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
9142                    .collect()
9143            })
9144            .unwrap_or_default();
9145        let role_mappings = props
9146            .get("RoleMappings")
9147            .and_then(|v| v.as_object())
9148            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
9149            .unwrap_or_default();
9150
9151        let attachment_id = Uuid::new_v4().simple().to_string();
9152        let physical_id = format!("{identity_pool_id}:{attachment_id}");
9153        let attachment = IdentityPoolRoleAttachment {
9154            identity_pool_id: identity_pool_id.clone(),
9155            attachment_id,
9156            roles,
9157            role_mappings,
9158        };
9159        state
9160            .identity_pool_role_attachments
9161            .insert(physical_id.clone(), attachment);
9162
9163        Ok(ProvisionResult::new(physical_id))
9164    }
9165
9166    fn delete_cognito_identity_pool_role_attachment(
9167        &self,
9168        physical_id: &str,
9169    ) -> Result<(), String> {
9170        let mut accounts = self.cognito_state.write();
9171        let state = accounts.get_or_create(&self.account_id);
9172        state.identity_pool_role_attachments.remove(physical_id);
9173        Ok(())
9174    }
9175
9176    // --- RDS ---
9177
9178    fn create_rds_subnet_group(
9179        &self,
9180        resource: &ResourceDefinition,
9181    ) -> Result<ProvisionResult, String> {
9182        let props = &resource.properties;
9183        let name = props
9184            .get("DBSubnetGroupName")
9185            .and_then(|v| v.as_str())
9186            .unwrap_or(&resource.logical_id)
9187            .to_string();
9188        let description = props
9189            .get("DBSubnetGroupDescription")
9190            .and_then(|v| v.as_str())
9191            .unwrap_or("")
9192            .to_string();
9193        let subnet_ids: Vec<String> = props
9194            .get("SubnetIds")
9195            .and_then(|v| v.as_array())
9196            .map(|arr| {
9197                arr.iter()
9198                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
9199                    .collect()
9200            })
9201            .unwrap_or_default();
9202        let tags = parse_rds_tags(props.get("Tags"));
9203        let mut accounts = self.rds_state.write();
9204        let state = accounts.get_or_create(&self.account_id);
9205        let arn = state.db_subnet_group_arn(&name);
9206        let group = DbSubnetGroup {
9207            db_subnet_group_name: name.clone(),
9208            db_subnet_group_arn: arn.clone(),
9209            db_subnet_group_description: description,
9210            vpc_id: String::new(),
9211            subnet_ids,
9212            subnet_availability_zones: Vec::new(),
9213            tags,
9214        };
9215        state.subnet_groups.insert(name.clone(), group);
9216        Ok(ProvisionResult::new(name).with("Arn", arn))
9217    }
9218
9219    fn delete_rds_subnet_group(&self, physical_id: &str) -> Result<(), String> {
9220        let mut accounts = self.rds_state.write();
9221        let state = accounts.get_or_create(&self.account_id);
9222        state.subnet_groups.remove(physical_id);
9223        Ok(())
9224    }
9225
9226    fn create_rds_parameter_group(
9227        &self,
9228        resource: &ResourceDefinition,
9229    ) -> Result<ProvisionResult, String> {
9230        let props = &resource.properties;
9231        let name = props
9232            .get("DBParameterGroupName")
9233            .and_then(|v| v.as_str())
9234            .unwrap_or(&resource.logical_id)
9235            .to_string();
9236        let family = props
9237            .get("Family")
9238            .and_then(|v| v.as_str())
9239            .unwrap_or("postgres16")
9240            .to_string();
9241        let description = props
9242            .get("Description")
9243            .and_then(|v| v.as_str())
9244            .unwrap_or("")
9245            .to_string();
9246        let parameters: std::collections::BTreeMap<String, String> = props
9247            .get("Parameters")
9248            .and_then(|v| v.as_object())
9249            .map(|m| {
9250                m.iter()
9251                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
9252                    .collect()
9253            })
9254            .unwrap_or_default();
9255        let tags = parse_rds_tags(props.get("Tags"));
9256
9257        let mut accounts = self.rds_state.write();
9258        let state = accounts.get_or_create(&self.account_id);
9259        let arn = state.db_parameter_group_arn(&name);
9260        let group = DbParameterGroup {
9261            db_parameter_group_name: name.clone(),
9262            db_parameter_group_arn: arn.clone(),
9263            db_parameter_group_family: family,
9264            description,
9265            parameters,
9266            tags,
9267        };
9268        state.parameter_groups.insert(name.clone(), group);
9269        Ok(ProvisionResult::new(name).with("Arn", arn))
9270    }
9271
9272    fn delete_rds_parameter_group(&self, physical_id: &str) -> Result<(), String> {
9273        let mut accounts = self.rds_state.write();
9274        let state = accounts.get_or_create(&self.account_id);
9275        state.parameter_groups.remove(physical_id);
9276        Ok(())
9277    }
9278
9279    fn create_rds_cluster_parameter_group(
9280        &self,
9281        resource: &ResourceDefinition,
9282    ) -> Result<ProvisionResult, String> {
9283        let props = &resource.properties;
9284        let name = props
9285            .get("DBClusterParameterGroupName")
9286            .or_else(|| props.get("Name"))
9287            .and_then(|v| v.as_str())
9288            .unwrap_or(&resource.logical_id)
9289            .to_string();
9290        let family = props
9291            .get("Family")
9292            .and_then(|v| v.as_str())
9293            .unwrap_or("aurora-postgresql15")
9294            .to_string();
9295        let description = props
9296            .get("Description")
9297            .and_then(|v| v.as_str())
9298            .unwrap_or("")
9299            .to_string();
9300        let arn = format!(
9301            "arn:aws:rds:{}:{}:cluster-pg:{}",
9302            self.region, self.account_id, name
9303        );
9304        let entry = serde_json::json!({
9305            "DBClusterParameterGroupName": name,
9306            "DBClusterParameterGroupArn": arn,
9307            "DBParameterGroupFamily": family,
9308            "Description": description,
9309        });
9310        let mut accounts = self.rds_state.write();
9311        let state = accounts.get_or_create(&self.account_id);
9312        rds_extras_mut(state, "cluster_param_groups").insert(name.clone(), entry);
9313        Ok(ProvisionResult::new(name).with("Arn", arn))
9314    }
9315
9316    fn delete_rds_cluster_parameter_group(&self, physical_id: &str) -> Result<(), String> {
9317        let mut accounts = self.rds_state.write();
9318        let state = accounts.get_or_create(&self.account_id);
9319        if let Some(m) = state.extras.get_mut("cluster_param_groups") {
9320            m.remove(physical_id);
9321        }
9322        Ok(())
9323    }
9324
9325    fn create_rds_option_group(
9326        &self,
9327        resource: &ResourceDefinition,
9328    ) -> Result<ProvisionResult, String> {
9329        let props = &resource.properties;
9330        let name = props
9331            .get("OptionGroupName")
9332            .and_then(|v| v.as_str())
9333            .unwrap_or(&resource.logical_id)
9334            .to_string();
9335        let engine_name = props
9336            .get("EngineName")
9337            .and_then(|v| v.as_str())
9338            .unwrap_or("mysql")
9339            .to_string();
9340        let major_engine_version = props
9341            .get("MajorEngineVersion")
9342            .and_then(|v| v.as_str())
9343            .unwrap_or("8.0")
9344            .to_string();
9345        let description = props
9346            .get("OptionGroupDescription")
9347            .and_then(|v| v.as_str())
9348            .unwrap_or("")
9349            .to_string();
9350        let arn = format!(
9351            "arn:aws:rds:{}:{}:og:{}",
9352            self.region, self.account_id, name
9353        );
9354        let entry = serde_json::json!({
9355            "OptionGroupName": name,
9356            "OptionGroupArn": arn,
9357            "EngineName": engine_name,
9358            "MajorEngineVersion": major_engine_version,
9359            "OptionGroupDescription": description,
9360        });
9361        let mut accounts = self.rds_state.write();
9362        let state = accounts.get_or_create(&self.account_id);
9363        rds_extras_mut(state, "option_groups").insert(name.clone(), entry);
9364        Ok(ProvisionResult::new(name).with("Arn", arn))
9365    }
9366
9367    fn delete_rds_option_group(&self, physical_id: &str) -> Result<(), String> {
9368        let mut accounts = self.rds_state.write();
9369        let state = accounts.get_or_create(&self.account_id);
9370        if let Some(m) = state.extras.get_mut("option_groups") {
9371            m.remove(physical_id);
9372        }
9373        Ok(())
9374    }
9375
9376    fn create_rds_event_subscription(
9377        &self,
9378        resource: &ResourceDefinition,
9379    ) -> Result<ProvisionResult, String> {
9380        let props = &resource.properties;
9381        let name = props
9382            .get("SubscriptionName")
9383            .and_then(|v| v.as_str())
9384            .unwrap_or(&resource.logical_id)
9385            .to_string();
9386        let sns_topic_arn = props
9387            .get("SnsTopicArn")
9388            .and_then(|v| v.as_str())
9389            .unwrap_or("")
9390            .to_string();
9391        let entry = serde_json::json!({
9392            "CustSubscriptionId": name,
9393            "SnsTopicArn": sns_topic_arn,
9394            "Status": "active",
9395            "Enabled": props.get("Enabled").and_then(|v| v.as_bool()).unwrap_or(true),
9396        });
9397        let mut accounts = self.rds_state.write();
9398        let state = accounts.get_or_create(&self.account_id);
9399        rds_extras_mut(state, "event_subscriptions").insert(name.clone(), entry);
9400        Ok(ProvisionResult::new(name))
9401    }
9402
9403    fn delete_rds_event_subscription(&self, physical_id: &str) -> Result<(), String> {
9404        let mut accounts = self.rds_state.write();
9405        let state = accounts.get_or_create(&self.account_id);
9406        if let Some(m) = state.extras.get_mut("event_subscriptions") {
9407            m.remove(physical_id);
9408        }
9409        Ok(())
9410    }
9411
9412    fn create_rds_security_group(
9413        &self,
9414        resource: &ResourceDefinition,
9415    ) -> Result<ProvisionResult, String> {
9416        let props = &resource.properties;
9417        let name = props
9418            .get("DBSecurityGroupName")
9419            .and_then(|v| v.as_str())
9420            .unwrap_or(&resource.logical_id)
9421            .to_string();
9422        let description = props
9423            .get("GroupDescription")
9424            .and_then(|v| v.as_str())
9425            .unwrap_or("")
9426            .to_string();
9427        let entry = serde_json::json!({
9428            "DBSecurityGroupName": name,
9429            "DBSecurityGroupDescription": description,
9430            "OwnerId": self.account_id,
9431        });
9432        let mut accounts = self.rds_state.write();
9433        let state = accounts.get_or_create(&self.account_id);
9434        rds_extras_mut(state, "security_groups").insert(name.clone(), entry);
9435        Ok(ProvisionResult::new(name))
9436    }
9437
9438    fn delete_rds_security_group(&self, physical_id: &str) -> Result<(), String> {
9439        let mut accounts = self.rds_state.write();
9440        let state = accounts.get_or_create(&self.account_id);
9441        if let Some(m) = state.extras.get_mut("security_groups") {
9442            m.remove(physical_id);
9443        }
9444        Ok(())
9445    }
9446
9447    fn create_rds_db_proxy(
9448        &self,
9449        resource: &ResourceDefinition,
9450    ) -> Result<ProvisionResult, String> {
9451        let props = &resource.properties;
9452        let name = props
9453            .get("DBProxyName")
9454            .and_then(|v| v.as_str())
9455            .unwrap_or(&resource.logical_id)
9456            .to_string();
9457        let engine_family = props
9458            .get("EngineFamily")
9459            .and_then(|v| v.as_str())
9460            .unwrap_or("POSTGRESQL")
9461            .to_string();
9462        let arn = format!(
9463            "arn:aws:rds:{}:{}:db-proxy:{}",
9464            self.region, self.account_id, name
9465        );
9466        let endpoint = format!("{name}.proxy-default.{}.rds.amazonaws.com", self.region);
9467        let entry = serde_json::json!({
9468            "DBProxyName": name,
9469            "DBProxyArn": arn,
9470            "Status": "available",
9471            "EngineFamily": engine_family,
9472            "Endpoint": endpoint,
9473        });
9474        let mut accounts = self.rds_state.write();
9475        let state = accounts.get_or_create(&self.account_id);
9476        rds_extras_mut(state, "proxies").insert(name.clone(), entry);
9477        Ok(ProvisionResult::new(name)
9478            .with("DBProxyArn", arn)
9479            .with("Endpoint", endpoint))
9480    }
9481
9482    fn delete_rds_db_proxy(&self, physical_id: &str) -> Result<(), String> {
9483        let mut accounts = self.rds_state.write();
9484        let state = accounts.get_or_create(&self.account_id);
9485        if let Some(m) = state.extras.get_mut("proxies") {
9486            m.remove(physical_id);
9487        }
9488        Ok(())
9489    }
9490
9491    fn create_rds_db_instance(
9492        &self,
9493        resource: &ResourceDefinition,
9494    ) -> Result<ProvisionResult, String> {
9495        let props = &resource.properties;
9496        let identifier = props
9497            .get("DBInstanceIdentifier")
9498            .and_then(|v| v.as_str())
9499            .map(String::from)
9500            .unwrap_or_else(|| {
9501                format!(
9502                    "cfn-{}-{}",
9503                    resource.logical_id.to_lowercase(),
9504                    Uuid::new_v4().simple().to_string()[..8].to_lowercase()
9505                )
9506            });
9507        let class = props
9508            .get("DBInstanceClass")
9509            .and_then(|v| v.as_str())
9510            .unwrap_or("db.t4g.micro")
9511            .to_string();
9512        let engine = props
9513            .get("Engine")
9514            .and_then(|v| v.as_str())
9515            .unwrap_or("postgres")
9516            .to_string();
9517        let engine_version = props
9518            .get("EngineVersion")
9519            .and_then(|v| v.as_str())
9520            .unwrap_or("16.0")
9521            .to_string();
9522        let master_username = props
9523            .get("MasterUsername")
9524            .and_then(|v| v.as_str())
9525            .unwrap_or("admin")
9526            .to_string();
9527        let master_user_password = props
9528            .get("MasterUserPassword")
9529            .and_then(|v| v.as_str())
9530            .unwrap_or("")
9531            .to_string();
9532        let db_name = props
9533            .get("DBName")
9534            .and_then(|v| v.as_str())
9535            .map(String::from);
9536        let port = props
9537            .get("Port")
9538            .and_then(|v| v.as_i64())
9539            .map(|n| n as i32)
9540            .unwrap_or(5432);
9541        let allocated_storage = props
9542            .get("AllocatedStorage")
9543            .and_then(|v| {
9544                v.as_i64()
9545                    .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
9546            })
9547            .map(|n| n as i32)
9548            .unwrap_or(20);
9549        let publicly_accessible = props
9550            .get("PubliclyAccessible")
9551            .and_then(|v| v.as_bool())
9552            .unwrap_or(false);
9553        let deletion_protection = props
9554            .get("DeletionProtection")
9555            .and_then(|v| v.as_bool())
9556            .unwrap_or(false);
9557        let backup_retention_period = props
9558            .get("BackupRetentionPeriod")
9559            .and_then(|v| v.as_i64())
9560            .map(|n| n as i32)
9561            .unwrap_or(0);
9562        let multi_az = props
9563            .get("MultiAZ")
9564            .and_then(|v| v.as_bool())
9565            .unwrap_or(false);
9566        let availability_zone = props
9567            .get("AvailabilityZone")
9568            .and_then(|v| v.as_str())
9569            .map(String::from);
9570        let storage_type = props
9571            .get("StorageType")
9572            .and_then(|v| v.as_str())
9573            .map(String::from);
9574        let storage_encrypted = props
9575            .get("StorageEncrypted")
9576            .and_then(|v| v.as_bool())
9577            .unwrap_or(false);
9578        let kms_key_id = props
9579            .get("KmsKeyId")
9580            .and_then(|v| v.as_str())
9581            .map(String::from);
9582        let iam_database_authentication_enabled = props
9583            .get("EnableIAMDatabaseAuthentication")
9584            .and_then(|v| v.as_bool())
9585            .unwrap_or(false);
9586        let db_parameter_group_name = props
9587            .get("DBParameterGroupName")
9588            .and_then(|v| v.as_str())
9589            .map(String::from);
9590        let option_group_name = props
9591            .get("OptionGroupName")
9592            .and_then(|v| v.as_str())
9593            .map(String::from);
9594        let vpc_security_group_ids: Vec<String> = props
9595            .get("VPCSecurityGroups")
9596            .and_then(|v| v.as_array())
9597            .map(|arr| {
9598                arr.iter()
9599                    .filter_map(|v| v.as_str().map(String::from))
9600                    .collect()
9601            })
9602            .unwrap_or_default();
9603        let enabled_cloudwatch_logs_exports: Vec<String> = props
9604            .get("EnableCloudwatchLogsExports")
9605            .and_then(|v| v.as_array())
9606            .map(|arr| {
9607                arr.iter()
9608                    .filter_map(|v| v.as_str().map(String::from))
9609                    .collect()
9610            })
9611            .unwrap_or_default();
9612        let tags = parse_rds_tags(props.get("Tags"));
9613
9614        let mut accounts = self.rds_state.write();
9615        let state = accounts.get_or_create(&self.account_id);
9616        let arn = state.db_instance_arn(&identifier);
9617        let endpoint_address = format!(
9618            "{identifier}.cluster-fakecloud.{}.rds.amazonaws.com",
9619            state.region
9620        );
9621        let dbi_resource_id = format!("db-{}", Uuid::new_v4().simple());
9622        let inst = DbInstance {
9623            db_instance_identifier: identifier.clone(),
9624            db_instance_arn: arn.clone(),
9625            db_instance_class: class,
9626            engine,
9627            engine_version,
9628            db_instance_status: "available".to_string(),
9629            master_username,
9630            db_name,
9631            endpoint_address,
9632            port,
9633            allocated_storage,
9634            publicly_accessible,
9635            deletion_protection,
9636            created_at: Utc::now(),
9637            dbi_resource_id,
9638            master_user_password,
9639            container_id: String::new(),
9640            host_port: 0,
9641            tags,
9642            read_replica_source_db_instance_identifier: None,
9643            read_replica_db_instance_identifiers: Vec::new(),
9644            vpc_security_group_ids,
9645            db_parameter_group_name,
9646            backup_retention_period,
9647            preferred_backup_window: "03:00-04:00".to_string(),
9648            preferred_maintenance_window: None,
9649            latest_restorable_time: None,
9650            option_group_name,
9651            multi_az,
9652            pending_modified_values: None,
9653            availability_zone,
9654            storage_type,
9655            storage_encrypted,
9656            kms_key_id,
9657            iam_database_authentication_enabled,
9658            iops: props.get("Iops").and_then(|v| v.as_i64()).map(|n| n as i32),
9659            monitoring_interval: props
9660                .get("MonitoringInterval")
9661                .and_then(|v| v.as_i64())
9662                .map(|n| n as i32),
9663            monitoring_role_arn: props
9664                .get("MonitoringRoleArn")
9665                .and_then(|v| v.as_str())
9666                .map(String::from),
9667            performance_insights_enabled: props
9668                .get("EnablePerformanceInsights")
9669                .and_then(|v| v.as_bool())
9670                .unwrap_or(false),
9671            performance_insights_kms_key_id: props
9672                .get("PerformanceInsightsKMSKeyId")
9673                .and_then(|v| v.as_str())
9674                .map(String::from),
9675            performance_insights_retention_period: props
9676                .get("PerformanceInsightsRetentionPeriod")
9677                .and_then(|v| v.as_i64())
9678                .map(|n| n as i32),
9679            enabled_cloudwatch_logs_exports,
9680            ca_certificate_identifier: props
9681                .get("CACertificateIdentifier")
9682                .and_then(|v| v.as_str())
9683                .map(String::from),
9684            network_type: props
9685                .get("NetworkType")
9686                .and_then(|v| v.as_str())
9687                .map(String::from),
9688            character_set_name: props
9689                .get("CharacterSetName")
9690                .and_then(|v| v.as_str())
9691                .map(String::from),
9692            auto_minor_version_upgrade: props
9693                .get("AutoMinorVersionUpgrade")
9694                .and_then(|v| v.as_bool()),
9695            copy_tags_to_snapshot: props.get("CopyTagsToSnapshot").and_then(|v| v.as_bool()),
9696            master_user_secret_arn: None,
9697            master_user_secret_kms_key_id: props
9698                .get("MasterUserSecret")
9699                .and_then(|v| v.get("KmsKeyId"))
9700                .and_then(|v| v.as_str())
9701                .map(String::from),
9702            license_model: props
9703                .get("LicenseModel")
9704                .and_then(|v| v.as_str())
9705                .map(String::from),
9706            max_allocated_storage: props
9707                .get("MaxAllocatedStorage")
9708                .and_then(|v| v.as_i64())
9709                .map(|n| n as i32),
9710            multi_tenant: props.get("MultiTenant").and_then(|v| v.as_bool()),
9711            storage_throughput: props
9712                .get("StorageThroughput")
9713                .and_then(|v| v.as_i64())
9714                .map(|n| n as i32),
9715            tde_credential_arn: props
9716                .get("TdeCredentialArn")
9717                .and_then(|v| v.as_str())
9718                .map(String::from),
9719            delete_automated_backups: props
9720                .get("DeleteAutomatedBackups")
9721                .and_then(|v| v.as_bool()),
9722            db_security_groups: props
9723                .get("DBSecurityGroups")
9724                .and_then(|v| v.as_array())
9725                .map(|arr| {
9726                    arr.iter()
9727                        .filter_map(|v| v.as_str().map(String::from))
9728                        .collect()
9729                })
9730                .unwrap_or_default(),
9731            domain: props
9732                .get("Domain")
9733                .and_then(|v| v.as_str())
9734                .map(String::from),
9735            domain_fqdn: props
9736                .get("DomainFqdn")
9737                .and_then(|v| v.as_str())
9738                .map(String::from),
9739            domain_ou: props
9740                .get("DomainOu")
9741                .and_then(|v| v.as_str())
9742                .map(String::from),
9743            domain_iam_role_name: props
9744                .get("DomainIAMRoleName")
9745                .and_then(|v| v.as_str())
9746                .map(String::from),
9747            domain_auth_secret_arn: props
9748                .get("DomainAuthSecretArn")
9749                .and_then(|v| v.as_str())
9750                .map(String::from),
9751            domain_dns_ips: props
9752                .get("DomainDnsIps")
9753                .and_then(|v| v.as_array())
9754                .map(|arr| {
9755                    arr.iter()
9756                        .filter_map(|v| v.as_str().map(String::from))
9757                        .collect()
9758                })
9759                .unwrap_or_default(),
9760            db_cluster_identifier: props
9761                .get("DBClusterIdentifier")
9762                .and_then(|v| v.as_str())
9763                .map(String::from),
9764        };
9765        let endpoint = inst.endpoint_address.clone();
9766        let endpoint_port = inst.port;
9767        state.instances.insert(identifier.clone(), inst);
9768
9769        Ok(ProvisionResult::new(identifier.clone())
9770            .with("DBInstanceArn", arn)
9771            .with("Endpoint.Address", endpoint)
9772            .with("Endpoint.Port", endpoint_port.to_string())
9773            .with("DbiResourceId", format!("db-{identifier}")))
9774    }
9775
9776    fn delete_rds_db_instance(&self, physical_id: &str) -> Result<(), String> {
9777        let mut accounts = self.rds_state.write();
9778        let state = accounts.get_or_create(&self.account_id);
9779        state.instances.remove(physical_id);
9780        Ok(())
9781    }
9782
9783    fn create_rds_db_cluster(
9784        &self,
9785        resource: &ResourceDefinition,
9786    ) -> Result<ProvisionResult, String> {
9787        let props = &resource.properties;
9788        let identifier = props
9789            .get("DBClusterIdentifier")
9790            .and_then(|v| v.as_str())
9791            .map(String::from)
9792            .unwrap_or_else(|| {
9793                format!(
9794                    "cfn-cluster-{}-{}",
9795                    resource.logical_id.to_lowercase(),
9796                    Uuid::new_v4().simple().to_string()[..8].to_lowercase()
9797                )
9798            });
9799        let engine = props
9800            .get("Engine")
9801            .and_then(|v| v.as_str())
9802            .unwrap_or("aurora-postgresql")
9803            .to_string();
9804        let engine_version = props
9805            .get("EngineVersion")
9806            .and_then(|v| v.as_str())
9807            .map(String::from);
9808        let master_username = props
9809            .get("MasterUsername")
9810            .and_then(|v| v.as_str())
9811            .map(String::from);
9812        let port = props.get("Port").and_then(|v| v.as_i64()).unwrap_or(5432);
9813        let mut accounts = self.rds_state.write();
9814        let state = accounts.get_or_create(&self.account_id);
9815        let arn = format!(
9816            "arn:aws:rds:{}:{}:cluster:{}",
9817            state.region, state.account_id, identifier
9818        );
9819        let cluster_resource_id = format!("cluster-{}", Uuid::new_v4().simple());
9820        let endpoint = format!(
9821            "{identifier}.cluster-fakecloud.{}.rds.amazonaws.com",
9822            state.region
9823        );
9824        let reader_endpoint = format!(
9825            "{identifier}.cluster-ro-fakecloud.{}.rds.amazonaws.com",
9826            state.region
9827        );
9828        let body = serde_json::json!({
9829            "DBClusterIdentifier": identifier,
9830            "DBClusterArn": arn,
9831            "Engine": engine,
9832            "EngineVersion": engine_version,
9833            "MasterUsername": master_username,
9834            "Status": "available",
9835            "DbClusterResourceId": cluster_resource_id,
9836            "Endpoint": endpoint,
9837            "ReaderEndpoint": reader_endpoint,
9838            "Port": port,
9839            "AllocatedStorage": props.get("AllocatedStorage").and_then(|v| v.as_i64()).unwrap_or(1),
9840            "BackupRetentionPeriod": props.get("BackupRetentionPeriod").and_then(|v| v.as_i64()).unwrap_or(1),
9841            "DatabaseName": props.get("DatabaseName").and_then(|v| v.as_str()),
9842            "DBSubnetGroup": props.get("DBSubnetGroupName").and_then(|v| v.as_str()),
9843            "VpcSecurityGroupIds": props.get("VpcSecurityGroupIds").cloned().unwrap_or(serde_json::json!([])),
9844            "StorageEncrypted": props.get("StorageEncrypted").and_then(|v| v.as_bool()).unwrap_or(false),
9845            "KmsKeyId": props.get("KmsKeyId").and_then(|v| v.as_str()),
9846            "DeletionProtection": props.get("DeletionProtection").and_then(|v| v.as_bool()).unwrap_or(false),
9847            "ClusterCreateTime": Utc::now().to_rfc3339(),
9848            "EnabledCloudwatchLogsExports": props.get("EnableCloudwatchLogsExports").cloned().unwrap_or(serde_json::json!([])),
9849            "MultiAZ": false,
9850            "DBClusterMembers": [],
9851        });
9852        state
9853            .extras
9854            .entry("clusters".to_string())
9855            .or_default()
9856            .insert(identifier.clone(), body);
9857        Ok(ProvisionResult::new(identifier.clone())
9858            .with("DBClusterArn", arn)
9859            .with("Endpoint.Address", endpoint)
9860            .with("ReadEndpoint.Address", reader_endpoint)
9861            .with("Endpoint.Port", port.to_string())
9862            .with("DBClusterResourceId", cluster_resource_id))
9863    }
9864
9865    fn delete_rds_db_cluster(&self, physical_id: &str) -> Result<(), String> {
9866        let mut accounts = self.rds_state.write();
9867        let state = accounts.get_or_create(&self.account_id);
9868        if let Some(m) = state.extras.get_mut("clusters") {
9869            m.remove(physical_id);
9870        }
9871        Ok(())
9872    }
9873
9874    // --- ECS ---
9875
9876    fn create_ecs_cluster(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
9877        let props = &resource.properties;
9878        let cluster_name = props
9879            .get("ClusterName")
9880            .and_then(|v| v.as_str())
9881            .unwrap_or(&resource.logical_id)
9882            .to_string();
9883        let cluster_arn = format!(
9884            "arn:aws:ecs:{}:{}:cluster/{}",
9885            self.region, self.account_id, cluster_name
9886        );
9887        let mut cluster = EcsCluster::new(&cluster_name, cluster_arn.clone());
9888        cluster.tags = parse_ecs_tags(props.get("Tags"));
9889        cluster.capacity_providers = props
9890            .get("CapacityProviders")
9891            .and_then(|v| v.as_array())
9892            .map(|arr| {
9893                arr.iter()
9894                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
9895                    .collect()
9896            })
9897            .unwrap_or_default();
9898        if let Some(strategy) = props
9899            .get("DefaultCapacityProviderStrategy")
9900            .and_then(|v| v.as_array())
9901        {
9902            cluster.default_capacity_provider_strategy =
9903                strategy.iter().cloned().map(lowercase_first_keys).collect();
9904        }
9905        if let Some(cfg) = props.get("Configuration") {
9906            cluster.configuration = Some(lowercase_first_keys(cfg.clone()));
9907        }
9908        if let Some(settings) = props.get("ClusterSettings").and_then(|v| v.as_array()) {
9909            cluster.settings = settings.iter().cloned().map(lowercase_first_keys).collect();
9910        }
9911        if let Some(scd) = props.get("ServiceConnectDefaults") {
9912            cluster.service_connect_defaults = Some(lowercase_first_keys(scd.clone()));
9913        }
9914        let mut accounts = self.ecs_state.write();
9915        let state = accounts.get_or_create(&self.account_id);
9916        state.clusters.insert(cluster_name.clone(), cluster);
9917        Ok(ProvisionResult::new(cluster_name).with("Arn", cluster_arn))
9918    }
9919
9920    fn delete_ecs_cluster(&self, physical_id: &str) -> Result<(), String> {
9921        let mut accounts = self.ecs_state.write();
9922        let state = accounts.get_or_create(&self.account_id);
9923        state.clusters.remove(physical_id);
9924        // Cascade: drop services + tasks tied to this cluster.
9925        state.services.retain(|_, s| s.cluster_name != physical_id);
9926        state
9927            .tasks
9928            .retain(|_, t| t.cluster_arn.split('/').next_back() != Some(physical_id));
9929        Ok(())
9930    }
9931
9932    fn create_ecs_task_definition(
9933        &self,
9934        resource: &ResourceDefinition,
9935    ) -> Result<ProvisionResult, String> {
9936        let props = &resource.properties;
9937        let family = props
9938            .get("Family")
9939            .and_then(|v| v.as_str())
9940            .unwrap_or(&resource.logical_id)
9941            .to_string();
9942        // ECS DescribeTaskDefinition emits camelCase keys; CFN ships PascalCase.
9943        // Recursively lower the leading char so SDKs deserialize cleanly.
9944        let container_definitions: Vec<serde_json::Value> = props
9945            .get("ContainerDefinitions")
9946            .and_then(|v| v.as_array())
9947            .cloned()
9948            .unwrap_or_default()
9949            .into_iter()
9950            .map(lowercase_first_keys)
9951            .collect();
9952        let task_role_arn = props
9953            .get("TaskRoleArn")
9954            .and_then(|v| v.as_str())
9955            .map(|s| s.to_string());
9956        let execution_role_arn = props
9957            .get("ExecutionRoleArn")
9958            .and_then(|v| v.as_str())
9959            .map(|s| s.to_string());
9960        let network_mode = props
9961            .get("NetworkMode")
9962            .and_then(|v| v.as_str())
9963            .map(|s| s.to_string());
9964        let requires_compatibilities: Vec<String> = props
9965            .get("RequiresCompatibilities")
9966            .and_then(|v| v.as_array())
9967            .map(|arr| {
9968                arr.iter()
9969                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
9970                    .collect()
9971            })
9972            .unwrap_or_default();
9973        let cpu = props
9974            .get("Cpu")
9975            .and_then(|v| v.as_str())
9976            .map(|s| s.to_string());
9977        let memory = props
9978            .get("Memory")
9979            .and_then(|v| v.as_str())
9980            .map(|s| s.to_string());
9981        let volumes: Vec<serde_json::Value> = props
9982            .get("Volumes")
9983            .and_then(|v| v.as_array())
9984            .cloned()
9985            .unwrap_or_default()
9986            .into_iter()
9987            .map(lowercase_first_keys)
9988            .collect();
9989        let placement_constraints: Vec<serde_json::Value> = props
9990            .get("PlacementConstraints")
9991            .and_then(|v| v.as_array())
9992            .cloned()
9993            .unwrap_or_default()
9994            .into_iter()
9995            .map(lowercase_first_keys)
9996            .collect();
9997        let proxy_configuration = props
9998            .get("ProxyConfiguration")
9999            .cloned()
10000            .map(lowercase_first_keys);
10001        let ephemeral_storage = props
10002            .get("EphemeralStorage")
10003            .cloned()
10004            .map(lowercase_first_keys);
10005        let runtime_platform = props
10006            .get("RuntimePlatform")
10007            .cloned()
10008            .map(lowercase_first_keys);
10009        let tags = parse_ecs_tags(props.get("Tags"));
10010
10011        let mut accounts = self.ecs_state.write();
10012        let state = accounts.get_or_create(&self.account_id);
10013        let revision = state
10014            .next_revision
10015            .entry(family.clone())
10016            .and_modify(|n| *n += 1)
10017            .or_insert(1);
10018        let revision = *revision;
10019        let arn = format!(
10020            "arn:aws:ecs:{}:{}:task-definition/{}:{}",
10021            self.region, self.account_id, family, revision
10022        );
10023        let td = EcsTaskDefinition {
10024            family: family.clone(),
10025            revision,
10026            task_definition_arn: arn.clone(),
10027            container_definitions,
10028            status: "ACTIVE".to_string(),
10029            task_role_arn,
10030            execution_role_arn,
10031            network_mode,
10032            requires_compatibilities: requires_compatibilities.clone(),
10033            compatibilities: requires_compatibilities,
10034            cpu,
10035            memory,
10036            pid_mode: None,
10037            ipc_mode: None,
10038            volumes,
10039            placement_constraints,
10040            proxy_configuration,
10041            inference_accelerators: Vec::new(),
10042            ephemeral_storage,
10043            runtime_platform,
10044            requires_attributes: Vec::new(),
10045            registered_at: Utc::now(),
10046            registered_by: None,
10047            deregistered_at: None,
10048            tags,
10049            enable_fault_injection: props.get("EnableFaultInjection").and_then(|v| v.as_bool()),
10050        };
10051        state
10052            .task_definitions
10053            .entry(family.clone())
10054            .or_default()
10055            .insert(revision, td);
10056        Ok(ProvisionResult::new(arn.clone()).with("TaskDefinitionArn", arn))
10057    }
10058
10059    fn delete_ecs_task_definition(&self, physical_id: &str) -> Result<(), String> {
10060        // physical_id is the full task-definition ARN; family + revision
10061        // sit at the trailing segment after `/`.
10062        let Some(suffix) = physical_id.rsplit('/').next() else {
10063            return Ok(());
10064        };
10065        let Some((family, rev)) = suffix.split_once(':') else {
10066            return Ok(());
10067        };
10068        let Ok(revision) = rev.parse::<i32>() else {
10069            return Ok(());
10070        };
10071        let mut accounts = self.ecs_state.write();
10072        let state = accounts.get_or_create(&self.account_id);
10073        if let Some(revs) = state.task_definitions.get_mut(family) {
10074            if let Some(td) = revs.get_mut(&revision) {
10075                td.status = "INACTIVE".to_string();
10076                td.deregistered_at = Some(Utc::now());
10077            }
10078        }
10079        Ok(())
10080    }
10081
10082    fn create_ecs_service(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
10083        let props = &resource.properties;
10084        let service_name = props
10085            .get("ServiceName")
10086            .and_then(|v| v.as_str())
10087            .unwrap_or(&resource.logical_id)
10088            .to_string();
10089        // Cluster: default to "default" if missing; accept name or ARN.
10090        let cluster_name = props
10091            .get("Cluster")
10092            .and_then(|v| v.as_str())
10093            .map(parse_ecs_cluster_name)
10094            .unwrap_or_else(|| "default".to_string());
10095        let task_definition_arn = props
10096            .get("TaskDefinition")
10097            .and_then(|v| v.as_str())
10098            .ok_or_else(|| "TaskDefinition is required".to_string())?
10099            .to_string();
10100        let desired_count = props
10101            .get("DesiredCount")
10102            .and_then(cfn_as_i64)
10103            .map(|n| n as i32)
10104            .unwrap_or(1);
10105        let launch_type = props
10106            .get("LaunchType")
10107            .and_then(|v| v.as_str())
10108            .unwrap_or("FARGATE")
10109            .to_string();
10110        let scheduling_strategy = props
10111            .get("SchedulingStrategy")
10112            .and_then(|v| v.as_str())
10113            .unwrap_or("REPLICA")
10114            .to_string();
10115        let deployment_controller = props
10116            .get("DeploymentController")
10117            .and_then(|v| v.get("Type"))
10118            .and_then(|v| v.as_str())
10119            .unwrap_or("ECS")
10120            .to_string();
10121        let load_balancers: Vec<serde_json::Value> = props
10122            .get("LoadBalancers")
10123            .and_then(|v| v.as_array())
10124            .cloned()
10125            .unwrap_or_default()
10126            .into_iter()
10127            .map(lowercase_first_keys)
10128            .collect();
10129        let service_registries: Vec<serde_json::Value> = props
10130            .get("ServiceRegistries")
10131            .and_then(|v| v.as_array())
10132            .cloned()
10133            .unwrap_or_default()
10134            .into_iter()
10135            .map(lowercase_first_keys)
10136            .collect();
10137        let placement_constraints: Vec<serde_json::Value> = props
10138            .get("PlacementConstraints")
10139            .and_then(|v| v.as_array())
10140            .cloned()
10141            .unwrap_or_default()
10142            .into_iter()
10143            .map(lowercase_first_keys)
10144            .collect();
10145        let placement_strategy: Vec<serde_json::Value> = props
10146            .get("PlacementStrategies")
10147            .and_then(|v| v.as_array())
10148            .cloned()
10149            .unwrap_or_default()
10150            .into_iter()
10151            .map(lowercase_first_keys)
10152            .collect();
10153        let network_configuration = props
10154            .get("NetworkConfiguration")
10155            .cloned()
10156            .map(lowercase_first_keys);
10157        let role_arn = props
10158            .get("Role")
10159            .and_then(|v| v.as_str())
10160            .map(|s| s.to_string());
10161        let platform_version = props
10162            .get("PlatformVersion")
10163            .and_then(|v| v.as_str())
10164            .map(|s| s.to_string());
10165        let health_check_grace_period_seconds = props
10166            .get("HealthCheckGracePeriodSeconds")
10167            .and_then(cfn_as_i64)
10168            .map(|n| n as i32);
10169        let enable_ecs_managed_tags = props
10170            .get("EnableECSManagedTags")
10171            .and_then(|v| v.as_bool())
10172            .unwrap_or(false);
10173        let enable_execute_command = props
10174            .get("EnableExecuteCommand")
10175            .and_then(|v| v.as_bool())
10176            .unwrap_or(false);
10177        let propagate_tags = props
10178            .get("PropagateTags")
10179            .and_then(|v| v.as_str())
10180            .map(|s| s.to_string());
10181        let capacity_provider_strategy: Vec<serde_json::Value> = props
10182            .get("CapacityProviderStrategy")
10183            .and_then(|v| v.as_array())
10184            .cloned()
10185            .unwrap_or_default()
10186            .into_iter()
10187            .map(lowercase_first_keys)
10188            .collect();
10189        let availability_zone_rebalancing = props
10190            .get("AvailabilityZoneRebalancing")
10191            .and_then(|v| v.as_str())
10192            .map(|s| s.to_string());
10193        let tags = parse_ecs_tags(props.get("Tags"));
10194
10195        // Family + revision parsed from the task definition ARN tail.
10196        let (family, revision) = parse_td_arn(&task_definition_arn);
10197
10198        let mut accounts = self.ecs_state.write();
10199        let state = accounts.get_or_create(&self.account_id);
10200        if !state.clusters.contains_key(&cluster_name) {
10201            return Err(format!(
10202                "Cluster {cluster_name} does not exist yet — retry once it has been provisioned"
10203            ));
10204        }
10205        let cluster_arn = state.clusters[&cluster_name].cluster_arn.clone();
10206        let service_arn = format!(
10207            "arn:aws:ecs:{}:{}:service/{}/{}",
10208            self.region, self.account_id, cluster_name, service_name
10209        );
10210        let key = format!("{cluster_name}/{service_name}");
10211        let service = EcsService {
10212            service_name: service_name.clone(),
10213            service_arn: service_arn.clone(),
10214            cluster_name: cluster_name.clone(),
10215            cluster_arn,
10216            task_definition_arn,
10217            family,
10218            revision,
10219            desired_count,
10220            running_count: 0,
10221            pending_count: 0,
10222            launch_type,
10223            status: "ACTIVE".to_string(),
10224            scheduling_strategy,
10225            deployment_controller,
10226            minimum_healthy_percent: props
10227                .get("DeploymentConfiguration")
10228                .and_then(|v| v.get("MinimumHealthyPercent"))
10229                .and_then(cfn_as_i64)
10230                .map(|n| n as i32),
10231            maximum_percent: props
10232                .get("DeploymentConfiguration")
10233                .and_then(|v| v.get("MaximumPercent"))
10234                .and_then(cfn_as_i64)
10235                .map(|n| n as i32),
10236            circuit_breaker: None,
10237            deployments: Vec::new(),
10238            load_balancers,
10239            service_registries,
10240            placement_constraints,
10241            placement_strategy,
10242            network_configuration,
10243            tags,
10244            created_at: Utc::now(),
10245            created_by: None,
10246            role_arn,
10247            platform_version,
10248            health_check_grace_period_seconds,
10249            enable_execute_command,
10250            enable_ecs_managed_tags,
10251            propagate_tags,
10252            capacity_provider_strategy,
10253            availability_zone_rebalancing,
10254            volume_configurations: Vec::new(),
10255        };
10256        state.services.insert(key.clone(), service);
10257        if let Some(c) = state.clusters.get_mut(&cluster_name) {
10258            c.active_services_count += 1;
10259        }
10260        Ok(ProvisionResult::new(service_arn.clone())
10261            .with("Name", service_name)
10262            .with("ServiceArn", service_arn))
10263    }
10264
10265    fn delete_ecs_service(&self, physical_id: &str) -> Result<(), String> {
10266        // physical_id is full Service ARN: .../service/<cluster>/<service>
10267        let Some((cluster, service)) = parse_service_arn(physical_id) else {
10268            return Ok(());
10269        };
10270        let key = format!("{cluster}/{service}");
10271        let mut accounts = self.ecs_state.write();
10272        let state = accounts.get_or_create(&self.account_id);
10273        if state.services.remove(&key).is_some() {
10274            if let Some(c) = state.clusters.get_mut(&cluster) {
10275                if c.active_services_count > 0 {
10276                    c.active_services_count -= 1;
10277                }
10278            }
10279        }
10280        Ok(())
10281    }
10282
10283    fn create_ecs_capacity_provider(
10284        &self,
10285        resource: &ResourceDefinition,
10286    ) -> Result<ProvisionResult, String> {
10287        let props = &resource.properties;
10288        let name = props
10289            .get("Name")
10290            .and_then(|v| v.as_str())
10291            .unwrap_or(&resource.logical_id)
10292            .to_string();
10293        let arn = format!(
10294            "arn:aws:ecs:{}:{}:capacity-provider/{}",
10295            self.region, self.account_id, name
10296        );
10297        let cp = EcsCapacityProvider {
10298            name: name.clone(),
10299            arn: arn.clone(),
10300            status: "ACTIVE".to_string(),
10301            auto_scaling_group_provider: props.get("AutoScalingGroupProvider").cloned(),
10302            update_status: None,
10303            update_status_reason: None,
10304            created_at: Utc::now(),
10305            tags: parse_ecs_tags(props.get("Tags")),
10306        };
10307        let mut accounts = self.ecs_state.write();
10308        let state = accounts.get_or_create(&self.account_id);
10309        state.capacity_providers.insert(name.clone(), cp);
10310        Ok(ProvisionResult::new(name).with("Arn", arn))
10311    }
10312
10313    fn delete_ecs_capacity_provider(&self, physical_id: &str) -> Result<(), String> {
10314        let mut accounts = self.ecs_state.write();
10315        let state = accounts.get_or_create(&self.account_id);
10316        state.capacity_providers.remove(physical_id);
10317        Ok(())
10318    }
10319
10320    /// In-place update for AWS::ECS::Cluster. ClusterName is immutable —
10321    /// CFN replaces the resource if it changes — so we only refresh
10322    /// settings/configuration/capacity-provider/tags here. The physical
10323    /// id is kept stable.
10324    fn update_ecs_cluster(
10325        &self,
10326        existing: &StackResource,
10327        resource: &ResourceDefinition,
10328    ) -> Result<ProvisionResult, String> {
10329        let props = &resource.properties;
10330        let cluster_name = existing.physical_id.clone();
10331        let mut accounts = self.ecs_state.write();
10332        let state = accounts.get_or_create(&self.account_id);
10333        let cluster = state
10334            .clusters
10335            .get_mut(&cluster_name)
10336            .ok_or_else(|| format!("Cluster {cluster_name} no longer exists"))?;
10337        if let Some(settings) = props.get("ClusterSettings").and_then(|v| v.as_array()) {
10338            cluster.settings = settings.iter().cloned().map(lowercase_first_keys).collect();
10339        }
10340        if let Some(cfg) = props.get("Configuration") {
10341            cluster.configuration = Some(lowercase_first_keys(cfg.clone()));
10342        }
10343        if let Some(cps) = props.get("CapacityProviders").and_then(|v| v.as_array()) {
10344            cluster.capacity_providers = cps
10345                .iter()
10346                .filter_map(|v| v.as_str().map(|s| s.to_string()))
10347                .collect();
10348        }
10349        if let Some(strategy) = props
10350            .get("DefaultCapacityProviderStrategy")
10351            .and_then(|v| v.as_array())
10352        {
10353            cluster.default_capacity_provider_strategy =
10354                strategy.iter().cloned().map(lowercase_first_keys).collect();
10355        }
10356        if let Some(scd) = props.get("ServiceConnectDefaults") {
10357            cluster.service_connect_defaults = Some(lowercase_first_keys(scd.clone()));
10358        }
10359        if props.get("Tags").is_some() {
10360            cluster.tags = parse_ecs_tags(props.get("Tags"));
10361        }
10362        let cluster_arn = cluster.cluster_arn.clone();
10363        Ok(ProvisionResult::new(cluster_name).with("Arn", cluster_arn))
10364    }
10365
10366    /// In-place update for AWS::ECS::Service. Service ARN is keyed by
10367    /// cluster + service name, both immutable. CFN replaces the resource
10368    /// if either changes, so we only mutate task definition / desired
10369    /// count / deployment-related fields here.
10370    fn update_ecs_service(
10371        &self,
10372        existing: &StackResource,
10373        resource: &ResourceDefinition,
10374    ) -> Result<ProvisionResult, String> {
10375        let props = &resource.properties;
10376        let service_arn = existing.physical_id.clone();
10377        let Some((cluster_name, service_name)) = parse_service_arn(&service_arn) else {
10378            return Err(format!("Cannot parse service ARN: {service_arn}"));
10379        };
10380        let key = format!("{cluster_name}/{service_name}");
10381        let mut accounts = self.ecs_state.write();
10382        let state = accounts.get_or_create(&self.account_id);
10383        let svc = state
10384            .services
10385            .get_mut(&key)
10386            .ok_or_else(|| format!("Service {service_arn} no longer exists"))?;
10387        if let Some(td) = props.get("TaskDefinition").and_then(|v| v.as_str()) {
10388            svc.task_definition_arn = td.to_string();
10389            let (family, revision) = parse_td_arn(td);
10390            svc.family = family;
10391            svc.revision = revision;
10392        }
10393        if let Some(n) = props.get("DesiredCount").and_then(cfn_as_i64) {
10394            svc.desired_count = n as i32;
10395        }
10396        if let Some(s) = props.get("LaunchType").and_then(|v| v.as_str()) {
10397            svc.launch_type = s.to_string();
10398        }
10399        if let Some(s) = props.get("PlatformVersion").and_then(|v| v.as_str()) {
10400            svc.platform_version = Some(s.to_string());
10401        }
10402        if let Some(n) = props
10403            .get("HealthCheckGracePeriodSeconds")
10404            .and_then(cfn_as_i64)
10405        {
10406            svc.health_check_grace_period_seconds = Some(n as i32);
10407        }
10408        if let Some(b) = props.get("EnableExecuteCommand").and_then(|v| v.as_bool()) {
10409            svc.enable_execute_command = b;
10410        }
10411        if let Some(b) = props.get("EnableECSManagedTags").and_then(|v| v.as_bool()) {
10412            svc.enable_ecs_managed_tags = b;
10413        }
10414        if let Some(s) = props.get("PropagateTags").and_then(|v| v.as_str()) {
10415            svc.propagate_tags = Some(s.to_string());
10416        }
10417        if let Some(s) = props
10418            .get("AvailabilityZoneRebalancing")
10419            .and_then(|v| v.as_str())
10420        {
10421            svc.availability_zone_rebalancing = Some(s.to_string());
10422        }
10423        if let Some(arr) = props
10424            .get("CapacityProviderStrategy")
10425            .and_then(|v| v.as_array())
10426        {
10427            svc.capacity_provider_strategy =
10428                arr.iter().cloned().map(lowercase_first_keys).collect();
10429        }
10430        if let Some(dc) = props.get("DeploymentConfiguration") {
10431            if let Some(n) = dc.get("MinimumHealthyPercent").and_then(cfn_as_i64) {
10432                svc.minimum_healthy_percent = Some(n as i32);
10433            }
10434            if let Some(n) = dc.get("MaximumPercent").and_then(cfn_as_i64) {
10435                svc.maximum_percent = Some(n as i32);
10436            }
10437        }
10438        if let Some(arr) = props.get("LoadBalancers").and_then(|v| v.as_array()) {
10439            svc.load_balancers = arr.iter().cloned().map(lowercase_first_keys).collect();
10440        }
10441        if let Some(arr) = props.get("ServiceRegistries").and_then(|v| v.as_array()) {
10442            svc.service_registries = arr.iter().cloned().map(lowercase_first_keys).collect();
10443        }
10444        if let Some(arr) = props.get("PlacementConstraints").and_then(|v| v.as_array()) {
10445            svc.placement_constraints = arr.iter().cloned().map(lowercase_first_keys).collect();
10446        }
10447        if let Some(arr) = props.get("PlacementStrategies").and_then(|v| v.as_array()) {
10448            svc.placement_strategy = arr.iter().cloned().map(lowercase_first_keys).collect();
10449        }
10450        if let Some(nc) = props.get("NetworkConfiguration") {
10451            svc.network_configuration = Some(lowercase_first_keys(nc.clone()));
10452        }
10453        if props.get("Tags").is_some() {
10454            svc.tags = parse_ecs_tags(props.get("Tags"));
10455        }
10456        let name = svc.service_name.clone();
10457        Ok(ProvisionResult::new(service_arn.clone())
10458            .with("Name", name)
10459            .with("ServiceArn", service_arn))
10460    }
10461
10462    /// In-place update for AWS::ECS::TaskDefinition. ECS treats every
10463    /// register call as a new revision, so a CFN update produces a fresh
10464    /// revision and the physical id (the full ARN) shifts. Returning a
10465    /// new ARN tells CFN's update path that the resource was replaced.
10466    fn update_ecs_task_definition(
10467        &self,
10468        _existing: &StackResource,
10469        resource: &ResourceDefinition,
10470    ) -> Result<ProvisionResult, String> {
10471        // Delegating to create_ecs_task_definition is safe because each
10472        // call bumps the revision counter — semantically the right
10473        // behaviour for ECS.
10474        self.create_ecs_task_definition(resource)
10475    }
10476
10477    /// In-place update for AWS::ECS::CapacityProvider. Name is immutable;
10478    /// only the underlying ASG provider config and tags can change.
10479    fn update_ecs_capacity_provider(
10480        &self,
10481        existing: &StackResource,
10482        resource: &ResourceDefinition,
10483    ) -> Result<ProvisionResult, String> {
10484        let props = &resource.properties;
10485        let name = existing.physical_id.clone();
10486        let mut accounts = self.ecs_state.write();
10487        let state = accounts.get_or_create(&self.account_id);
10488        let cp = state
10489            .capacity_providers
10490            .get_mut(&name)
10491            .ok_or_else(|| format!("CapacityProvider {name} no longer exists"))?;
10492        if props.get("AutoScalingGroupProvider").is_some() {
10493            cp.auto_scaling_group_provider = props.get("AutoScalingGroupProvider").cloned();
10494        }
10495        if props.get("Tags").is_some() {
10496            cp.tags = parse_ecs_tags(props.get("Tags"));
10497        }
10498        let arn = cp.arn.clone();
10499        Ok(ProvisionResult::new(name).with("Arn", arn))
10500    }
10501
10502    fn get_att_ecs_cluster(&self, physical_id: &str, attribute: &str) -> Option<String> {
10503        let mut accounts = self.ecs_state.write();
10504        let state = accounts.get_or_create(&self.account_id);
10505        let cluster = state.clusters.get(physical_id)?;
10506        match attribute {
10507            "Arn" => Some(cluster.cluster_arn.clone()),
10508            _ => None,
10509        }
10510    }
10511
10512    fn get_att_ecs_service(&self, physical_id: &str, attribute: &str) -> Option<String> {
10513        let (cluster, service) = parse_service_arn(physical_id)?;
10514        let key = format!("{cluster}/{service}");
10515        let mut accounts = self.ecs_state.write();
10516        let state = accounts.get_or_create(&self.account_id);
10517        let svc = state.services.get(&key)?;
10518        match attribute {
10519            "Name" => Some(svc.service_name.clone()),
10520            "ServiceArn" => Some(svc.service_arn.clone()),
10521            _ => None,
10522        }
10523    }
10524
10525    fn get_att_ecs_capacity_provider(&self, physical_id: &str, attribute: &str) -> Option<String> {
10526        let mut accounts = self.ecs_state.write();
10527        let state = accounts.get_or_create(&self.account_id);
10528        let cp = state.capacity_providers.get(physical_id)?;
10529        match attribute {
10530            "Arn" => Some(cp.arn.clone()),
10531            _ => None,
10532        }
10533    }
10534
10535    // --- ACM ---
10536
10537    fn create_acm_certificate(
10538        &self,
10539        resource: &ResourceDefinition,
10540    ) -> Result<ProvisionResult, String> {
10541        let props = &resource.properties;
10542        let domain_name = props
10543            .get("DomainName")
10544            .and_then(|v| v.as_str())
10545            .ok_or_else(|| "DomainName is required".to_string())?
10546            .to_string();
10547        let sans: Vec<String> = props
10548            .get("SubjectAlternativeNames")
10549            .and_then(|v| v.as_array())
10550            .map(|arr| {
10551                arr.iter()
10552                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
10553                    .collect()
10554            })
10555            .unwrap_or_default();
10556        let key_algorithm = props
10557            .get("KeyAlgorithm")
10558            .and_then(|v| v.as_str())
10559            .unwrap_or("RSA_2048")
10560            .to_string();
10561        let validation_method = props
10562            .get("ValidationMethod")
10563            .and_then(|v| v.as_str())
10564            .unwrap_or("DNS")
10565            .to_string();
10566        let ca_arn = props
10567            .get("CertificateAuthorityArn")
10568            .and_then(|v| v.as_str())
10569            .map(|s| s.to_string());
10570        let tags = parse_acm_tags(props.get("Tags"));
10571        let cert_transparency = props
10572            .get("CertificateTransparencyLoggingPreference")
10573            .and_then(|v| v.as_str())
10574            .unwrap_or("ENABLED")
10575            .to_string();
10576
10577        // Mint a deterministic-ish ARN — ACM uses a UUID per certificate.
10578        let arn = format!(
10579            "arn:aws:acm:{}:{}:certificate/{}",
10580            self.region,
10581            self.account_id,
10582            Uuid::new_v4()
10583        );
10584        let now = Utc::now();
10585
10586        // Build a real self-signed PEM via rcgen for the cert+SANs so
10587        // GetCertificate / DescribeCertificate round-trip parseable
10588        // X.509 (matches the runtime RequestCertificate path).
10589        let mut all_names = vec![domain_name.clone()];
10590        for s in &sans {
10591            if !all_names.contains(s) {
10592                all_names.push(s.clone());
10593            }
10594        }
10595        let (cert_pem, key_pem) = rcgen::generate_simple_self_signed(all_names.clone())
10596            .map(|c| (c.cert.pem(), c.key_pair.serialize_pem()))
10597            .map(|(c, k)| (Some(c), Some(k)))
10598            .unwrap_or((None, None));
10599
10600        // CFN-provisioned certs land as ISSUED right away — real CFN
10601        // blocks until validation completes, but fakecloud doesn't run
10602        // a DNS server, so leaving the cert PENDING_VALIDATION would
10603        // wedge dependent resources (CloudFront/ELBv2) forever. The
10604        // runtime RequestCertificate path uses the async auto-issue
10605        // tick (DNS) or admin `/approve` endpoint (EMAIL) for parity
10606        // with ACM's async behaviour.
10607        let domain_validation: Vec<AcmDomainValidation> =
10608            synth_acm_domain_validation(&domain_name, &sans, &validation_method)
10609                .into_iter()
10610                .map(|mut dv| {
10611                    dv.validation_status = "SUCCESS".to_string();
10612                    dv
10613                })
10614                .collect();
10615        let renewal_summary = Some(AcmRenewalSummary {
10616            renewal_status: "PENDING_AUTO_RENEWAL".to_string(),
10617            domain_validation: domain_validation.clone(),
10618            renewal_status_reason: None,
10619            updated_at: now,
10620        });
10621        let cert = AcmStoredCertificate {
10622            arn: arn.clone(),
10623            domain_name: domain_name.clone(),
10624            subject_alternative_names: all_names,
10625            status: "ISSUED".to_string(),
10626            cert_type: "AMAZON_ISSUED".to_string(),
10627            certificate_pem: cert_pem,
10628            certificate_chain_pem: None,
10629            private_key_pem: key_pem,
10630            idempotency_token: None,
10631            serial: format!("{:032x}", Uuid::new_v4().as_u128()),
10632            subject: format!("CN={domain_name}"),
10633            issuer: "Amazon".to_string(),
10634            key_algorithm,
10635            signature_algorithm: "SHA256WITHRSA".to_string(),
10636            created_at: now,
10637            issued_at: Some(now),
10638            imported_at: None,
10639            revoked_at: None,
10640            revocation_reason: None,
10641            not_before: now,
10642            // 13 months (matches real ACM issued-cert lifetime).
10643            not_after: now + chrono::Duration::days(395),
10644            validation_method: Some(validation_method.clone()),
10645            domain_validation,
10646            options: AcmCertificateOptions {
10647                certificate_transparency_logging_preference: cert_transparency,
10648                export: "DISABLED".to_string(),
10649            },
10650            renewal_eligibility: "ELIGIBLE".to_string(),
10651            managed_by: None,
10652            certificate_authority_arn: ca_arn,
10653            tags,
10654            in_use_by: Vec::new(),
10655            describe_read_count: 0,
10656            failure_reason: None,
10657            renewal_summary,
10658        };
10659
10660        let mut accounts = self.acm_state.write();
10661        let account = accounts
10662            .accounts
10663            .entry(self.account_id.clone())
10664            .or_default();
10665        account.certificates.insert(arn.clone(), cert);
10666
10667        Ok(ProvisionResult::new(arn))
10668    }
10669
10670    fn delete_acm_certificate(&self, physical_id: &str) -> Result<(), String> {
10671        let mut accounts = self.acm_state.write();
10672        if let Some(account) = accounts.accounts.get_mut(&self.account_id) {
10673            account.certificates.remove(physical_id);
10674        }
10675        Ok(())
10676    }
10677
10678    /// Provision the singleton `AWS::CertificateManager::Account` resource —
10679    /// stores `ExpiryEventsConfiguration.DaysBeforeExpiry` on the account
10680    /// config so `GetAccountConfiguration` reflects it.
10681    fn create_acm_account(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
10682        let days = resource
10683            .properties
10684            .get("ExpiryEventsConfiguration")
10685            .and_then(|v| v.get("DaysBeforeExpiry"))
10686            .and_then(|v| v.as_i64())
10687            .map(|n| n as i32);
10688        let mut accounts = self.acm_state.write();
10689        let account = accounts
10690            .accounts
10691            .entry(self.account_id.clone())
10692            .or_default();
10693        account.account_config.expiry_events_days_before_expiry = days;
10694        Ok(ProvisionResult::new(format!(
10695            "acm-account-{}",
10696            self.account_id
10697        )))
10698    }
10699
10700    /// Reset the account config back to the default (no expiry events).
10701    /// AWS keeps the account around — the CFN deletion just clears the
10702    /// per-account override.
10703    fn delete_acm_account(&self) -> Result<(), String> {
10704        let mut accounts = self.acm_state.write();
10705        if let Some(account) = accounts.accounts.get_mut(&self.account_id) {
10706            account.account_config.expiry_events_days_before_expiry = None;
10707        }
10708        Ok(())
10709    }
10710
10711    // --- ElastiCache ---
10712
10713    fn create_ec_parameter_group(
10714        &self,
10715        resource: &ResourceDefinition,
10716    ) -> Result<ProvisionResult, String> {
10717        let props = &resource.properties;
10718        let name = props
10719            .get("CacheParameterGroupName")
10720            .and_then(|v| v.as_str())
10721            .unwrap_or(&resource.logical_id)
10722            .to_string();
10723        let family = props
10724            .get("CacheParameterGroupFamily")
10725            .and_then(|v| v.as_str())
10726            .unwrap_or("redis7")
10727            .to_string();
10728        let description = props
10729            .get("Description")
10730            .and_then(|v| v.as_str())
10731            .unwrap_or("")
10732            .to_string();
10733        let arn = format!(
10734            "arn:aws:elasticache:{}:{}:parametergroup:{}",
10735            self.region, self.account_id, name
10736        );
10737        let group = CacheParameterGroup {
10738            cache_parameter_group_name: name.clone(),
10739            cache_parameter_group_family: family,
10740            description,
10741            is_global: false,
10742            arn: arn.clone(),
10743        };
10744        let mut accounts = self.elasticache_state.write();
10745        let state = accounts.get_or_create(&self.account_id);
10746        // ParameterGroups stored as Vec — replace any existing entry.
10747        state
10748            .parameter_groups
10749            .retain(|p| p.cache_parameter_group_name != name);
10750        state.parameter_groups.push(group);
10751        Ok(ProvisionResult::new(name).with("Arn", arn))
10752    }
10753
10754    fn delete_ec_parameter_group(&self, physical_id: &str) -> Result<(), String> {
10755        let mut accounts = self.elasticache_state.write();
10756        let state = accounts.get_or_create(&self.account_id);
10757        state
10758            .parameter_groups
10759            .retain(|p| p.cache_parameter_group_name != physical_id);
10760        Ok(())
10761    }
10762
10763    fn create_ec_subnet_group(
10764        &self,
10765        resource: &ResourceDefinition,
10766    ) -> Result<ProvisionResult, String> {
10767        let props = &resource.properties;
10768        let name = props
10769            .get("CacheSubnetGroupName")
10770            .and_then(|v| v.as_str())
10771            .unwrap_or(&resource.logical_id)
10772            .to_string();
10773        let description = props
10774            .get("Description")
10775            .and_then(|v| v.as_str())
10776            .unwrap_or("")
10777            .to_string();
10778        let subnet_ids: Vec<String> = props
10779            .get("SubnetIds")
10780            .and_then(|v| v.as_array())
10781            .map(|arr| {
10782                arr.iter()
10783                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
10784                    .collect()
10785            })
10786            .unwrap_or_default();
10787        let arn = format!(
10788            "arn:aws:elasticache:{}:{}:subnetgroup:{}",
10789            self.region, self.account_id, name
10790        );
10791        let group = CacheSubnetGroup {
10792            cache_subnet_group_name: name.clone(),
10793            cache_subnet_group_description: description,
10794            vpc_id: String::new(),
10795            subnet_ids,
10796            arn: arn.clone(),
10797        };
10798        let mut accounts = self.elasticache_state.write();
10799        let state = accounts.get_or_create(&self.account_id);
10800        state.subnet_groups.insert(name.clone(), group);
10801        Ok(ProvisionResult::new(name).with("Arn", arn))
10802    }
10803
10804    fn delete_ec_subnet_group(&self, physical_id: &str) -> Result<(), String> {
10805        let mut accounts = self.elasticache_state.write();
10806        let state = accounts.get_or_create(&self.account_id);
10807        state.subnet_groups.remove(physical_id);
10808        Ok(())
10809    }
10810
10811    fn create_ec_security_group(
10812        &self,
10813        resource: &ResourceDefinition,
10814    ) -> Result<ProvisionResult, String> {
10815        let props = &resource.properties;
10816        let name = props
10817            .get("CacheSecurityGroupName")
10818            .and_then(|v| v.as_str())
10819            .unwrap_or(&resource.logical_id)
10820            .to_string();
10821        let description = props
10822            .get("Description")
10823            .and_then(|v| v.as_str())
10824            .unwrap_or("")
10825            .to_string();
10826        let arn = format!(
10827            "arn:aws:elasticache:{}:{}:securitygroup:{}",
10828            self.region, self.account_id, name
10829        );
10830        let group = CacheSecurityGroup {
10831            cache_security_group_name: name.clone(),
10832            description,
10833            owner_id: self.account_id.clone(),
10834            arn: arn.clone(),
10835            ec2_security_groups: Vec::new(),
10836        };
10837        let mut accounts = self.elasticache_state.write();
10838        let state = accounts.get_or_create(&self.account_id);
10839        state.security_groups.insert(name.clone(), group);
10840        Ok(ProvisionResult::new(name).with("Arn", arn))
10841    }
10842
10843    fn delete_ec_security_group(&self, physical_id: &str) -> Result<(), String> {
10844        let mut accounts = self.elasticache_state.write();
10845        let state = accounts.get_or_create(&self.account_id);
10846        state.security_groups.remove(physical_id);
10847        Ok(())
10848    }
10849
10850    fn create_ec_user(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
10851        let props = &resource.properties;
10852        let user_id = props
10853            .get("UserId")
10854            .and_then(|v| v.as_str())
10855            .unwrap_or(&resource.logical_id)
10856            .to_string();
10857        let user_name = props
10858            .get("UserName")
10859            .and_then(|v| v.as_str())
10860            .unwrap_or(&user_id)
10861            .to_string();
10862        let engine = props
10863            .get("Engine")
10864            .and_then(|v| v.as_str())
10865            .unwrap_or("redis")
10866            .to_string();
10867        let access_string = props
10868            .get("AccessString")
10869            .and_then(|v| v.as_str())
10870            .unwrap_or("on ~* +@all")
10871            .to_string();
10872        let authentication_type = props
10873            .get("AuthenticationMode")
10874            .and_then(|v| v.get("Type"))
10875            .and_then(|v| v.as_str())
10876            .unwrap_or("no-password-required")
10877            .to_string();
10878        let arn = format!(
10879            "arn:aws:elasticache:{}:{}:user:{}",
10880            self.region, self.account_id, user_id
10881        );
10882        let user = EcUser {
10883            user_id: user_id.clone(),
10884            user_name,
10885            engine,
10886            access_string,
10887            status: "active".to_string(),
10888            authentication_type,
10889            password_count: 0,
10890            arn: arn.clone(),
10891            minimum_engine_version: "6.0".to_string(),
10892            user_group_ids: Vec::new(),
10893        };
10894        let mut accounts = self.elasticache_state.write();
10895        let state = accounts.get_or_create(&self.account_id);
10896        state.users.insert(user_id.clone(), user);
10897        Ok(ProvisionResult::new(user_id).with("Arn", arn))
10898    }
10899
10900    fn delete_ec_user(&self, physical_id: &str) -> Result<(), String> {
10901        let mut accounts = self.elasticache_state.write();
10902        let state = accounts.get_or_create(&self.account_id);
10903        state.users.remove(physical_id);
10904        Ok(())
10905    }
10906
10907    fn create_ec_user_group(
10908        &self,
10909        resource: &ResourceDefinition,
10910    ) -> Result<ProvisionResult, String> {
10911        let props = &resource.properties;
10912        let user_group_id = props
10913            .get("UserGroupId")
10914            .and_then(|v| v.as_str())
10915            .unwrap_or(&resource.logical_id)
10916            .to_string();
10917        let engine = props
10918            .get("Engine")
10919            .and_then(|v| v.as_str())
10920            .unwrap_or("redis")
10921            .to_string();
10922        let user_ids: Vec<String> = props
10923            .get("UserIds")
10924            .and_then(|v| v.as_array())
10925            .map(|arr| {
10926                arr.iter()
10927                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
10928                    .collect()
10929            })
10930            .unwrap_or_default();
10931        let arn = format!(
10932            "arn:aws:elasticache:{}:{}:usergroup:{}",
10933            self.region, self.account_id, user_group_id
10934        );
10935        let group = EcUserGroup {
10936            user_group_id: user_group_id.clone(),
10937            engine,
10938            status: "active".to_string(),
10939            user_ids,
10940            arn: arn.clone(),
10941            minimum_engine_version: "6.0".to_string(),
10942            pending_changes: None,
10943            replication_groups: Vec::new(),
10944        };
10945        let mut accounts = self.elasticache_state.write();
10946        let state = accounts.get_or_create(&self.account_id);
10947        state.user_groups.insert(user_group_id.clone(), group);
10948        Ok(ProvisionResult::new(user_group_id).with("Arn", arn))
10949    }
10950
10951    fn delete_ec_user_group(&self, physical_id: &str) -> Result<(), String> {
10952        let mut accounts = self.elasticache_state.write();
10953        let state = accounts.get_or_create(&self.account_id);
10954        state.user_groups.remove(physical_id);
10955        Ok(())
10956    }
10957
10958    fn create_ec_cache_cluster(
10959        &self,
10960        resource: &ResourceDefinition,
10961    ) -> Result<ProvisionResult, String> {
10962        let props = &resource.properties;
10963        let id = props
10964            .get("ClusterName")
10965            .and_then(|v| v.as_str())
10966            .map(String::from)
10967            .unwrap_or_else(|| format!("cfn-cc-{}", resource.logical_id.to_lowercase()));
10968        let cache_node_type = props
10969            .get("CacheNodeType")
10970            .and_then(|v| v.as_str())
10971            .unwrap_or("cache.t4g.micro")
10972            .to_string();
10973        let engine = props
10974            .get("Engine")
10975            .and_then(|v| v.as_str())
10976            .unwrap_or("redis")
10977            .to_string();
10978        let engine_version = props
10979            .get("EngineVersion")
10980            .and_then(|v| v.as_str())
10981            .unwrap_or("7.1")
10982            .to_string();
10983        let num_cache_nodes = props
10984            .get("NumCacheNodes")
10985            .and_then(|v| v.as_i64())
10986            .map(|n| n as i32)
10987            .unwrap_or(1);
10988        let preferred_az = props
10989            .get("PreferredAvailabilityZone")
10990            .and_then(|v| v.as_str())
10991            .unwrap_or("us-east-1a")
10992            .to_string();
10993        let cache_subnet_group_name = props
10994            .get("CacheSubnetGroupName")
10995            .and_then(|v| v.as_str())
10996            .map(String::from);
10997        let auto_minor_version_upgrade = props
10998            .get("AutoMinorVersionUpgrade")
10999            .and_then(|v| v.as_bool())
11000            .unwrap_or(true);
11001        let default_port = if engine == "memcached" { 11211 } else { 6379 };
11002        let port = props
11003            .get("Port")
11004            .and_then(|v| v.as_i64())
11005            .map(|n| n as u16)
11006            .unwrap_or(default_port);
11007        let cache_parameter_group_name = props
11008            .get("CacheParameterGroupName")
11009            .and_then(|v| v.as_str())
11010            .map(String::from);
11011        let security_group_ids: Vec<String> = props
11012            .get("VpcSecurityGroupIds")
11013            .and_then(|v| v.as_array())
11014            .map(|arr| {
11015                arr.iter()
11016                    .filter_map(|v| v.as_str().map(String::from))
11017                    .collect()
11018            })
11019            .unwrap_or_default();
11020        let cache_security_group_names: Vec<String> = props
11021            .get("CacheSecurityGroupNames")
11022            .and_then(|v| v.as_array())
11023            .map(|arr| {
11024                arr.iter()
11025                    .filter_map(|v| v.as_str().map(String::from))
11026                    .collect()
11027            })
11028            .unwrap_or_default();
11029        let preferred_availability_zones: Vec<String> = props
11030            .get("PreferredAvailabilityZones")
11031            .and_then(|v| v.as_array())
11032            .map(|arr| {
11033                arr.iter()
11034                    .filter_map(|v| v.as_str().map(String::from))
11035                    .collect()
11036            })
11037            .unwrap_or_default();
11038        let snapshot_arns: Vec<String> = props
11039            .get("SnapshotArns")
11040            .and_then(|v| v.as_array())
11041            .map(|arr| {
11042                arr.iter()
11043                    .filter_map(|v| v.as_str().map(String::from))
11044                    .collect()
11045            })
11046            .unwrap_or_default();
11047        let snapshot_name = props
11048            .get("SnapshotName")
11049            .and_then(|v| v.as_str())
11050            .map(String::from);
11051        let snapshot_retention_limit = props
11052            .get("SnapshotRetentionLimit")
11053            .and_then(|v| v.as_i64())
11054            .map(|n| n as i32)
11055            .unwrap_or(0);
11056        let snapshot_window = props
11057            .get("SnapshotWindow")
11058            .and_then(|v| v.as_str())
11059            .map(String::from);
11060        let preferred_maintenance_window = props
11061            .get("PreferredMaintenanceWindow")
11062            .and_then(|v| v.as_str())
11063            .map(String::from);
11064        let notification_topic_arn = props
11065            .get("NotificationTopicArn")
11066            .and_then(|v| v.as_str())
11067            .map(String::from);
11068        let transit_encryption_enabled = props
11069            .get("TransitEncryptionEnabled")
11070            .and_then(|v| v.as_bool())
11071            .unwrap_or(false);
11072        let auth_token = props
11073            .get("AuthToken")
11074            .and_then(|v| v.as_str())
11075            .filter(|s| !s.is_empty())
11076            .map(String::from);
11077        let auth_token_enabled = auth_token.is_some();
11078        let network_type = props
11079            .get("NetworkType")
11080            .and_then(|v| v.as_str())
11081            .map(String::from)
11082            .or_else(|| Some("ipv4".to_string()));
11083        let ip_discovery = props
11084            .get("IpDiscovery")
11085            .and_then(|v| v.as_str())
11086            .map(String::from)
11087            .or_else(|| Some("ipv4".to_string()));
11088        let az_mode = props
11089            .get("AZMode")
11090            .and_then(|v| v.as_str())
11091            .map(String::from)
11092            .or_else(|| Some("single-az".to_string()));
11093        let outpost_mode = props
11094            .get("OutpostMode")
11095            .and_then(|v| v.as_str())
11096            .map(String::from);
11097        let preferred_outpost_arn = props
11098            .get("PreferredOutpostArn")
11099            .and_then(|v| v.as_str())
11100            .map(String::from);
11101
11102        let mut accounts = self.elasticache_state.write();
11103        let state = accounts.get_or_create(&self.account_id);
11104        let arn = format!(
11105            "arn:aws:elasticache:{}:{}:cluster:{}",
11106            state.region, state.account_id, id
11107        );
11108        let endpoint_address = format!("{id}.fakecloud.{}.cache.amazonaws.com", state.region);
11109        let cluster = EcCacheCluster {
11110            cache_cluster_id: id.clone(),
11111            cache_node_type,
11112            engine,
11113            engine_version,
11114            cache_cluster_status: "available".to_string(),
11115            num_cache_nodes,
11116            preferred_availability_zone: preferred_az,
11117            cache_subnet_group_name,
11118            auto_minor_version_upgrade,
11119            arn: arn.clone(),
11120            created_at: Utc::now().to_rfc3339(),
11121            endpoint_address: endpoint_address.clone(),
11122            endpoint_port: port,
11123            container_id: String::new(),
11124            host_port: 0,
11125            replication_group_id: None,
11126            cache_parameter_group_name,
11127            security_group_ids,
11128            log_delivery_configurations: Vec::new(),
11129            transit_encryption_enabled,
11130            at_rest_encryption_enabled: false,
11131            auth_token_enabled,
11132            port,
11133            preferred_maintenance_window,
11134            preferred_availability_zones,
11135            notification_topic_arn,
11136            cache_security_group_names,
11137            snapshot_arns,
11138            snapshot_name,
11139            snapshot_retention_limit,
11140            snapshot_window,
11141            outpost_mode,
11142            preferred_outpost_arn,
11143            network_type,
11144            ip_discovery,
11145            az_mode,
11146            auth_token,
11147            kms_key_id: None,
11148            transit_encryption_mode: None,
11149            data_tiering_enabled: None,
11150            cluster_mode: None,
11151            preferred_outpost_arns: Vec::new(),
11152        };
11153        state.cache_clusters.insert(id.clone(), cluster);
11154        Ok(ProvisionResult::new(id.clone())
11155            .with("Arn", arn)
11156            .with("RedisEndpoint.Address", endpoint_address.clone())
11157            .with("RedisEndpoint.Port", port.to_string())
11158            .with("ConfigurationEndpoint.Address", endpoint_address)
11159            .with("ConfigurationEndpoint.Port", port.to_string()))
11160    }
11161
11162    fn delete_ec_cache_cluster(&self, physical_id: &str) -> Result<(), String> {
11163        let mut accounts = self.elasticache_state.write();
11164        let state = accounts.get_or_create(&self.account_id);
11165        state.cache_clusters.remove(physical_id);
11166        Ok(())
11167    }
11168
11169    fn create_ec_replication_group(
11170        &self,
11171        resource: &ResourceDefinition,
11172    ) -> Result<ProvisionResult, String> {
11173        let props = &resource.properties;
11174        let id = props
11175            .get("ReplicationGroupId")
11176            .and_then(|v| v.as_str())
11177            .map(String::from)
11178            .unwrap_or_else(|| format!("cfn-rg-{}", resource.logical_id.to_lowercase()));
11179        let description = props
11180            .get("ReplicationGroupDescription")
11181            .and_then(|v| v.as_str())
11182            .unwrap_or("CFN-provisioned replication group")
11183            .to_string();
11184        let cache_node_type = props
11185            .get("CacheNodeType")
11186            .and_then(|v| v.as_str())
11187            .unwrap_or("cache.t4g.micro")
11188            .to_string();
11189        let engine = props
11190            .get("Engine")
11191            .and_then(|v| v.as_str())
11192            .unwrap_or("redis")
11193            .to_string();
11194        let engine_version = props
11195            .get("EngineVersion")
11196            .and_then(|v| v.as_str())
11197            .unwrap_or("7.1")
11198            .to_string();
11199        let num_cache_clusters = props
11200            .get("NumCacheClusters")
11201            .and_then(|v| v.as_i64())
11202            .map(|n| n as i32)
11203            .unwrap_or(1);
11204        let num_node_groups = props
11205            .get("NumNodeGroups")
11206            .and_then(|v| v.as_i64())
11207            .map(|n| n as i32)
11208            .unwrap_or(0);
11209        let replicas_per_node_group = props
11210            .get("ReplicasPerNodeGroup")
11211            .and_then(|v| v.as_i64())
11212            .map(|n| n as i32);
11213        let automatic_failover_enabled = props
11214            .get("AutomaticFailoverEnabled")
11215            .and_then(|v| v.as_bool())
11216            .unwrap_or(false);
11217        let multi_az_enabled = props
11218            .get("MultiAZEnabled")
11219            .and_then(|v| v.as_bool())
11220            .unwrap_or(false);
11221        let transit_encryption_enabled = props
11222            .get("TransitEncryptionEnabled")
11223            .and_then(|v| v.as_bool())
11224            .unwrap_or(false);
11225        let at_rest_encryption_enabled = props
11226            .get("AtRestEncryptionEnabled")
11227            .and_then(|v| v.as_bool())
11228            .unwrap_or(false);
11229        let kms_key_id = props
11230            .get("KmsKeyId")
11231            .and_then(|v| v.as_str())
11232            .map(String::from);
11233        let auth_token_enabled = props
11234            .get("AuthToken")
11235            .and_then(|v| v.as_str())
11236            .filter(|s| !s.is_empty())
11237            .is_some();
11238        let user_group_ids: Vec<String> = props
11239            .get("UserGroupIds")
11240            .and_then(|v| v.as_array())
11241            .map(|arr| {
11242                arr.iter()
11243                    .filter_map(|v| v.as_str().map(String::from))
11244                    .collect()
11245            })
11246            .unwrap_or_default();
11247        let snapshot_retention_limit = props
11248            .get("SnapshotRetentionLimit")
11249            .and_then(|v| v.as_i64())
11250            .map(|n| n as i32)
11251            .unwrap_or(0);
11252        let snapshot_window = props
11253            .get("SnapshotWindow")
11254            .and_then(|v| v.as_str())
11255            .unwrap_or("00:00-01:00")
11256            .to_string();
11257        let port = props
11258            .get("Port")
11259            .and_then(|v| v.as_i64())
11260            .map(|n| n as u16)
11261            .unwrap_or(6379);
11262        let cluster_enabled = num_node_groups > 1
11263            || props
11264                .get("ClusterEnabled")
11265                .and_then(|v| v.as_bool())
11266                .unwrap_or(false);
11267
11268        let mut accounts = self.elasticache_state.write();
11269        let state = accounts.get_or_create(&self.account_id);
11270        let arn = format!(
11271            "arn:aws:elasticache:{}:{}:replicationgroup:{}",
11272            state.region, state.account_id, id
11273        );
11274        let endpoint_address = format!(
11275            "{id}.fakecloud.ng.0001.{}.cache.amazonaws.com",
11276            state.region
11277        );
11278        let configuration_endpoint = if cluster_enabled {
11279            Some(format!(
11280                "{id}.fakecloud.cfg.{}.cache.amazonaws.com",
11281                state.region
11282            ))
11283        } else {
11284            None
11285        };
11286
11287        let group = EcReplicationGroup {
11288            replication_group_id: id.clone(),
11289            description,
11290            global_replication_group_id: None,
11291            global_replication_group_role: None,
11292            status: "available".to_string(),
11293            cache_node_type,
11294            engine,
11295            engine_version,
11296            num_cache_clusters,
11297            automatic_failover_enabled,
11298            endpoint_address: endpoint_address.clone(),
11299            endpoint_port: port,
11300            arn: arn.clone(),
11301            created_at: Utc::now().to_rfc3339(),
11302            container_id: String::new(),
11303            host_port: 0,
11304            member_clusters: Vec::new(),
11305            snapshot_retention_limit,
11306            snapshot_window,
11307            transit_encryption_enabled,
11308            at_rest_encryption_enabled,
11309            cluster_enabled,
11310            kms_key_id,
11311            auth_token_enabled,
11312            user_group_ids,
11313            multi_az_enabled,
11314            log_delivery_configurations: Vec::new(),
11315            data_tiering: props
11316                .get("DataTieringEnabled")
11317                .and_then(|v| v.as_bool())
11318                .map(|b| if b { "enabled" } else { "disabled" }.to_string()),
11319            ip_discovery: props
11320                .get("IpDiscovery")
11321                .and_then(|v| v.as_str())
11322                .map(String::from),
11323            network_type: props
11324                .get("NetworkType")
11325                .and_then(|v| v.as_str())
11326                .map(String::from),
11327            transit_encryption_mode: props
11328                .get("TransitEncryptionMode")
11329                .and_then(|v| v.as_str())
11330                .map(String::from),
11331            num_node_groups,
11332            configuration_endpoint_address: configuration_endpoint.clone(),
11333            configuration_endpoint_port: configuration_endpoint.as_ref().map(|_| port),
11334            replicas_per_node_group,
11335            auth_token: props
11336                .get("AuthToken")
11337                .and_then(|v| v.as_str())
11338                .filter(|s| !s.is_empty())
11339                .map(String::from),
11340            port,
11341            notification_topic_arn: props
11342                .get("NotificationTopicArn")
11343                .and_then(|v| v.as_str())
11344                .map(String::from),
11345            cluster_mode: props
11346                .get("ClusterMode")
11347                .and_then(|v| v.as_str())
11348                .map(String::from),
11349            data_tiering_enabled: props.get("DataTieringEnabled").and_then(|v| v.as_bool()),
11350            notification_topic_status: None,
11351            cache_parameter_group_name: props
11352                .get("CacheParameterGroupName")
11353                .and_then(|v| v.as_str())
11354                .map(String::from),
11355            cache_subnet_group_name: props
11356                .get("CacheSubnetGroupName")
11357                .and_then(|v| v.as_str())
11358                .map(String::from),
11359            security_group_ids: props
11360                .get("SecurityGroupIds")
11361                .and_then(|v| v.as_array())
11362                .map(|arr| {
11363                    arr.iter()
11364                        .filter_map(|v| v.as_str().map(String::from))
11365                        .collect()
11366                })
11367                .unwrap_or_default(),
11368            preferred_maintenance_window: props
11369                .get("PreferredMaintenanceWindow")
11370                .and_then(|v| v.as_str())
11371                .map(String::from),
11372            snapshot_name: props
11373                .get("SnapshotName")
11374                .and_then(|v| v.as_str())
11375                .map(String::from),
11376            snapshot_arns: props
11377                .get("SnapshotArns")
11378                .and_then(|v| v.as_array())
11379                .map(|arr| {
11380                    arr.iter()
11381                        .filter_map(|v| v.as_str().map(String::from))
11382                        .collect()
11383                })
11384                .unwrap_or_default(),
11385            auto_minor_version_upgrade: props
11386                .get("AutoMinorVersionUpgrade")
11387                .and_then(|v| v.as_bool())
11388                .unwrap_or(true),
11389        };
11390        state.replication_groups.insert(id.clone(), group);
11391
11392        let mut result = ProvisionResult::new(id.clone())
11393            .with("Arn", arn)
11394            .with("PrimaryEndPoint.Address", endpoint_address.clone())
11395            .with("PrimaryEndPoint.Port", port.to_string())
11396            .with("ReadEndPoint.Addresses", endpoint_address.clone())
11397            .with("ReadEndPoint.Ports", port.to_string());
11398        if let Some(cfg) = configuration_endpoint {
11399            result = result
11400                .with("ConfigurationEndPoint.Address", cfg)
11401                .with("ConfigurationEndPoint.Port", port.to_string());
11402        }
11403        Ok(result)
11404    }
11405
11406    fn delete_ec_replication_group(&self, physical_id: &str) -> Result<(), String> {
11407        let mut accounts = self.elasticache_state.write();
11408        let state = accounts.get_or_create(&self.account_id);
11409        state.replication_groups.remove(physical_id);
11410        Ok(())
11411    }
11412
11413    // --- Route53 ---
11414
11415    fn create_route53_hosted_zone(
11416        &self,
11417        resource: &ResourceDefinition,
11418    ) -> Result<ProvisionResult, String> {
11419        let props = &resource.properties;
11420        let name = props
11421            .get("Name")
11422            .and_then(|v| v.as_str())
11423            .ok_or("Name is required")?
11424            .to_string();
11425        let normalized_name = if name.ends_with('.') {
11426            name.clone()
11427        } else {
11428            format!("{name}.")
11429        };
11430        let comment = props
11431            .get("HostedZoneConfig")
11432            .and_then(|v| v.get("Comment"))
11433            .and_then(|v| v.as_str())
11434            .map(|s| s.to_string());
11435        let private_zone = props
11436            .get("VPCs")
11437            .and_then(|v| v.as_array())
11438            .map(|a| !a.is_empty())
11439            .unwrap_or(false);
11440        let vpcs: Vec<fakecloud_route53::model::VPC> = props
11441            .get("VPCs")
11442            .and_then(|v| v.as_array())
11443            .map(|arr| {
11444                arr.iter()
11445                    .map(|vpc| fakecloud_route53::model::VPC {
11446                        vpc_id: vpc.get("VPCId").and_then(|v| v.as_str()).map(String::from),
11447                        vpc_region: vpc
11448                            .get("VPCRegion")
11449                            .and_then(|v| v.as_str())
11450                            .map(String::from),
11451                    })
11452                    .collect()
11453            })
11454            .unwrap_or_default();
11455
11456        let id = format!(
11457            "Z{}",
11458            Uuid::new_v4().simple().to_string()[..14].to_uppercase()
11459        );
11460        let name_servers = (1..=4)
11461            .map(|i| format!("ns-{}.awsdns-{:02}.com", 100 + i, i))
11462            .collect::<Vec<_>>();
11463
11464        let zone = StoredHostedZone {
11465            id: id.clone(),
11466            name: normalized_name,
11467            caller_reference: format!("cfn-{}", resource.logical_id),
11468            comment,
11469            private_zone,
11470            features: Some(HostedZoneFeatures::default()),
11471            vpcs,
11472            delegation_set_id: None,
11473            name_servers: name_servers.clone(),
11474            created_time: Utc::now(),
11475            resource_record_sets: Vec::new(),
11476        };
11477
11478        let mut accounts = self.route53_state.write();
11479        // Route53 is a global service in fakecloud; all entries share the
11480        // default account bucket so SDK reads land on the same data.
11481        let state = accounts.entry("000000000000");
11482        state.hosted_zones.insert(id.clone(), zone);
11483
11484        let mut result = ProvisionResult::new(id.clone()).with("Id", id);
11485        for (i, ns) in name_servers.iter().enumerate() {
11486            result = result.with(&format!("NameServers.{i}"), ns.clone());
11487        }
11488        result = result.with("NameServers", name_servers.join(","));
11489        Ok(result)
11490    }
11491
11492    fn delete_route53_hosted_zone(&self, physical_id: &str) -> Result<(), String> {
11493        let mut accounts = self.route53_state.write();
11494        // Route53 is a global service in fakecloud; all entries share the
11495        // default account bucket so SDK reads land on the same data.
11496        let state = accounts.entry("000000000000");
11497        state.hosted_zones.remove(physical_id);
11498        Ok(())
11499    }
11500
11501    fn create_route53_record_set(
11502        &self,
11503        resource: &ResourceDefinition,
11504    ) -> Result<ProvisionResult, String> {
11505        let props = &resource.properties;
11506        let zone_id = props
11507            .get("HostedZoneId")
11508            .and_then(|v| v.as_str())
11509            .ok_or_else(|| {
11510                "HostedZoneId is required (HostedZoneName lookups not supported)".to_string()
11511            })?
11512            .to_string();
11513        let name = props
11514            .get("Name")
11515            .and_then(|v| v.as_str())
11516            .ok_or("Name is required")?
11517            .to_string();
11518        let normalized_name = if name.ends_with('.') {
11519            name.clone()
11520        } else {
11521            format!("{name}.")
11522        };
11523        let record_type = props
11524            .get("Type")
11525            .and_then(|v| v.as_str())
11526            .ok_or("Type is required")?
11527            .to_string();
11528        let ttl = props.get("TTL").and_then(|v| {
11529            v.as_str()
11530                .and_then(|s| s.parse::<i64>().ok())
11531                .or_else(|| v.as_i64())
11532        });
11533        let resource_records = props
11534            .get("ResourceRecords")
11535            .and_then(|v| v.as_array())
11536            .map(|arr| {
11537                let recs: Vec<fakecloud_route53::model::ResourceRecord> = arr
11538                    .iter()
11539                    .filter_map(|v| {
11540                        v.as_str()
11541                            .map(|s| fakecloud_route53::model::ResourceRecord {
11542                                value: s.to_string(),
11543                            })
11544                    })
11545                    .collect();
11546                fakecloud_route53::model::ResourceRecords {
11547                    resource_record: recs,
11548                }
11549            });
11550        let alias_target =
11551            props
11552                .get("AliasTarget")
11553                .map(|v| fakecloud_route53::model::AliasTarget {
11554                    hosted_zone_id: v
11555                        .get("HostedZoneId")
11556                        .and_then(|x| x.as_str())
11557                        .unwrap_or("")
11558                        .to_string(),
11559                    dns_name: v
11560                        .get("DNSName")
11561                        .and_then(|x| x.as_str())
11562                        .unwrap_or("")
11563                        .to_string(),
11564                    evaluate_target_health: v
11565                        .get("EvaluateTargetHealth")
11566                        .and_then(|x| x.as_bool())
11567                        .unwrap_or(false),
11568                });
11569        let set_identifier = props
11570            .get("SetIdentifier")
11571            .and_then(|v| v.as_str())
11572            .map(String::from);
11573        let weight = props.get("Weight").and_then(|v| {
11574            v.as_i64()
11575                .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
11576        });
11577        let region = props
11578            .get("Region")
11579            .and_then(|v| v.as_str())
11580            .map(String::from);
11581        let failover = props
11582            .get("Failover")
11583            .and_then(|v| v.as_str())
11584            .map(String::from);
11585        let multi_value_answer = props.get("MultiValueAnswer").and_then(|v| v.as_bool());
11586        let health_check_id = props
11587            .get("HealthCheckId")
11588            .and_then(|v| v.as_str())
11589            .map(String::from);
11590
11591        let rrset = ResourceRecordSet {
11592            name: normalized_name.clone(),
11593            record_type: record_type.clone(),
11594            set_identifier: set_identifier.clone(),
11595            weight,
11596            region,
11597            geo_location: None,
11598            failover,
11599            multi_value_answer,
11600            ttl,
11601            resource_records,
11602            alias_target,
11603            health_check_id,
11604            traffic_policy_instance_id: None,
11605            cidr_routing_config: None,
11606            geo_proximity_location: None,
11607        };
11608
11609        let mut accounts = self.route53_state.write();
11610        // Route53 is a global service in fakecloud; all entries share the
11611        // default account bucket so SDK reads land on the same data.
11612        let state = accounts.entry("000000000000");
11613        let zone = state.hosted_zones.get_mut(&zone_id).ok_or_else(|| {
11614            format!(
11615                "HostedZone {zone_id} not yet provisioned; will retry once it has been provisioned"
11616            )
11617        })?;
11618        // Replace existing record with matching (name, type, set_identifier).
11619        zone.resource_record_sets.retain(|r| {
11620            !(r.name == rrset.name
11621                && r.record_type == rrset.record_type
11622                && r.set_identifier == rrset.set_identifier)
11623        });
11624        zone.resource_record_sets.push(rrset);
11625
11626        let physical_id = match &set_identifier {
11627            Some(sid) => format!("{zone_id}|{normalized_name}|{record_type}|{sid}"),
11628            None => format!("{zone_id}|{normalized_name}|{record_type}"),
11629        };
11630        Ok(ProvisionResult::new(physical_id))
11631    }
11632
11633    fn delete_route53_record_set(
11634        &self,
11635        physical_id: &str,
11636        _attributes: &BTreeMap<String, String>,
11637    ) -> Result<(), String> {
11638        let parts: Vec<&str> = physical_id.split('|').collect();
11639        if parts.len() < 3 {
11640            return Ok(());
11641        }
11642        let zone_id = parts[0];
11643        let name = parts[1];
11644        let record_type = parts[2];
11645        let set_identifier = parts.get(3).map(|s| s.to_string());
11646
11647        let mut accounts = self.route53_state.write();
11648        // Route53 is a global service in fakecloud; all entries share the
11649        // default account bucket so SDK reads land on the same data.
11650        let state = accounts.entry("000000000000");
11651        if let Some(zone) = state.hosted_zones.get_mut(zone_id) {
11652            zone.resource_record_sets.retain(|r| {
11653                !(r.name == name
11654                    && r.record_type == record_type
11655                    && r.set_identifier == set_identifier)
11656            });
11657        }
11658        Ok(())
11659    }
11660
11661    fn create_route53_health_check(
11662        &self,
11663        resource: &ResourceDefinition,
11664    ) -> Result<ProvisionResult, String> {
11665        let props = &resource.properties;
11666        let cfg_value = props
11667            .get("HealthCheckConfig")
11668            .ok_or("HealthCheckConfig is required")?;
11669
11670        let health_check_type = cfg_value
11671            .get("Type")
11672            .and_then(|v| v.as_str())
11673            .ok_or("HealthCheckConfig.Type is required")?
11674            .to_string();
11675
11676        let cfg = HealthCheckConfig {
11677            ip_address: cfg_value
11678                .get("IPAddress")
11679                .and_then(|v| v.as_str())
11680                .map(String::from),
11681            port: cfg_value
11682                .get("Port")
11683                .and_then(|v| {
11684                    v.as_i64()
11685                        .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
11686                })
11687                .map(|n| n as i32),
11688            health_check_type,
11689            resource_path: cfg_value
11690                .get("ResourcePath")
11691                .and_then(|v| v.as_str())
11692                .map(String::from),
11693            fully_qualified_domain_name: cfg_value
11694                .get("FullyQualifiedDomainName")
11695                .and_then(|v| v.as_str())
11696                .map(String::from),
11697            search_string: cfg_value
11698                .get("SearchString")
11699                .and_then(|v| v.as_str())
11700                .map(String::from),
11701            request_interval: cfg_value
11702                .get("RequestInterval")
11703                .and_then(|v| {
11704                    v.as_i64()
11705                        .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
11706                })
11707                .map(|n| n as i32),
11708            failure_threshold: cfg_value
11709                .get("FailureThreshold")
11710                .and_then(|v| {
11711                    v.as_i64()
11712                        .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
11713                })
11714                .map(|n| n as i32),
11715            measure_latency: cfg_value.get("MeasureLatency").and_then(|v| v.as_bool()),
11716            inverted: cfg_value.get("Inverted").and_then(|v| v.as_bool()),
11717            disabled: cfg_value.get("Disabled").and_then(|v| v.as_bool()),
11718            health_threshold: cfg_value
11719                .get("HealthThreshold")
11720                .and_then(|v| {
11721                    v.as_i64()
11722                        .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
11723                })
11724                .map(|n| n as i32),
11725            child_health_checks: cfg_value
11726                .get("ChildHealthChecks")
11727                .and_then(|v| v.as_array())
11728                .map(|arr| fakecloud_route53::model::ChildHealthChecks {
11729                    child_health_check: arr
11730                        .iter()
11731                        .filter_map(|v| v.as_str().map(String::from))
11732                        .collect(),
11733                }),
11734            enable_sni: cfg_value.get("EnableSNI").and_then(|v| v.as_bool()),
11735            regions: cfg_value
11736                .get("Regions")
11737                .and_then(|v| v.as_array())
11738                .map(|arr| fakecloud_route53::model::HealthCheckRegions {
11739                    region: arr
11740                        .iter()
11741                        .filter_map(|v| v.as_str().map(String::from))
11742                        .collect(),
11743                }),
11744            alarm_identifier: cfg_value.get("AlarmIdentifier").map(|v| {
11745                fakecloud_route53::model::AlarmIdentifier {
11746                    region: v
11747                        .get("Region")
11748                        .and_then(|x| x.as_str())
11749                        .unwrap_or("")
11750                        .to_string(),
11751                    name: v
11752                        .get("Name")
11753                        .and_then(|x| x.as_str())
11754                        .unwrap_or("")
11755                        .to_string(),
11756                }
11757            }),
11758            insufficient_data_health_status: cfg_value
11759                .get("InsufficientDataHealthStatus")
11760                .and_then(|v| v.as_str())
11761                .map(String::from),
11762            routing_control_arn: cfg_value
11763                .get("RoutingControlArn")
11764                .and_then(|v| v.as_str())
11765                .map(String::from),
11766        };
11767
11768        let id = Uuid::new_v4().to_string();
11769        let hc = StoredHealthCheck {
11770            id: id.clone(),
11771            caller_reference: format!("cfn-{}", resource.logical_id),
11772            version: 1,
11773            config: cfg,
11774            created_time: Utc::now(),
11775            status: fakecloud_route53::HealthCheckStatus::Success,
11776            last_failure_reason: None,
11777        };
11778
11779        let mut accounts = self.route53_state.write();
11780        // Route53 is a global service in fakecloud; all entries share the
11781        // default account bucket so SDK reads land on the same data.
11782        let state = accounts.entry("000000000000");
11783        state.health_checks.insert(id.clone(), hc);
11784
11785        Ok(ProvisionResult::new(id.clone()).with("HealthCheckId", id))
11786    }
11787
11788    fn delete_route53_health_check(&self, physical_id: &str) -> Result<(), String> {
11789        let mut accounts = self.route53_state.write();
11790        // Route53 is a global service in fakecloud; all entries share the
11791        // default account bucket so SDK reads land on the same data.
11792        let state = accounts.entry("000000000000");
11793        state.health_checks.remove(physical_id);
11794        Ok(())
11795    }
11796
11797    /// `AWS::Route53::DNSSEC` flips the hosted zone's `dnssec_status` to
11798    /// `SIGNING`. Physical id is the hosted zone id so the delete path can
11799    /// flip it back without consulting the template.
11800    fn create_route53_dnssec(
11801        &self,
11802        resource: &ResourceDefinition,
11803    ) -> Result<ProvisionResult, String> {
11804        let zone_id = resource
11805            .properties
11806            .get("HostedZoneId")
11807            .and_then(|v| v.as_str())
11808            .ok_or_else(|| "HostedZoneId is required".to_string())?
11809            .trim_start_matches("/hostedzone/")
11810            .to_string();
11811        let mut accounts = self.route53_state.write();
11812        let state = accounts.entry("000000000000");
11813        if !state.hosted_zones.contains_key(&zone_id) {
11814            return Err(format!("HostedZone {zone_id} does not exist"));
11815        }
11816        state
11817            .dnssec_status
11818            .insert(zone_id.clone(), "SIGNING".to_string());
11819        Ok(ProvisionResult::new(zone_id))
11820    }
11821
11822    fn delete_route53_dnssec(&self, physical_id: &str) -> Result<(), String> {
11823        let mut accounts = self.route53_state.write();
11824        let state = accounts.entry("000000000000");
11825        state.dnssec_status.remove(physical_id);
11826        Ok(())
11827    }
11828
11829    /// `AWS::Route53::KeySigningKey` registers a KSK against a hosted
11830    /// zone. Physical id encodes `<hosted_zone_id>/<name>` so the delete
11831    /// path can find the (zone, name) tuple without re-reading inputs.
11832    fn create_route53_key_signing_key(
11833        &self,
11834        resource: &ResourceDefinition,
11835    ) -> Result<ProvisionResult, String> {
11836        let props = &resource.properties;
11837        let zone_id = props
11838            .get("HostedZoneId")
11839            .and_then(|v| v.as_str())
11840            .ok_or_else(|| "HostedZoneId is required".to_string())?
11841            .trim_start_matches("/hostedzone/")
11842            .to_string();
11843        let name = props
11844            .get("Name")
11845            .and_then(|v| v.as_str())
11846            .ok_or_else(|| "Name is required".to_string())?
11847            .to_string();
11848        let kms_arn = props
11849            .get("KeyManagementServiceArn")
11850            .and_then(|v| v.as_str())
11851            .ok_or_else(|| "KeyManagementServiceArn is required".to_string())?
11852            .to_string();
11853        let status = props
11854            .get("Status")
11855            .and_then(|v| v.as_str())
11856            .unwrap_or("ACTIVE")
11857            .to_string();
11858        let now = Utc::now();
11859        // Derive the same deterministic ECDSA P-256 KSK material the
11860        // direct CreateKeySigningKey path produces so DNSSEC features
11861        // (DS digest, RRSIG signing, the
11862        // `/_fakecloud/route53/zones/{id}/dnssec` admin endpoint)
11863        // round-trip identically through CloudFormation.
11864        let key_material = fakecloud_route53::dnssec::derive_keypair(&zone_id, &name);
11865        let key_tag = fakecloud_route53::dnssec::key_tag_for(&key_material.dnskey_public_key);
11866        let mut accounts = self.route53_state.write();
11867        let state = accounts.entry("000000000000");
11868        if !state.hosted_zones.contains_key(&zone_id) {
11869            return Err(format!("HostedZone {zone_id} does not exist"));
11870        }
11871        let zone_name = state
11872            .hosted_zones
11873            .get(&zone_id)
11874            .map(|z| z.name.clone())
11875            .unwrap_or_else(|| ".".to_string());
11876        let ds_digest_hex = fakecloud_route53::dnssec::ds_digest_sha256(
11877            &zone_name,
11878            key_tag,
11879            &key_material.dnskey_public_key,
11880        );
11881        let ksk = fakecloud_route53::StoredKeySigningKey {
11882            hosted_zone_id: zone_id.clone(),
11883            name: name.clone(),
11884            kms_arn,
11885            status,
11886            caller_reference: format!("cfn-{}", Uuid::new_v4()),
11887            created_date: now,
11888            last_modified_date: now,
11889            key_tag: key_tag as i32,
11890            private_key_pem: key_material.private_key_pem,
11891            public_key_der: key_material.public_key_der,
11892            ds_digest_hex,
11893        };
11894        state
11895            .key_signing_keys
11896            .insert((zone_id.clone(), name.clone()), ksk);
11897        Ok(ProvisionResult::new(format!("{zone_id}/{name}")))
11898    }
11899
11900    fn delete_route53_key_signing_key(&self, physical_id: &str) -> Result<(), String> {
11901        let (zone_id, name) = match physical_id.split_once('/') {
11902            Some(parts) => parts,
11903            None => return Ok(()),
11904        };
11905        let mut accounts = self.route53_state.write();
11906        let state = accounts.entry("000000000000");
11907        state
11908            .key_signing_keys
11909            .remove(&(zone_id.to_string(), name.to_string()));
11910        Ok(())
11911    }
11912
11913    // --- CloudFront ---
11914    //
11915    // CloudFront is a global service that stores data under the default
11916    // account bucket; mirror that here so SDK reads land on the same data
11917    // CFN wrote.
11918
11919    fn create_cf_origin_access_identity(
11920        &self,
11921        resource: &ResourceDefinition,
11922    ) -> Result<ProvisionResult, String> {
11923        let props = &resource.properties;
11924        let cfg = props
11925            .get("CloudFrontOriginAccessIdentityConfig")
11926            .ok_or("CloudFrontOriginAccessIdentityConfig is required")?;
11927        let comment = cfg
11928            .get("Comment")
11929            .and_then(|v| v.as_str())
11930            .unwrap_or("")
11931            .to_string();
11932        let caller_reference = format!("cfn-{}", resource.logical_id);
11933
11934        let id = format!(
11935            "E{}",
11936            Uuid::new_v4().simple().to_string()[..13].to_uppercase()
11937        );
11938        let etag = format!(
11939            "E{}",
11940            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
11941        );
11942        let s3_canonical_user_id = format!(
11943            "{:0<64}",
11944            Uuid::new_v4().simple().to_string().to_lowercase()
11945        );
11946
11947        let oai = StoredOriginAccessIdentity {
11948            id: id.clone(),
11949            etag,
11950            s3_canonical_user_id: s3_canonical_user_id.clone(),
11951            config: CloudFrontOriginAccessIdentityConfig {
11952                caller_reference,
11953                comment,
11954            },
11955        };
11956
11957        let mut accounts = self.cloudfront_state.write();
11958        let state = accounts.entry("000000000000");
11959        state.origin_access_identities.insert(id.clone(), oai);
11960
11961        Ok(ProvisionResult::new(id.clone())
11962            .with("Id", id)
11963            .with("S3CanonicalUserId", s3_canonical_user_id))
11964    }
11965
11966    fn delete_cf_origin_access_identity(&self, physical_id: &str) -> Result<(), String> {
11967        let mut accounts = self.cloudfront_state.write();
11968        let state = accounts.entry("000000000000");
11969        state.origin_access_identities.remove(physical_id);
11970        Ok(())
11971    }
11972
11973    /// Provision an `AWS::CloudFront::Distribution`. Reads
11974    /// DistributionConfig.Origins/DefaultCacheBehavior/etc. and persists
11975    /// a StoredDistribution in CloudFront state. CFN's Origins property
11976    /// is a flat array, so we wrap it back into the wire shape with a
11977    /// quantity + Items.Origin nesting.
11978    fn create_cf_distribution(
11979        &self,
11980        resource: &ResourceDefinition,
11981    ) -> Result<ProvisionResult, String> {
11982        let cfg = resource
11983            .properties
11984            .get("DistributionConfig")
11985            .ok_or_else(|| "DistributionConfig is required".to_string())?;
11986
11987        // CFN Origins is a flat JSON array; the wire shape is
11988        // { Quantity, Items: { Origin: [...] } }. Translate. CFN's
11989        // PascalCase doesn't always match the wire model — Origin's
11990        // CustomOriginConfig uses HTTPPort/HTTPSPort while the model
11991        // expects HttpPort/HttpsPort, so we patch a few well-known
11992        // renames before letting serde finish the parse.
11993        let origin_entries: Vec<Origin> = cfg
11994            .get("Origins")
11995            .and_then(|v| v.as_array())
11996            .ok_or_else(|| "DistributionConfig.Origins is required".to_string())?
11997            .iter()
11998            .map(|o| {
11999                let mut patched = o.clone();
12000                if let Some(custom) = patched
12001                    .get_mut("CustomOriginConfig")
12002                    .and_then(|v| v.as_object_mut())
12003                {
12004                    if let Some(v) = custom.remove("HTTPPort") {
12005                        custom.insert("HttpPort".to_string(), v);
12006                    }
12007                    if let Some(v) = custom.remove("HTTPSPort") {
12008                        custom.insert("HttpsPort".to_string(), v);
12009                    }
12010                }
12011                serde_json::from_value::<Origin>(patched)
12012                    .map_err(|e| format!("Invalid Origin entry: {e}"))
12013            })
12014            .collect::<Result<Vec<_>, _>>()?;
12015        if origin_entries.is_empty() {
12016            return Err("DistributionConfig.Origins must contain at least one origin".to_string());
12017        }
12018        let origins = Origins {
12019            quantity: origin_entries.len() as i32,
12020            items: Some(OriginItems {
12021                origin: origin_entries,
12022            }),
12023        };
12024
12025        let dcb_value = cfg
12026            .get("DefaultCacheBehavior")
12027            .ok_or_else(|| "DistributionConfig.DefaultCacheBehavior is required".to_string())?;
12028        let default_cache_behavior: DefaultCacheBehavior =
12029            serde_json::from_value(dcb_value.clone())
12030                .map_err(|e| format!("Invalid DefaultCacheBehavior: {e}"))?;
12031
12032        let comment = cfg
12033            .get("Comment")
12034            .and_then(|v| v.as_str())
12035            .unwrap_or("")
12036            .to_string();
12037        let enabled = cfg.get("Enabled").and_then(|v| v.as_bool()).unwrap_or(true);
12038        let price_class = cfg
12039            .get("PriceClass")
12040            .and_then(|v| v.as_str())
12041            .map(|s| s.to_string());
12042        let http_version = cfg
12043            .get("HttpVersion")
12044            .and_then(|v| v.as_str())
12045            .map(|s| s.to_string());
12046        let is_ipv6_enabled = cfg.get("IPV6Enabled").and_then(|v| v.as_bool());
12047        let default_root_object = cfg
12048            .get("DefaultRootObject")
12049            .and_then(|v| v.as_str())
12050            .map(|s| s.to_string());
12051        let web_acl_id = cfg
12052            .get("WebACLId")
12053            .and_then(|v| v.as_str())
12054            .map(|s| s.to_string());
12055
12056        let viewer_certificate: Option<ViewerCertificate> = cfg
12057            .get("ViewerCertificate")
12058            .map(|v| serde_json::from_value(v.clone()))
12059            .transpose()
12060            .map_err(|e| format!("Invalid ViewerCertificate: {e}"))?;
12061
12062        let caller_reference = format!("cfn-{}-{}", resource.logical_id, Uuid::new_v4().simple());
12063
12064        let mut config = DistributionConfig {
12065            caller_reference,
12066            comment,
12067            enabled,
12068            origins,
12069            default_cache_behavior,
12070            ..Default::default()
12071        };
12072        config.price_class = price_class;
12073        config.http_version = http_version;
12074        config.is_ipv6_enabled = is_ipv6_enabled;
12075        config.default_root_object = default_root_object;
12076        config.web_acl_id = web_acl_id;
12077        config.viewer_certificate = viewer_certificate;
12078
12079        // Mint distribution id + ARN + domain in the same shape the
12080        // CloudFront service uses.
12081        let id_suffix: String = Uuid::new_v4()
12082            .simple()
12083            .to_string()
12084            .chars()
12085            .take(13)
12086            .collect::<String>()
12087            .to_uppercase();
12088        let id = format!("E{id_suffix}");
12089        let etag_suffix: String = Uuid::new_v4()
12090            .simple()
12091            .to_string()
12092            .chars()
12093            .take(7)
12094            .collect::<String>()
12095            .to_uppercase();
12096        let etag = format!("E{etag_suffix}");
12097        let domain_name = format!("{}.cloudfront.net", id.to_lowercase());
12098        let arn = format!(
12099            "arn:aws:cloudfront::{}:distribution/{}",
12100            self.account_id, id
12101        );
12102
12103        let stored = StoredDistribution {
12104            id: id.clone(),
12105            arn: arn.clone(),
12106            // CloudFront flips this to Deployed on the first GetDistribution
12107            // poll, matching the rest of the service.
12108            status: "InProgress".to_string(),
12109            last_modified_time: Utc::now(),
12110            domain_name: domain_name.clone(),
12111            in_progress_invalidation_batches: 0,
12112            etag,
12113            config,
12114        };
12115
12116        let mut accounts = self.cloudfront_state.write();
12117        let state = accounts.entry("000000000000");
12118        state.distributions.insert(id.clone(), stored);
12119        Ok(ProvisionResult::new(id.clone())
12120            .with("Id", id)
12121            .with("DomainName", domain_name)
12122            .with("Arn", arn))
12123    }
12124
12125    fn delete_cf_distribution(&self, physical_id: &str) -> Result<(), String> {
12126        let mut accounts = self.cloudfront_state.write();
12127        let state = accounts.entry("000000000000");
12128        state.distributions.remove(physical_id);
12129        Ok(())
12130    }
12131
12132    fn create_cf_origin_access_control(
12133        &self,
12134        resource: &ResourceDefinition,
12135    ) -> Result<ProvisionResult, String> {
12136        let props = &resource.properties;
12137        let cfg = props
12138            .get("OriginAccessControlConfig")
12139            .ok_or("OriginAccessControlConfig is required")?;
12140        let name = cfg
12141            .get("Name")
12142            .and_then(|v| v.as_str())
12143            .ok_or("OriginAccessControlConfig.Name is required")?
12144            .to_string();
12145        let signing_protocol = cfg
12146            .get("SigningProtocol")
12147            .and_then(|v| v.as_str())
12148            .unwrap_or("sigv4")
12149            .to_string();
12150        let signing_behavior = cfg
12151            .get("SigningBehavior")
12152            .and_then(|v| v.as_str())
12153            .unwrap_or("always")
12154            .to_string();
12155        let origin_type = cfg
12156            .get("OriginAccessControlOriginType")
12157            .and_then(|v| v.as_str())
12158            .ok_or("OriginAccessControlConfig.OriginAccessControlOriginType is required")?
12159            .to_string();
12160        let description = cfg
12161            .get("Description")
12162            .and_then(|v| v.as_str())
12163            .map(String::from);
12164
12165        let id = format!(
12166            "E{}",
12167            Uuid::new_v4().simple().to_string()[..13].to_uppercase()
12168        );
12169        let etag = format!(
12170            "E{}",
12171            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
12172        );
12173        let oac = StoredOriginAccessControl {
12174            id: id.clone(),
12175            etag,
12176            config: OriginAccessControlConfig {
12177                name,
12178                description,
12179                signing_protocol,
12180                signing_behavior,
12181                origin_access_control_origin_type: origin_type,
12182            },
12183        };
12184
12185        let mut accounts = self.cloudfront_state.write();
12186        let state = accounts.entry("000000000000");
12187        state.origin_access_controls.insert(id.clone(), oac);
12188
12189        Ok(ProvisionResult::new(id.clone()).with("Id", id))
12190    }
12191
12192    fn delete_cf_origin_access_control(&self, physical_id: &str) -> Result<(), String> {
12193        let mut accounts = self.cloudfront_state.write();
12194        let state = accounts.entry("000000000000");
12195        state.origin_access_controls.remove(physical_id);
12196        Ok(())
12197    }
12198
12199    fn create_cf_public_key(
12200        &self,
12201        resource: &ResourceDefinition,
12202    ) -> Result<ProvisionResult, String> {
12203        let props = &resource.properties;
12204        let cfg = props
12205            .get("PublicKeyConfig")
12206            .ok_or("PublicKeyConfig is required")?;
12207        let name = cfg
12208            .get("Name")
12209            .and_then(|v| v.as_str())
12210            .ok_or("PublicKeyConfig.Name is required")?
12211            .to_string();
12212        let encoded_key = cfg
12213            .get("EncodedKey")
12214            .and_then(|v| v.as_str())
12215            .ok_or("PublicKeyConfig.EncodedKey is required")?
12216            .to_string();
12217        let comment = cfg
12218            .get("Comment")
12219            .and_then(|v| v.as_str())
12220            .map(String::from);
12221        let caller_reference = cfg
12222            .get("CallerReference")
12223            .and_then(|v| v.as_str())
12224            .unwrap_or("")
12225            .to_string();
12226        let caller_reference = if caller_reference.is_empty() {
12227            format!("cfn-{}", resource.logical_id)
12228        } else {
12229            caller_reference
12230        };
12231
12232        let id = format!(
12233            "K{}",
12234            Uuid::new_v4().simple().to_string()[..13].to_uppercase()
12235        );
12236        let etag = format!(
12237            "E{}",
12238            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
12239        );
12240
12241        let pk = StoredPublicKey {
12242            id: id.clone(),
12243            etag,
12244            created_time: Utc::now(),
12245            config: PublicKeyConfig {
12246                caller_reference,
12247                name,
12248                encoded_key,
12249                comment,
12250            },
12251        };
12252
12253        let mut accounts = self.cloudfront_state.write();
12254        let state = accounts.entry("000000000000");
12255        state.public_keys.insert(id.clone(), pk);
12256
12257        Ok(ProvisionResult::new(id.clone()).with("Id", id))
12258    }
12259
12260    fn delete_cf_public_key(&self, physical_id: &str) -> Result<(), String> {
12261        let mut accounts = self.cloudfront_state.write();
12262        let state = accounts.entry("000000000000");
12263        state.public_keys.remove(physical_id);
12264        Ok(())
12265    }
12266
12267    fn create_cf_key_group(
12268        &self,
12269        resource: &ResourceDefinition,
12270    ) -> Result<ProvisionResult, String> {
12271        let props = &resource.properties;
12272        let cfg = props
12273            .get("KeyGroupConfig")
12274            .ok_or("KeyGroupConfig is required")?;
12275        let name = cfg
12276            .get("Name")
12277            .and_then(|v| v.as_str())
12278            .ok_or("KeyGroupConfig.Name is required")?
12279            .to_string();
12280        let items: Vec<String> = cfg
12281            .get("Items")
12282            .and_then(|v| v.as_array())
12283            .map(|arr| {
12284                arr.iter()
12285                    .filter_map(|v| v.as_str().map(String::from))
12286                    .collect()
12287            })
12288            .unwrap_or_default();
12289        let comment = cfg
12290            .get("Comment")
12291            .and_then(|v| v.as_str())
12292            .map(String::from);
12293
12294        let id = format!(
12295            "KG{}",
12296            Uuid::new_v4().simple().to_string()[..12].to_uppercase()
12297        );
12298        let etag = format!(
12299            "E{}",
12300            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
12301        );
12302
12303        let kg = StoredKeyGroup {
12304            id: id.clone(),
12305            etag,
12306            last_modified_time: Utc::now(),
12307            config: KeyGroupConfig {
12308                name,
12309                items: KeyGroupItems { public_key: items },
12310                comment,
12311            },
12312        };
12313
12314        let mut accounts = self.cloudfront_state.write();
12315        let state = accounts.entry("000000000000");
12316        state.key_groups.insert(id.clone(), kg);
12317
12318        Ok(ProvisionResult::new(id.clone()).with("Id", id))
12319    }
12320
12321    fn delete_cf_key_group(&self, physical_id: &str) -> Result<(), String> {
12322        let mut accounts = self.cloudfront_state.write();
12323        let state = accounts.entry("000000000000");
12324        state.key_groups.remove(physical_id);
12325        Ok(())
12326    }
12327
12328    fn create_cf_function(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
12329        let props = &resource.properties;
12330        let name = props
12331            .get("Name")
12332            .and_then(|v| v.as_str())
12333            .ok_or("Name is required")?
12334            .to_string();
12335        let function_code = props
12336            .get("FunctionCode")
12337            .and_then(|v| v.as_str())
12338            .ok_or("FunctionCode is required")?
12339            .to_string();
12340        let cfg = props
12341            .get("FunctionConfig")
12342            .ok_or("FunctionConfig is required")?;
12343        let runtime = cfg
12344            .get("Runtime")
12345            .and_then(|v| v.as_str())
12346            .unwrap_or("cloudfront-js-2.0")
12347            .to_string();
12348        let comment = cfg
12349            .get("Comment")
12350            .and_then(|v| v.as_str())
12351            .map(String::from);
12352
12353        let id = format!(
12354            "FN{}",
12355            Uuid::new_v4().simple().to_string()[..12].to_uppercase()
12356        );
12357        let etag = format!(
12358            "E{}",
12359            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
12360        );
12361        let function_arn = format!("arn:aws:cloudfront::{}:function/{}", self.account_id, name);
12362
12363        let now = Utc::now();
12364        let func = StoredFunction {
12365            name: name.clone(),
12366            etag,
12367            status: "UNPUBLISHED".to_string(),
12368            stage: "DEVELOPMENT".to_string(),
12369            function_arn: function_arn.clone(),
12370            created_time: now,
12371            last_modified_time: now,
12372            config: FunctionConfig {
12373                comment,
12374                runtime,
12375                key_value_store_associations: None,
12376            },
12377            function_code,
12378            live_function_code: None,
12379        };
12380
12381        let mut accounts = self.cloudfront_state.write();
12382        let state = accounts.entry("000000000000");
12383        // Use the function's ARN/name as the registry key so subsequent
12384        // operations (Get/Update/Delete) keyed by name resolve.
12385        state.functions.insert(name.clone(), func);
12386
12387        Ok(ProvisionResult::new(name.clone())
12388            .with("FunctionARN", function_arn)
12389            .with("FunctionMetadata.FunctionARN", id)
12390            .with("Stage", "DEVELOPMENT"))
12391    }
12392
12393    fn delete_cf_function(&self, physical_id: &str) -> Result<(), String> {
12394        let mut accounts = self.cloudfront_state.write();
12395        let state = accounts.entry("000000000000");
12396        state.functions.remove(physical_id);
12397        Ok(())
12398    }
12399
12400    fn create_cf_cache_policy(
12401        &self,
12402        resource: &ResourceDefinition,
12403    ) -> Result<ProvisionResult, String> {
12404        let props = &resource.properties;
12405        let cfg = props
12406            .get("CachePolicyConfig")
12407            .ok_or("CachePolicyConfig is required")?;
12408        let name = cfg
12409            .get("Name")
12410            .and_then(|v| v.as_str())
12411            .ok_or("CachePolicyConfig.Name is required")?
12412            .to_string();
12413        let min_ttl = cfg
12414            .get("MinTTL")
12415            .and_then(|v| {
12416                v.as_i64()
12417                    .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
12418            })
12419            .unwrap_or(0);
12420        let default_ttl = cfg.get("DefaultTTL").and_then(|v| {
12421            v.as_i64()
12422                .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
12423        });
12424        let max_ttl = cfg.get("MaxTTL").and_then(|v| {
12425            v.as_i64()
12426                .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
12427        });
12428        let comment = cfg
12429            .get("Comment")
12430            .and_then(|v| v.as_str())
12431            .map(String::from);
12432
12433        let id = format!(
12434            "CP{}",
12435            Uuid::new_v4().simple().to_string()[..12].to_uppercase()
12436        );
12437        let etag = format!(
12438            "E{}",
12439            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
12440        );
12441
12442        let cache_policy = StoredCachePolicy {
12443            id: id.clone(),
12444            etag,
12445            last_modified_time: Utc::now(),
12446            config: CachePolicyConfig {
12447                comment,
12448                name,
12449                default_ttl,
12450                max_ttl,
12451                min_ttl,
12452                parameters_in_cache_key_and_forwarded_to_origin: None,
12453            },
12454            policy_type: "custom".to_string(),
12455        };
12456
12457        let mut accounts = self.cloudfront_state.write();
12458        let state = accounts.entry("000000000000");
12459        state.cache_policies.insert(id.clone(), cache_policy);
12460
12461        Ok(ProvisionResult::new(id.clone()).with("Id", id))
12462    }
12463
12464    fn delete_cf_cache_policy(&self, physical_id: &str) -> Result<(), String> {
12465        let mut accounts = self.cloudfront_state.write();
12466        let state = accounts.entry("000000000000");
12467        state.cache_policies.remove(physical_id);
12468        Ok(())
12469    }
12470
12471    fn create_cf_origin_request_policy(
12472        &self,
12473        resource: &ResourceDefinition,
12474    ) -> Result<ProvisionResult, String> {
12475        let props = &resource.properties;
12476        let cfg = props
12477            .get("OriginRequestPolicyConfig")
12478            .ok_or("OriginRequestPolicyConfig is required")?;
12479        let name = cfg
12480            .get("Name")
12481            .and_then(|v| v.as_str())
12482            .ok_or("OriginRequestPolicyConfig.Name is required")?
12483            .to_string();
12484        let header_behavior = cfg
12485            .get("HeadersConfig")
12486            .and_then(|v| v.get("HeaderBehavior"))
12487            .and_then(|v| v.as_str())
12488            .unwrap_or("none")
12489            .to_string();
12490        let cookie_behavior = cfg
12491            .get("CookiesConfig")
12492            .and_then(|v| v.get("CookieBehavior"))
12493            .and_then(|v| v.as_str())
12494            .unwrap_or("none")
12495            .to_string();
12496        let query_string_behavior = cfg
12497            .get("QueryStringsConfig")
12498            .and_then(|v| v.get("QueryStringBehavior"))
12499            .and_then(|v| v.as_str())
12500            .unwrap_or("none")
12501            .to_string();
12502        let comment = cfg
12503            .get("Comment")
12504            .and_then(|v| v.as_str())
12505            .map(String::from);
12506
12507        let id = format!(
12508            "ORP{}",
12509            Uuid::new_v4().simple().to_string()[..11].to_uppercase()
12510        );
12511        let etag = format!(
12512            "E{}",
12513            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
12514        );
12515
12516        let policy = StoredOriginRequestPolicy {
12517            id: id.clone(),
12518            etag,
12519            last_modified_time: Utc::now(),
12520            config: OriginRequestPolicyConfig {
12521                comment,
12522                name,
12523                headers_config: OriginRequestPolicyHeadersConfig {
12524                    header_behavior,
12525                    headers: None,
12526                },
12527                cookies_config: OriginRequestPolicyCookiesConfig {
12528                    cookie_behavior,
12529                    cookies: None,
12530                },
12531                query_strings_config: OriginRequestPolicyQueryStringsConfig {
12532                    query_string_behavior,
12533                    query_strings: None,
12534                },
12535            },
12536            policy_type: "custom".to_string(),
12537        };
12538
12539        let mut accounts = self.cloudfront_state.write();
12540        let state = accounts.entry("000000000000");
12541        state.origin_request_policies.insert(id.clone(), policy);
12542
12543        Ok(ProvisionResult::new(id.clone()).with("Id", id))
12544    }
12545
12546    fn delete_cf_origin_request_policy(&self, physical_id: &str) -> Result<(), String> {
12547        let mut accounts = self.cloudfront_state.write();
12548        let state = accounts.entry("000000000000");
12549        state.origin_request_policies.remove(physical_id);
12550        Ok(())
12551    }
12552
12553    fn create_cf_response_headers_policy(
12554        &self,
12555        resource: &ResourceDefinition,
12556    ) -> Result<ProvisionResult, String> {
12557        let props = &resource.properties;
12558        let cfg = props
12559            .get("ResponseHeadersPolicyConfig")
12560            .ok_or("ResponseHeadersPolicyConfig is required")?;
12561        let name = cfg
12562            .get("Name")
12563            .and_then(|v| v.as_str())
12564            .ok_or("ResponseHeadersPolicyConfig.Name is required")?
12565            .to_string();
12566        let comment = cfg
12567            .get("Comment")
12568            .and_then(|v| v.as_str())
12569            .map(String::from);
12570
12571        let id = format!(
12572            "RHP{}",
12573            Uuid::new_v4().simple().to_string()[..11].to_uppercase()
12574        );
12575        let etag = format!(
12576            "E{}",
12577            Uuid::new_v4().simple().to_string()[..7].to_uppercase()
12578        );
12579
12580        let policy = StoredResponseHeadersPolicy {
12581            id: id.clone(),
12582            etag,
12583            last_modified_time: Utc::now(),
12584            config: ResponseHeadersPolicyConfig {
12585                comment,
12586                name,
12587                cors_config: None,
12588                security_headers_config: None,
12589                server_timing_headers_config: None,
12590                custom_headers_config: None,
12591                remove_headers_config: None,
12592            },
12593            policy_type: "custom".to_string(),
12594        };
12595
12596        let mut accounts = self.cloudfront_state.write();
12597        let state = accounts.entry("000000000000");
12598        state.response_headers_policies.insert(id.clone(), policy);
12599
12600        Ok(ProvisionResult::new(id.clone()).with("Id", id))
12601    }
12602
12603    fn delete_cf_response_headers_policy(&self, physical_id: &str) -> Result<(), String> {
12604        let mut accounts = self.cloudfront_state.write();
12605        let state = accounts.entry("000000000000");
12606        state.response_headers_policies.remove(physical_id);
12607        Ok(())
12608    }
12609
12610    // --- Step Functions ---
12611
12612    fn create_sfn_state_machine(
12613        &self,
12614        resource: &ResourceDefinition,
12615    ) -> Result<ProvisionResult, String> {
12616        let props = &resource.properties;
12617        let name = props
12618            .get("StateMachineName")
12619            .and_then(|v| v.as_str())
12620            .map(String::from)
12621            .unwrap_or_else(|| {
12622                let suffix = Uuid::new_v4().simple().to_string();
12623                format!("{}-{}", resource.logical_id, &suffix[..8])
12624            });
12625        let role_arn = props
12626            .get("RoleArn")
12627            .and_then(|v| v.as_str())
12628            .ok_or("RoleArn is required")?
12629            .to_string();
12630        let machine_type_str = props
12631            .get("StateMachineType")
12632            .and_then(|v| v.as_str())
12633            .unwrap_or("STANDARD");
12634        let machine_type = StateMachineType::parse(machine_type_str)
12635            .ok_or_else(|| format!("Invalid StateMachineType: {machine_type_str}"))?;
12636        let definition = props
12637            .get("DefinitionString")
12638            .and_then(|v| v.as_str())
12639            .map(String::from)
12640            .or_else(|| {
12641                props
12642                    .get("Definition")
12643                    .map(|v| serde_json::to_string(v).unwrap_or_default())
12644            })
12645            .ok_or("Definition or DefinitionString is required")?;
12646        let logging_configuration = props.get("LoggingConfiguration").cloned();
12647        let tracing_configuration = props.get("TracingConfiguration").cloned();
12648
12649        let arn = format!(
12650            "arn:aws:states:{}:{}:stateMachine:{}",
12651            self.region, self.account_id, name
12652        );
12653        let now = Utc::now();
12654        let revision_id = Uuid::new_v4().to_string();
12655
12656        let sm = StateMachine {
12657            name: name.clone(),
12658            arn: arn.clone(),
12659            definition,
12660            role_arn,
12661            machine_type,
12662            status: StateMachineStatus::Active,
12663            creation_date: now,
12664            update_date: now,
12665            tags: BTreeMap::new(),
12666            revision_id,
12667            logging_configuration,
12668            tracing_configuration,
12669            description: String::new(),
12670        };
12671
12672        let mut accounts = self.stepfunctions_state.write();
12673        let state = accounts.get_or_create(&self.account_id);
12674        state.state_machines.insert(arn.clone(), sm);
12675
12676        Ok(ProvisionResult::new(arn.clone())
12677            .with("Arn", arn.clone())
12678            .with("Name", name)
12679            .with("StateMachineRevisionId", "INITIAL"))
12680    }
12681
12682    fn delete_sfn_state_machine(&self, physical_id: &str) -> Result<(), String> {
12683        let mut accounts = self.stepfunctions_state.write();
12684        let state = accounts.get_or_create(&self.account_id);
12685        state.state_machines.remove(physical_id);
12686        Ok(())
12687    }
12688
12689    fn create_sfn_activity(
12690        &self,
12691        resource: &ResourceDefinition,
12692    ) -> Result<ProvisionResult, String> {
12693        let props = &resource.properties;
12694        let name = props
12695            .get("Name")
12696            .and_then(|v| v.as_str())
12697            .ok_or("Name is required")?
12698            .to_string();
12699        let arn = format!(
12700            "arn:aws:states:{}:{}:activity:{}",
12701            self.region, self.account_id, name
12702        );
12703        let activity = SfnActivity {
12704            name: name.clone(),
12705            arn: arn.clone(),
12706            creation_date: Utc::now(),
12707            tags: BTreeMap::new(),
12708        };
12709
12710        let mut accounts = self.stepfunctions_state.write();
12711        let state = accounts.get_or_create(&self.account_id);
12712        state.activities.insert(arn.clone(), activity);
12713
12714        Ok(ProvisionResult::new(arn.clone())
12715            .with("Arn", arn)
12716            .with("Name", name))
12717    }
12718
12719    fn delete_sfn_activity(&self, physical_id: &str) -> Result<(), String> {
12720        let mut accounts = self.stepfunctions_state.write();
12721        let state = accounts.get_or_create(&self.account_id);
12722        state.activities.remove(physical_id);
12723        Ok(())
12724    }
12725
12726    fn create_sfn_version(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
12727        let props = &resource.properties;
12728        let sm_arn = props
12729            .get("StateMachineArn")
12730            .and_then(|v| v.as_str())
12731            .ok_or("StateMachineArn is required")?
12732            .to_string();
12733        let description = props
12734            .get("Description")
12735            .and_then(|v| v.as_str())
12736            .unwrap_or("")
12737            .to_string();
12738        let revision_id = props
12739            .get("StateMachineRevisionId")
12740            .and_then(|v| v.as_str())
12741            .unwrap_or("INITIAL")
12742            .to_string();
12743
12744        let mut accounts = self.stepfunctions_state.write();
12745        let state = accounts.get_or_create(&self.account_id);
12746
12747        // Derive next version number for this state machine.
12748        let next_version = state
12749            .state_machine_versions
12750            .values()
12751            .filter(|v| v.state_machine_arn == sm_arn)
12752            .map(|v| v.version)
12753            .max()
12754            .unwrap_or(0)
12755            + 1;
12756        let version_arn = format!("{sm_arn}:{next_version}");
12757
12758        let version = StateMachineVersion {
12759            state_machine_arn: sm_arn,
12760            version: next_version,
12761            revision_id,
12762            description,
12763            creation_date: Utc::now(),
12764        };
12765        state
12766            .state_machine_versions
12767            .insert(version_arn.clone(), version);
12768
12769        Ok(ProvisionResult::new(version_arn.clone()).with("Arn", version_arn))
12770    }
12771
12772    fn delete_sfn_version(&self, physical_id: &str) -> Result<(), String> {
12773        let mut accounts = self.stepfunctions_state.write();
12774        let state = accounts.get_or_create(&self.account_id);
12775        state.state_machine_versions.remove(physical_id);
12776        Ok(())
12777    }
12778
12779    fn create_sfn_alias(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
12780        let props = &resource.properties;
12781        let name = props
12782            .get("Name")
12783            .and_then(|v| v.as_str())
12784            .ok_or("Name is required")?
12785            .to_string();
12786        let description = props
12787            .get("Description")
12788            .and_then(|v| v.as_str())
12789            .unwrap_or("")
12790            .to_string();
12791        let routes_value = props
12792            .get("RoutingConfiguration")
12793            .and_then(|v| v.as_array())
12794            .ok_or("RoutingConfiguration is required")?;
12795        let routing_configuration: Vec<AliasRoute> = routes_value
12796            .iter()
12797            .map(|r| AliasRoute {
12798                state_machine_version_arn: r
12799                    .get("StateMachineVersionArn")
12800                    .and_then(|x| x.as_str())
12801                    .unwrap_or("")
12802                    .to_string(),
12803                weight: r
12804                    .get("Weight")
12805                    .and_then(|x| {
12806                        x.as_i64()
12807                            .or_else(|| x.as_str().and_then(|s| s.parse::<i64>().ok()))
12808                    })
12809                    .map(|w| w as i32)
12810                    .unwrap_or(0),
12811            })
12812            .collect();
12813
12814        let first_version_arn = routing_configuration
12815            .first()
12816            .map(|r| r.state_machine_version_arn.clone())
12817            .unwrap_or_default();
12818        // Alias ARN derives from the parent state machine ARN (everything
12819        // before `:<version>`) + the alias name.
12820        let sm_arn_root = first_version_arn
12821            .rsplit_once(':')
12822            .map(|(root, _)| root.to_string())
12823            .unwrap_or_else(|| {
12824                format!(
12825                    "arn:aws:states:{}:{}:stateMachine:unknown",
12826                    self.region, self.account_id
12827                )
12828            });
12829        let arn = format!("{sm_arn_root}:{name}");
12830        let now = Utc::now();
12831        let alias = StateMachineAlias {
12832            name: name.clone(),
12833            arn: arn.clone(),
12834            description,
12835            routing_configuration,
12836            creation_date: now,
12837            update_date: now,
12838        };
12839
12840        let mut accounts = self.stepfunctions_state.write();
12841        let state = accounts.get_or_create(&self.account_id);
12842        state.state_machine_aliases.insert(arn.clone(), alias);
12843
12844        Ok(ProvisionResult::new(arn.clone())
12845            .with("Arn", arn)
12846            .with("Name", name))
12847    }
12848
12849    fn delete_sfn_alias(&self, physical_id: &str) -> Result<(), String> {
12850        let mut accounts = self.stepfunctions_state.write();
12851        let state = accounts.get_or_create(&self.account_id);
12852        state.state_machine_aliases.remove(physical_id);
12853        Ok(())
12854    }
12855
12856    // --- WAFv2 ---
12857    //
12858    // CFN exclusively writes WAFv2 resources at the global scope
12859    // (`CLOUDFRONT`) for global resources or `REGIONAL` for everything
12860    // else. We honor whatever the template specifies via the `Scope`
12861    // property and store under (scope, name).
12862
12863    fn create_wafv2_web_acl(
12864        &self,
12865        resource: &ResourceDefinition,
12866    ) -> Result<ProvisionResult, String> {
12867        let props = &resource.properties;
12868        let name = props
12869            .get("Name")
12870            .and_then(|v| v.as_str())
12871            .ok_or("Name is required")?
12872            .to_string();
12873        let scope = props
12874            .get("Scope")
12875            .and_then(|v| v.as_str())
12876            .ok_or("Scope is required")?
12877            .to_string();
12878        let default_action = props
12879            .get("DefaultAction")
12880            .cloned()
12881            .unwrap_or_else(|| serde_json::json!({"Allow": {}}));
12882        let description = props
12883            .get("Description")
12884            .and_then(|v| v.as_str())
12885            .map(String::from);
12886        let rules = props
12887            .get("Rules")
12888            .and_then(|v| v.as_array())
12889            .cloned()
12890            .unwrap_or_default();
12891        let visibility_config = props
12892            .get("VisibilityConfig")
12893            .cloned()
12894            .unwrap_or_else(|| serde_json::json!({}));
12895        let capacity = props.get("Capacity").and_then(|v| v.as_i64()).unwrap_or(0);
12896
12897        let id = Uuid::new_v4().to_string();
12898        let (region_in_arn, scope_seg): (&str, String) = if scope == "CLOUDFRONT" {
12899            ("us-east-1", "global".to_string())
12900        } else {
12901            (self.region.as_str(), self.region.clone())
12902        };
12903        let arn = format!(
12904            "arn:aws:wafv2:{}:{}:{}/webacl/{}/{}",
12905            region_in_arn, self.account_id, scope_seg, name, id
12906        );
12907        let acl = WebAcl {
12908            id: id.clone(),
12909            name: name.clone(),
12910            arn: arn.clone(),
12911            scope: scope.clone(),
12912            default_action,
12913            description,
12914            rules,
12915            visibility_config,
12916            capacity,
12917            lock_token: Uuid::new_v4().simple().to_string(),
12918            label_namespace: format!("awswaf:{}:webacl:{}:", self.account_id, name),
12919            custom_response_bodies: BTreeMap::new(),
12920            captcha_config: None,
12921            challenge_config: None,
12922            token_domains: Vec::new(),
12923            association_config: None,
12924            data_protection_config: None,
12925            on_source_d_do_s_protection_config: None,
12926            application_config: None,
12927            retrofitted_by_firewall_manager: false,
12928            pre_process_firewall_manager_rule_groups: Vec::new(),
12929            post_process_firewall_manager_rule_groups: Vec::new(),
12930            managed_by_firewall_manager: false,
12931            created_time: Utc::now(),
12932        };
12933
12934        let mut accounts = self.wafv2_state.write();
12935        let state = accounts
12936            .accounts
12937            .entry(self.account_id.clone())
12938            .or_default();
12939        state.web_acls.insert((scope.clone(), name.clone()), acl);
12940
12941        Ok(ProvisionResult::new(arn.clone())
12942            .with("Arn", arn)
12943            .with("Id", id)
12944            .with("Name", name)
12945            .with("Capacity", capacity.to_string()))
12946    }
12947
12948    fn delete_wafv2_web_acl(&self, physical_id: &str) -> Result<(), String> {
12949        let mut accounts = self.wafv2_state.write();
12950        let state = accounts
12951            .accounts
12952            .entry(self.account_id.clone())
12953            .or_default();
12954        state.web_acls.retain(|_, v| v.arn != physical_id);
12955        Ok(())
12956    }
12957
12958    fn create_wafv2_ip_set(
12959        &self,
12960        resource: &ResourceDefinition,
12961    ) -> Result<ProvisionResult, String> {
12962        let props = &resource.properties;
12963        let name = props
12964            .get("Name")
12965            .and_then(|v| v.as_str())
12966            .ok_or("Name is required")?
12967            .to_string();
12968        let scope = props
12969            .get("Scope")
12970            .and_then(|v| v.as_str())
12971            .ok_or("Scope is required")?
12972            .to_string();
12973        let ip_address_version = props
12974            .get("IPAddressVersion")
12975            .and_then(|v| v.as_str())
12976            .ok_or("IPAddressVersion is required")?
12977            .to_string();
12978        let addresses: Vec<String> = props
12979            .get("Addresses")
12980            .and_then(|v| v.as_array())
12981            .map(|arr| {
12982                arr.iter()
12983                    .filter_map(|v| v.as_str().map(String::from))
12984                    .collect()
12985            })
12986            .unwrap_or_default();
12987        let description = props
12988            .get("Description")
12989            .and_then(|v| v.as_str())
12990            .map(String::from);
12991
12992        let id = Uuid::new_v4().to_string();
12993        let (region_in_arn, scope_seg): (&str, String) = if scope == "CLOUDFRONT" {
12994            ("us-east-1", "global".to_string())
12995        } else {
12996            (self.region.as_str(), self.region.clone())
12997        };
12998        let arn = format!(
12999            "arn:aws:wafv2:{}:{}:{}/ipset/{}/{}",
13000            region_in_arn, self.account_id, scope_seg, name, id
13001        );
13002        let ip_set = IpSet {
13003            id: id.clone(),
13004            name: name.clone(),
13005            arn: arn.clone(),
13006            scope: scope.clone(),
13007            description,
13008            ip_address_version,
13009            addresses,
13010            lock_token: Uuid::new_v4().simple().to_string(),
13011            created_time: Utc::now(),
13012        };
13013
13014        let mut accounts = self.wafv2_state.write();
13015        let state = accounts
13016            .accounts
13017            .entry(self.account_id.clone())
13018            .or_default();
13019        state.ip_sets.insert((scope, name.clone()), ip_set);
13020
13021        Ok(ProvisionResult::new(arn.clone())
13022            .with("Arn", arn)
13023            .with("Id", id)
13024            .with("Name", name))
13025    }
13026
13027    fn delete_wafv2_ip_set(&self, physical_id: &str) -> Result<(), String> {
13028        let mut accounts = self.wafv2_state.write();
13029        let state = accounts
13030            .accounts
13031            .entry(self.account_id.clone())
13032            .or_default();
13033        state.ip_sets.retain(|_, v| v.arn != physical_id);
13034        Ok(())
13035    }
13036
13037    fn create_wafv2_regex_pattern_set(
13038        &self,
13039        resource: &ResourceDefinition,
13040    ) -> Result<ProvisionResult, String> {
13041        let props = &resource.properties;
13042        let name = props
13043            .get("Name")
13044            .and_then(|v| v.as_str())
13045            .ok_or("Name is required")?
13046            .to_string();
13047        let scope = props
13048            .get("Scope")
13049            .and_then(|v| v.as_str())
13050            .ok_or("Scope is required")?
13051            .to_string();
13052        let regular_expressions: Vec<serde_json::Value> = props
13053            .get("RegularExpressionList")
13054            .and_then(|v| v.as_array())
13055            .map(|arr| {
13056                arr.iter()
13057                    .map(|s| {
13058                        if let Some(s) = s.as_str() {
13059                            serde_json::json!({"RegexString": s})
13060                        } else {
13061                            s.clone()
13062                        }
13063                    })
13064                    .collect()
13065            })
13066            .unwrap_or_default();
13067        let description = props
13068            .get("Description")
13069            .and_then(|v| v.as_str())
13070            .map(String::from);
13071
13072        let id = Uuid::new_v4().to_string();
13073        let (region_in_arn, scope_seg): (&str, String) = if scope == "CLOUDFRONT" {
13074            ("us-east-1", "global".to_string())
13075        } else {
13076            (self.region.as_str(), self.region.clone())
13077        };
13078        let arn = format!(
13079            "arn:aws:wafv2:{}:{}:{}/regexpatternset/{}/{}",
13080            region_in_arn, self.account_id, scope_seg, name, id
13081        );
13082        let set = RegexPatternSet {
13083            id: id.clone(),
13084            name: name.clone(),
13085            arn: arn.clone(),
13086            scope: scope.clone(),
13087            description,
13088            regular_expressions,
13089            lock_token: Uuid::new_v4().simple().to_string(),
13090            created_time: Utc::now(),
13091        };
13092
13093        let mut accounts = self.wafv2_state.write();
13094        let state = accounts
13095            .accounts
13096            .entry(self.account_id.clone())
13097            .or_default();
13098        state.regex_pattern_sets.insert((scope, name.clone()), set);
13099
13100        Ok(ProvisionResult::new(arn.clone())
13101            .with("Arn", arn)
13102            .with("Id", id)
13103            .with("Name", name))
13104    }
13105
13106    fn delete_wafv2_regex_pattern_set(&self, physical_id: &str) -> Result<(), String> {
13107        let mut accounts = self.wafv2_state.write();
13108        let state = accounts
13109            .accounts
13110            .entry(self.account_id.clone())
13111            .or_default();
13112        state.regex_pattern_sets.retain(|_, v| v.arn != physical_id);
13113        Ok(())
13114    }
13115
13116    fn create_wafv2_rule_group(
13117        &self,
13118        resource: &ResourceDefinition,
13119    ) -> Result<ProvisionResult, String> {
13120        let props = &resource.properties;
13121        let name = props
13122            .get("Name")
13123            .and_then(|v| v.as_str())
13124            .ok_or("Name is required")?
13125            .to_string();
13126        let scope = props
13127            .get("Scope")
13128            .and_then(|v| v.as_str())
13129            .ok_or("Scope is required")?
13130            .to_string();
13131        let capacity = props
13132            .get("Capacity")
13133            .and_then(|v| v.as_i64())
13134            .ok_or("Capacity is required")?;
13135        let description = props
13136            .get("Description")
13137            .and_then(|v| v.as_str())
13138            .map(String::from);
13139        let rules = props
13140            .get("Rules")
13141            .and_then(|v| v.as_array())
13142            .cloned()
13143            .unwrap_or_default();
13144        let visibility_config = props
13145            .get("VisibilityConfig")
13146            .cloned()
13147            .unwrap_or_else(|| serde_json::json!({}));
13148
13149        let id = Uuid::new_v4().to_string();
13150        let (region_in_arn, scope_seg): (&str, String) = if scope == "CLOUDFRONT" {
13151            ("us-east-1", "global".to_string())
13152        } else {
13153            (self.region.as_str(), self.region.clone())
13154        };
13155        let arn = format!(
13156            "arn:aws:wafv2:{}:{}:{}/rulegroup/{}/{}",
13157            region_in_arn, self.account_id, scope_seg, name, id
13158        );
13159        let rg = RuleGroup {
13160            id: id.clone(),
13161            name: name.clone(),
13162            arn: arn.clone(),
13163            scope: scope.clone(),
13164            capacity,
13165            description,
13166            rules,
13167            visibility_config,
13168            lock_token: Uuid::new_v4().simple().to_string(),
13169            label_namespace: format!("awswaf:{}:rulegroup:{}:", self.account_id, name),
13170            custom_response_bodies: BTreeMap::new(),
13171            available_labels: Vec::new(),
13172            consumed_labels: Vec::new(),
13173            created_time: Utc::now(),
13174        };
13175
13176        let mut accounts = self.wafv2_state.write();
13177        let state = accounts
13178            .accounts
13179            .entry(self.account_id.clone())
13180            .or_default();
13181        state.rule_groups.insert((scope, name.clone()), rg);
13182
13183        Ok(ProvisionResult::new(arn.clone())
13184            .with("Arn", arn)
13185            .with("Id", id)
13186            .with("Name", name)
13187            .with("Capacity", capacity.to_string()))
13188    }
13189
13190    fn delete_wafv2_rule_group(&self, physical_id: &str) -> Result<(), String> {
13191        let mut accounts = self.wafv2_state.write();
13192        let state = accounts
13193            .accounts
13194            .entry(self.account_id.clone())
13195            .or_default();
13196        state.rule_groups.retain(|_, v| v.arn != physical_id);
13197        Ok(())
13198    }
13199
13200    fn create_wafv2_logging_configuration(
13201        &self,
13202        resource: &ResourceDefinition,
13203    ) -> Result<ProvisionResult, String> {
13204        let props = &resource.properties;
13205        let resource_arn = props
13206            .get("ResourceArn")
13207            .and_then(|v| v.as_str())
13208            .ok_or("ResourceArn is required")?
13209            .to_string();
13210        let cfg = serde_json::json!({
13211            "ResourceArn": resource_arn,
13212            "LogDestinationConfigs": props.get("LogDestinationConfigs").cloned().unwrap_or_else(|| serde_json::json!([])),
13213            "RedactedFields": props.get("RedactedFields").cloned().unwrap_or_else(|| serde_json::json!([])),
13214            "LoggingFilter": props.get("LoggingFilter").cloned(),
13215        });
13216
13217        let mut accounts = self.wafv2_state.write();
13218        let state = accounts
13219            .accounts
13220            .entry(self.account_id.clone())
13221            .or_default();
13222        state.logging_configs.insert(resource_arn.clone(), cfg);
13223
13224        Ok(ProvisionResult::new(resource_arn))
13225    }
13226
13227    fn delete_wafv2_logging_configuration(&self, physical_id: &str) -> Result<(), String> {
13228        let mut accounts = self.wafv2_state.write();
13229        let state = accounts
13230            .accounts
13231            .entry(self.account_id.clone())
13232            .or_default();
13233        state.logging_configs.remove(physical_id);
13234        Ok(())
13235    }
13236
13237    fn create_wafv2_web_acl_association(
13238        &self,
13239        resource: &ResourceDefinition,
13240    ) -> Result<ProvisionResult, String> {
13241        let props = &resource.properties;
13242        let resource_arn = props
13243            .get("ResourceArn")
13244            .and_then(|v| v.as_str())
13245            .ok_or("ResourceArn is required")?
13246            .to_string();
13247        let web_acl_arn = props
13248            .get("WebACLArn")
13249            .and_then(|v| v.as_str())
13250            .ok_or("WebACLArn is required")?
13251            .to_string();
13252
13253        let mut accounts = self.wafv2_state.write();
13254        let state = accounts
13255            .accounts
13256            .entry(self.account_id.clone())
13257            .or_default();
13258        state.associations.insert(resource_arn.clone(), web_acl_arn);
13259
13260        // Physical id encodes the resource arn so delete can find it.
13261        Ok(ProvisionResult::new(resource_arn))
13262    }
13263
13264    fn delete_wafv2_web_acl_association(&self, physical_id: &str) -> Result<(), String> {
13265        let mut accounts = self.wafv2_state.write();
13266        let state = accounts
13267            .accounts
13268            .entry(self.account_id.clone())
13269            .or_default();
13270        state.associations.remove(physical_id);
13271        Ok(())
13272    }
13273
13274    // --- API Gateway v1 ---
13275
13276    fn create_apigw_rest_api(
13277        &self,
13278        resource: &ResourceDefinition,
13279    ) -> Result<ProvisionResult, String> {
13280        let props = &resource.properties;
13281        let name = props
13282            .get("Name")
13283            .and_then(|v| v.as_str())
13284            .ok_or("Name is required")?
13285            .to_string();
13286        let description = props
13287            .get("Description")
13288            .and_then(|v| v.as_str())
13289            .map(String::from);
13290        let api_key_source = props
13291            .get("ApiKeySourceType")
13292            .and_then(|v| v.as_str())
13293            .unwrap_or("HEADER")
13294            .to_string();
13295        let endpoint_configuration = props
13296            .get("EndpointConfiguration")
13297            .cloned()
13298            .unwrap_or_else(|| serde_json::json!({"types": ["EDGE"]}));
13299        let policy = props
13300            .get("Policy")
13301            .map(|v| v.to_string().trim_matches('"').to_string());
13302        let binary_media_types: Vec<String> = props
13303            .get("BinaryMediaTypes")
13304            .and_then(|v| v.as_array())
13305            .map(|arr| {
13306                arr.iter()
13307                    .filter_map(|v| v.as_str().map(String::from))
13308                    .collect()
13309            })
13310            .unwrap_or_default();
13311        let minimum_compression_size = props.get("MinimumCompressionSize").and_then(|v| v.as_i64());
13312        let disable_execute_api_endpoint = props
13313            .get("DisableExecuteApiEndpoint")
13314            .and_then(|v| v.as_bool())
13315            .unwrap_or(false);
13316        // CFN exposes optional `Body`/`BodyS3Location`/`CloneFrom` for OpenAPI
13317        // import. We don't run a full Swagger import — we record the source
13318        // in the api's `import_source` field so callers can reason about it.
13319        let import_source = if props.get("Body").is_some() {
13320            Some("Body".to_string())
13321        } else if props.get("BodyS3Location").is_some() {
13322            Some("BodyS3Location".to_string())
13323        } else if props.get("CloneFrom").is_some() {
13324            Some("CloneFrom".to_string())
13325        } else {
13326            None
13327        };
13328        let tags = parse_acm_tags(props.get("Tags"));
13329
13330        let id = apigw_make_id();
13331        let root_resource_id = apigw_make_id();
13332        let now = Utc::now();
13333
13334        let api = ApiGwRestApi {
13335            id: id.clone(),
13336            name,
13337            description,
13338            version: props
13339                .get("Version")
13340                .and_then(|v| v.as_str())
13341                .map(String::from),
13342            created_date: now,
13343            api_key_source,
13344            endpoint_configuration,
13345            policy,
13346            binary_media_types,
13347            minimum_compression_size,
13348            disable_execute_api_endpoint,
13349            root_resource_id: root_resource_id.clone(),
13350            tags,
13351            import_source,
13352        };
13353
13354        let mut accounts = self.apigateway_state.write();
13355        let state = accounts.get_or_create(&self.account_id);
13356        state.apis.insert(id.clone(), api);
13357        let mut resources = BTreeMap::new();
13358        resources.insert(
13359            root_resource_id.clone(),
13360            ApiGwResource {
13361                id: root_resource_id.clone(),
13362                parent_id: None,
13363                path_part: None,
13364                path: "/".to_string(),
13365            },
13366        );
13367        state.resources.insert(id.clone(), resources);
13368
13369        Ok(ProvisionResult::new(id.clone())
13370            .with("RestApiId", id.clone())
13371            .with("RootResourceId", root_resource_id))
13372    }
13373
13374    fn update_apigw_rest_api(
13375        &self,
13376        existing: &StackResource,
13377        resource: &ResourceDefinition,
13378    ) -> Result<ProvisionResult, String> {
13379        let props = &resource.properties;
13380        let id = existing.physical_id.clone();
13381        let mut accounts = self.apigateway_state.write();
13382        let state = accounts.get_or_create(&self.account_id);
13383        let api = state
13384            .apis
13385            .get_mut(&id)
13386            .ok_or_else(|| format!("RestApi {id} not found for update"))?;
13387        if let Some(name) = props.get("Name").and_then(|v| v.as_str()) {
13388            api.name = name.to_string();
13389        }
13390        if let Some(desc) = props.get("Description").and_then(|v| v.as_str()) {
13391            api.description = Some(desc.to_string());
13392        }
13393        if let Some(source) = props.get("ApiKeySourceType").and_then(|v| v.as_str()) {
13394            api.api_key_source = source.to_string();
13395        }
13396        if let Some(ep) = props.get("EndpointConfiguration").cloned() {
13397            api.endpoint_configuration = ep;
13398        }
13399        if let Some(arr) = props.get("BinaryMediaTypes").and_then(|v| v.as_array()) {
13400            api.binary_media_types = arr
13401                .iter()
13402                .filter_map(|v| v.as_str().map(String::from))
13403                .collect();
13404        }
13405        if let Some(size) = props.get("MinimumCompressionSize").and_then(|v| v.as_i64()) {
13406            api.minimum_compression_size = Some(size);
13407        }
13408        if let Some(b) = props
13409            .get("DisableExecuteApiEndpoint")
13410            .and_then(|v| v.as_bool())
13411        {
13412            api.disable_execute_api_endpoint = b;
13413        }
13414        if props.get("Tags").is_some() {
13415            api.tags = parse_acm_tags(props.get("Tags"));
13416        }
13417        let root = api.root_resource_id.clone();
13418        Ok(ProvisionResult::new(id.clone())
13419            .with("RestApiId", id)
13420            .with("RootResourceId", root))
13421    }
13422
13423    fn delete_apigw_rest_api(&self, physical_id: &str) -> Result<(), String> {
13424        let mut accounts = self.apigateway_state.write();
13425        let state = accounts.get_or_create(&self.account_id);
13426        state.apis.remove(physical_id);
13427        state.resources.remove(physical_id);
13428        let prefix = format!("{physical_id}/");
13429        state.methods.retain(|k, _| !k.starts_with(&prefix));
13430        state.integrations.retain(|k, _| !k.starts_with(&prefix));
13431        state
13432            .integration_responses
13433            .retain(|k, _| !k.starts_with(&prefix));
13434        state
13435            .method_responses
13436            .retain(|k, _| !k.starts_with(&prefix));
13437        state.deployments.remove(physical_id);
13438        state.stages.remove(physical_id);
13439        state.models.remove(physical_id);
13440        state.request_validators.remove(physical_id);
13441        state.authorizers.remove(physical_id);
13442        state.gateway_responses.remove(physical_id);
13443        Ok(())
13444    }
13445
13446    fn create_apigw_resource(
13447        &self,
13448        resource: &ResourceDefinition,
13449    ) -> Result<ProvisionResult, String> {
13450        let props = &resource.properties;
13451        let rest_api_id = props
13452            .get("RestApiId")
13453            .and_then(|v| v.as_str())
13454            .ok_or("RestApiId is required")?
13455            .to_string();
13456        let parent_id = props
13457            .get("ParentId")
13458            .and_then(|v| v.as_str())
13459            .ok_or("ParentId is required")?
13460            .to_string();
13461        let path_part = props
13462            .get("PathPart")
13463            .and_then(|v| v.as_str())
13464            .ok_or("PathPart is required")?
13465            .to_string();
13466
13467        let mut accounts = self.apigateway_state.write();
13468        let state = accounts.get_or_create(&self.account_id);
13469        let api_resources = state
13470            .resources
13471            .get(&rest_api_id)
13472            .ok_or_else(|| format!("RestApi {rest_api_id} not found"))?;
13473        let parent = api_resources
13474            .get(&parent_id)
13475            .ok_or_else(|| format!("Parent resource {parent_id} not found"))?;
13476        let parent_path = parent.path.clone();
13477        let path = if parent_path == "/" {
13478            format!("/{path_part}")
13479        } else {
13480            format!("{parent_path}/{path_part}")
13481        };
13482
13483        let id = apigw_make_id();
13484        let new_resource = ApiGwResource {
13485            id: id.clone(),
13486            parent_id: Some(parent_id),
13487            path_part: Some(path_part),
13488            path,
13489        };
13490        state
13491            .resources
13492            .entry(rest_api_id.clone())
13493            .or_default()
13494            .insert(id.clone(), new_resource);
13495
13496        Ok(ProvisionResult::new(id.clone())
13497            .with("ResourceId", id)
13498            .with("RestApiId", rest_api_id))
13499    }
13500
13501    fn delete_apigw_resource(
13502        &self,
13503        physical_id: &str,
13504        attributes: &BTreeMap<String, String>,
13505    ) -> Result<(), String> {
13506        let Some(rest_api_id) = attributes.get("RestApiId") else {
13507            return Ok(());
13508        };
13509        let mut accounts = self.apigateway_state.write();
13510        let state = accounts.get_or_create(&self.account_id);
13511        if let Some(map) = state.resources.get_mut(rest_api_id) {
13512            map.remove(physical_id);
13513        }
13514        let prefix = format!("{rest_api_id}/{physical_id}/");
13515        state.methods.retain(|k, _| !k.starts_with(&prefix));
13516        state.integrations.retain(|k, _| !k.starts_with(&prefix));
13517        Ok(())
13518    }
13519
13520    fn create_apigw_method(
13521        &self,
13522        resource: &ResourceDefinition,
13523    ) -> Result<ProvisionResult, String> {
13524        let props = &resource.properties;
13525        let rest_api_id = props
13526            .get("RestApiId")
13527            .and_then(|v| v.as_str())
13528            .ok_or("RestApiId is required")?
13529            .to_string();
13530        let resource_id = props
13531            .get("ResourceId")
13532            .and_then(|v| v.as_str())
13533            .ok_or("ResourceId is required")?
13534            .to_string();
13535        let http_method = props
13536            .get("HttpMethod")
13537            .and_then(|v| v.as_str())
13538            .ok_or("HttpMethod is required")?
13539            .to_uppercase();
13540        let authorization_type = props
13541            .get("AuthorizationType")
13542            .and_then(|v| v.as_str())
13543            .unwrap_or("NONE")
13544            .to_string();
13545        let authorizer_id = props
13546            .get("AuthorizerId")
13547            .and_then(|v| v.as_str())
13548            .map(String::from);
13549        let api_key_required = props
13550            .get("ApiKeyRequired")
13551            .and_then(|v| v.as_bool())
13552            .unwrap_or(false);
13553        let operation_name = props
13554            .get("OperationName")
13555            .and_then(|v| v.as_str())
13556            .map(String::from);
13557        let request_validator_id = props
13558            .get("RequestValidatorId")
13559            .and_then(|v| v.as_str())
13560            .map(String::from);
13561        let request_parameters: BTreeMap<String, bool> = props
13562            .get("RequestParameters")
13563            .and_then(|v| v.as_object())
13564            .map(|obj| {
13565                obj.iter()
13566                    .map(|(k, v)| (k.clone(), v.as_bool().unwrap_or(false)))
13567                    .collect()
13568            })
13569            .unwrap_or_default();
13570        let request_models: BTreeMap<String, String> = props
13571            .get("RequestModels")
13572            .and_then(|v| v.as_object())
13573            .map(|obj| {
13574                obj.iter()
13575                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
13576                    .collect()
13577            })
13578            .unwrap_or_default();
13579        let authorization_scopes: Vec<String> = props
13580            .get("AuthorizationScopes")
13581            .and_then(|v| v.as_array())
13582            .map(|arr| {
13583                arr.iter()
13584                    .filter_map(|v| v.as_str().map(String::from))
13585                    .collect()
13586            })
13587            .unwrap_or_default();
13588
13589        let composite_key = format!("{rest_api_id}/{resource_id}/{http_method}");
13590        let method = ApiGwMethod {
13591            rest_api_id: rest_api_id.clone(),
13592            resource_id: resource_id.clone(),
13593            http_method: http_method.clone(),
13594            authorization_type,
13595            authorizer_id,
13596            api_key_required,
13597            operation_name,
13598            request_parameters,
13599            request_models,
13600            request_validator_id,
13601            authorization_scopes,
13602        };
13603
13604        let mut accounts = self.apigateway_state.write();
13605        let state = accounts.get_or_create(&self.account_id);
13606        if !state.apis.contains_key(&rest_api_id) {
13607            return Err(format!("RestApi {rest_api_id} not found"));
13608        }
13609        // Multi-pass provisioning: if `Ref: SomeResource` resolved to the
13610        // logical id (because the referenced resource hasn't been
13611        // provisioned yet on this pass), bail so CFN retries us next pass.
13612        let resource_known = state
13613            .resources
13614            .get(&rest_api_id)
13615            .map(|m| m.contains_key(&resource_id))
13616            .unwrap_or(false);
13617        if !resource_known {
13618            return Err(format!(
13619                "Resource {resource_id} not yet provisioned for api {rest_api_id}"
13620            ));
13621        }
13622        state.methods.insert(composite_key.clone(), method);
13623
13624        if let Some(integ_props) = props.get("Integration").and_then(|v| v.as_object()) {
13625            let integration = ApiGwIntegration {
13626                rest_api_id: rest_api_id.clone(),
13627                resource_id: resource_id.clone(),
13628                http_method: http_method.clone(),
13629                integration_type: integ_props
13630                    .get("Type")
13631                    .and_then(|v| v.as_str())
13632                    .unwrap_or("MOCK")
13633                    .to_string(),
13634                integration_http_method: integ_props
13635                    .get("IntegrationHttpMethod")
13636                    .and_then(|v| v.as_str())
13637                    .map(String::from),
13638                uri: integ_props
13639                    .get("Uri")
13640                    .and_then(|v| v.as_str())
13641                    .map(String::from),
13642                credentials: integ_props
13643                    .get("Credentials")
13644                    .and_then(|v| v.as_str())
13645                    .map(String::from),
13646                request_parameters: integ_props
13647                    .get("RequestParameters")
13648                    .and_then(|v| v.as_object())
13649                    .map(|obj| {
13650                        obj.iter()
13651                            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
13652                            .collect()
13653                    })
13654                    .unwrap_or_default(),
13655                request_templates: integ_props
13656                    .get("RequestTemplates")
13657                    .and_then(|v| v.as_object())
13658                    .map(|obj| {
13659                        obj.iter()
13660                            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
13661                            .collect()
13662                    })
13663                    .unwrap_or_default(),
13664                passthrough_behavior: integ_props
13665                    .get("PassthroughBehavior")
13666                    .and_then(|v| v.as_str())
13667                    .unwrap_or("WHEN_NO_MATCH")
13668                    .to_string(),
13669                timeout_in_millis: integ_props
13670                    .get("TimeoutInMillis")
13671                    .and_then(|v| v.as_i64())
13672                    .map(|n| n as i32),
13673                cache_namespace: integ_props
13674                    .get("CacheNamespace")
13675                    .and_then(|v| v.as_str())
13676                    .map(String::from),
13677                cache_key_parameters: integ_props
13678                    .get("CacheKeyParameters")
13679                    .and_then(|v| v.as_array())
13680                    .map(|arr| {
13681                        arr.iter()
13682                            .filter_map(|v| v.as_str().map(String::from))
13683                            .collect()
13684                    })
13685                    .unwrap_or_default(),
13686                content_handling: integ_props
13687                    .get("ContentHandling")
13688                    .and_then(|v| v.as_str())
13689                    .map(String::from),
13690                connection_type: integ_props
13691                    .get("ConnectionType")
13692                    .and_then(|v| v.as_str())
13693                    .map(String::from),
13694                connection_id: integ_props
13695                    .get("ConnectionId")
13696                    .and_then(|v| v.as_str())
13697                    .map(String::from),
13698                tls_config: integ_props.get("TlsConfig").cloned(),
13699            };
13700            state
13701                .integrations
13702                .insert(composite_key.clone(), integration);
13703        }
13704
13705        Ok(ProvisionResult::new(composite_key.clone())
13706            .with("MethodKey", composite_key)
13707            .with("RestApiId", rest_api_id)
13708            .with("ResourceId", resource_id)
13709            .with("HttpMethod", http_method))
13710    }
13711
13712    fn delete_apigw_method(&self, physical_id: &str) -> Result<(), String> {
13713        let mut accounts = self.apigateway_state.write();
13714        let state = accounts.get_or_create(&self.account_id);
13715        state.methods.remove(physical_id);
13716        state.integrations.remove(physical_id);
13717        let prefix = format!("{physical_id}/");
13718        state
13719            .integration_responses
13720            .retain(|k, _| !k.starts_with(&prefix));
13721        state
13722            .method_responses
13723            .retain(|k, _| !k.starts_with(&prefix));
13724        Ok(())
13725    }
13726
13727    fn create_apigw_deployment(
13728        &self,
13729        resource: &ResourceDefinition,
13730    ) -> Result<ProvisionResult, String> {
13731        let props = &resource.properties;
13732        let rest_api_id = props
13733            .get("RestApiId")
13734            .and_then(|v| v.as_str())
13735            .ok_or("RestApiId is required")?
13736            .to_string();
13737        let description = props
13738            .get("Description")
13739            .and_then(|v| v.as_str())
13740            .map(String::from);
13741
13742        let id = apigw_make_id();
13743        let mut accounts = self.apigateway_state.write();
13744        let state = accounts.get_or_create(&self.account_id);
13745        if !state.apis.contains_key(&rest_api_id) {
13746            return Err(format!("RestApi {rest_api_id} not found"));
13747        }
13748        let api_summary = serde_json::to_value(
13749            state
13750                .resources
13751                .get(&rest_api_id)
13752                .cloned()
13753                .unwrap_or_default(),
13754        )
13755        .unwrap_or(serde_json::Value::Null);
13756        let deployment = ApiGwDeployment {
13757            id: id.clone(),
13758            description,
13759            created_date: Utc::now(),
13760            api_summary,
13761        };
13762        state
13763            .deployments
13764            .entry(rest_api_id.clone())
13765            .or_default()
13766            .insert(id.clone(), deployment);
13767
13768        // CFN inline `StageName` creates a Stage referencing this deployment.
13769        if let Some(stage_name) = props
13770            .get("StageName")
13771            .and_then(|v| v.as_str())
13772            .map(String::from)
13773        {
13774            let stage = ApiGwStage {
13775                stage_name: stage_name.clone(),
13776                deployment_id: id.clone(),
13777                description: props
13778                    .get("StageDescription")
13779                    .and_then(|v| v.get("Description"))
13780                    .and_then(|v| v.as_str())
13781                    .map(String::from),
13782                cache_cluster_enabled: false,
13783                cache_cluster_size: None,
13784                variables: BTreeMap::new(),
13785                method_settings: BTreeMap::new(),
13786                created_date: Utc::now(),
13787                last_updated_date: Utc::now(),
13788                tracing_enabled: false,
13789                web_acl_arn: None,
13790                canary_settings: None,
13791                access_log_settings: None,
13792                tags: BTreeMap::new(),
13793            };
13794            state
13795                .stages
13796                .entry(rest_api_id.clone())
13797                .or_default()
13798                .insert(stage_name, stage);
13799        }
13800
13801        Ok(ProvisionResult::new(id.clone())
13802            .with("DeploymentId", id)
13803            .with("RestApiId", rest_api_id))
13804    }
13805
13806    fn delete_apigw_deployment(
13807        &self,
13808        physical_id: &str,
13809        attributes: &BTreeMap<String, String>,
13810    ) -> Result<(), String> {
13811        let Some(rest_api_id) = attributes.get("RestApiId") else {
13812            return Ok(());
13813        };
13814        let mut accounts = self.apigateway_state.write();
13815        let state = accounts.get_or_create(&self.account_id);
13816        if let Some(map) = state.deployments.get_mut(rest_api_id) {
13817            map.remove(physical_id);
13818        }
13819        Ok(())
13820    }
13821
13822    fn create_apigw_stage(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
13823        let props = &resource.properties;
13824        let rest_api_id = props
13825            .get("RestApiId")
13826            .and_then(|v| v.as_str())
13827            .ok_or("RestApiId is required")?
13828            .to_string();
13829        let stage_name = props
13830            .get("StageName")
13831            .and_then(|v| v.as_str())
13832            .ok_or("StageName is required")?
13833            .to_string();
13834        let deployment_id = props
13835            .get("DeploymentId")
13836            .and_then(|v| v.as_str())
13837            .ok_or("DeploymentId is required")?
13838            .to_string();
13839
13840        let variables: BTreeMap<String, String> = props
13841            .get("Variables")
13842            .and_then(|v| v.as_object())
13843            .map(|obj| {
13844                obj.iter()
13845                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
13846                    .collect()
13847            })
13848            .unwrap_or_default();
13849        let tracing_enabled = props
13850            .get("TracingEnabled")
13851            .and_then(|v| v.as_bool())
13852            .unwrap_or(false);
13853        let cache_cluster_enabled = props
13854            .get("CacheClusterEnabled")
13855            .and_then(|v| v.as_bool())
13856            .unwrap_or(false);
13857        let cache_cluster_size = props
13858            .get("CacheClusterSize")
13859            .and_then(|v| v.as_str())
13860            .map(String::from);
13861        // CFN models MethodSettings as a list of `{ResourcePath,HttpMethod,...}`
13862        // entries; the live API stores them as a `path/method -> settings`
13863        // map. Translate by joining ResourcePath + HttpMethod into the key.
13864        let method_settings: BTreeMap<String, serde_json::Value> = props
13865            .get("MethodSettings")
13866            .and_then(|v| v.as_array())
13867            .map(|arr| {
13868                arr.iter()
13869                    .filter_map(|s| {
13870                        let path = s.get("ResourcePath").and_then(|v| v.as_str())?;
13871                        let http = s.get("HttpMethod").and_then(|v| v.as_str())?;
13872                        let key = format!("{}/{http}", path.strip_prefix('/').unwrap_or(path));
13873                        Some((key, s.clone()))
13874                    })
13875                    .collect()
13876            })
13877            .unwrap_or_default();
13878        let tags = parse_acm_tags(props.get("Tags"));
13879
13880        let stage = ApiGwStage {
13881            stage_name: stage_name.clone(),
13882            deployment_id,
13883            description: props
13884                .get("Description")
13885                .and_then(|v| v.as_str())
13886                .map(String::from),
13887            cache_cluster_enabled,
13888            cache_cluster_size,
13889            variables,
13890            method_settings,
13891            created_date: Utc::now(),
13892            last_updated_date: Utc::now(),
13893            tracing_enabled,
13894            web_acl_arn: None,
13895            canary_settings: props.get("CanarySetting").cloned(),
13896            access_log_settings: props.get("AccessLogSetting").cloned(),
13897            tags,
13898        };
13899
13900        let mut accounts = self.apigateway_state.write();
13901        let state = accounts.get_or_create(&self.account_id);
13902        if !state.apis.contains_key(&rest_api_id) {
13903            return Err(format!("RestApi {rest_api_id} not found"));
13904        }
13905        let dep_known = state
13906            .deployments
13907            .get(&rest_api_id)
13908            .map(|m| m.contains_key(&stage.deployment_id))
13909            .unwrap_or(false);
13910        if !dep_known {
13911            return Err(format!(
13912                "Deployment {} not yet provisioned for api {rest_api_id}",
13913                stage.deployment_id
13914            ));
13915        }
13916        state
13917            .stages
13918            .entry(rest_api_id.clone())
13919            .or_default()
13920            .insert(stage_name.clone(), stage);
13921
13922        Ok(ProvisionResult::new(stage_name.clone())
13923            .with("StageName", stage_name)
13924            .with("RestApiId", rest_api_id))
13925    }
13926
13927    fn delete_apigw_stage(
13928        &self,
13929        physical_id: &str,
13930        attributes: &BTreeMap<String, String>,
13931    ) -> Result<(), String> {
13932        let Some(rest_api_id) = attributes.get("RestApiId") else {
13933            return Ok(());
13934        };
13935        let mut accounts = self.apigateway_state.write();
13936        let state = accounts.get_or_create(&self.account_id);
13937        if let Some(map) = state.stages.get_mut(rest_api_id) {
13938            map.remove(physical_id);
13939        }
13940        Ok(())
13941    }
13942
13943    fn create_apigw_authorizer(
13944        &self,
13945        resource: &ResourceDefinition,
13946    ) -> Result<ProvisionResult, String> {
13947        let props = &resource.properties;
13948        let rest_api_id = props
13949            .get("RestApiId")
13950            .and_then(|v| v.as_str())
13951            .ok_or("RestApiId is required")?
13952            .to_string();
13953        let name = props
13954            .get("Name")
13955            .and_then(|v| v.as_str())
13956            .ok_or("Name is required")?
13957            .to_string();
13958        let authorizer_type = props
13959            .get("Type")
13960            .and_then(|v| v.as_str())
13961            .unwrap_or("TOKEN")
13962            .to_string();
13963        let provider_arns: Vec<String> = props
13964            .get("ProviderARNs")
13965            .and_then(|v| v.as_array())
13966            .map(|arr| {
13967                arr.iter()
13968                    .filter_map(|v| v.as_str().map(String::from))
13969                    .collect()
13970            })
13971            .unwrap_or_default();
13972
13973        let id = apigw_make_id();
13974        let auth = ApiGwAuthorizer {
13975            id: id.clone(),
13976            name,
13977            authorizer_type,
13978            provider_arns,
13979            auth_type: props
13980                .get("AuthType")
13981                .and_then(|v| v.as_str())
13982                .map(String::from),
13983            authorizer_uri: props
13984                .get("AuthorizerUri")
13985                .and_then(|v| v.as_str())
13986                .map(String::from),
13987            authorizer_credentials: props
13988                .get("AuthorizerCredentials")
13989                .and_then(|v| v.as_str())
13990                .map(String::from),
13991            identity_source: props
13992                .get("IdentitySource")
13993                .and_then(|v| v.as_str())
13994                .map(String::from),
13995            identity_validation_expression: props
13996                .get("IdentityValidationExpression")
13997                .and_then(|v| v.as_str())
13998                .map(String::from),
13999            authorizer_result_ttl_in_seconds: props
14000                .get("AuthorizerResultTtlInSeconds")
14001                .and_then(|v| v.as_i64())
14002                .map(|n| n as i32),
14003        };
14004
14005        let mut accounts = self.apigateway_state.write();
14006        let state = accounts.get_or_create(&self.account_id);
14007        if !state.apis.contains_key(&rest_api_id) {
14008            return Err(format!("RestApi {rest_api_id} not found"));
14009        }
14010        state
14011            .authorizers
14012            .entry(rest_api_id.clone())
14013            .or_default()
14014            .insert(id.clone(), auth);
14015
14016        Ok(ProvisionResult::new(id.clone())
14017            .with("AuthorizerId", id)
14018            .with("RestApiId", rest_api_id))
14019    }
14020
14021    fn delete_apigw_authorizer(
14022        &self,
14023        physical_id: &str,
14024        attributes: &BTreeMap<String, String>,
14025    ) -> Result<(), String> {
14026        let Some(rest_api_id) = attributes.get("RestApiId") else {
14027            return Ok(());
14028        };
14029        let mut accounts = self.apigateway_state.write();
14030        let state = accounts.get_or_create(&self.account_id);
14031        if let Some(map) = state.authorizers.get_mut(rest_api_id) {
14032            map.remove(physical_id);
14033        }
14034        Ok(())
14035    }
14036
14037    fn create_apigw_request_validator(
14038        &self,
14039        resource: &ResourceDefinition,
14040    ) -> Result<ProvisionResult, String> {
14041        let props = &resource.properties;
14042        let rest_api_id = props
14043            .get("RestApiId")
14044            .and_then(|v| v.as_str())
14045            .ok_or("RestApiId is required")?
14046            .to_string();
14047        let name = props.get("Name").and_then(|v| v.as_str()).map(String::from);
14048        let validate_body = props
14049            .get("ValidateRequestBody")
14050            .and_then(|v| v.as_bool())
14051            .unwrap_or(false);
14052        let validate_params = props
14053            .get("ValidateRequestParameters")
14054            .and_then(|v| v.as_bool())
14055            .unwrap_or(false);
14056        let id = apigw_make_id();
14057        let body = serde_json::json!({
14058            "id": id,
14059            "name": name,
14060            "validateRequestBody": validate_body,
14061            "validateRequestParameters": validate_params,
14062        });
14063        let mut accounts = self.apigateway_state.write();
14064        let state = accounts.get_or_create(&self.account_id);
14065        state
14066            .request_validators
14067            .entry(rest_api_id.clone())
14068            .or_default()
14069            .insert(id.clone(), body);
14070        Ok(ProvisionResult::new(id.clone())
14071            .with("RequestValidatorId", id)
14072            .with("RestApiId", rest_api_id))
14073    }
14074
14075    fn delete_apigw_request_validator(
14076        &self,
14077        physical_id: &str,
14078        attributes: &BTreeMap<String, String>,
14079    ) -> Result<(), String> {
14080        let Some(rest_api_id) = attributes.get("RestApiId") else {
14081            return Ok(());
14082        };
14083        let mut accounts = self.apigateway_state.write();
14084        let state = accounts.get_or_create(&self.account_id);
14085        if let Some(map) = state.request_validators.get_mut(rest_api_id) {
14086            map.remove(physical_id);
14087        }
14088        Ok(())
14089    }
14090
14091    fn create_apigw_model(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
14092        let props = &resource.properties;
14093        let rest_api_id = props
14094            .get("RestApiId")
14095            .and_then(|v| v.as_str())
14096            .ok_or("RestApiId is required")?
14097            .to_string();
14098        let name = props
14099            .get("Name")
14100            .and_then(|v| v.as_str())
14101            .ok_or("Name is required")?
14102            .to_string();
14103        let content_type = props
14104            .get("ContentType")
14105            .and_then(|v| v.as_str())
14106            .unwrap_or("application/json")
14107            .to_string();
14108        let schema = props.get("Schema").map(|v| {
14109            if let Some(s) = v.as_str() {
14110                s.to_string()
14111            } else {
14112                v.to_string()
14113            }
14114        });
14115        let id = apigw_make_id();
14116        let model = ApiGwModel {
14117            id: id.clone(),
14118            name: name.clone(),
14119            description: props
14120                .get("Description")
14121                .and_then(|v| v.as_str())
14122                .map(String::from),
14123            schema,
14124            content_type,
14125        };
14126        let mut accounts = self.apigateway_state.write();
14127        let state = accounts.get_or_create(&self.account_id);
14128        state
14129            .models
14130            .entry(rest_api_id.clone())
14131            .or_default()
14132            .insert(name.clone(), model);
14133        Ok(ProvisionResult::new(name.clone())
14134            .with("ModelName", name)
14135            .with("RestApiId", rest_api_id))
14136    }
14137
14138    fn delete_apigw_model(
14139        &self,
14140        physical_id: &str,
14141        attributes: &BTreeMap<String, String>,
14142    ) -> Result<(), String> {
14143        let Some(rest_api_id) = attributes.get("RestApiId") else {
14144            return Ok(());
14145        };
14146        let mut accounts = self.apigateway_state.write();
14147        let state = accounts.get_or_create(&self.account_id);
14148        if let Some(map) = state.models.get_mut(rest_api_id) {
14149            map.remove(physical_id);
14150        }
14151        Ok(())
14152    }
14153
14154    fn create_apigw_gateway_response(
14155        &self,
14156        resource: &ResourceDefinition,
14157    ) -> Result<ProvisionResult, String> {
14158        let props = &resource.properties;
14159        let rest_api_id = props
14160            .get("RestApiId")
14161            .and_then(|v| v.as_str())
14162            .ok_or("RestApiId is required")?
14163            .to_string();
14164        let response_type = props
14165            .get("ResponseType")
14166            .and_then(|v| v.as_str())
14167            .ok_or("ResponseType is required")?
14168            .to_string();
14169        let body = serde_json::json!({
14170            "responseType": response_type,
14171            "statusCode": props.get("StatusCode").and_then(|v| v.as_str()),
14172            "responseParameters": props.get("ResponseParameters").cloned().unwrap_or(serde_json::json!({})),
14173            "responseTemplates": props.get("ResponseTemplates").cloned().unwrap_or(serde_json::json!({})),
14174        });
14175        let mut accounts = self.apigateway_state.write();
14176        let state = accounts.get_or_create(&self.account_id);
14177        state
14178            .gateway_responses
14179            .entry(rest_api_id.clone())
14180            .or_default()
14181            .insert(response_type.clone(), body);
14182        Ok(ProvisionResult::new(response_type.clone())
14183            .with("ResponseType", response_type)
14184            .with("RestApiId", rest_api_id))
14185    }
14186
14187    fn delete_apigw_gateway_response(
14188        &self,
14189        physical_id: &str,
14190        attributes: &BTreeMap<String, String>,
14191    ) -> Result<(), String> {
14192        let Some(rest_api_id) = attributes.get("RestApiId") else {
14193            return Ok(());
14194        };
14195        let mut accounts = self.apigateway_state.write();
14196        let state = accounts.get_or_create(&self.account_id);
14197        if let Some(map) = state.gateway_responses.get_mut(rest_api_id) {
14198            map.remove(physical_id);
14199        }
14200        Ok(())
14201    }
14202
14203    fn create_apigw_usage_plan(
14204        &self,
14205        resource: &ResourceDefinition,
14206    ) -> Result<ProvisionResult, String> {
14207        let props = &resource.properties;
14208        let name = props
14209            .get("UsagePlanName")
14210            .and_then(|v| v.as_str())
14211            .ok_or("UsagePlanName is required")?
14212            .to_string();
14213        let id = apigw_make_id();
14214        let plan = ApiGwUsagePlan {
14215            id: id.clone(),
14216            name,
14217            description: props
14218                .get("Description")
14219                .and_then(|v| v.as_str())
14220                .map(String::from),
14221            api_stages: props
14222                .get("ApiStages")
14223                .and_then(|v| v.as_array())
14224                .cloned()
14225                .unwrap_or_default()
14226                .into_iter()
14227                .map(lowercase_first_keys)
14228                .collect(),
14229            throttle: props.get("Throttle").cloned().map(lowercase_first_keys),
14230            quota: props.get("Quota").cloned().map(lowercase_first_keys),
14231            product_code: None,
14232            tags: parse_acm_tags(props.get("Tags")),
14233        };
14234        let mut accounts = self.apigateway_state.write();
14235        let state = accounts.get_or_create(&self.account_id);
14236        state.usage_plans.insert(id.clone(), plan);
14237        Ok(ProvisionResult::new(id.clone()).with("UsagePlanId", id))
14238    }
14239
14240    fn delete_apigw_usage_plan(&self, physical_id: &str) -> Result<(), String> {
14241        let mut accounts = self.apigateway_state.write();
14242        let state = accounts.get_or_create(&self.account_id);
14243        state.usage_plans.remove(physical_id);
14244        state.usage_plan_keys.remove(physical_id);
14245        Ok(())
14246    }
14247
14248    fn create_apigw_api_key(
14249        &self,
14250        resource: &ResourceDefinition,
14251    ) -> Result<ProvisionResult, String> {
14252        let props = &resource.properties;
14253        let generate_distinct_id = props
14254            .get("GenerateDistinctId")
14255            .and_then(|v| v.as_bool())
14256            .unwrap_or(false);
14257        let name = props
14258            .get("Name")
14259            .and_then(|v| v.as_str())
14260            .map(String::from)
14261            .unwrap_or_else(|| {
14262                if generate_distinct_id {
14263                    format!("cfn-key-{}-{}", resource.logical_id, apigw_make_id())
14264                } else {
14265                    format!("cfn-key-{}", resource.logical_id)
14266                }
14267            });
14268        let value = props
14269            .get("Value")
14270            .and_then(|v| v.as_str())
14271            .map(String::from)
14272            .unwrap_or_else(|| Uuid::new_v4().simple().to_string());
14273        let enabled = props
14274            .get("Enabled")
14275            .and_then(|v| v.as_bool())
14276            .unwrap_or(true);
14277        // CFN's StageKeys are `[{RestApiId, StageName}, ...]` — we store each
14278        // as `restApiId/stageName` per the live API key shape.
14279        let stage_keys: Vec<String> = props
14280            .get("StageKeys")
14281            .and_then(|v| v.as_array())
14282            .map(|arr| {
14283                arr.iter()
14284                    .filter_map(|s| {
14285                        let rest = s.get("RestApiId").and_then(|v| v.as_str())?;
14286                        let stage = s.get("StageName").and_then(|v| v.as_str())?;
14287                        Some(format!("{rest}/{stage}"))
14288                    })
14289                    .collect()
14290            })
14291            .unwrap_or_default();
14292        let id = apigw_make_id();
14293        let now = Utc::now();
14294        let key = ApiGwApiKey {
14295            id: id.clone(),
14296            value,
14297            name,
14298            description: props
14299                .get("Description")
14300                .and_then(|v| v.as_str())
14301                .map(String::from),
14302            enabled,
14303            created_date: now,
14304            last_updated_date: now,
14305            stage_keys,
14306            tags: parse_acm_tags(props.get("Tags")),
14307            customer_id: props
14308                .get("CustomerId")
14309                .and_then(|v| v.as_str())
14310                .map(String::from),
14311        };
14312        let mut accounts = self.apigateway_state.write();
14313        let state = accounts.get_or_create(&self.account_id);
14314        state.api_keys.insert(id.clone(), key);
14315        Ok(ProvisionResult::new(id.clone()).with("ApiKeyId", id))
14316    }
14317
14318    fn delete_apigw_api_key(&self, physical_id: &str) -> Result<(), String> {
14319        let mut accounts = self.apigateway_state.write();
14320        let state = accounts.get_or_create(&self.account_id);
14321        state.api_keys.remove(physical_id);
14322        Ok(())
14323    }
14324
14325    fn create_apigw_usage_plan_key(
14326        &self,
14327        resource: &ResourceDefinition,
14328    ) -> Result<ProvisionResult, String> {
14329        let props = &resource.properties;
14330        let usage_plan_id = props
14331            .get("UsagePlanId")
14332            .and_then(|v| v.as_str())
14333            .ok_or("UsagePlanId is required")?
14334            .to_string();
14335        let key_id = props
14336            .get("KeyId")
14337            .and_then(|v| v.as_str())
14338            .ok_or("KeyId is required")?
14339            .to_string();
14340        let key_type = props
14341            .get("KeyType")
14342            .and_then(|v| v.as_str())
14343            .unwrap_or("API_KEY")
14344            .to_string();
14345        let body = serde_json::json!({
14346            "id": key_id,
14347            "type": key_type,
14348        });
14349        let mut accounts = self.apigateway_state.write();
14350        let state = accounts.get_or_create(&self.account_id);
14351        if !state.usage_plans.contains_key(&usage_plan_id) {
14352            return Err(format!("UsagePlan {usage_plan_id} not yet provisioned"));
14353        }
14354        if !state.api_keys.contains_key(&key_id) {
14355            return Err(format!("ApiKey {key_id} not yet provisioned"));
14356        }
14357        state
14358            .usage_plan_keys
14359            .entry(usage_plan_id.clone())
14360            .or_default()
14361            .insert(key_id.clone(), body);
14362        let physical = format!("{usage_plan_id}/{key_id}");
14363        Ok(ProvisionResult::new(physical)
14364            .with("UsagePlanId", usage_plan_id)
14365            .with("KeyId", key_id))
14366    }
14367
14368    fn delete_apigw_usage_plan_key(
14369        &self,
14370        physical_id: &str,
14371        _attributes: &BTreeMap<String, String>,
14372    ) -> Result<(), String> {
14373        let mut parts = physical_id.splitn(2, '/');
14374        let Some(plan_id) = parts.next() else {
14375            return Ok(());
14376        };
14377        let Some(key_id) = parts.next() else {
14378            return Ok(());
14379        };
14380        let mut accounts = self.apigateway_state.write();
14381        let state = accounts.get_or_create(&self.account_id);
14382        if let Some(map) = state.usage_plan_keys.get_mut(plan_id) {
14383            map.remove(key_id);
14384        }
14385        Ok(())
14386    }
14387
14388    fn create_apigw_domain_name(
14389        &self,
14390        resource: &ResourceDefinition,
14391    ) -> Result<ProvisionResult, String> {
14392        let props = &resource.properties;
14393        let domain_name = props
14394            .get("DomainName")
14395            .and_then(|v| v.as_str())
14396            .ok_or("DomainName is required")?
14397            .to_string();
14398        let mtls = props
14399            .get("MutualTlsAuthentication")
14400            .cloned()
14401            .map(lowercase_first_keys);
14402        let regional_domain = format!(
14403            "d-{}.execute-api.{}.amazonaws.com",
14404            apigw_make_id(),
14405            self.region
14406        );
14407        let distribution_domain = format!("d{}.cloudfront.net", apigw_make_id());
14408        let body = serde_json::json!({
14409            "domainName": domain_name,
14410            "certificateArn": props.get("CertificateArn").and_then(|v| v.as_str()),
14411            "regionalCertificateArn": props.get("RegionalCertificateArn").and_then(|v| v.as_str()),
14412            "endpointConfiguration": props.get("EndpointConfiguration").cloned().unwrap_or(serde_json::json!({"types": ["EDGE"]})),
14413            "securityPolicy": props.get("SecurityPolicy").and_then(|v| v.as_str()),
14414            "ownershipVerificationCertificateArn": props.get("OwnershipVerificationCertificateArn").and_then(|v| v.as_str()),
14415            "regionalDomainName": regional_domain,
14416            "regionalHostedZoneId": "Z2FDTNDATAQYW2",
14417            "distributionDomainName": distribution_domain,
14418            "distributionHostedZoneId": "Z2FDTNDATAQYW2",
14419            "mutualTlsAuthentication": mtls,
14420            "tags": serde_json::Value::Object(
14421                parse_acm_tags(props.get("Tags"))
14422                    .into_iter()
14423                    .map(|(k, v)| (k, serde_json::Value::String(v)))
14424                    .collect(),
14425            ),
14426        });
14427        let mut accounts = self.apigateway_state.write();
14428        let state = accounts.get_or_create(&self.account_id);
14429        state.domain_names.insert(domain_name.clone(), body);
14430        Ok(ProvisionResult::new(domain_name.clone())
14431            .with("DomainName", domain_name)
14432            .with("RegionalHostedZoneId", "Z2FDTNDATAQYW2".to_string())
14433            .with("DistributionHostedZoneId", "Z2FDTNDATAQYW2".to_string()))
14434    }
14435
14436    fn delete_apigw_domain_name(&self, physical_id: &str) -> Result<(), String> {
14437        let mut accounts = self.apigateway_state.write();
14438        let state = accounts.get_or_create(&self.account_id);
14439        state.domain_names.remove(physical_id);
14440        state.base_path_mappings.remove(physical_id);
14441        Ok(())
14442    }
14443
14444    fn create_apigw_base_path_mapping(
14445        &self,
14446        resource: &ResourceDefinition,
14447    ) -> Result<ProvisionResult, String> {
14448        let props = &resource.properties;
14449        let domain_name = props
14450            .get("DomainName")
14451            .and_then(|v| v.as_str())
14452            .ok_or("DomainName is required")?
14453            .to_string();
14454        let rest_api_id = props
14455            .get("RestApiId")
14456            .and_then(|v| v.as_str())
14457            .ok_or("RestApiId is required")?
14458            .to_string();
14459        let base_path = props
14460            .get("BasePath")
14461            .and_then(|v| v.as_str())
14462            .unwrap_or("(none)")
14463            .to_string();
14464        let stage = props
14465            .get("Stage")
14466            .and_then(|v| v.as_str())
14467            .map(String::from);
14468        let body = serde_json::json!({
14469            "basePath": base_path,
14470            "restApiId": rest_api_id,
14471            "stage": stage,
14472        });
14473        let mut accounts = self.apigateway_state.write();
14474        let state = accounts.get_or_create(&self.account_id);
14475        state
14476            .base_path_mappings
14477            .entry(domain_name.clone())
14478            .or_default()
14479            .insert(base_path.clone(), body);
14480        let physical = format!("{domain_name}/{base_path}");
14481        Ok(ProvisionResult::new(physical)
14482            .with("DomainName", domain_name)
14483            .with("BasePath", base_path))
14484    }
14485
14486    fn delete_apigw_base_path_mapping(
14487        &self,
14488        physical_id: &str,
14489        _attributes: &BTreeMap<String, String>,
14490    ) -> Result<(), String> {
14491        let mut parts = physical_id.splitn(2, '/');
14492        let Some(domain) = parts.next() else {
14493            return Ok(());
14494        };
14495        let Some(base_path) = parts.next() else {
14496            return Ok(());
14497        };
14498        let mut accounts = self.apigateway_state.write();
14499        let state = accounts.get_or_create(&self.account_id);
14500        if let Some(map) = state.base_path_mappings.get_mut(domain) {
14501            map.remove(base_path);
14502        }
14503        Ok(())
14504    }
14505
14506    // --- API Gateway v1 update paths ---
14507    //
14508    // These mirror the create_* helpers above but mutate an existing
14509    // resource instead of inserting a new one. The physical id is
14510    // preserved across updates so other stack resources keep referencing
14511    // the same logical entity.
14512
14513    fn update_apigw_resource(
14514        &self,
14515        existing: &StackResource,
14516        resource: &ResourceDefinition,
14517    ) -> Result<ProvisionResult, String> {
14518        let props = &resource.properties;
14519        let rest_api_id = existing
14520            .attributes
14521            .get("RestApiId")
14522            .cloned()
14523            .or_else(|| {
14524                props
14525                    .get("RestApiId")
14526                    .and_then(|v| v.as_str())
14527                    .map(String::from)
14528            })
14529            .ok_or("RestApiId is required")?;
14530        let physical = existing.physical_id.clone();
14531        let mut accounts = self.apigateway_state.write();
14532        let state = accounts.get_or_create(&self.account_id);
14533        let api_resources = state
14534            .resources
14535            .get_mut(&rest_api_id)
14536            .ok_or_else(|| format!("RestApi {rest_api_id} not found"))?;
14537        if !api_resources.contains_key(&physical) {
14538            return Err(format!("Resource {physical} not found"));
14539        }
14540        if let Some(part) = props.get("PathPart").and_then(|v| v.as_str()) {
14541            // Read parent's path first (immutable borrow), then mutate.
14542            let parent_id = api_resources
14543                .get(&physical)
14544                .and_then(|r| r.parent_id.clone());
14545            let parent_path = parent_id
14546                .as_ref()
14547                .and_then(|pid| api_resources.get(pid).map(|p| p.path.clone()))
14548                .unwrap_or_else(|| "/".to_string());
14549            let new_path = if parent_path == "/" {
14550                format!("/{part}")
14551            } else {
14552                format!("{parent_path}/{part}")
14553            };
14554            let res = api_resources
14555                .get_mut(&physical)
14556                .expect("contains_key checked above");
14557            res.path_part = Some(part.to_string());
14558            res.path = new_path;
14559        }
14560        Ok(ProvisionResult::new(physical.clone())
14561            .with("ResourceId", physical)
14562            .with("RestApiId", rest_api_id))
14563    }
14564
14565    fn update_apigw_method(
14566        &self,
14567        existing: &StackResource,
14568        resource: &ResourceDefinition,
14569    ) -> Result<ProvisionResult, String> {
14570        // Method's physical id is the composite "rest/resource/method"
14571        // key. Identity props can't change without replacement, so we
14572        // simply rewrite the stored Method/Integration with current
14573        // properties. We delegate to create_apigw_method which already
14574        // handles the insert-or-replace semantics.
14575        self.create_apigw_method(resource).map(|r| {
14576            // Make sure the physical id stays stable.
14577            ProvisionResult {
14578                physical_id: existing.physical_id.clone(),
14579                attributes: r.attributes,
14580            }
14581        })
14582    }
14583
14584    fn update_apigw_deployment(
14585        &self,
14586        existing: &StackResource,
14587        resource: &ResourceDefinition,
14588    ) -> Result<ProvisionResult, String> {
14589        let props = &resource.properties;
14590        let rest_api_id = existing
14591            .attributes
14592            .get("RestApiId")
14593            .cloned()
14594            .or_else(|| {
14595                props
14596                    .get("RestApiId")
14597                    .and_then(|v| v.as_str())
14598                    .map(String::from)
14599            })
14600            .ok_or("RestApiId is required")?;
14601        let physical = existing.physical_id.clone();
14602        let mut accounts = self.apigateway_state.write();
14603        let state = accounts.get_or_create(&self.account_id);
14604        let dep = state
14605            .deployments
14606            .get_mut(&rest_api_id)
14607            .and_then(|m| m.get_mut(&physical))
14608            .ok_or_else(|| format!("Deployment {physical} not found"))?;
14609        if let Some(desc) = props.get("Description").and_then(|v| v.as_str()) {
14610            dep.description = Some(desc.to_string());
14611        }
14612        Ok(ProvisionResult::new(physical.clone())
14613            .with("DeploymentId", physical)
14614            .with("RestApiId", rest_api_id))
14615    }
14616
14617    fn update_apigw_stage(
14618        &self,
14619        existing: &StackResource,
14620        resource: &ResourceDefinition,
14621    ) -> Result<ProvisionResult, String> {
14622        let props = &resource.properties;
14623        let rest_api_id = existing
14624            .attributes
14625            .get("RestApiId")
14626            .cloned()
14627            .or_else(|| {
14628                props
14629                    .get("RestApiId")
14630                    .and_then(|v| v.as_str())
14631                    .map(String::from)
14632            })
14633            .ok_or("RestApiId is required")?;
14634        let stage_name = existing.physical_id.clone();
14635        let mut accounts = self.apigateway_state.write();
14636        let state = accounts.get_or_create(&self.account_id);
14637        let stage = state
14638            .stages
14639            .get_mut(&rest_api_id)
14640            .and_then(|m| m.get_mut(&stage_name))
14641            .ok_or_else(|| format!("Stage {stage_name} not found"))?;
14642        if let Some(desc) = props.get("Description").and_then(|v| v.as_str()) {
14643            stage.description = Some(desc.to_string());
14644        }
14645        if let Some(b) = props.get("TracingEnabled").and_then(|v| v.as_bool()) {
14646            stage.tracing_enabled = b;
14647        }
14648        if let Some(b) = props.get("CacheClusterEnabled").and_then(|v| v.as_bool()) {
14649            stage.cache_cluster_enabled = b;
14650        }
14651        if let Some(s) = props.get("CacheClusterSize").and_then(|v| v.as_str()) {
14652            stage.cache_cluster_size = Some(s.to_string());
14653        }
14654        if let Some(obj) = props.get("Variables").and_then(|v| v.as_object()) {
14655            stage.variables = obj
14656                .iter()
14657                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
14658                .collect();
14659        }
14660        if let Some(dep) = props.get("DeploymentId").and_then(|v| v.as_str()) {
14661            stage.deployment_id = dep.to_string();
14662        }
14663        if props.get("Tags").is_some() {
14664            stage.tags = parse_acm_tags(props.get("Tags"));
14665        }
14666        if let Some(arr) = props.get("MethodSettings").and_then(|v| v.as_array()) {
14667            stage.method_settings = arr
14668                .iter()
14669                .filter_map(|s| {
14670                    let path = s.get("ResourcePath").and_then(|v| v.as_str())?;
14671                    let http = s.get("HttpMethod").and_then(|v| v.as_str())?;
14672                    let key = format!("{}/{http}", path.strip_prefix('/').unwrap_or(path));
14673                    Some((key, s.clone()))
14674                })
14675                .collect();
14676        }
14677        if let Some(canary) = props.get("CanarySetting").cloned() {
14678            stage.canary_settings = Some(canary);
14679        }
14680        if let Some(access) = props.get("AccessLogSetting").cloned() {
14681            stage.access_log_settings = Some(access);
14682        }
14683        stage.last_updated_date = Utc::now();
14684        Ok(ProvisionResult::new(stage_name.clone())
14685            .with("StageName", stage_name)
14686            .with("RestApiId", rest_api_id))
14687    }
14688
14689    fn update_apigw_authorizer(
14690        &self,
14691        existing: &StackResource,
14692        resource: &ResourceDefinition,
14693    ) -> Result<ProvisionResult, String> {
14694        let props = &resource.properties;
14695        let rest_api_id = existing
14696            .attributes
14697            .get("RestApiId")
14698            .cloned()
14699            .or_else(|| {
14700                props
14701                    .get("RestApiId")
14702                    .and_then(|v| v.as_str())
14703                    .map(String::from)
14704            })
14705            .ok_or("RestApiId is required")?;
14706        let physical = existing.physical_id.clone();
14707        let mut accounts = self.apigateway_state.write();
14708        let state = accounts.get_or_create(&self.account_id);
14709        let auth = state
14710            .authorizers
14711            .get_mut(&rest_api_id)
14712            .and_then(|m| m.get_mut(&physical))
14713            .ok_or_else(|| format!("Authorizer {physical} not found"))?;
14714        if let Some(name) = props.get("Name").and_then(|v| v.as_str()) {
14715            auth.name = name.to_string();
14716        }
14717        if let Some(t) = props.get("Type").and_then(|v| v.as_str()) {
14718            auth.authorizer_type = t.to_string();
14719        }
14720        if let Some(uri) = props.get("AuthorizerUri").and_then(|v| v.as_str()) {
14721            auth.authorizer_uri = Some(uri.to_string());
14722        }
14723        if let Some(arr) = props.get("ProviderARNs").and_then(|v| v.as_array()) {
14724            auth.provider_arns = arr
14725                .iter()
14726                .filter_map(|v| v.as_str().map(String::from))
14727                .collect();
14728        }
14729        if let Some(s) = props.get("IdentitySource").and_then(|v| v.as_str()) {
14730            auth.identity_source = Some(s.to_string());
14731        }
14732        if let Some(s) = props
14733            .get("IdentityValidationExpression")
14734            .and_then(|v| v.as_str())
14735        {
14736            auth.identity_validation_expression = Some(s.to_string());
14737        }
14738        if let Some(n) = props
14739            .get("AuthorizerResultTtlInSeconds")
14740            .and_then(|v| v.as_i64())
14741        {
14742            auth.authorizer_result_ttl_in_seconds = Some(n as i32);
14743        }
14744        if let Some(s) = props.get("AuthType").and_then(|v| v.as_str()) {
14745            auth.auth_type = Some(s.to_string());
14746        }
14747        if let Some(s) = props.get("AuthorizerCredentials").and_then(|v| v.as_str()) {
14748            auth.authorizer_credentials = Some(s.to_string());
14749        }
14750        Ok(ProvisionResult::new(physical.clone())
14751            .with("AuthorizerId", physical)
14752            .with("RestApiId", rest_api_id))
14753    }
14754
14755    fn update_apigw_request_validator(
14756        &self,
14757        existing: &StackResource,
14758        resource: &ResourceDefinition,
14759    ) -> Result<ProvisionResult, String> {
14760        let props = &resource.properties;
14761        let rest_api_id = existing
14762            .attributes
14763            .get("RestApiId")
14764            .cloned()
14765            .or_else(|| {
14766                props
14767                    .get("RestApiId")
14768                    .and_then(|v| v.as_str())
14769                    .map(String::from)
14770            })
14771            .ok_or("RestApiId is required")?;
14772        let physical = existing.physical_id.clone();
14773        let mut accounts = self.apigateway_state.write();
14774        let state = accounts.get_or_create(&self.account_id);
14775        let body = state
14776            .request_validators
14777            .get_mut(&rest_api_id)
14778            .and_then(|m| m.get_mut(&physical))
14779            .ok_or_else(|| format!("RequestValidator {physical} not found"))?;
14780        let obj = body.as_object_mut().ok_or("validator body not object")?;
14781        if let Some(name) = props.get("Name").and_then(|v| v.as_str()) {
14782            obj.insert("name".into(), serde_json::Value::String(name.into()));
14783        }
14784        if let Some(b) = props.get("ValidateRequestBody").and_then(|v| v.as_bool()) {
14785            obj.insert("validateRequestBody".into(), serde_json::Value::Bool(b));
14786        }
14787        if let Some(b) = props
14788            .get("ValidateRequestParameters")
14789            .and_then(|v| v.as_bool())
14790        {
14791            obj.insert(
14792                "validateRequestParameters".into(),
14793                serde_json::Value::Bool(b),
14794            );
14795        }
14796        Ok(ProvisionResult::new(physical.clone())
14797            .with("RequestValidatorId", physical)
14798            .with("RestApiId", rest_api_id))
14799    }
14800
14801    fn update_apigw_model(
14802        &self,
14803        existing: &StackResource,
14804        resource: &ResourceDefinition,
14805    ) -> Result<ProvisionResult, String> {
14806        let props = &resource.properties;
14807        let rest_api_id = existing
14808            .attributes
14809            .get("RestApiId")
14810            .cloned()
14811            .or_else(|| {
14812                props
14813                    .get("RestApiId")
14814                    .and_then(|v| v.as_str())
14815                    .map(String::from)
14816            })
14817            .ok_or("RestApiId is required")?;
14818        let model_name = existing.physical_id.clone();
14819        let mut accounts = self.apigateway_state.write();
14820        let state = accounts.get_or_create(&self.account_id);
14821        let model = state
14822            .models
14823            .get_mut(&rest_api_id)
14824            .and_then(|m| m.get_mut(&model_name))
14825            .ok_or_else(|| format!("Model {model_name} not found"))?;
14826        if let Some(desc) = props.get("Description").and_then(|v| v.as_str()) {
14827            model.description = Some(desc.to_string());
14828        }
14829        if let Some(s) = props.get("ContentType").and_then(|v| v.as_str()) {
14830            model.content_type = s.to_string();
14831        }
14832        if let Some(schema) = props.get("Schema") {
14833            model.schema = Some(if let Some(s) = schema.as_str() {
14834                s.to_string()
14835            } else {
14836                schema.to_string()
14837            });
14838        }
14839        Ok(ProvisionResult::new(model_name.clone())
14840            .with("ModelName", model_name)
14841            .with("RestApiId", rest_api_id))
14842    }
14843
14844    fn update_apigw_gateway_response(
14845        &self,
14846        existing: &StackResource,
14847        resource: &ResourceDefinition,
14848    ) -> Result<ProvisionResult, String> {
14849        let props = &resource.properties;
14850        let rest_api_id = existing
14851            .attributes
14852            .get("RestApiId")
14853            .cloned()
14854            .or_else(|| {
14855                props
14856                    .get("RestApiId")
14857                    .and_then(|v| v.as_str())
14858                    .map(String::from)
14859            })
14860            .ok_or("RestApiId is required")?;
14861        let response_type = existing.physical_id.clone();
14862        let mut accounts = self.apigateway_state.write();
14863        let state = accounts.get_or_create(&self.account_id);
14864        let body = state
14865            .gateway_responses
14866            .get_mut(&rest_api_id)
14867            .and_then(|m| m.get_mut(&response_type))
14868            .ok_or_else(|| format!("GatewayResponse {response_type} not found"))?;
14869        let obj = body.as_object_mut().ok_or("response body not object")?;
14870        if let Some(s) = props.get("StatusCode").and_then(|v| v.as_str()) {
14871            obj.insert("statusCode".into(), serde_json::Value::String(s.into()));
14872        }
14873        if let Some(v) = props.get("ResponseParameters").cloned() {
14874            obj.insert("responseParameters".into(), v);
14875        }
14876        if let Some(v) = props.get("ResponseTemplates").cloned() {
14877            obj.insert("responseTemplates".into(), v);
14878        }
14879        Ok(ProvisionResult::new(response_type.clone())
14880            .with("ResponseType", response_type)
14881            .with("RestApiId", rest_api_id))
14882    }
14883
14884    fn update_apigw_usage_plan(
14885        &self,
14886        existing: &StackResource,
14887        resource: &ResourceDefinition,
14888    ) -> Result<ProvisionResult, String> {
14889        let props = &resource.properties;
14890        let physical = existing.physical_id.clone();
14891        let mut accounts = self.apigateway_state.write();
14892        let state = accounts.get_or_create(&self.account_id);
14893        let plan = state
14894            .usage_plans
14895            .get_mut(&physical)
14896            .ok_or_else(|| format!("UsagePlan {physical} not found"))?;
14897        if let Some(name) = props.get("UsagePlanName").and_then(|v| v.as_str()) {
14898            plan.name = name.to_string();
14899        }
14900        if let Some(s) = props.get("Description").and_then(|v| v.as_str()) {
14901            plan.description = Some(s.to_string());
14902        }
14903        if let Some(arr) = props.get("ApiStages").and_then(|v| v.as_array()) {
14904            plan.api_stages = arr.iter().cloned().map(lowercase_first_keys).collect();
14905        }
14906        if let Some(t) = props.get("Throttle").cloned() {
14907            plan.throttle = Some(lowercase_first_keys(t));
14908        }
14909        if let Some(q) = props.get("Quota").cloned() {
14910            plan.quota = Some(lowercase_first_keys(q));
14911        }
14912        if props.get("Tags").is_some() {
14913            plan.tags = parse_acm_tags(props.get("Tags"));
14914        }
14915        Ok(ProvisionResult::new(physical.clone()).with("UsagePlanId", physical))
14916    }
14917
14918    fn update_apigw_api_key(
14919        &self,
14920        existing: &StackResource,
14921        resource: &ResourceDefinition,
14922    ) -> Result<ProvisionResult, String> {
14923        let props = &resource.properties;
14924        let physical = existing.physical_id.clone();
14925        let mut accounts = self.apigateway_state.write();
14926        let state = accounts.get_or_create(&self.account_id);
14927        let key = state
14928            .api_keys
14929            .get_mut(&physical)
14930            .ok_or_else(|| format!("ApiKey {physical} not found"))?;
14931        if let Some(name) = props.get("Name").and_then(|v| v.as_str()) {
14932            key.name = name.to_string();
14933        }
14934        if let Some(s) = props.get("Description").and_then(|v| v.as_str()) {
14935            key.description = Some(s.to_string());
14936        }
14937        if let Some(b) = props.get("Enabled").and_then(|v| v.as_bool()) {
14938            key.enabled = b;
14939        }
14940        if let Some(s) = props.get("CustomerId").and_then(|v| v.as_str()) {
14941            key.customer_id = Some(s.to_string());
14942        }
14943        if props.get("Tags").is_some() {
14944            key.tags = parse_acm_tags(props.get("Tags"));
14945        }
14946        if let Some(arr) = props.get("StageKeys").and_then(|v| v.as_array()) {
14947            key.stage_keys = arr
14948                .iter()
14949                .filter_map(|s| {
14950                    let rest = s.get("RestApiId").and_then(|v| v.as_str())?;
14951                    let stage = s.get("StageName").and_then(|v| v.as_str())?;
14952                    Some(format!("{rest}/{stage}"))
14953                })
14954                .collect();
14955        }
14956        key.last_updated_date = Utc::now();
14957        Ok(ProvisionResult::new(physical.clone()).with("ApiKeyId", physical))
14958    }
14959
14960    fn update_apigw_usage_plan_key(
14961        &self,
14962        existing: &StackResource,
14963        _resource: &ResourceDefinition,
14964    ) -> Result<ProvisionResult, String> {
14965        // UsagePlanKey is a pure association (UsagePlan + ApiKey + Type) —
14966        // CFN treats every property as `requires-replacement`, so a real
14967        // UpdateStack would Delete+Create. Here we just preserve the
14968        // existing physical id so resolution stays stable.
14969        let physical = existing.physical_id.clone();
14970        let mut parts = physical.splitn(2, '/');
14971        let plan = parts.next().unwrap_or("").to_string();
14972        let key = parts.next().unwrap_or("").to_string();
14973        Ok(ProvisionResult::new(physical)
14974            .with("UsagePlanId", plan)
14975            .with("KeyId", key))
14976    }
14977
14978    fn update_apigw_domain_name(
14979        &self,
14980        existing: &StackResource,
14981        resource: &ResourceDefinition,
14982    ) -> Result<ProvisionResult, String> {
14983        let props = &resource.properties;
14984        let domain = existing.physical_id.clone();
14985        let mut accounts = self.apigateway_state.write();
14986        let state = accounts.get_or_create(&self.account_id);
14987        let body = state
14988            .domain_names
14989            .get_mut(&domain)
14990            .ok_or_else(|| format!("DomainName {domain} not found"))?;
14991        let obj = body.as_object_mut().ok_or("domain body not object")?;
14992        if let Some(s) = props.get("CertificateArn").and_then(|v| v.as_str()) {
14993            obj.insert("certificateArn".into(), serde_json::Value::String(s.into()));
14994        }
14995        if let Some(s) = props.get("RegionalCertificateArn").and_then(|v| v.as_str()) {
14996            obj.insert(
14997                "regionalCertificateArn".into(),
14998                serde_json::Value::String(s.into()),
14999            );
15000        }
15001        if let Some(v) = props.get("EndpointConfiguration").cloned() {
15002            obj.insert("endpointConfiguration".into(), v);
15003        }
15004        if let Some(s) = props.get("SecurityPolicy").and_then(|v| v.as_str()) {
15005            obj.insert("securityPolicy".into(), serde_json::Value::String(s.into()));
15006        }
15007        if let Some(v) = props.get("MutualTlsAuthentication").cloned() {
15008            obj.insert("mutualTlsAuthentication".into(), lowercase_first_keys(v));
15009        }
15010        if let Some(s) = props
15011            .get("OwnershipVerificationCertificateArn")
15012            .and_then(|v| v.as_str())
15013        {
15014            obj.insert(
15015                "ownershipVerificationCertificateArn".into(),
15016                serde_json::Value::String(s.into()),
15017            );
15018        }
15019        if props.get("Tags").is_some() {
15020            obj.insert(
15021                "tags".into(),
15022                serde_json::Value::Object(
15023                    parse_acm_tags(props.get("Tags"))
15024                        .into_iter()
15025                        .map(|(k, v)| (k, serde_json::Value::String(v)))
15026                        .collect(),
15027                ),
15028            );
15029        }
15030        Ok(ProvisionResult::new(domain.clone())
15031            .with("DomainName", domain)
15032            .with("RegionalHostedZoneId", "Z2FDTNDATAQYW2".to_string())
15033            .with("DistributionHostedZoneId", "Z2FDTNDATAQYW2".to_string()))
15034    }
15035
15036    fn update_apigw_base_path_mapping(
15037        &self,
15038        existing: &StackResource,
15039        resource: &ResourceDefinition,
15040    ) -> Result<ProvisionResult, String> {
15041        let props = &resource.properties;
15042        let physical = existing.physical_id.clone();
15043        let mut parts = physical.splitn(2, '/');
15044        let domain = parts
15045            .next()
15046            .ok_or("malformed base path mapping id")?
15047            .to_string();
15048        let base_path = parts
15049            .next()
15050            .ok_or("malformed base path mapping id")?
15051            .to_string();
15052        let mut accounts = self.apigateway_state.write();
15053        let state = accounts.get_or_create(&self.account_id);
15054        let map = state
15055            .base_path_mappings
15056            .get_mut(&domain)
15057            .ok_or_else(|| format!("DomainName {domain} not found"))?;
15058        let body = map
15059            .get_mut(&base_path)
15060            .ok_or_else(|| format!("BasePath {base_path} not found"))?;
15061        let obj = body.as_object_mut().ok_or("mapping body not object")?;
15062        if let Some(s) = props.get("RestApiId").and_then(|v| v.as_str()) {
15063            obj.insert("restApiId".into(), serde_json::Value::String(s.into()));
15064        }
15065        if let Some(s) = props.get("Stage").and_then(|v| v.as_str()) {
15066            obj.insert("stage".into(), serde_json::Value::String(s.into()));
15067        }
15068        Ok(ProvisionResult::new(physical)
15069            .with("DomainName", domain)
15070            .with("BasePath", base_path))
15071    }
15072
15073    // --- API Gateway v2 (HTTP/WebSocket APIs) ---
15074
15075    fn create_apigwv2_api(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
15076        let props = &resource.properties;
15077        let name = props
15078            .get("Name")
15079            .and_then(|v| v.as_str())
15080            .ok_or("Name is required")?
15081            .to_string();
15082        let protocol_type = props
15083            .get("ProtocolType")
15084            .and_then(|v| v.as_str())
15085            .unwrap_or("HTTP")
15086            .to_string();
15087        let description = props
15088            .get("Description")
15089            .and_then(|v| v.as_str())
15090            .map(String::from);
15091        let tags: Option<BTreeMap<String, String>> =
15092            props.get("Tags").and_then(|v| v.as_object()).map(|obj| {
15093                obj.iter()
15094                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
15095                    .collect()
15096            });
15097
15098        let id = make_apigwv2_id(10);
15099        let mut api = ApiGwV2HttpApi::new(id.clone(), name, description, tags, &self.region);
15100        api.protocol_type = protocol_type.clone();
15101        if let Some(expr) = props
15102            .get("RouteSelectionExpression")
15103            .and_then(|v| v.as_str())
15104        {
15105            api.route_selection_expression = expr.to_string();
15106        }
15107        if let Some(expr) = props
15108            .get("ApiKeySelectionExpression")
15109            .and_then(|v| v.as_str())
15110        {
15111            api.api_key_selection_expression = expr.to_string();
15112        }
15113        if let Some(b) = props
15114            .get("DisableExecuteApiEndpoint")
15115            .and_then(|v| v.as_bool())
15116        {
15117            api.disable_execute_api_endpoint = b;
15118        }
15119        if let Some(s) = props.get("IpAddressType").and_then(|v| v.as_str()) {
15120            api.ip_address_type = s.to_string();
15121        }
15122        if let Some(cors) = props.get("CorsConfiguration").and_then(|v| v.as_object()) {
15123            api.cors_configuration = Some(ApiGwV2CorsConfiguration {
15124                allow_credentials: cors.get("AllowCredentials").and_then(|v| v.as_bool()),
15125                allow_headers: cors
15126                    .get("AllowHeaders")
15127                    .and_then(|v| v.as_array())
15128                    .map(|a| {
15129                        a.iter()
15130                            .filter_map(|v| v.as_str().map(String::from))
15131                            .collect()
15132                    }),
15133                allow_methods: cors
15134                    .get("AllowMethods")
15135                    .and_then(|v| v.as_array())
15136                    .map(|a| {
15137                        a.iter()
15138                            .filter_map(|v| v.as_str().map(String::from))
15139                            .collect()
15140                    }),
15141                allow_origins: cors
15142                    .get("AllowOrigins")
15143                    .and_then(|v| v.as_array())
15144                    .map(|a| {
15145                        a.iter()
15146                            .filter_map(|v| v.as_str().map(String::from))
15147                            .collect()
15148                    }),
15149                expose_headers: cors
15150                    .get("ExposeHeaders")
15151                    .and_then(|v| v.as_array())
15152                    .map(|a| {
15153                        a.iter()
15154                            .filter_map(|v| v.as_str().map(String::from))
15155                            .collect()
15156                    }),
15157                max_age: cors
15158                    .get("MaxAge")
15159                    .and_then(|v| v.as_i64())
15160                    .map(|n| n as i32),
15161            });
15162        }
15163
15164        let api_endpoint = api.api_endpoint.clone();
15165        let mut accounts = self.apigatewayv2_state.write();
15166        let state = accounts.get_or_create(&self.account_id);
15167        state.apis.insert(id.clone(), api);
15168
15169        Ok(ProvisionResult::new(id.clone())
15170            .with("ApiId", id)
15171            .with("ApiEndpoint", api_endpoint))
15172    }
15173
15174    fn delete_apigwv2_api(&self, physical_id: &str) -> Result<(), String> {
15175        let mut accounts = self.apigatewayv2_state.write();
15176        let state = accounts.get_or_create(&self.account_id);
15177        state.apis.remove(physical_id);
15178        state.routes.remove(physical_id);
15179        state.integrations.remove(physical_id);
15180        state.stages.remove(physical_id);
15181        state.deployments.remove(physical_id);
15182        state.authorizers.remove(physical_id);
15183        state.models.remove(physical_id);
15184        state.integration_responses.remove(physical_id);
15185        state.route_responses.remove(physical_id);
15186        Ok(())
15187    }
15188
15189    fn create_apigwv2_route(
15190        &self,
15191        resource: &ResourceDefinition,
15192    ) -> Result<ProvisionResult, String> {
15193        let props = &resource.properties;
15194        let api_id = props
15195            .get("ApiId")
15196            .and_then(|v| v.as_str())
15197            .ok_or("ApiId is required")?
15198            .to_string();
15199        let route_key = props
15200            .get("RouteKey")
15201            .and_then(|v| v.as_str())
15202            .ok_or("RouteKey is required")?
15203            .to_string();
15204
15205        let mut accounts = self.apigatewayv2_state.write();
15206        let state = accounts.get_or_create(&self.account_id);
15207        if !state.apis.contains_key(&api_id) {
15208            return Err(format!("Api {api_id} not yet provisioned"));
15209        }
15210        let id = make_apigwv2_id(10);
15211        let route = ApiGwV2Route {
15212            route_id: id.clone(),
15213            route_key,
15214            target: props
15215                .get("Target")
15216                .and_then(|v| v.as_str())
15217                .map(String::from),
15218            authorization_type: props
15219                .get("AuthorizationType")
15220                .and_then(|v| v.as_str())
15221                .map(String::from),
15222            authorizer_id: props
15223                .get("AuthorizerId")
15224                .and_then(|v| v.as_str())
15225                .map(String::from),
15226        };
15227        state
15228            .routes
15229            .entry(api_id.clone())
15230            .or_default()
15231            .insert(id.clone(), route);
15232
15233        Ok(ProvisionResult::new(id.clone())
15234            .with("RouteId", id)
15235            .with("ApiId", api_id))
15236    }
15237
15238    fn delete_apigwv2_route(
15239        &self,
15240        physical_id: &str,
15241        attributes: &BTreeMap<String, String>,
15242    ) -> Result<(), String> {
15243        let Some(api_id) = attributes.get("ApiId") else {
15244            return Ok(());
15245        };
15246        let mut accounts = self.apigatewayv2_state.write();
15247        let state = accounts.get_or_create(&self.account_id);
15248        if let Some(map) = state.routes.get_mut(api_id) {
15249            map.remove(physical_id);
15250        }
15251        Ok(())
15252    }
15253
15254    fn create_apigwv2_integration(
15255        &self,
15256        resource: &ResourceDefinition,
15257    ) -> Result<ProvisionResult, String> {
15258        let props = &resource.properties;
15259        let api_id = props
15260            .get("ApiId")
15261            .and_then(|v| v.as_str())
15262            .ok_or("ApiId is required")?
15263            .to_string();
15264        let integration_type = props
15265            .get("IntegrationType")
15266            .and_then(|v| v.as_str())
15267            .ok_or("IntegrationType is required")?
15268            .to_string();
15269
15270        let mut accounts = self.apigatewayv2_state.write();
15271        let state = accounts.get_or_create(&self.account_id);
15272        if !state.apis.contains_key(&api_id) {
15273            return Err(format!("Api {api_id} not yet provisioned"));
15274        }
15275        let id = make_apigwv2_id(10);
15276        let integration = ApiGwV2Integration {
15277            integration_id: id.clone(),
15278            integration_type,
15279            integration_uri: props
15280                .get("IntegrationUri")
15281                .and_then(|v| v.as_str())
15282                .map(String::from),
15283            payload_format_version: props
15284                .get("PayloadFormatVersion")
15285                .and_then(|v| v.as_str())
15286                .map(String::from),
15287            timeout_in_millis: props.get("TimeoutInMillis").and_then(|v| v.as_i64()),
15288        };
15289        state
15290            .integrations
15291            .entry(api_id.clone())
15292            .or_default()
15293            .insert(id.clone(), integration);
15294
15295        Ok(ProvisionResult::new(id.clone())
15296            .with("IntegrationId", id)
15297            .with("ApiId", api_id))
15298    }
15299
15300    fn delete_apigwv2_integration(
15301        &self,
15302        physical_id: &str,
15303        attributes: &BTreeMap<String, String>,
15304    ) -> Result<(), String> {
15305        let Some(api_id) = attributes.get("ApiId") else {
15306            return Ok(());
15307        };
15308        let mut accounts = self.apigatewayv2_state.write();
15309        let state = accounts.get_or_create(&self.account_id);
15310        if let Some(map) = state.integrations.get_mut(api_id) {
15311            map.remove(physical_id);
15312        }
15313        Ok(())
15314    }
15315
15316    fn create_apigwv2_integration_response(
15317        &self,
15318        resource: &ResourceDefinition,
15319    ) -> Result<ProvisionResult, String> {
15320        let props = &resource.properties;
15321        let api_id = props
15322            .get("ApiId")
15323            .and_then(|v| v.as_str())
15324            .ok_or("ApiId is required")?
15325            .to_string();
15326        let integration_id = props
15327            .get("IntegrationId")
15328            .and_then(|v| v.as_str())
15329            .ok_or("IntegrationId is required")?
15330            .to_string();
15331        let key_expr = props
15332            .get("IntegrationResponseKey")
15333            .and_then(|v| v.as_str())
15334            .ok_or("IntegrationResponseKey is required")?
15335            .to_string();
15336        let id = make_apigwv2_id(10);
15337        let body = serde_json::json!({
15338            "integrationResponseId": id,
15339            "integrationId": integration_id,
15340            "integrationResponseKey": key_expr,
15341            "responseTemplates": props.get("ResponseTemplates").cloned().unwrap_or(serde_json::json!({})),
15342            "responseParameters": props.get("ResponseParameters").cloned().unwrap_or(serde_json::json!({})),
15343            "templateSelectionExpression": props.get("TemplateSelectionExpression").and_then(|v| v.as_str()),
15344            "contentHandlingStrategy": props.get("ContentHandlingStrategy").and_then(|v| v.as_str()),
15345        });
15346        let composite_key = format!("{integration_id}/{id}");
15347        let mut accounts = self.apigatewayv2_state.write();
15348        let state = accounts.get_or_create(&self.account_id);
15349        if !state
15350            .integrations
15351            .get(&api_id)
15352            .map(|m| m.contains_key(&integration_id))
15353            .unwrap_or(false)
15354        {
15355            return Err(format!(
15356                "Integration {integration_id} not yet provisioned for api {api_id}"
15357            ));
15358        }
15359        state
15360            .integration_responses
15361            .entry(api_id.clone())
15362            .or_default()
15363            .insert(composite_key.clone(), body);
15364        Ok(ProvisionResult::new(composite_key.clone())
15365            .with("IntegrationResponseId", id)
15366            .with("IntegrationId", integration_id)
15367            .with("ApiId", api_id))
15368    }
15369
15370    fn delete_apigwv2_integration_response(
15371        &self,
15372        physical_id: &str,
15373        attributes: &BTreeMap<String, String>,
15374    ) -> Result<(), String> {
15375        let Some(api_id) = attributes.get("ApiId") else {
15376            return Ok(());
15377        };
15378        let mut accounts = self.apigatewayv2_state.write();
15379        let state = accounts.get_or_create(&self.account_id);
15380        if let Some(map) = state.integration_responses.get_mut(api_id) {
15381            map.remove(physical_id);
15382        }
15383        Ok(())
15384    }
15385
15386    fn create_apigwv2_route_response(
15387        &self,
15388        resource: &ResourceDefinition,
15389    ) -> Result<ProvisionResult, String> {
15390        let props = &resource.properties;
15391        let api_id = props
15392            .get("ApiId")
15393            .and_then(|v| v.as_str())
15394            .ok_or("ApiId is required")?
15395            .to_string();
15396        let route_id = props
15397            .get("RouteId")
15398            .and_then(|v| v.as_str())
15399            .ok_or("RouteId is required")?
15400            .to_string();
15401        let key_expr = props
15402            .get("RouteResponseKey")
15403            .and_then(|v| v.as_str())
15404            .ok_or("RouteResponseKey is required")?
15405            .to_string();
15406        let id = make_apigwv2_id(10);
15407        let body = serde_json::json!({
15408            "routeResponseId": id,
15409            "routeId": route_id,
15410            "routeResponseKey": key_expr,
15411            "responseModels": props.get("ResponseModels").cloned().unwrap_or(serde_json::json!({})),
15412            "modelSelectionExpression": props.get("ModelSelectionExpression").and_then(|v| v.as_str()),
15413            "responseParameters": props.get("ResponseParameters").cloned().unwrap_or(serde_json::json!({})),
15414        });
15415        let composite = format!("{route_id}/{id}");
15416        let mut accounts = self.apigatewayv2_state.write();
15417        let state = accounts.get_or_create(&self.account_id);
15418        if !state
15419            .routes
15420            .get(&api_id)
15421            .map(|m| m.contains_key(&route_id))
15422            .unwrap_or(false)
15423        {
15424            return Err(format!(
15425                "Route {route_id} not yet provisioned for api {api_id}"
15426            ));
15427        }
15428        state
15429            .route_responses
15430            .entry(api_id.clone())
15431            .or_default()
15432            .insert(composite.clone(), body);
15433        Ok(ProvisionResult::new(composite.clone())
15434            .with("RouteResponseId", id)
15435            .with("RouteId", route_id)
15436            .with("ApiId", api_id))
15437    }
15438
15439    fn delete_apigwv2_route_response(
15440        &self,
15441        physical_id: &str,
15442        attributes: &BTreeMap<String, String>,
15443    ) -> Result<(), String> {
15444        let Some(api_id) = attributes.get("ApiId") else {
15445            return Ok(());
15446        };
15447        let mut accounts = self.apigatewayv2_state.write();
15448        let state = accounts.get_or_create(&self.account_id);
15449        if let Some(map) = state.route_responses.get_mut(api_id) {
15450            map.remove(physical_id);
15451        }
15452        Ok(())
15453    }
15454
15455    fn create_apigwv2_stage(
15456        &self,
15457        resource: &ResourceDefinition,
15458    ) -> Result<ProvisionResult, String> {
15459        let props = &resource.properties;
15460        let api_id = props
15461            .get("ApiId")
15462            .and_then(|v| v.as_str())
15463            .ok_or("ApiId is required")?
15464            .to_string();
15465        let stage_name = props
15466            .get("StageName")
15467            .and_then(|v| v.as_str())
15468            .ok_or("StageName is required")?
15469            .to_string();
15470        let auto_deploy = props
15471            .get("AutoDeploy")
15472            .and_then(|v| v.as_bool())
15473            .unwrap_or(false);
15474        let deployment_id = props
15475            .get("DeploymentId")
15476            .and_then(|v| v.as_str())
15477            .map(String::from);
15478
15479        let stage_variables = props
15480            .get("StageVariables")
15481            .and_then(|v| v.as_object())
15482            .map(|obj| {
15483                obj.iter()
15484                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
15485                    .collect()
15486            });
15487
15488        let access_log_settings = props.get("AccessLogSettings").and_then(|v| {
15489            let destination_arn = v.get("DestinationArn")?.as_str()?.to_string();
15490            let format = v.get("Format").and_then(|f| f.as_str().map(String::from));
15491            Some(fakecloud_apigatewayv2::AccessLogSettings {
15492                destination_arn,
15493                format,
15494            })
15495        });
15496
15497        let stage = ApiGwV2Stage {
15498            stage_name: stage_name.clone(),
15499            description: props
15500                .get("Description")
15501                .and_then(|v| v.as_str())
15502                .map(String::from),
15503            deployment_id: deployment_id.clone(),
15504            auto_deploy,
15505            created_date: Utc::now(),
15506            last_updated_date: None,
15507            web_acl_arn: None,
15508            stage_variables,
15509            access_log_settings,
15510        };
15511
15512        let mut accounts = self.apigatewayv2_state.write();
15513        let state = accounts.get_or_create(&self.account_id);
15514        if !state.apis.contains_key(&api_id) {
15515            return Err(format!("Api {api_id} not yet provisioned"));
15516        }
15517        if let Some(dep) = &deployment_id {
15518            if !state
15519                .deployments
15520                .get(&api_id)
15521                .map(|m| m.contains_key(dep))
15522                .unwrap_or(false)
15523            {
15524                return Err(format!(
15525                    "Deployment {dep} not yet provisioned for api {api_id}"
15526                ));
15527            }
15528        }
15529        state
15530            .stages
15531            .entry(api_id.clone())
15532            .or_default()
15533            .insert(stage_name.clone(), stage);
15534
15535        Ok(ProvisionResult::new(stage_name.clone())
15536            .with("StageName", stage_name)
15537            .with("ApiId", api_id))
15538    }
15539
15540    fn delete_apigwv2_stage(
15541        &self,
15542        physical_id: &str,
15543        attributes: &BTreeMap<String, String>,
15544    ) -> Result<(), String> {
15545        let Some(api_id) = attributes.get("ApiId") else {
15546            return Ok(());
15547        };
15548        let mut accounts = self.apigatewayv2_state.write();
15549        let state = accounts.get_or_create(&self.account_id);
15550        if let Some(map) = state.stages.get_mut(api_id) {
15551            map.remove(physical_id);
15552        }
15553        Ok(())
15554    }
15555
15556    fn create_apigwv2_deployment(
15557        &self,
15558        resource: &ResourceDefinition,
15559    ) -> Result<ProvisionResult, String> {
15560        let props = &resource.properties;
15561        let api_id = props
15562            .get("ApiId")
15563            .and_then(|v| v.as_str())
15564            .ok_or("ApiId is required")?
15565            .to_string();
15566        let id = make_apigwv2_id(10);
15567        let deployment = ApiGwV2Deployment {
15568            deployment_id: id.clone(),
15569            description: props
15570                .get("Description")
15571                .and_then(|v| v.as_str())
15572                .map(String::from),
15573            created_date: Utc::now(),
15574            auto_deployed: false,
15575        };
15576        let mut accounts = self.apigatewayv2_state.write();
15577        let state = accounts.get_or_create(&self.account_id);
15578        if !state.apis.contains_key(&api_id) {
15579            return Err(format!("Api {api_id} not yet provisioned"));
15580        }
15581        state
15582            .deployments
15583            .entry(api_id.clone())
15584            .or_default()
15585            .insert(id.clone(), deployment);
15586        Ok(ProvisionResult::new(id.clone())
15587            .with("DeploymentId", id)
15588            .with("ApiId", api_id))
15589    }
15590
15591    fn delete_apigwv2_deployment(
15592        &self,
15593        physical_id: &str,
15594        attributes: &BTreeMap<String, String>,
15595    ) -> Result<(), String> {
15596        let Some(api_id) = attributes.get("ApiId") else {
15597            return Ok(());
15598        };
15599        let mut accounts = self.apigatewayv2_state.write();
15600        let state = accounts.get_or_create(&self.account_id);
15601        if let Some(map) = state.deployments.get_mut(api_id) {
15602            map.remove(physical_id);
15603        }
15604        Ok(())
15605    }
15606
15607    fn create_apigwv2_authorizer(
15608        &self,
15609        resource: &ResourceDefinition,
15610    ) -> Result<ProvisionResult, String> {
15611        let props = &resource.properties;
15612        let api_id = props
15613            .get("ApiId")
15614            .and_then(|v| v.as_str())
15615            .ok_or("ApiId is required")?
15616            .to_string();
15617        let name = props
15618            .get("Name")
15619            .and_then(|v| v.as_str())
15620            .ok_or("Name is required")?
15621            .to_string();
15622        let authorizer_type = props
15623            .get("AuthorizerType")
15624            .and_then(|v| v.as_str())
15625            .unwrap_or("REQUEST")
15626            .to_string();
15627        let identity_source = props
15628            .get("IdentitySource")
15629            .and_then(|v| v.as_array())
15630            .map(|arr| {
15631                arr.iter()
15632                    .filter_map(|v| v.as_str().map(String::from))
15633                    .collect::<Vec<String>>()
15634            });
15635        let jwt_configuration = props
15636            .get("JwtConfiguration")
15637            .and_then(|v| v.as_object())
15638            .map(|obj| ApiGwV2JwtConfiguration {
15639                audience: obj.get("Audience").and_then(|v| v.as_array()).map(|a| {
15640                    a.iter()
15641                        .filter_map(|v| v.as_str().map(String::from))
15642                        .collect()
15643                }),
15644                issuer: obj.get("Issuer").and_then(|v| v.as_str()).map(String::from),
15645            });
15646
15647        let id = make_apigwv2_id(10);
15648        let auth = ApiGwV2Authorizer {
15649            authorizer_id: id.clone(),
15650            name,
15651            authorizer_type,
15652            authorizer_uri: props
15653                .get("AuthorizerUri")
15654                .and_then(|v| v.as_str())
15655                .map(String::from),
15656            identity_source,
15657            jwt_configuration,
15658        };
15659        let mut accounts = self.apigatewayv2_state.write();
15660        let state = accounts.get_or_create(&self.account_id);
15661        if !state.apis.contains_key(&api_id) {
15662            return Err(format!("Api {api_id} not yet provisioned"));
15663        }
15664        state
15665            .authorizers
15666            .entry(api_id.clone())
15667            .or_default()
15668            .insert(id.clone(), auth);
15669        Ok(ProvisionResult::new(id.clone())
15670            .with("AuthorizerId", id)
15671            .with("ApiId", api_id))
15672    }
15673
15674    fn delete_apigwv2_authorizer(
15675        &self,
15676        physical_id: &str,
15677        attributes: &BTreeMap<String, String>,
15678    ) -> Result<(), String> {
15679        let Some(api_id) = attributes.get("ApiId") else {
15680            return Ok(());
15681        };
15682        let mut accounts = self.apigatewayv2_state.write();
15683        let state = accounts.get_or_create(&self.account_id);
15684        if let Some(map) = state.authorizers.get_mut(api_id) {
15685            map.remove(physical_id);
15686        }
15687        Ok(())
15688    }
15689
15690    fn create_apigwv2_domain_name(
15691        &self,
15692        resource: &ResourceDefinition,
15693    ) -> Result<ProvisionResult, String> {
15694        let props = &resource.properties;
15695        let domain_name = props
15696            .get("DomainName")
15697            .and_then(|v| v.as_str())
15698            .ok_or("DomainName is required")?
15699            .to_string();
15700        let body = serde_json::json!({
15701            "domainName": domain_name,
15702            "domainNameConfigurations": props.get("DomainNameConfigurations").cloned().unwrap_or(serde_json::json!([])),
15703            "mutualTlsAuthentication": props.get("MutualTlsAuthentication").cloned(),
15704            "apiMappingSelectionExpression": null,
15705        });
15706        let mut accounts = self.apigatewayv2_state.write();
15707        let state = accounts.get_or_create(&self.account_id);
15708        state.domain_names.insert(domain_name.clone(), body);
15709        Ok(ProvisionResult::new(domain_name.clone()).with("DomainName", domain_name))
15710    }
15711
15712    fn delete_apigwv2_domain_name(&self, physical_id: &str) -> Result<(), String> {
15713        let mut accounts = self.apigatewayv2_state.write();
15714        let state = accounts.get_or_create(&self.account_id);
15715        state.domain_names.remove(physical_id);
15716        state.api_mappings.remove(physical_id);
15717        Ok(())
15718    }
15719
15720    fn create_apigwv2_api_mapping(
15721        &self,
15722        resource: &ResourceDefinition,
15723    ) -> Result<ProvisionResult, String> {
15724        let props = &resource.properties;
15725        let domain_name = props
15726            .get("DomainName")
15727            .and_then(|v| v.as_str())
15728            .ok_or("DomainName is required")?
15729            .to_string();
15730        let api_id = props
15731            .get("ApiId")
15732            .and_then(|v| v.as_str())
15733            .ok_or("ApiId is required")?
15734            .to_string();
15735        let stage = props
15736            .get("Stage")
15737            .and_then(|v| v.as_str())
15738            .ok_or("Stage is required")?
15739            .to_string();
15740        let api_mapping_key = props
15741            .get("ApiMappingKey")
15742            .and_then(|v| v.as_str())
15743            .map(String::from);
15744        let id = make_apigwv2_id(10);
15745        let body = serde_json::json!({
15746            "apiMappingId": id,
15747            "apiId": api_id,
15748            "stage": stage,
15749            "apiMappingKey": api_mapping_key,
15750        });
15751        let mut accounts = self.apigatewayv2_state.write();
15752        let state = accounts.get_or_create(&self.account_id);
15753        if !state.domain_names.contains_key(&domain_name) {
15754            return Err(format!("DomainName {domain_name} not yet provisioned"));
15755        }
15756        if !state.apis.contains_key(&api_id) {
15757            return Err(format!("Api {api_id} not yet provisioned"));
15758        }
15759        state
15760            .api_mappings
15761            .entry(domain_name.clone())
15762            .or_default()
15763            .insert(id.clone(), body);
15764        Ok(ProvisionResult::new(id.clone())
15765            .with("ApiMappingId", id)
15766            .with("DomainName", domain_name))
15767    }
15768
15769    fn delete_apigwv2_api_mapping(
15770        &self,
15771        physical_id: &str,
15772        attributes: &BTreeMap<String, String>,
15773    ) -> Result<(), String> {
15774        let Some(domain) = attributes.get("DomainName") else {
15775            return Ok(());
15776        };
15777        let mut accounts = self.apigatewayv2_state.write();
15778        let state = accounts.get_or_create(&self.account_id);
15779        if let Some(map) = state.api_mappings.get_mut(domain) {
15780            map.remove(physical_id);
15781        }
15782        Ok(())
15783    }
15784
15785    fn create_apigwv2_vpc_link(
15786        &self,
15787        resource: &ResourceDefinition,
15788    ) -> Result<ProvisionResult, String> {
15789        let props = &resource.properties;
15790        let name = props
15791            .get("Name")
15792            .and_then(|v| v.as_str())
15793            .ok_or("Name is required")?
15794            .to_string();
15795        let id = make_apigwv2_id(10);
15796        let body = serde_json::json!({
15797            "vpcLinkId": id,
15798            "name": name,
15799            "subnetIds": props.get("SubnetIds").cloned().unwrap_or(serde_json::json!([])),
15800            "securityGroupIds": props.get("SecurityGroupIds").cloned().unwrap_or(serde_json::json!([])),
15801            "tags": props.get("Tags").cloned().unwrap_or(serde_json::json!({})),
15802            "vpcLinkStatus": "AVAILABLE",
15803            "vpcLinkVersion": "V2",
15804            "createdDate": Utc::now().to_rfc3339(),
15805        });
15806        let mut accounts = self.apigatewayv2_state.write();
15807        let state = accounts.get_or_create(&self.account_id);
15808        state.vpc_links.insert(id.clone(), body);
15809        Ok(ProvisionResult::new(id.clone()).with("VpcLinkId", id))
15810    }
15811
15812    fn delete_apigwv2_vpc_link(&self, physical_id: &str) -> Result<(), String> {
15813        let mut accounts = self.apigatewayv2_state.write();
15814        let state = accounts.get_or_create(&self.account_id);
15815        state.vpc_links.remove(physical_id);
15816        Ok(())
15817    }
15818
15819    fn create_apigwv2_model(
15820        &self,
15821        resource: &ResourceDefinition,
15822    ) -> Result<ProvisionResult, String> {
15823        let props = &resource.properties;
15824        let api_id = props
15825            .get("ApiId")
15826            .and_then(|v| v.as_str())
15827            .ok_or("ApiId is required")?
15828            .to_string();
15829        let name = props
15830            .get("Name")
15831            .and_then(|v| v.as_str())
15832            .ok_or("Name is required")?
15833            .to_string();
15834        let id = make_apigwv2_id(10);
15835        let body = serde_json::json!({
15836            "modelId": id,
15837            "name": name,
15838            "contentType": props.get("ContentType").and_then(|v| v.as_str()).unwrap_or("application/json"),
15839            "description": props.get("Description").and_then(|v| v.as_str()),
15840            "schema": props.get("Schema").map(|v| if let Some(s) = v.as_str() { s.to_string() } else { v.to_string() }),
15841        });
15842        let mut accounts = self.apigatewayv2_state.write();
15843        let state = accounts.get_or_create(&self.account_id);
15844        if !state.apis.contains_key(&api_id) {
15845            return Err(format!("Api {api_id} not yet provisioned"));
15846        }
15847        state
15848            .models
15849            .entry(api_id.clone())
15850            .or_default()
15851            .insert(id.clone(), body);
15852        Ok(ProvisionResult::new(id.clone())
15853            .with("ModelId", id)
15854            .with("ApiId", api_id))
15855    }
15856
15857    fn delete_apigwv2_model(
15858        &self,
15859        physical_id: &str,
15860        attributes: &BTreeMap<String, String>,
15861    ) -> Result<(), String> {
15862        let Some(api_id) = attributes.get("ApiId") else {
15863            return Ok(());
15864        };
15865        let mut accounts = self.apigatewayv2_state.write();
15866        let state = accounts.get_or_create(&self.account_id);
15867        if let Some(map) = state.models.get_mut(api_id) {
15868            map.remove(physical_id);
15869        }
15870        Ok(())
15871    }
15872
15873    /// In-place update for AWS::ApiGatewayV2::Api. CFN keeps the same
15874    /// ApiId across updates; only mutable properties (Name, Description,
15875    /// CORS, RouteSelectionExpression, etc.) are rewritten.
15876    fn update_apigwv2_api(
15877        &self,
15878        existing: &StackResource,
15879        resource: &ResourceDefinition,
15880    ) -> Result<ProvisionResult, String> {
15881        let props = &resource.properties;
15882        let api_id = existing.physical_id.clone();
15883        let mut accounts = self.apigatewayv2_state.write();
15884        let state = accounts.get_or_create(&self.account_id);
15885        let api = state
15886            .apis
15887            .get_mut(&api_id)
15888            .ok_or_else(|| format!("Api {api_id} no longer exists in state"))?;
15889
15890        if let Some(s) = props.get("Name").and_then(|v| v.as_str()) {
15891            api.name = s.to_string();
15892        }
15893        if let Some(s) = props.get("ProtocolType").and_then(|v| v.as_str()) {
15894            api.protocol_type = s.to_string();
15895        }
15896        api.description = props
15897            .get("Description")
15898            .and_then(|v| v.as_str())
15899            .map(String::from)
15900            .or_else(|| api.description.clone());
15901        if let Some(s) = props
15902            .get("RouteSelectionExpression")
15903            .and_then(|v| v.as_str())
15904        {
15905            api.route_selection_expression = s.to_string();
15906        }
15907        if let Some(s) = props
15908            .get("ApiKeySelectionExpression")
15909            .and_then(|v| v.as_str())
15910        {
15911            api.api_key_selection_expression = s.to_string();
15912        }
15913        if let Some(b) = props
15914            .get("DisableExecuteApiEndpoint")
15915            .and_then(|v| v.as_bool())
15916        {
15917            api.disable_execute_api_endpoint = b;
15918        }
15919        if let Some(s) = props.get("IpAddressType").and_then(|v| v.as_str()) {
15920            api.ip_address_type = s.to_string();
15921        }
15922        if let Some(cors) = props.get("CorsConfiguration").and_then(|v| v.as_object()) {
15923            api.cors_configuration = Some(ApiGwV2CorsConfiguration {
15924                allow_credentials: cors.get("AllowCredentials").and_then(|v| v.as_bool()),
15925                allow_headers: cors
15926                    .get("AllowHeaders")
15927                    .and_then(|v| v.as_array())
15928                    .map(|a| {
15929                        a.iter()
15930                            .filter_map(|v| v.as_str().map(String::from))
15931                            .collect()
15932                    }),
15933                allow_methods: cors
15934                    .get("AllowMethods")
15935                    .and_then(|v| v.as_array())
15936                    .map(|a| {
15937                        a.iter()
15938                            .filter_map(|v| v.as_str().map(String::from))
15939                            .collect()
15940                    }),
15941                allow_origins: cors
15942                    .get("AllowOrigins")
15943                    .and_then(|v| v.as_array())
15944                    .map(|a| {
15945                        a.iter()
15946                            .filter_map(|v| v.as_str().map(String::from))
15947                            .collect()
15948                    }),
15949                expose_headers: cors
15950                    .get("ExposeHeaders")
15951                    .and_then(|v| v.as_array())
15952                    .map(|a| {
15953                        a.iter()
15954                            .filter_map(|v| v.as_str().map(String::from))
15955                            .collect()
15956                    }),
15957                max_age: cors
15958                    .get("MaxAge")
15959                    .and_then(|v| v.as_i64())
15960                    .map(|n| n as i32),
15961            });
15962        }
15963        if let Some(obj) = props.get("Tags").and_then(|v| v.as_object()) {
15964            let tags: BTreeMap<String, String> = obj
15965                .iter()
15966                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
15967                .collect();
15968            api.tags = Some(tags);
15969        }
15970
15971        let api_endpoint = api.api_endpoint.clone();
15972        Ok(ProvisionResult::new(api_id.clone())
15973            .with("ApiId", api_id)
15974            .with("ApiEndpoint", api_endpoint))
15975    }
15976
15977    /// In-place update for AWS::ApiGatewayV2::Route. The RouteId is
15978    /// stable across updates; only Target / AuthorizationType /
15979    /// AuthorizerId / RouteKey are rewritten.
15980    fn update_apigwv2_route(
15981        &self,
15982        existing: &StackResource,
15983        resource: &ResourceDefinition,
15984    ) -> Result<ProvisionResult, String> {
15985        let props = &resource.properties;
15986        let api_id = props
15987            .get("ApiId")
15988            .and_then(|v| v.as_str())
15989            .ok_or("ApiId is required")?
15990            .to_string();
15991        let route_id = existing.physical_id.clone();
15992
15993        let mut accounts = self.apigatewayv2_state.write();
15994        let state = accounts.get_or_create(&self.account_id);
15995        let routes = state
15996            .routes
15997            .get_mut(&api_id)
15998            .ok_or_else(|| format!("Api {api_id} not yet provisioned"))?;
15999        let route = routes
16000            .get_mut(&route_id)
16001            .ok_or_else(|| format!("Route {route_id} not yet provisioned for api {api_id}"))?;
16002        if let Some(s) = props.get("RouteKey").and_then(|v| v.as_str()) {
16003            route.route_key = s.to_string();
16004        }
16005        if let Some(s) = props.get("Target").and_then(|v| v.as_str()) {
16006            route.target = Some(s.to_string());
16007        }
16008        if let Some(s) = props.get("AuthorizationType").and_then(|v| v.as_str()) {
16009            route.authorization_type = Some(s.to_string());
16010        }
16011        if let Some(s) = props.get("AuthorizerId").and_then(|v| v.as_str()) {
16012            route.authorizer_id = Some(s.to_string());
16013        }
16014        Ok(ProvisionResult::new(route_id.clone())
16015            .with("RouteId", route_id)
16016            .with("ApiId", api_id))
16017    }
16018
16019    /// In-place update for AWS::ApiGatewayV2::Integration.
16020    fn update_apigwv2_integration(
16021        &self,
16022        existing: &StackResource,
16023        resource: &ResourceDefinition,
16024    ) -> Result<ProvisionResult, String> {
16025        let props = &resource.properties;
16026        let api_id = props
16027            .get("ApiId")
16028            .and_then(|v| v.as_str())
16029            .ok_or("ApiId is required")?
16030            .to_string();
16031        let integration_id = existing.physical_id.clone();
16032
16033        let mut accounts = self.apigatewayv2_state.write();
16034        let state = accounts.get_or_create(&self.account_id);
16035        let integrations = state
16036            .integrations
16037            .get_mut(&api_id)
16038            .ok_or_else(|| format!("Api {api_id} not yet provisioned"))?;
16039        let integ = integrations.get_mut(&integration_id).ok_or_else(|| {
16040            format!("Integration {integration_id} not yet provisioned for api {api_id}")
16041        })?;
16042        if let Some(s) = props.get("IntegrationType").and_then(|v| v.as_str()) {
16043            integ.integration_type = s.to_string();
16044        }
16045        if let Some(s) = props.get("IntegrationUri").and_then(|v| v.as_str()) {
16046            integ.integration_uri = Some(s.to_string());
16047        }
16048        if let Some(s) = props.get("PayloadFormatVersion").and_then(|v| v.as_str()) {
16049            integ.payload_format_version = Some(s.to_string());
16050        }
16051        if let Some(n) = props.get("TimeoutInMillis").and_then(|v| v.as_i64()) {
16052            integ.timeout_in_millis = Some(n);
16053        }
16054        Ok(ProvisionResult::new(integration_id.clone())
16055            .with("IntegrationId", integration_id)
16056            .with("ApiId", api_id))
16057    }
16058
16059    /// In-place update for AWS::ApiGatewayV2::IntegrationResponse. The
16060    /// composite physical id `{integration_id}/{response_id}` is stable
16061    /// across updates; only the embedded response body is rewritten.
16062    fn update_apigwv2_integration_response(
16063        &self,
16064        existing: &StackResource,
16065        resource: &ResourceDefinition,
16066    ) -> Result<ProvisionResult, String> {
16067        let props = &resource.properties;
16068        let api_id = props
16069            .get("ApiId")
16070            .and_then(|v| v.as_str())
16071            .ok_or("ApiId is required")?
16072            .to_string();
16073        let composite_key = existing.physical_id.clone();
16074        let (integration_id, response_id) = composite_key
16075            .split_once('/')
16076            .map(|(a, b)| (a.to_string(), b.to_string()))
16077            .ok_or_else(|| format!("Invalid IntegrationResponse physical id: {composite_key}"))?;
16078        let key_expr = props
16079            .get("IntegrationResponseKey")
16080            .and_then(|v| v.as_str())
16081            .ok_or("IntegrationResponseKey is required")?
16082            .to_string();
16083        let body = serde_json::json!({
16084            "integrationResponseId": response_id,
16085            "integrationId": integration_id,
16086            "integrationResponseKey": key_expr,
16087            "responseTemplates": props.get("ResponseTemplates").cloned().unwrap_or(serde_json::json!({})),
16088            "responseParameters": props.get("ResponseParameters").cloned().unwrap_or(serde_json::json!({})),
16089            "templateSelectionExpression": props.get("TemplateSelectionExpression").and_then(|v| v.as_str()),
16090            "contentHandlingStrategy": props.get("ContentHandlingStrategy").and_then(|v| v.as_str()),
16091        });
16092        let mut accounts = self.apigatewayv2_state.write();
16093        let state = accounts.get_or_create(&self.account_id);
16094        let map = state
16095            .integration_responses
16096            .get_mut(&api_id)
16097            .ok_or_else(|| format!("No integration responses found for api {api_id}"))?;
16098        if !map.contains_key(&composite_key) {
16099            return Err(format!(
16100                "IntegrationResponse {composite_key} not yet provisioned for api {api_id}"
16101            ));
16102        }
16103        map.insert(composite_key.clone(), body);
16104        Ok(ProvisionResult::new(composite_key)
16105            .with("IntegrationResponseId", response_id)
16106            .with("IntegrationId", integration_id)
16107            .with("ApiId", api_id))
16108    }
16109
16110    /// In-place update for AWS::ApiGatewayV2::RouteResponse. Composite
16111    /// physical id `{route_id}/{response_id}` is preserved.
16112    fn update_apigwv2_route_response(
16113        &self,
16114        existing: &StackResource,
16115        resource: &ResourceDefinition,
16116    ) -> Result<ProvisionResult, String> {
16117        let props = &resource.properties;
16118        let api_id = props
16119            .get("ApiId")
16120            .and_then(|v| v.as_str())
16121            .ok_or("ApiId is required")?
16122            .to_string();
16123        let composite = existing.physical_id.clone();
16124        let (route_id, response_id) = composite
16125            .split_once('/')
16126            .map(|(a, b)| (a.to_string(), b.to_string()))
16127            .ok_or_else(|| format!("Invalid RouteResponse physical id: {composite}"))?;
16128        let key_expr = props
16129            .get("RouteResponseKey")
16130            .and_then(|v| v.as_str())
16131            .ok_or("RouteResponseKey is required")?
16132            .to_string();
16133        let body = serde_json::json!({
16134            "routeResponseId": response_id,
16135            "routeId": route_id,
16136            "routeResponseKey": key_expr,
16137            "responseModels": props.get("ResponseModels").cloned().unwrap_or(serde_json::json!({})),
16138            "modelSelectionExpression": props.get("ModelSelectionExpression").and_then(|v| v.as_str()),
16139            "responseParameters": props.get("ResponseParameters").cloned().unwrap_or(serde_json::json!({})),
16140        });
16141        let mut accounts = self.apigatewayv2_state.write();
16142        let state = accounts.get_or_create(&self.account_id);
16143        let map = state
16144            .route_responses
16145            .get_mut(&api_id)
16146            .ok_or_else(|| format!("No route responses found for api {api_id}"))?;
16147        if !map.contains_key(&composite) {
16148            return Err(format!(
16149                "RouteResponse {composite} not yet provisioned for api {api_id}"
16150            ));
16151        }
16152        map.insert(composite.clone(), body);
16153        Ok(ProvisionResult::new(composite)
16154            .with("RouteResponseId", response_id)
16155            .with("RouteId", route_id)
16156            .with("ApiId", api_id))
16157    }
16158
16159    /// In-place update for AWS::ApiGatewayV2::Stage. StageName is the
16160    /// physical id and stays stable; description / deployment id /
16161    /// auto-deploy can change.
16162    fn update_apigwv2_stage(
16163        &self,
16164        existing: &StackResource,
16165        resource: &ResourceDefinition,
16166    ) -> Result<ProvisionResult, String> {
16167        let props = &resource.properties;
16168        let api_id = props
16169            .get("ApiId")
16170            .and_then(|v| v.as_str())
16171            .ok_or("ApiId is required")?
16172            .to_string();
16173        let stage_name = existing.physical_id.clone();
16174
16175        let mut accounts = self.apigatewayv2_state.write();
16176        let state = accounts.get_or_create(&self.account_id);
16177        let stages = state
16178            .stages
16179            .get_mut(&api_id)
16180            .ok_or_else(|| format!("Api {api_id} not yet provisioned"))?;
16181        let stage = stages
16182            .get_mut(&stage_name)
16183            .ok_or_else(|| format!("Stage {stage_name} not yet provisioned for api {api_id}"))?;
16184        if let Some(s) = props.get("Description").and_then(|v| v.as_str()) {
16185            stage.description = Some(s.to_string());
16186        }
16187        if let Some(s) = props.get("DeploymentId").and_then(|v| v.as_str()) {
16188            stage.deployment_id = Some(s.to_string());
16189        }
16190        if let Some(b) = props.get("AutoDeploy").and_then(|v| v.as_bool()) {
16191            stage.auto_deploy = b;
16192        }
16193        stage.last_updated_date = Some(Utc::now());
16194        Ok(ProvisionResult::new(stage_name.clone())
16195            .with("StageName", stage_name)
16196            .with("ApiId", api_id))
16197    }
16198
16199    /// In-place update for AWS::ApiGatewayV2::Deployment. CFN allows
16200    /// editing the Description; the DeploymentId is stable.
16201    fn update_apigwv2_deployment(
16202        &self,
16203        existing: &StackResource,
16204        resource: &ResourceDefinition,
16205    ) -> Result<ProvisionResult, String> {
16206        let props = &resource.properties;
16207        let api_id = props
16208            .get("ApiId")
16209            .and_then(|v| v.as_str())
16210            .ok_or("ApiId is required")?
16211            .to_string();
16212        let deployment_id = existing.physical_id.clone();
16213        let mut accounts = self.apigatewayv2_state.write();
16214        let state = accounts.get_or_create(&self.account_id);
16215        let deps = state
16216            .deployments
16217            .get_mut(&api_id)
16218            .ok_or_else(|| format!("Api {api_id} not yet provisioned"))?;
16219        let dep = deps.get_mut(&deployment_id).ok_or_else(|| {
16220            format!("Deployment {deployment_id} not yet provisioned for api {api_id}")
16221        })?;
16222        if let Some(s) = props.get("Description").and_then(|v| v.as_str()) {
16223            dep.description = Some(s.to_string());
16224        }
16225        Ok(ProvisionResult::new(deployment_id.clone())
16226            .with("DeploymentId", deployment_id)
16227            .with("ApiId", api_id))
16228    }
16229
16230    /// In-place update for AWS::ApiGatewayV2::Authorizer. Mutates the
16231    /// stored authorizer in place; the AuthorizerId remains stable.
16232    fn update_apigwv2_authorizer(
16233        &self,
16234        existing: &StackResource,
16235        resource: &ResourceDefinition,
16236    ) -> Result<ProvisionResult, String> {
16237        let props = &resource.properties;
16238        let api_id = props
16239            .get("ApiId")
16240            .and_then(|v| v.as_str())
16241            .ok_or("ApiId is required")?
16242            .to_string();
16243        let authorizer_id = existing.physical_id.clone();
16244
16245        let mut accounts = self.apigatewayv2_state.write();
16246        let state = accounts.get_or_create(&self.account_id);
16247        let auths = state
16248            .authorizers
16249            .get_mut(&api_id)
16250            .ok_or_else(|| format!("Api {api_id} not yet provisioned"))?;
16251        let auth = auths.get_mut(&authorizer_id).ok_or_else(|| {
16252            format!("Authorizer {authorizer_id} not yet provisioned for api {api_id}")
16253        })?;
16254        if let Some(s) = props.get("Name").and_then(|v| v.as_str()) {
16255            auth.name = s.to_string();
16256        }
16257        if let Some(s) = props.get("AuthorizerType").and_then(|v| v.as_str()) {
16258            auth.authorizer_type = s.to_string();
16259        }
16260        if let Some(s) = props.get("AuthorizerUri").and_then(|v| v.as_str()) {
16261            auth.authorizer_uri = Some(s.to_string());
16262        }
16263        if let Some(arr) = props.get("IdentitySource").and_then(|v| v.as_array()) {
16264            auth.identity_source = Some(
16265                arr.iter()
16266                    .filter_map(|v| v.as_str().map(String::from))
16267                    .collect(),
16268            );
16269        }
16270        if let Some(obj) = props.get("JwtConfiguration").and_then(|v| v.as_object()) {
16271            auth.jwt_configuration = Some(ApiGwV2JwtConfiguration {
16272                audience: obj.get("Audience").and_then(|v| v.as_array()).map(|a| {
16273                    a.iter()
16274                        .filter_map(|v| v.as_str().map(String::from))
16275                        .collect()
16276                }),
16277                issuer: obj.get("Issuer").and_then(|v| v.as_str()).map(String::from),
16278            });
16279        }
16280        Ok(ProvisionResult::new(authorizer_id.clone())
16281            .with("AuthorizerId", authorizer_id)
16282            .with("ApiId", api_id))
16283    }
16284
16285    /// In-place update for AWS::ApiGatewayV2::DomainName. The DomainName
16286    /// is the physical id and stays stable; configuration / mTLS can
16287    /// change.
16288    fn update_apigwv2_domain_name(
16289        &self,
16290        existing: &StackResource,
16291        resource: &ResourceDefinition,
16292    ) -> Result<ProvisionResult, String> {
16293        let props = &resource.properties;
16294        let domain_name = existing.physical_id.clone();
16295        let body = serde_json::json!({
16296            "domainName": domain_name,
16297            "domainNameConfigurations": props.get("DomainNameConfigurations").cloned().unwrap_or(serde_json::json!([])),
16298            "mutualTlsAuthentication": props.get("MutualTlsAuthentication").cloned(),
16299            "apiMappingSelectionExpression": null,
16300        });
16301        let mut accounts = self.apigatewayv2_state.write();
16302        let state = accounts.get_or_create(&self.account_id);
16303        if !state.domain_names.contains_key(&domain_name) {
16304            return Err(format!("DomainName {domain_name} no longer exists"));
16305        }
16306        state.domain_names.insert(domain_name.clone(), body);
16307        Ok(ProvisionResult::new(domain_name.clone()).with("DomainName", domain_name))
16308    }
16309
16310    /// In-place update for AWS::ApiGatewayV2::ApiMapping. The ApiMappingId
16311    /// is the physical id; ApiId / Stage / ApiMappingKey can change.
16312    fn update_apigwv2_api_mapping(
16313        &self,
16314        existing: &StackResource,
16315        resource: &ResourceDefinition,
16316    ) -> Result<ProvisionResult, String> {
16317        let props = &resource.properties;
16318        let domain_name = props
16319            .get("DomainName")
16320            .and_then(|v| v.as_str())
16321            .ok_or("DomainName is required")?
16322            .to_string();
16323        let api_id = props
16324            .get("ApiId")
16325            .and_then(|v| v.as_str())
16326            .ok_or("ApiId is required")?
16327            .to_string();
16328        let stage = props
16329            .get("Stage")
16330            .and_then(|v| v.as_str())
16331            .ok_or("Stage is required")?
16332            .to_string();
16333        let api_mapping_key = props
16334            .get("ApiMappingKey")
16335            .and_then(|v| v.as_str())
16336            .map(String::from);
16337        let id = existing.physical_id.clone();
16338        let body = serde_json::json!({
16339            "apiMappingId": id,
16340            "apiId": api_id,
16341            "stage": stage,
16342            "apiMappingKey": api_mapping_key,
16343        });
16344        let mut accounts = self.apigatewayv2_state.write();
16345        let state = accounts.get_or_create(&self.account_id);
16346        let map = state
16347            .api_mappings
16348            .get_mut(&domain_name)
16349            .ok_or_else(|| format!("DomainName {domain_name} no longer exists"))?;
16350        map.insert(id.clone(), body);
16351        Ok(ProvisionResult::new(id.clone())
16352            .with("ApiMappingId", id)
16353            .with("DomainName", domain_name))
16354    }
16355
16356    /// In-place update for AWS::ApiGatewayV2::VpcLink. The VpcLinkId is
16357    /// the physical id; Name / SubnetIds / SecurityGroupIds / Tags can
16358    /// change.
16359    fn update_apigwv2_vpc_link(
16360        &self,
16361        existing: &StackResource,
16362        resource: &ResourceDefinition,
16363    ) -> Result<ProvisionResult, String> {
16364        let props = &resource.properties;
16365        let id = existing.physical_id.clone();
16366        let mut accounts = self.apigatewayv2_state.write();
16367        let state = accounts.get_or_create(&self.account_id);
16368        let body = state
16369            .vpc_links
16370            .get_mut(&id)
16371            .ok_or_else(|| format!("VpcLink {id} no longer exists"))?;
16372        if let Some(s) = props.get("Name").and_then(|v| v.as_str()) {
16373            body["name"] = serde_json::Value::String(s.to_string());
16374        }
16375        if let Some(v) = props.get("SubnetIds").cloned() {
16376            body["subnetIds"] = v;
16377        }
16378        if let Some(v) = props.get("SecurityGroupIds").cloned() {
16379            body["securityGroupIds"] = v;
16380        }
16381        if let Some(v) = props.get("Tags").cloned() {
16382            body["tags"] = v;
16383        }
16384        Ok(ProvisionResult::new(id.clone()).with("VpcLinkId", id))
16385    }
16386
16387    /// In-place update for AWS::ApiGatewayV2::Model. The ModelId is the
16388    /// physical id and stays stable; Schema / Description / ContentType
16389    /// / Name can change.
16390    fn update_apigwv2_model(
16391        &self,
16392        existing: &StackResource,
16393        resource: &ResourceDefinition,
16394    ) -> Result<ProvisionResult, String> {
16395        let props = &resource.properties;
16396        let api_id = props
16397            .get("ApiId")
16398            .and_then(|v| v.as_str())
16399            .ok_or("ApiId is required")?
16400            .to_string();
16401        let id = existing.physical_id.clone();
16402        let mut accounts = self.apigatewayv2_state.write();
16403        let state = accounts.get_or_create(&self.account_id);
16404        let map = state
16405            .models
16406            .get_mut(&api_id)
16407            .ok_or_else(|| format!("Api {api_id} not yet provisioned"))?;
16408        let body = map
16409            .get_mut(&id)
16410            .ok_or_else(|| format!("Model {id} not yet provisioned for api {api_id}"))?;
16411        if let Some(s) = props.get("Name").and_then(|v| v.as_str()) {
16412            body["name"] = serde_json::Value::String(s.to_string());
16413        }
16414        if let Some(s) = props.get("ContentType").and_then(|v| v.as_str()) {
16415            body["contentType"] = serde_json::Value::String(s.to_string());
16416        }
16417        if let Some(s) = props.get("Description").and_then(|v| v.as_str()) {
16418            body["description"] = serde_json::Value::String(s.to_string());
16419        }
16420        if let Some(v) = props.get("Schema") {
16421            body["schema"] = serde_json::Value::String(if let Some(s) = v.as_str() {
16422                s.to_string()
16423            } else {
16424                v.to_string()
16425            });
16426        }
16427        Ok(ProvisionResult::new(id.clone())
16428            .with("ModelId", id)
16429            .with("ApiId", api_id))
16430    }
16431
16432    // --- SES ---
16433
16434    fn create_ses_configuration_set(
16435        &self,
16436        resource: &ResourceDefinition,
16437    ) -> Result<ProvisionResult, String> {
16438        let props = &resource.properties;
16439        let name = props
16440            .get("Name")
16441            .and_then(|v| v.as_str())
16442            .map(String::from)
16443            .unwrap_or_else(|| format!("cfn-cs-{}", resource.logical_id));
16444        let sending_enabled = props
16445            .get("SendingOptions")
16446            .and_then(|v| v.get("SendingEnabled"))
16447            .and_then(|v| v.as_bool())
16448            .unwrap_or(true);
16449        let tls_policy = props
16450            .get("DeliveryOptions")
16451            .and_then(|v| v.get("TlsPolicy"))
16452            .and_then(|v| v.as_str())
16453            .unwrap_or("OPTIONAL")
16454            .to_string();
16455        let sending_pool_name = props
16456            .get("DeliveryOptions")
16457            .and_then(|v| v.get("SendingPoolName"))
16458            .and_then(|v| v.as_str())
16459            .map(String::from);
16460        let custom_redirect_domain = props
16461            .get("TrackingOptions")
16462            .and_then(|v| v.get("CustomRedirectDomain"))
16463            .and_then(|v| v.as_str())
16464            .map(String::from);
16465        let suppressed_reasons: Vec<String> = props
16466            .get("SuppressionOptions")
16467            .and_then(|v| v.get("SuppressedReasons"))
16468            .and_then(|v| v.as_array())
16469            .map(|arr| {
16470                arr.iter()
16471                    .filter_map(|v| v.as_str().map(String::from))
16472                    .collect()
16473            })
16474            .unwrap_or_default();
16475        let reputation_metrics_enabled = props
16476            .get("ReputationOptions")
16477            .and_then(|v| v.get("ReputationMetricsEnabled"))
16478            .and_then(|v| v.as_bool())
16479            .unwrap_or(false);
16480
16481        let cs = SesConfigurationSet {
16482            name: name.clone(),
16483            sending_enabled,
16484            tls_policy,
16485            sending_pool_name,
16486            custom_redirect_domain,
16487            https_policy: props
16488                .get("TrackingOptions")
16489                .and_then(|v| v.get("HttpsPolicy"))
16490                .and_then(|v| v.as_str())
16491                .map(String::from),
16492            suppressed_reasons,
16493            reputation_metrics_enabled,
16494            vdm_options: props.get("VdmOptions").cloned(),
16495            archive_arn: props
16496                .get("ArchivingOptions")
16497                .and_then(|v| v.get("ArchiveArn"))
16498                .and_then(|v| v.as_str())
16499                .map(String::from),
16500        };
16501        let mut accounts = self.ses_state.write();
16502        let state = accounts.get_or_create(&self.account_id);
16503        state.configuration_sets.insert(name.clone(), cs);
16504        Ok(ProvisionResult::new(name.clone()).with("Name", name))
16505    }
16506
16507    fn delete_ses_configuration_set(&self, physical_id: &str) -> Result<(), String> {
16508        let mut accounts = self.ses_state.write();
16509        let state = accounts.get_or_create(&self.account_id);
16510        state.configuration_sets.remove(physical_id);
16511        state.event_destinations.remove(physical_id);
16512        Ok(())
16513    }
16514
16515    fn create_ses_event_destination(
16516        &self,
16517        resource: &ResourceDefinition,
16518    ) -> Result<ProvisionResult, String> {
16519        let props = &resource.properties;
16520        let cs_name = props
16521            .get("ConfigurationSetName")
16522            .and_then(|v| v.as_str())
16523            .ok_or("ConfigurationSetName is required")?
16524            .to_string();
16525        let dest_props = props
16526            .get("EventDestination")
16527            .and_then(|v| v.as_object())
16528            .ok_or("EventDestination is required")?;
16529        let name = dest_props
16530            .get("Name")
16531            .and_then(|v| v.as_str())
16532            .map(String::from)
16533            .unwrap_or_else(|| format!("cfn-ed-{}", resource.logical_id));
16534        let enabled = dest_props
16535            .get("Enabled")
16536            .and_then(|v| v.as_bool())
16537            .unwrap_or(true);
16538        let matching_event_types: Vec<String> = dest_props
16539            .get("MatchingEventTypes")
16540            .and_then(|v| v.as_array())
16541            .map(|arr| {
16542                arr.iter()
16543                    .filter_map(|v| v.as_str().map(String::from))
16544                    .collect()
16545            })
16546            .unwrap_or_default();
16547        let dest = SesEventDestination {
16548            name: name.clone(),
16549            enabled,
16550            matching_event_types,
16551            kinesis_firehose_destination: dest_props.get("KinesisFirehoseDestination").cloned(),
16552            cloud_watch_destination: dest_props.get("CloudWatchDestination").cloned(),
16553            sns_destination: dest_props.get("SnsDestination").cloned(),
16554            event_bridge_destination: dest_props.get("EventBridgeDestination").cloned(),
16555            pinpoint_destination: dest_props.get("PinpointDestination").cloned(),
16556        };
16557        let mut accounts = self.ses_state.write();
16558        let state = accounts.get_or_create(&self.account_id);
16559        if !state.configuration_sets.contains_key(&cs_name) {
16560            return Err(format!("ConfigurationSet {cs_name} not yet provisioned"));
16561        }
16562        let dests = state.event_destinations.entry(cs_name.clone()).or_default();
16563        dests.retain(|d| d.name != name);
16564        dests.push(dest);
16565        let physical = format!("{cs_name}|{name}");
16566        Ok(ProvisionResult::new(physical)
16567            .with("Name", name)
16568            .with("ConfigurationSetName", cs_name))
16569    }
16570
16571    fn delete_ses_event_destination(
16572        &self,
16573        physical_id: &str,
16574        _attributes: &BTreeMap<String, String>,
16575    ) -> Result<(), String> {
16576        let mut parts = physical_id.splitn(2, '|');
16577        let Some(cs) = parts.next() else {
16578            return Ok(());
16579        };
16580        let Some(name) = parts.next() else {
16581            return Ok(());
16582        };
16583        let mut accounts = self.ses_state.write();
16584        let state = accounts.get_or_create(&self.account_id);
16585        if let Some(dests) = state.event_destinations.get_mut(cs) {
16586            dests.retain(|d| d.name != name);
16587        }
16588        Ok(())
16589    }
16590
16591    fn create_ses_email_identity(
16592        &self,
16593        resource: &ResourceDefinition,
16594    ) -> Result<ProvisionResult, String> {
16595        let props = &resource.properties;
16596        let identity_name = props
16597            .get("EmailIdentity")
16598            .and_then(|v| v.as_str())
16599            .ok_or("EmailIdentity is required")?
16600            .to_string();
16601        let identity_type = if identity_name.contains('@') {
16602            "EMAIL_ADDRESS"
16603        } else {
16604            "DOMAIN"
16605        }
16606        .to_string();
16607        let dkim_signing_enabled = props
16608            .get("DkimAttributes")
16609            .and_then(|v| v.get("SigningEnabled"))
16610            .and_then(|v| v.as_bool())
16611            .unwrap_or(true);
16612        let dkim_signing_attributes_origin = props
16613            .get("DkimSigningAttributes")
16614            .map(|_| "EXTERNAL")
16615            .unwrap_or("AWS_SES")
16616            .to_string();
16617        let mail_from_domain = props
16618            .get("MailFromAttributes")
16619            .and_then(|v| v.get("MailFromDomain"))
16620            .and_then(|v| v.as_str())
16621            .map(String::from);
16622        let mail_from_behavior = props
16623            .get("MailFromAttributes")
16624            .and_then(|v| v.get("BehaviorOnMxFailure"))
16625            .and_then(|v| v.as_str())
16626            .unwrap_or("USE_DEFAULT_VALUE")
16627            .to_string();
16628        let configuration_set_name = props
16629            .get("ConfigurationSetAttributes")
16630            .and_then(|v| v.get("ConfigurationSetName"))
16631            .and_then(|v| v.as_str())
16632            .map(String::from);
16633        let email_forwarding_enabled = props
16634            .get("FeedbackAttributes")
16635            .and_then(|v| v.get("EmailForwardingEnabled"))
16636            .and_then(|v| v.as_bool())
16637            .unwrap_or(true);
16638
16639        let identity = SesEmailIdentity {
16640            identity_name: identity_name.clone(),
16641            identity_type,
16642            verified: true,
16643            created_at: Utc::now(),
16644            dkim_signing_enabled,
16645            dkim_signing_attributes_origin,
16646            dkim_domain_signing_private_key: None,
16647            dkim_domain_signing_selector: None,
16648            dkim_next_signing_key_length: None,
16649            dkim_public_key_b64: None,
16650            email_forwarding_enabled,
16651            mail_from_domain,
16652            mail_from_behavior_on_mx_failure: mail_from_behavior,
16653            mail_from_domain_status: "Success".to_string(),
16654            configuration_set_name,
16655        };
16656        let mut accounts = self.ses_state.write();
16657        let state = accounts.get_or_create(&self.account_id);
16658        state.identities.insert(identity_name.clone(), identity);
16659        Ok(ProvisionResult::new(identity_name.clone()).with("EmailIdentity", identity_name))
16660    }
16661
16662    fn delete_ses_email_identity(&self, physical_id: &str) -> Result<(), String> {
16663        let mut accounts = self.ses_state.write();
16664        let state = accounts.get_or_create(&self.account_id);
16665        state.identities.remove(physical_id);
16666        Ok(())
16667    }
16668
16669    fn create_ses_template(
16670        &self,
16671        resource: &ResourceDefinition,
16672    ) -> Result<ProvisionResult, String> {
16673        let props = &resource.properties;
16674        let template_block = props
16675            .get("Template")
16676            .and_then(|v| v.as_object())
16677            .ok_or("Template is required")?;
16678        let template_name = template_block
16679            .get("TemplateName")
16680            .and_then(|v| v.as_str())
16681            .map(String::from)
16682            .unwrap_or_else(|| format!("cfn-tpl-{}", resource.logical_id));
16683        let tpl = SesEmailTemplate {
16684            template_name: template_name.clone(),
16685            subject: template_block
16686                .get("SubjectPart")
16687                .and_then(|v| v.as_str())
16688                .map(String::from),
16689            html_body: template_block
16690                .get("HtmlPart")
16691                .and_then(|v| v.as_str())
16692                .map(String::from),
16693            text_body: template_block
16694                .get("TextPart")
16695                .and_then(|v| v.as_str())
16696                .map(String::from),
16697            created_at: Utc::now(),
16698        };
16699        let mut accounts = self.ses_state.write();
16700        let state = accounts.get_or_create(&self.account_id);
16701        state.templates.insert(template_name.clone(), tpl);
16702        Ok(ProvisionResult::new(template_name.clone()).with("TemplateName", template_name))
16703    }
16704
16705    fn delete_ses_template(&self, physical_id: &str) -> Result<(), String> {
16706        let mut accounts = self.ses_state.write();
16707        let state = accounts.get_or_create(&self.account_id);
16708        state.templates.remove(physical_id);
16709        Ok(())
16710    }
16711
16712    fn create_ses_contact_list(
16713        &self,
16714        resource: &ResourceDefinition,
16715    ) -> Result<ProvisionResult, String> {
16716        let props = &resource.properties;
16717        let name = props
16718            .get("ContactListName")
16719            .and_then(|v| v.as_str())
16720            .map(String::from)
16721            .unwrap_or_else(|| format!("cfn-cl-{}", resource.logical_id));
16722        let description = props
16723            .get("Description")
16724            .and_then(|v| v.as_str())
16725            .map(String::from);
16726        let now = Utc::now();
16727        let cl = SesContactList {
16728            contact_list_name: name.clone(),
16729            description,
16730            topics: Vec::new(),
16731            created_at: now,
16732            last_updated_at: now,
16733        };
16734        let mut accounts = self.ses_state.write();
16735        let state = accounts.get_or_create(&self.account_id);
16736        state.contact_lists.insert(name.clone(), cl);
16737        Ok(ProvisionResult::new(name.clone()).with("ContactListName", name))
16738    }
16739
16740    fn delete_ses_contact_list(&self, physical_id: &str) -> Result<(), String> {
16741        let mut accounts = self.ses_state.write();
16742        let state = accounts.get_or_create(&self.account_id);
16743        state.contact_lists.remove(physical_id);
16744        state.contacts.remove(physical_id);
16745        Ok(())
16746    }
16747
16748    fn create_ses_dedicated_ip_pool(
16749        &self,
16750        resource: &ResourceDefinition,
16751    ) -> Result<ProvisionResult, String> {
16752        let props = &resource.properties;
16753        let name = props
16754            .get("PoolName")
16755            .and_then(|v| v.as_str())
16756            .map(String::from)
16757            .unwrap_or_else(|| format!("cfn-pool-{}", resource.logical_id));
16758        let scaling_mode = props
16759            .get("ScalingMode")
16760            .and_then(|v| v.as_str())
16761            .unwrap_or("STANDARD")
16762            .to_string();
16763        let pool = SesDedicatedIpPool {
16764            pool_name: name.clone(),
16765            scaling_mode,
16766        };
16767        let mut accounts = self.ses_state.write();
16768        let state = accounts.get_or_create(&self.account_id);
16769        state.dedicated_ip_pools.insert(name.clone(), pool);
16770        Ok(ProvisionResult::new(name.clone()).with("PoolName", name))
16771    }
16772
16773    fn delete_ses_dedicated_ip_pool(&self, physical_id: &str) -> Result<(), String> {
16774        let mut accounts = self.ses_state.write();
16775        let state = accounts.get_or_create(&self.account_id);
16776        state.dedicated_ip_pools.remove(physical_id);
16777        Ok(())
16778    }
16779
16780    fn create_ses_receipt_rule_set(
16781        &self,
16782        resource: &ResourceDefinition,
16783    ) -> Result<ProvisionResult, String> {
16784        let props = &resource.properties;
16785        let name = props
16786            .get("RuleSetName")
16787            .and_then(|v| v.as_str())
16788            .map(String::from)
16789            .unwrap_or_else(|| format!("cfn-rs-{}", resource.logical_id));
16790        let rs = SesReceiptRuleSet {
16791            name: name.clone(),
16792            rules: Vec::new(),
16793            created_at: Utc::now(),
16794        };
16795        let mut accounts = self.ses_state.write();
16796        let state = accounts.get_or_create(&self.account_id);
16797        state.receipt_rule_sets.insert(name.clone(), rs);
16798        Ok(ProvisionResult::new(name.clone()).with("RuleSetName", name))
16799    }
16800
16801    fn delete_ses_receipt_rule_set(&self, physical_id: &str) -> Result<(), String> {
16802        let mut accounts = self.ses_state.write();
16803        let state = accounts.get_or_create(&self.account_id);
16804        state.receipt_rule_sets.remove(physical_id);
16805        Ok(())
16806    }
16807
16808    fn create_ses_receipt_rule(
16809        &self,
16810        resource: &ResourceDefinition,
16811    ) -> Result<ProvisionResult, String> {
16812        let props = &resource.properties;
16813        let rule_set_name = props
16814            .get("RuleSetName")
16815            .and_then(|v| v.as_str())
16816            .ok_or("RuleSetName is required")?
16817            .to_string();
16818        let rule_block = props
16819            .get("Rule")
16820            .and_then(|v| v.as_object())
16821            .ok_or("Rule is required")?;
16822        let name = rule_block
16823            .get("Name")
16824            .and_then(|v| v.as_str())
16825            .map(String::from)
16826            .unwrap_or_else(|| format!("cfn-rule-{}", resource.logical_id));
16827        let enabled = rule_block
16828            .get("Enabled")
16829            .and_then(|v| v.as_bool())
16830            .unwrap_or(true);
16831        let scan_enabled = rule_block
16832            .get("ScanEnabled")
16833            .and_then(|v| v.as_bool())
16834            .unwrap_or(false);
16835        let tls_policy = rule_block
16836            .get("TlsPolicy")
16837            .and_then(|v| v.as_str())
16838            .unwrap_or("Optional")
16839            .to_string();
16840        let recipients: Vec<String> = rule_block
16841            .get("Recipients")
16842            .and_then(|v| v.as_array())
16843            .map(|arr| {
16844                arr.iter()
16845                    .filter_map(|v| v.as_str().map(String::from))
16846                    .collect()
16847            })
16848            .unwrap_or_default();
16849        let actions: Vec<SesReceiptAction> = rule_block
16850            .get("Actions")
16851            .and_then(|v| v.as_array())
16852            .map(|arr| arr.iter().filter_map(parse_ses_receipt_action).collect())
16853            .unwrap_or_default();
16854
16855        let rule = SesReceiptRule {
16856            name: name.clone(),
16857            enabled,
16858            scan_enabled,
16859            tls_policy,
16860            recipients,
16861            actions,
16862        };
16863        let mut accounts = self.ses_state.write();
16864        let state = accounts.get_or_create(&self.account_id);
16865        let rs = state
16866            .receipt_rule_sets
16867            .get_mut(&rule_set_name)
16868            .ok_or_else(|| format!("ReceiptRuleSet {rule_set_name} not yet provisioned"))?;
16869        rs.rules.retain(|r| r.name != name);
16870        rs.rules.push(rule);
16871        let physical = format!("{rule_set_name}|{name}");
16872        Ok(ProvisionResult::new(physical)
16873            .with("Name", name)
16874            .with("RuleSetName", rule_set_name))
16875    }
16876
16877    fn delete_ses_receipt_rule(
16878        &self,
16879        physical_id: &str,
16880        _attributes: &BTreeMap<String, String>,
16881    ) -> Result<(), String> {
16882        let mut parts = physical_id.splitn(2, '|');
16883        let Some(rs_name) = parts.next() else {
16884            return Ok(());
16885        };
16886        let Some(rule_name) = parts.next() else {
16887            return Ok(());
16888        };
16889        let mut accounts = self.ses_state.write();
16890        let state = accounts.get_or_create(&self.account_id);
16891        if let Some(rs) = state.receipt_rule_sets.get_mut(rs_name) {
16892            rs.rules.retain(|r| r.name != rule_name);
16893        }
16894        Ok(())
16895    }
16896
16897    fn create_ses_receipt_filter(
16898        &self,
16899        resource: &ResourceDefinition,
16900    ) -> Result<ProvisionResult, String> {
16901        let props = &resource.properties;
16902        let filter_block = props
16903            .get("Filter")
16904            .and_then(|v| v.as_object())
16905            .ok_or("Filter is required")?;
16906        let name = filter_block
16907            .get("Name")
16908            .and_then(|v| v.as_str())
16909            .map(String::from)
16910            .unwrap_or_else(|| format!("cfn-filter-{}", resource.logical_id));
16911        let ip_block = filter_block
16912            .get("IpFilter")
16913            .and_then(|v| v.as_object())
16914            .ok_or("Filter.IpFilter is required")?;
16915        let cidr = ip_block
16916            .get("Cidr")
16917            .and_then(|v| v.as_str())
16918            .ok_or("Filter.IpFilter.Cidr is required")?
16919            .to_string();
16920        let policy = ip_block
16921            .get("Policy")
16922            .and_then(|v| v.as_str())
16923            .unwrap_or("Block")
16924            .to_string();
16925        let filter = SesReceiptFilter {
16926            name: name.clone(),
16927            ip_filter: SesIpFilter { cidr, policy },
16928        };
16929        let mut accounts = self.ses_state.write();
16930        let state = accounts.get_or_create(&self.account_id);
16931        state.receipt_filters.insert(name.clone(), filter);
16932        Ok(ProvisionResult::new(name.clone()).with("Name", name))
16933    }
16934
16935    fn delete_ses_receipt_filter(&self, physical_id: &str) -> Result<(), String> {
16936        let mut accounts = self.ses_state.write();
16937        let state = accounts.get_or_create(&self.account_id);
16938        state.receipt_filters.remove(physical_id);
16939        Ok(())
16940    }
16941
16942    fn create_ses_vdm_attributes(
16943        &self,
16944        resource: &ResourceDefinition,
16945    ) -> Result<ProvisionResult, String> {
16946        let props = &resource.properties;
16947        let mut accounts = self.ses_state.write();
16948        let state = accounts.get_or_create(&self.account_id);
16949        state.account_settings.vdm_attributes = Some(props.clone());
16950        Ok(ProvisionResult::new(format!("vdm-{}", resource.logical_id)))
16951    }
16952
16953    // --- SecretsManager extras ---
16954
16955    fn create_secrets_manager_rotation_schedule(
16956        &self,
16957        resource: &ResourceDefinition,
16958    ) -> Result<ProvisionResult, String> {
16959        let props = &resource.properties;
16960        let secret_id = props
16961            .get("SecretId")
16962            .and_then(|v| v.as_str())
16963            .ok_or("SecretId is required")?
16964            .to_string();
16965        let rotation_lambda_arn = props
16966            .get("RotationLambdaARN")
16967            .and_then(|v| v.as_str())
16968            .map(String::from);
16969        let automatically_after_days = props
16970            .get("RotationRules")
16971            .and_then(|v| v.get("AutomaticallyAfterDays"))
16972            .and_then(|v| v.as_i64());
16973        let mut accounts = self.secretsmanager_state.write();
16974        let state = accounts.get_or_create(&self.account_id);
16975        let secret_arn = if state.secrets.contains_key(&secret_id) {
16976            secret_id.clone()
16977        } else {
16978            let candidate = format!(
16979                "arn:aws:secretsmanager:{}:{}:secret:{}",
16980                state.region, state.account_id, secret_id
16981            );
16982            if state.secrets.contains_key(&candidate) {
16983                candidate
16984            } else {
16985                return Err(format!("Secret {secret_id} not yet provisioned"));
16986            }
16987        };
16988        let secret = state
16989            .secrets
16990            .get_mut(&secret_arn)
16991            .ok_or_else(|| format!("Secret {secret_arn} not found"))?;
16992        secret.rotation_enabled = Some(true);
16993        secret.rotation_lambda_arn = rotation_lambda_arn;
16994        secret.rotation_rules = Some(RotationRules {
16995            automatically_after_days,
16996        });
16997        secret.last_changed_at = Utc::now();
16998        Ok(ProvisionResult::new(secret_arn.clone()).with("SecretArn", secret_arn))
16999    }
17000
17001    fn delete_secrets_manager_rotation_schedule(&self, physical_id: &str) -> Result<(), String> {
17002        let mut accounts = self.secretsmanager_state.write();
17003        let state = accounts.get_or_create(&self.account_id);
17004        if let Some(secret) = state.secrets.get_mut(physical_id) {
17005            secret.rotation_enabled = Some(false);
17006            secret.rotation_lambda_arn = None;
17007            secret.rotation_rules = None;
17008            secret.last_changed_at = Utc::now();
17009        }
17010        Ok(())
17011    }
17012
17013    fn create_secrets_manager_resource_policy(
17014        &self,
17015        resource: &ResourceDefinition,
17016    ) -> Result<ProvisionResult, String> {
17017        let props = &resource.properties;
17018        let secret_id = props
17019            .get("SecretId")
17020            .and_then(|v| v.as_str())
17021            .ok_or("SecretId is required")?
17022            .to_string();
17023        let policy_doc = props
17024            .get("ResourcePolicy")
17025            .ok_or("ResourcePolicy is required")?;
17026        let policy_str = match policy_doc {
17027            serde_json::Value::String(s) => s.clone(),
17028            other => other.to_string(),
17029        };
17030        let mut accounts = self.secretsmanager_state.write();
17031        let state = accounts.get_or_create(&self.account_id);
17032        let secret_arn = if state.secrets.contains_key(&secret_id) {
17033            secret_id.clone()
17034        } else {
17035            let candidate = format!(
17036                "arn:aws:secretsmanager:{}:{}:secret:{}",
17037                state.region, state.account_id, secret_id
17038            );
17039            if state.secrets.contains_key(&candidate) {
17040                candidate
17041            } else {
17042                return Err(format!("Secret {secret_id} not yet provisioned"));
17043            }
17044        };
17045        let secret = state
17046            .secrets
17047            .get_mut(&secret_arn)
17048            .ok_or_else(|| format!("Secret {secret_arn} not found"))?;
17049        secret.resource_policy = Some(policy_str);
17050        secret.last_changed_at = Utc::now();
17051        Ok(ProvisionResult::new(secret_arn.clone()).with("SecretArn", secret_arn))
17052    }
17053
17054    fn delete_secrets_manager_resource_policy(&self, physical_id: &str) -> Result<(), String> {
17055        let mut accounts = self.secretsmanager_state.write();
17056        let state = accounts.get_or_create(&self.account_id);
17057        if let Some(secret) = state.secrets.get_mut(physical_id) {
17058            secret.resource_policy = None;
17059            secret.last_changed_at = Utc::now();
17060        }
17061        Ok(())
17062    }
17063
17064    fn create_secrets_manager_target_attachment(
17065        &self,
17066        resource: &ResourceDefinition,
17067    ) -> Result<ProvisionResult, String> {
17068        let props = &resource.properties;
17069        let secret_id = props
17070            .get("SecretId")
17071            .and_then(|v| v.as_str())
17072            .ok_or("SecretId is required")?
17073            .to_string();
17074        let target_type = props
17075            .get("TargetType")
17076            .and_then(|v| v.as_str())
17077            .ok_or("TargetType is required")?;
17078        let target_id = props
17079            .get("TargetId")
17080            .and_then(|v| v.as_str())
17081            .ok_or("TargetId is required")?;
17082        let mut accounts = self.secretsmanager_state.write();
17083        let state = accounts.get_or_create(&self.account_id);
17084        let secret_arn = if state.secrets.contains_key(&secret_id) {
17085            secret_id.clone()
17086        } else {
17087            let candidate = format!(
17088                "arn:aws:secretsmanager:{}:{}:secret:{}",
17089                state.region, state.account_id, secret_id
17090            );
17091            if state.secrets.contains_key(&candidate) {
17092                candidate
17093            } else {
17094                return Err(format!("Secret {secret_id} not yet provisioned"));
17095            }
17096        };
17097        let secret = state
17098            .secrets
17099            .get_mut(&secret_arn)
17100            .ok_or_else(|| format!("Secret {secret_arn} not found"))?;
17101        // Patch the AWSCURRENT version with engine/host/dbInstanceIdentifier
17102        // so it shows as "attached" via the RDS-style schema CFN expects.
17103        // If the secret has no version yet (created without SecretString or
17104        // GenerateSecretString), seed one — this matches CFN's behaviour of
17105        // making the attachment usable on its own.
17106        let now = Utc::now();
17107        if secret.current_version_id.is_none() {
17108            let version_id = Uuid::new_v4().to_string();
17109            secret.versions.insert(
17110                version_id.clone(),
17111                SecretVersion {
17112                    version_id: version_id.clone(),
17113                    secret_string: Some("{}".to_string()),
17114                    secret_binary: None,
17115                    stages: vec!["AWSCURRENT".to_string()],
17116                    created_at: now,
17117                },
17118            );
17119            secret.current_version_id = Some(version_id);
17120        }
17121        if let Some(version_id) = secret.current_version_id.clone() {
17122            if let Some(version) = secret.versions.get_mut(&version_id) {
17123                let mut existing: serde_json::Value = version
17124                    .secret_string
17125                    .as_deref()
17126                    .and_then(|s| serde_json::from_str(s).ok())
17127                    .unwrap_or_else(|| serde_json::json!({}));
17128                if let Some(obj) = existing.as_object_mut() {
17129                    let engine = match target_type {
17130                        "AWS::RDS::DBInstance" | "AWS::RDS::DBCluster" => "postgres",
17131                        _ => "unknown",
17132                    };
17133                    obj.entry("engine".to_string())
17134                        .or_insert(serde_json::json!(engine));
17135                    obj.insert("host".to_string(), serde_json::json!(target_id));
17136                    obj.entry("dbInstanceIdentifier".to_string())
17137                        .or_insert(serde_json::json!(target_id));
17138                }
17139                version.secret_string = Some(existing.to_string());
17140            }
17141        }
17142        secret.last_changed_at = now;
17143        Ok(ProvisionResult::new(secret_arn.clone()).with("SecretArn", secret_arn))
17144    }
17145
17146    // --- Athena ---
17147
17148    fn create_athena_work_group(
17149        &self,
17150        resource: &ResourceDefinition,
17151    ) -> Result<ProvisionResult, String> {
17152        let props = &resource.properties;
17153        let name = props
17154            .get("Name")
17155            .and_then(|v| v.as_str())
17156            .unwrap_or(&resource.logical_id)
17157            .to_string();
17158        let description = props
17159            .get("Description")
17160            .and_then(|v| v.as_str())
17161            .map(|s| s.to_string());
17162        let configuration = props.get("Configuration").cloned();
17163        let state_str = props
17164            .get("State")
17165            .and_then(|v| v.as_str())
17166            .unwrap_or("ENABLED");
17167        let tags = Self::parse_athena_tags(props.get("Tags"));
17168
17169        let mut accounts = self.athena_state.write();
17170        let account = accounts
17171            .accounts
17172            .entry(self.account_id.clone())
17173            .or_default();
17174        account.ensure_initialized();
17175        if account.work_groups.contains_key(&name) {
17176            return Err(format!("WorkGroup {name} already exists"));
17177        }
17178        let wg = WorkGroup {
17179            name: name.clone(),
17180            state: state_str.to_string(),
17181            description,
17182            configuration,
17183            creation_time: Utc::now(),
17184            engine_version: Some("AUTO".to_string()),
17185        };
17186        let arn = format!(
17187            "arn:aws:athena:{}:{}:workgroup/{}",
17188            self.region, self.account_id, name
17189        );
17190        account.work_groups.insert(name.clone(), wg);
17191        if !tags.is_empty() {
17192            account.tags.insert(arn.clone(), tags);
17193        }
17194        Ok(ProvisionResult::new(name.clone())
17195            .with("Arn", arn)
17196            .with("Name", name))
17197    }
17198
17199    fn delete_athena_work_group(&self, physical_id: &str) -> Result<(), String> {
17200        let mut accounts = self.athena_state.write();
17201        let account = accounts
17202            .accounts
17203            .entry(self.account_id.clone())
17204            .or_default();
17205        account.work_groups.remove(physical_id);
17206        Ok(())
17207    }
17208
17209    fn get_att_athena_work_group(&self, physical_id: &str, attribute: &str) -> Option<String> {
17210        let mut accounts = self.athena_state.write();
17211        let account = accounts
17212            .accounts
17213            .entry(self.account_id.clone())
17214            .or_default();
17215        let wg = account.work_groups.get(physical_id)?;
17216        match attribute {
17217            "Arn" => Some(format!(
17218                "arn:aws:athena:{}:{}:workgroup/{}",
17219                self.region, self.account_id, wg.name
17220            )),
17221            "Name" => Some(wg.name.clone()),
17222            _ => None,
17223        }
17224    }
17225
17226    fn create_athena_data_catalog(
17227        &self,
17228        resource: &ResourceDefinition,
17229    ) -> Result<ProvisionResult, String> {
17230        let props = &resource.properties;
17231        let name = props
17232            .get("Name")
17233            .and_then(|v| v.as_str())
17234            .unwrap_or(&resource.logical_id)
17235            .to_string();
17236        let cat_type = props
17237            .get("Type")
17238            .and_then(|v| v.as_str())
17239            .ok_or("Type is required")?
17240            .to_string();
17241        let description = props
17242            .get("Description")
17243            .and_then(|v| v.as_str())
17244            .map(|s| s.to_string());
17245        let parameters = props
17246            .get("Parameters")
17247            .and_then(|v| v.as_object())
17248            .map(|m| {
17249                m.iter()
17250                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
17251                    .collect()
17252            })
17253            .unwrap_or_default();
17254        let connection_type = props
17255            .get("ConnectionType")
17256            .and_then(|v| v.as_str())
17257            .map(|s| s.to_string());
17258        let tags = Self::parse_athena_tags(props.get("Tags"));
17259
17260        let mut accounts = self.athena_state.write();
17261        let account = accounts
17262            .accounts
17263            .entry(self.account_id.clone())
17264            .or_default();
17265        account.ensure_initialized();
17266        if account.data_catalogs.contains_key(&name) {
17267            return Err(format!("DataCatalog {name} already exists"));
17268        }
17269        let cat = DataCatalog {
17270            name: name.clone(),
17271            description,
17272            cat_type,
17273            parameters,
17274            status: "CREATE_COMPLETE".to_string(),
17275            connection_type,
17276            error: None,
17277        };
17278        let arn = format!(
17279            "arn:aws:athena:{}:{}:datacatalog/{}",
17280            self.region, self.account_id, name
17281        );
17282        account.data_catalogs.insert(name.clone(), cat);
17283        if !tags.is_empty() {
17284            account.tags.insert(arn.clone(), tags);
17285        }
17286        Ok(ProvisionResult::new(name.clone())
17287            .with("Arn", arn)
17288            .with("Name", name))
17289    }
17290
17291    fn delete_athena_data_catalog(&self, physical_id: &str) -> Result<(), String> {
17292        let mut accounts = self.athena_state.write();
17293        let account = accounts
17294            .accounts
17295            .entry(self.account_id.clone())
17296            .or_default();
17297        account.data_catalogs.remove(physical_id);
17298        Ok(())
17299    }
17300
17301    fn get_att_athena_data_catalog(&self, physical_id: &str, attribute: &str) -> Option<String> {
17302        let mut accounts = self.athena_state.write();
17303        let account = accounts
17304            .accounts
17305            .entry(self.account_id.clone())
17306            .or_default();
17307        let cat = account.data_catalogs.get(physical_id)?;
17308        match attribute {
17309            "Arn" => Some(format!(
17310                "arn:aws:athena:{}:{}:datacatalog/{}",
17311                self.region, self.account_id, cat.name
17312            )),
17313            "Name" => Some(cat.name.clone()),
17314            _ => None,
17315        }
17316    }
17317
17318    fn create_athena_named_query(
17319        &self,
17320        resource: &ResourceDefinition,
17321    ) -> Result<ProvisionResult, String> {
17322        let props = &resource.properties;
17323        let name = props
17324            .get("Name")
17325            .and_then(|v| v.as_str())
17326            .ok_or("Name is required")?
17327            .to_string();
17328        let database = props
17329            .get("Database")
17330            .and_then(|v| v.as_str())
17331            .ok_or("Database is required")?
17332            .to_string();
17333        let query_string = props
17334            .get("QueryString")
17335            .and_then(|v| v.as_str())
17336            .ok_or("QueryString is required")?
17337            .to_string();
17338        let description = props
17339            .get("Description")
17340            .and_then(|v| v.as_str())
17341            .map(|s| s.to_string());
17342        let work_group = props
17343            .get("WorkGroup")
17344            .and_then(|v| v.as_str())
17345            .unwrap_or("primary")
17346            .to_string();
17347
17348        let mut accounts = self.athena_state.write();
17349        let account = accounts
17350            .accounts
17351            .entry(self.account_id.clone())
17352            .or_default();
17353        account.ensure_initialized();
17354        if !account.work_groups.contains_key(&work_group) {
17355            return Err(format!("Workgroup {work_group} not found"));
17356        }
17357        let id = Uuid::new_v4().to_string();
17358        let nq = NamedQuery {
17359            named_query_id: id.clone(),
17360            name,
17361            description,
17362            database,
17363            query_string,
17364            work_group,
17365            last_used_at: None,
17366        };
17367        account.named_queries.insert(id.clone(), nq);
17368        Ok(ProvisionResult::new(id.clone()).with("NamedQueryId", id))
17369    }
17370
17371    fn delete_athena_named_query(&self, physical_id: &str) -> Result<(), String> {
17372        let mut accounts = self.athena_state.write();
17373        let account = accounts
17374            .accounts
17375            .entry(self.account_id.clone())
17376            .or_default();
17377        account.named_queries.remove(physical_id);
17378        Ok(())
17379    }
17380
17381    fn get_att_athena_named_query(&self, physical_id: &str, attribute: &str) -> Option<String> {
17382        let mut accounts = self.athena_state.write();
17383        let account = accounts
17384            .accounts
17385            .entry(self.account_id.clone())
17386            .or_default();
17387        let nq = account.named_queries.get(physical_id)?;
17388        match attribute {
17389            "NamedQueryId" => Some(nq.named_query_id.clone()),
17390            _ => None,
17391        }
17392    }
17393
17394    fn create_athena_prepared_statement(
17395        &self,
17396        resource: &ResourceDefinition,
17397    ) -> Result<ProvisionResult, String> {
17398        let props = &resource.properties;
17399        let statement_name = props
17400            .get("StatementName")
17401            .and_then(|v| v.as_str())
17402            .ok_or("StatementName is required")?
17403            .to_string();
17404        let work_group_name = props
17405            .get("WorkGroupName")
17406            .and_then(|v| v.as_str())
17407            .ok_or("WorkGroupName is required")?
17408            .to_string();
17409        let query_statement = props
17410            .get("QueryStatement")
17411            .and_then(|v| v.as_str())
17412            .ok_or("QueryStatement is required")?
17413            .to_string();
17414        let description = props
17415            .get("Description")
17416            .and_then(|v| v.as_str())
17417            .map(|s| s.to_string());
17418
17419        let mut accounts = self.athena_state.write();
17420        let account = accounts
17421            .accounts
17422            .entry(self.account_id.clone())
17423            .or_default();
17424        account.ensure_initialized();
17425        if !account.work_groups.contains_key(&work_group_name) {
17426            return Err(format!("Workgroup {work_group_name} not found"));
17427        }
17428        let key = (work_group_name.clone(), statement_name.clone());
17429        if account.prepared_statements.contains_key(&key) {
17430            return Err(format!(
17431                "PreparedStatement {statement_name} already exists in {work_group_name}"
17432            ));
17433        }
17434        let ps = PreparedStatement {
17435            statement_name: statement_name.clone(),
17436            work_group_name: work_group_name.clone(),
17437            query_statement,
17438            description,
17439            last_modified_time: Utc::now(),
17440        };
17441        let physical_id = format!("{work_group_name}|{statement_name}");
17442        account.prepared_statements.insert(key, ps);
17443        Ok(ProvisionResult::new(physical_id))
17444    }
17445
17446    fn delete_athena_prepared_statement(
17447        &self,
17448        physical_id: &str,
17449        _attrs: &BTreeMap<String, String>,
17450    ) -> Result<(), String> {
17451        let mut accounts = self.athena_state.write();
17452        let account = accounts
17453            .accounts
17454            .entry(self.account_id.clone())
17455            .or_default();
17456        let parts: Vec<&str> = physical_id.split('|').collect();
17457        if parts.len() != 2 {
17458            return Err(format!(
17459                "Invalid PreparedStatement physical id: {physical_id}"
17460            ));
17461        }
17462        let key = (parts[0].to_string(), parts[1].to_string());
17463        account.prepared_statements.remove(&key);
17464        Ok(())
17465    }
17466
17467    fn get_att_athena_prepared_statement(
17468        &self,
17469        physical_id: &str,
17470        attribute: &str,
17471    ) -> Option<String> {
17472        let mut accounts = self.athena_state.write();
17473        let account = accounts
17474            .accounts
17475            .entry(self.account_id.clone())
17476            .or_default();
17477        let parts: Vec<&str> = physical_id.split('|').collect();
17478        if parts.len() != 2 {
17479            return None;
17480        }
17481        let ps = account
17482            .prepared_statements
17483            .get(&(parts[0].to_string(), parts[1].to_string()))?;
17484        match attribute {
17485            "StatementName" => Some(ps.statement_name.clone()),
17486            "WorkGroupName" => Some(ps.work_group_name.clone()),
17487            _ => None,
17488        }
17489    }
17490
17491    fn parse_athena_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
17492        let mut out = BTreeMap::new();
17493        let Some(arr) = value.and_then(|v| v.as_array()) else {
17494            return out;
17495        };
17496        for tag in arr {
17497            if let (Some(k), Some(v)) = (
17498                tag.get("Key").and_then(|v| v.as_str()),
17499                tag.get("Value").and_then(|v| v.as_str()),
17500            ) {
17501                out.insert(k.to_string(), v.to_string());
17502            }
17503        }
17504        out
17505    }
17506
17507    fn create_glue_database(
17508        &self,
17509        resource: &ResourceDefinition,
17510    ) -> Result<ProvisionResult, String> {
17511        let props = &resource.properties;
17512        let input = props
17513            .get("DatabaseInput")
17514            .ok_or("DatabaseInput is required")?;
17515        let name = input
17516            .get("Name")
17517            .and_then(|v| v.as_str())
17518            .unwrap_or(&resource.logical_id)
17519            .to_string();
17520        let description = input
17521            .get("Description")
17522            .and_then(|v| v.as_str())
17523            .map(|s| s.to_string());
17524        let location_uri = input
17525            .get("LocationUri")
17526            .and_then(|v| v.as_str())
17527            .map(|s| s.to_string());
17528        let parameters = input
17529            .get("Parameters")
17530            .and_then(|v| v.as_object())
17531            .map(|m| {
17532                m.iter()
17533                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
17534                    .collect()
17535            })
17536            .unwrap_or_default();
17537
17538        let mut accounts = self.glue_state.write();
17539        let state = accounts.get_or_create(&self.account_id, &self.region);
17540        let dbs = state.dbs_in_mut(&self.region);
17541        if dbs.contains_key(&name) {
17542            return Err(format!("Database {name} already exists"));
17543        }
17544        dbs.insert(
17545            name.clone(),
17546            fakecloud_glue::Database {
17547                name: name.clone(),
17548                description,
17549                location_uri,
17550                parameters,
17551                created_at: Utc::now(),
17552                catalog_id: self.account_id.clone(),
17553                tables: BTreeMap::new(),
17554            },
17555        );
17556        Ok(ProvisionResult::new(name.clone()))
17557    }
17558
17559    fn delete_glue_database(&self, physical_id: &str) -> Result<(), String> {
17560        let mut accounts = self.glue_state.write();
17561        let state = accounts.get_or_create(&self.account_id, &self.region);
17562        state.dbs_in_mut(&self.region).remove(physical_id);
17563        Ok(())
17564    }
17565
17566    fn create_glue_table(&self, resource: &ResourceDefinition) -> Result<ProvisionResult, String> {
17567        let props = &resource.properties;
17568        let db_name = props
17569            .get("DatabaseName")
17570            .and_then(|v| v.as_str())
17571            .ok_or("DatabaseName is required")?
17572            .to_string();
17573        let input = props.get("TableInput").ok_or("TableInput is required")?;
17574        let name = input
17575            .get("Name")
17576            .and_then(|v| v.as_str())
17577            .ok_or("TableInput.Name is required")?
17578            .to_string();
17579        let now = Utc::now();
17580
17581        let mut accounts = self.glue_state.write();
17582        let state = accounts.get_or_create(&self.account_id, &self.region);
17583        let dbs = state.dbs_in_mut(&self.region);
17584        let db = dbs
17585            .get_mut(&db_name)
17586            .ok_or_else(|| format!("Database {db_name} not found"))?;
17587        if db.tables.contains_key(&name) {
17588            return Err(format!("Table {name} already exists"));
17589        }
17590        db.tables.insert(
17591            name.clone(),
17592            fakecloud_glue::Table {
17593                name: name.clone(),
17594                database_name: db_name.clone(),
17595                description: input
17596                    .get("Description")
17597                    .and_then(|v| v.as_str())
17598                    .map(|s| s.to_string()),
17599                owner: input
17600                    .get("Owner")
17601                    .and_then(|v| v.as_str())
17602                    .map(|s| s.to_string()),
17603                create_time: now,
17604                update_time: now,
17605                last_access_time: None,
17606                retention: input.get("Retention").and_then(|v| v.as_i64()).unwrap_or(0),
17607                storage_descriptor: None,
17608                partition_keys: Vec::new(),
17609                view_original_text: input
17610                    .get("ViewOriginalText")
17611                    .and_then(|v| v.as_str())
17612                    .map(|s| s.to_string()),
17613                view_expanded_text: input
17614                    .get("ViewExpandedText")
17615                    .and_then(|v| v.as_str())
17616                    .map(|s| s.to_string()),
17617                table_type: input
17618                    .get("TableType")
17619                    .and_then(|v| v.as_str())
17620                    .map(|s| s.to_string()),
17621                parameters: BTreeMap::new(),
17622                partitions: BTreeMap::new(),
17623            },
17624        );
17625        let physical_id = format!("{db_name}|{name}");
17626        Ok(ProvisionResult::new(physical_id))
17627    }
17628
17629    fn delete_glue_table(&self, physical_id: &str) -> Result<(), String> {
17630        let mut accounts = self.glue_state.write();
17631        let state = accounts.get_or_create(&self.account_id, &self.region);
17632        let parts: Vec<&str> = physical_id.split('|').collect();
17633        if parts.len() != 2 {
17634            return Err(format!("Invalid Glue table physical id: {physical_id}"));
17635        }
17636        let dbs = state.dbs_in_mut(&self.region);
17637        let db = dbs
17638            .get_mut(parts[0])
17639            .ok_or_else(|| format!("Database {} not found", parts[0]))?;
17640        db.tables.remove(parts[1]);
17641        Ok(())
17642    }
17643
17644    fn create_glue_partition(
17645        &self,
17646        resource: &ResourceDefinition,
17647    ) -> Result<ProvisionResult, String> {
17648        let props = &resource.properties;
17649        let db_name = props
17650            .get("DatabaseName")
17651            .and_then(|v| v.as_str())
17652            .ok_or("DatabaseName is required")?
17653            .to_string();
17654        let table_name = props
17655            .get("TableName")
17656            .and_then(|v| v.as_str())
17657            .ok_or("TableName is required")?
17658            .to_string();
17659        let input = props
17660            .get("PartitionInput")
17661            .ok_or("PartitionInput is required")?;
17662        let values: Vec<String> = input
17663            .get("Values")
17664            .and_then(|v| v.as_array())
17665            .map(|arr| {
17666                arr.iter()
17667                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
17668                    .collect()
17669            })
17670            .unwrap_or_default();
17671        if values.is_empty() {
17672            return Err("PartitionInput.Values is required".to_string());
17673        }
17674        let key = values
17675            .iter()
17676            .map(|v| {
17677                v.replace('%', "%25")
17678                    .replace('|', "%7C")
17679                    .replace('/', "%2F")
17680            })
17681            .collect::<Vec<_>>()
17682            .join("/");
17683
17684        let mut accounts = self.glue_state.write();
17685        let state = accounts.get_or_create(&self.account_id, &self.region);
17686        let dbs = state.dbs_in_mut(&self.region);
17687        let db = dbs
17688            .get_mut(&db_name)
17689            .ok_or_else(|| format!("Database {db_name} not found"))?;
17690        let table = db
17691            .tables
17692            .get_mut(&table_name)
17693            .ok_or_else(|| format!("Table {table_name} not found"))?;
17694        if table.partitions.contains_key(&key) {
17695            return Err(format!("Partition {key} already exists"));
17696        }
17697        table.partitions.insert(
17698            key.clone(),
17699            fakecloud_glue::Partition {
17700                values: values.clone(),
17701                database_name: db_name.clone(),
17702                table_name: table_name.clone(),
17703                create_time: Utc::now(),
17704                last_access_time: None,
17705                storage_descriptor: None,
17706                parameters: BTreeMap::new(),
17707            },
17708        );
17709        let physical_id = format!("{db_name}|{table_name}|{key}");
17710        Ok(ProvisionResult::new(physical_id))
17711    }
17712
17713    fn delete_glue_partition(
17714        &self,
17715        physical_id: &str,
17716        _attrs: &BTreeMap<String, String>,
17717    ) -> Result<(), String> {
17718        let mut accounts = self.glue_state.write();
17719        let state = accounts.get_or_create(&self.account_id, &self.region);
17720        let parts: Vec<&str> = physical_id.split('|').collect();
17721        if parts.len() != 3 {
17722            return Err(format!("Invalid Glue partition physical id: {physical_id}"));
17723        }
17724        let dbs = state.dbs_in_mut(&self.region);
17725        let db = dbs
17726            .get_mut(parts[0])
17727            .ok_or_else(|| format!("Database {} not found", parts[0]))?;
17728        let table = db
17729            .tables
17730            .get_mut(parts[1])
17731            .ok_or_else(|| format!("Table {} not found", parts[1]))?;
17732        table.partitions.remove(parts[2]);
17733        Ok(())
17734    }
17735
17736    // --- CloudFormation Nested Stack ---
17737
17738    fn create_cloudformation_stack(
17739        &self,
17740        resource: &ResourceDefinition,
17741    ) -> Result<ProvisionResult, String> {
17742        let props = &resource.properties;
17743
17744        let template_body = if let Some(body) = props.get("TemplateBody").and_then(|v| v.as_str()) {
17745            body.to_string()
17746        } else if let Some(url) = props.get("TemplateURL").and_then(|v| v.as_str()) {
17747            self.fetch_template_from_url(url)?
17748        } else {
17749            return Err(
17750                "AWS::CloudFormation::Stack requires TemplateURL or TemplateBody".to_string(),
17751            );
17752        };
17753
17754        let child_parameters =
17755            if let Some(params) = props.get("Parameters").and_then(|v| v.as_object()) {
17756                let mut map = BTreeMap::new();
17757                for (k, v) in params {
17758                    if let Some(s) = v.as_str() {
17759                        map.insert(k.clone(), s.to_string());
17760                    } else {
17761                        map.insert(k.clone(), v.to_string());
17762                    }
17763                }
17764                map
17765            } else {
17766                BTreeMap::new()
17767            };
17768
17769        let child_tags = if let Some(tags) = props.get("Tags").and_then(|v| v.as_object()) {
17770            let mut map = BTreeMap::new();
17771            for (k, v) in tags {
17772                if let Some(s) = v.as_str() {
17773                    map.insert(k.clone(), s.to_string());
17774                }
17775            }
17776            map
17777        } else {
17778            BTreeMap::new()
17779        };
17780
17781        let parsed = crate::template::parse_template(&template_body, &child_parameters)
17782            .map_err(|e| format!("Failed to parse nested template: {e}"))?;
17783
17784        let child_stack_name = format!(
17785            "{}-Nested-{}",
17786            resource.logical_id,
17787            std::time::SystemTime::now()
17788                .duration_since(std::time::UNIX_EPOCH)
17789                .map(|d| d.as_nanos())
17790                .unwrap_or(0)
17791        );
17792        let child_stack_id = format!(
17793            "arn:aws:cloudformation:{}:{}:stack/{}/{}",
17794            self.region,
17795            self.account_id,
17796            child_stack_name,
17797            Uuid::new_v4()
17798        );
17799
17800        let child_provisioner = ResourceProvisioner {
17801            sqs_state: self.sqs_state.clone(),
17802            sns_state: self.sns_state.clone(),
17803            ssm_state: self.ssm_state.clone(),
17804            iam_state: self.iam_state.clone(),
17805            s3_state: self.s3_state.clone(),
17806            eventbridge_state: self.eventbridge_state.clone(),
17807            dynamodb_state: self.dynamodb_state.clone(),
17808            logs_state: self.logs_state.clone(),
17809            lambda_state: self.lambda_state.clone(),
17810            secretsmanager_state: self.secretsmanager_state.clone(),
17811            kinesis_state: self.kinesis_state.clone(),
17812            kms_state: self.kms_state.clone(),
17813            ecr_state: self.ecr_state.clone(),
17814            cloudwatch_state: self.cloudwatch_state.clone(),
17815            elbv2_state: self.elbv2_state.clone(),
17816            organizations_state: self.organizations_state.clone(),
17817            cognito_state: self.cognito_state.clone(),
17818            rds_state: self.rds_state.clone(),
17819            ecs_state: self.ecs_state.clone(),
17820            acm_state: self.acm_state.clone(),
17821            elasticache_state: self.elasticache_state.clone(),
17822            route53_state: self.route53_state.clone(),
17823            cloudfront_state: self.cloudfront_state.clone(),
17824            stepfunctions_state: self.stepfunctions_state.clone(),
17825            wafv2_state: self.wafv2_state.clone(),
17826            apigateway_state: self.apigateway_state.clone(),
17827            apigatewayv2_state: self.apigatewayv2_state.clone(),
17828            ses_state: self.ses_state.clone(),
17829            app_autoscaling_state: self.app_autoscaling_state.clone(),
17830            athena_state: self.athena_state.clone(),
17831            firehose_state: self.firehose_state.clone(),
17832            glue_state: self.glue_state.clone(),
17833            cloudformation_state: self.cloudformation_state.clone(),
17834            delivery: self.delivery.clone(),
17835            account_id: self.account_id.clone(),
17836            region: self.region.clone(),
17837            stack_id: child_stack_id.clone(),
17838        };
17839
17840        let child_resources = crate::service::provision_stack_resources(
17841            &child_provisioner,
17842            &parsed.resources,
17843            &template_body,
17844            &child_parameters,
17845        )
17846        .map_err(|e| format!("Failed to provision nested stack: {e}"))?;
17847
17848        let child_outputs = parsed.outputs;
17849
17850        let stack = crate::state::Stack {
17851            name: child_stack_name.clone(),
17852            stack_id: child_stack_id.clone(),
17853            template: template_body.clone(),
17854            status: "CREATE_COMPLETE".to_string(),
17855            resources: child_resources.clone(),
17856            parameters: child_parameters,
17857            tags: child_tags,
17858            created_at: Utc::now(),
17859            updated_at: None,
17860            description: parsed.description,
17861            notification_arns: Vec::new(),
17862            outputs: child_outputs
17863                .iter()
17864                .map(|o| crate::state::StackOutput {
17865                    key: o.logical_id.clone(),
17866                    value: o.value.clone(),
17867                    description: o.description.clone(),
17868                    export_name: o.export_name.clone(),
17869                })
17870                .collect(),
17871        };
17872
17873        {
17874            let mut accounts = self.cloudformation_state.write();
17875            let state = accounts.get_or_create(&self.account_id);
17876            state.stacks.insert(child_stack_name.clone(), stack);
17877
17878            crate::service::record_stack_status_event(
17879                state,
17880                &child_stack_id,
17881                &child_stack_name,
17882                "AWS::CloudFormation::Stack",
17883                "CREATE_IN_PROGRESS",
17884            );
17885            let changes: Vec<crate::service::ResourceChange> = child_resources
17886                .iter()
17887                .map(|r| crate::service::ResourceChange {
17888                    action: crate::service::ResourceChangeAction::Create,
17889                    logical_id: r.logical_id.clone(),
17890                    physical_id: r.physical_id.clone(),
17891                    resource_type: r.resource_type.clone(),
17892                })
17893                .collect();
17894            crate::service::record_stack_events(
17895                state,
17896                &child_stack_id,
17897                &child_stack_name,
17898                &changes,
17899            );
17900            crate::service::record_stack_status_event(
17901                state,
17902                &child_stack_id,
17903                &child_stack_name,
17904                "AWS::CloudFormation::Stack",
17905                "CREATE_COMPLETE",
17906            );
17907        }
17908
17909        let mut result = ProvisionResult::new(child_stack_id.clone());
17910        for output in &child_outputs {
17911            result.attributes.insert(
17912                format!("Outputs.{}", output.logical_id),
17913                output.value.clone(),
17914            );
17915        }
17916        Ok(result)
17917    }
17918
17919    fn delete_cloudformation_stack(&self, physical_id: &str) -> Result<(), String> {
17920        let stack = {
17921            let accounts = self.cloudformation_state.read();
17922            let state = accounts.get(&self.account_id);
17923            state.and_then(|s| {
17924                s.stacks
17925                    .values()
17926                    .find(|st| st.stack_id == physical_id)
17927                    .cloned()
17928            })
17929        };
17930
17931        if let Some(stack) = stack {
17932            let stack_name = stack.name.clone();
17933            let stack_id = stack.stack_id.clone();
17934
17935            for resource in stack.resources.iter().rev() {
17936                let _ = self.delete_resource(resource);
17937            }
17938
17939            {
17940                let mut accounts = self.cloudformation_state.write();
17941                let state = accounts.get_or_create(&self.account_id);
17942                state.stacks.remove(&stack_name);
17943
17944                crate::service::record_stack_status_event(
17945                    state,
17946                    &stack_id,
17947                    &stack_name,
17948                    "AWS::CloudFormation::Stack",
17949                    "DELETE_IN_PROGRESS",
17950                );
17951                crate::service::record_stack_status_event(
17952                    state,
17953                    &stack_id,
17954                    &stack_name,
17955                    "AWS::CloudFormation::Stack",
17956                    "DELETE_COMPLETE",
17957                );
17958            }
17959        }
17960
17961        Ok(())
17962    }
17963
17964    fn get_att_cloudformation_stack(&self, physical_id: &str, attribute: &str) -> Option<String> {
17965        let accounts = self.cloudformation_state.read();
17966        let state = accounts.get(&self.account_id)?;
17967        let stack = state.stacks.values().find(|s| s.stack_id == physical_id)?;
17968
17969        if let Some(output_key) = attribute.strip_prefix("Outputs.") {
17970            return stack
17971                .outputs
17972                .iter()
17973                .find(|o| o.key == output_key)
17974                .map(|o| o.value.clone());
17975        }
17976
17977        match attribute {
17978            "Outputs" => Some(
17979                serde_json::to_string(
17980                    &stack
17981                        .outputs
17982                        .iter()
17983                        .map(|o| (o.key.clone(), o.value.clone()))
17984                        .collect::<std::collections::BTreeMap<String, String>>(),
17985                )
17986                .unwrap_or_default(),
17987            ),
17988            _ => None,
17989        }
17990    }
17991
17992    fn fetch_template_from_url(&self, url: &str) -> Result<String, String> {
17993        if let Some(rest) = url.strip_prefix("s3://") {
17994            let parts: Vec<&str> = rest.splitn(2, '/').collect();
17995            if parts.len() != 2 {
17996                return Err("Invalid s3:// URL".to_string());
17997            }
17998            return self.fetch_s3_template(parts[0], parts[1]);
17999        }
18000
18001        if let Some(rest) = url.strip_prefix("https://s3.amazonaws.com/") {
18002            let parts: Vec<&str> = rest.splitn(2, '/').collect();
18003            if parts.len() != 2 {
18004                return Err("Invalid S3 HTTPS URL".to_string());
18005            }
18006            return self.fetch_s3_template(parts[0], parts[1]);
18007        }
18008
18009        if let Some(host_rest) = url.strip_prefix("https://") {
18010            if let Some(slash_pos) = host_rest.find('/') {
18011                let host = &host_rest[..slash_pos];
18012                let key = &host_rest[slash_pos + 1..];
18013                if let Some(bucket) = host.strip_suffix(".s3.amazonaws.com") {
18014                    return self.fetch_s3_template(bucket, key);
18015                }
18016                if host.contains(".s3.") && host.ends_with(".amazonaws.com") {
18017                    let bucket = host.split(".s3.").next().unwrap_or("");
18018                    if !bucket.is_empty() {
18019                        return self.fetch_s3_template(bucket, key);
18020                    }
18021                }
18022            }
18023        }
18024
18025        Err(format!("Unsupported TemplateURL: {url}"))
18026    }
18027
18028    fn fetch_s3_template(&self, bucket: &str, key: &str) -> Result<String, String> {
18029        let mut s3_accounts = self.s3_state.write();
18030        let s3_state = s3_accounts.get_or_create(&self.account_id);
18031        let bucket_obj = s3_state
18032            .buckets
18033            .get(bucket)
18034            .ok_or_else(|| format!("S3 bucket not found: {bucket}"))?;
18035        let obj = bucket_obj
18036            .objects
18037            .get(key)
18038            .ok_or_else(|| format!("S3 object not found: {bucket}/{key}"))?;
18039        let bytes = s3_state
18040            .read_body(&obj.body)
18041            .map_err(|e| format!("Failed to read S3 object body: {e}"))?;
18042        String::from_utf8(bytes.to_vec()).map_err(|e| format!("S3 object is not valid UTF-8: {e}"))
18043    }
18044}
18045
18046/// Implements the CloudFormation `GenerateSecretString` shape on
18047/// `AWS::SecretsManager::Secret`. Produces the plaintext payload that
18048/// will become the AWSCURRENT version of the secret.
18049///
18050/// When `SecretStringTemplate` is set together with `GenerateStringKey`,
18051/// the generated password is inserted under `GenerateStringKey` in the
18052/// JSON template (this is the standard "DB credential" pattern).
18053/// Without those two, the bare generated password is returned.
18054fn generate_secret_string_payload(gen: &serde_json::Value) -> Result<String, String> {
18055    let length = gen
18056        .get("PasswordLength")
18057        .and_then(|v| v.as_i64())
18058        .unwrap_or(32) as usize;
18059    let exclude_lowercase = gen
18060        .get("ExcludeLowercase")
18061        .and_then(|v| v.as_bool())
18062        .unwrap_or(false);
18063    let exclude_uppercase = gen
18064        .get("ExcludeUppercase")
18065        .and_then(|v| v.as_bool())
18066        .unwrap_or(false);
18067    let exclude_numbers = gen
18068        .get("ExcludeNumbers")
18069        .and_then(|v| v.as_bool())
18070        .unwrap_or(false);
18071    let exclude_punctuation = gen
18072        .get("ExcludePunctuation")
18073        .and_then(|v| v.as_bool())
18074        .unwrap_or(false);
18075    let include_space = gen
18076        .get("IncludeSpace")
18077        .and_then(|v| v.as_bool())
18078        .unwrap_or(false);
18079    let exclude_chars = gen
18080        .get("ExcludeCharacters")
18081        .and_then(|v| v.as_str())
18082        .unwrap_or("")
18083        .to_string();
18084
18085    let lowercase = "abcdefghijklmnopqrstuvwxyz";
18086    let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
18087    let digits = "0123456789";
18088    let punctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
18089
18090    let mut pool = String::new();
18091    if !exclude_lowercase {
18092        pool.extend(lowercase.chars().filter(|c| !exclude_chars.contains(*c)));
18093    }
18094    if !exclude_uppercase {
18095        pool.extend(uppercase.chars().filter(|c| !exclude_chars.contains(*c)));
18096    }
18097    if !exclude_numbers {
18098        pool.extend(digits.chars().filter(|c| !exclude_chars.contains(*c)));
18099    }
18100    if !exclude_punctuation {
18101        pool.extend(punctuation.chars().filter(|c| !exclude_chars.contains(*c)));
18102    }
18103    if include_space && !exclude_chars.contains(' ') {
18104        pool.push(' ');
18105    }
18106    if pool.is_empty() {
18107        return Err("GenerateSecretString character pool is empty".to_string());
18108    }
18109
18110    let pool_chars: Vec<char> = pool.chars().collect();
18111    let mut password = String::with_capacity(length);
18112    let mut counter: u64 = std::time::SystemTime::now()
18113        .duration_since(std::time::UNIX_EPOCH)
18114        .map(|d| d.as_nanos() as u64)
18115        .unwrap_or(0);
18116    while password.len() < length {
18117        // Splitmix64 — deterministic, no_std-friendly, good enough for
18118        // fake-cloud password generation. Real entropy isn't a goal here.
18119        counter = counter.wrapping_add(0x9E3779B97F4A7C15);
18120        let mut z = counter;
18121        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
18122        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
18123        z ^= z >> 31;
18124        let idx = (z as usize) % pool_chars.len();
18125        password.push(pool_chars[idx]);
18126    }
18127
18128    let template = gen.get("SecretStringTemplate").and_then(|v| v.as_str());
18129    let key = gen.get("GenerateStringKey").and_then(|v| v.as_str());
18130    match (template, key) {
18131        (Some(tmpl), Some(k)) => {
18132            let mut value: serde_json::Value = serde_json::from_str(tmpl)
18133                .map_err(|e| format!("SecretStringTemplate is not valid JSON: {e}"))?;
18134            if let Some(obj) = value.as_object_mut() {
18135                obj.insert(k.to_string(), serde_json::Value::String(password));
18136                Ok(value.to_string())
18137            } else {
18138                Err("SecretStringTemplate must be a JSON object".to_string())
18139            }
18140        }
18141        _ => Ok(password),
18142    }
18143}
18144
18145fn parse_ses_receipt_action(value: &serde_json::Value) -> Option<SesReceiptAction> {
18146    let obj = value.as_object()?;
18147    if let Some(s3) = obj.get("S3Action").and_then(|v| v.as_object()) {
18148        let bucket_name = s3.get("BucketName").and_then(|v| v.as_str())?.to_string();
18149        return Some(SesReceiptAction::S3 {
18150            bucket_name,
18151            object_key_prefix: s3
18152                .get("ObjectKeyPrefix")
18153                .and_then(|v| v.as_str())
18154                .map(String::from),
18155            topic_arn: s3
18156                .get("TopicArn")
18157                .and_then(|v| v.as_str())
18158                .map(String::from),
18159            kms_key_arn: s3
18160                .get("KmsKeyArn")
18161                .and_then(|v| v.as_str())
18162                .map(String::from),
18163        });
18164    }
18165    if let Some(sns) = obj.get("SNSAction").and_then(|v| v.as_object()) {
18166        return Some(SesReceiptAction::Sns {
18167            topic_arn: sns.get("TopicArn").and_then(|v| v.as_str())?.to_string(),
18168            encoding: sns
18169                .get("Encoding")
18170                .and_then(|v| v.as_str())
18171                .map(String::from),
18172        });
18173    }
18174    if let Some(la) = obj.get("LambdaAction").and_then(|v| v.as_object()) {
18175        return Some(SesReceiptAction::Lambda {
18176            function_arn: la.get("FunctionArn").and_then(|v| v.as_str())?.to_string(),
18177            invocation_type: la
18178                .get("InvocationType")
18179                .and_then(|v| v.as_str())
18180                .map(String::from),
18181            topic_arn: la
18182                .get("TopicArn")
18183                .and_then(|v| v.as_str())
18184                .map(String::from),
18185        });
18186    }
18187    if let Some(b) = obj.get("BounceAction").and_then(|v| v.as_object()) {
18188        return Some(SesReceiptAction::Bounce {
18189            smtp_reply_code: b
18190                .get("SmtpReplyCode")
18191                .and_then(|v| v.as_str())
18192                .unwrap_or("550")
18193                .to_string(),
18194            message: b
18195                .get("Message")
18196                .and_then(|v| v.as_str())
18197                .unwrap_or("")
18198                .to_string(),
18199            sender: b
18200                .get("Sender")
18201                .and_then(|v| v.as_str())
18202                .unwrap_or("")
18203                .to_string(),
18204            status_code: b
18205                .get("StatusCode")
18206                .and_then(|v| v.as_str())
18207                .map(String::from),
18208            topic_arn: b.get("TopicArn").and_then(|v| v.as_str()).map(String::from),
18209        });
18210    }
18211    if let Some(ah) = obj.get("AddHeaderAction").and_then(|v| v.as_object()) {
18212        return Some(SesReceiptAction::AddHeader {
18213            header_name: ah.get("HeaderName").and_then(|v| v.as_str())?.to_string(),
18214            header_value: ah.get("HeaderValue").and_then(|v| v.as_str())?.to_string(),
18215        });
18216    }
18217    if let Some(s) = obj.get("StopAction").and_then(|v| v.as_object()) {
18218        return Some(SesReceiptAction::Stop {
18219            scope: s
18220                .get("Scope")
18221                .and_then(|v| v.as_str())
18222                .unwrap_or("RuleSet")
18223                .to_string(),
18224            topic_arn: s.get("TopicArn").and_then(|v| v.as_str()).map(String::from),
18225        });
18226    }
18227    None
18228}
18229
18230/// Generate an N-character alphanumeric id for API Gateway v2 resources.
18231/// AWS HTTP API ids are 10 chars; routes/integrations/etc are similarly
18232/// short. This mirrors the runtime crate's id shape.
18233fn make_apigwv2_id(n: usize) -> String {
18234    let s = uuid::Uuid::new_v4().simple().to_string();
18235    s[..n.min(s.len())].to_string()
18236}
18237
18238/// Lowercase the first letter of each key in a JSON object, recursively.
18239/// CloudFormation property names are PascalCase (`BurstLimit`,
18240/// `RateLimit`); the runtime API Gateway service stores values keyed in
18241/// camelCase (`burstLimit`, `rateLimit`). Used at the CFN/service
18242/// boundary so JSON pulled from the template can flow into the runtime
18243/// state without renaming each leaf by hand.
18244/// Coerce a CFN-resolved Number/String parameter into i64. CFN
18245/// parameters surface here as `Value::String` after `Ref` substitution,
18246/// even when the parameter `Type` is `Number`, so we need both paths.
18247fn cfn_as_i64(v: &serde_json::Value) -> Option<i64> {
18248    if let Some(n) = v.as_i64() {
18249        return Some(n);
18250    }
18251    v.as_str().and_then(|s| s.parse::<i64>().ok())
18252}
18253
18254fn lowercase_first_keys(value: serde_json::Value) -> serde_json::Value {
18255    match value {
18256        serde_json::Value::Object(map) => {
18257            let mut out = serde_json::Map::new();
18258            for (k, v) in map {
18259                let new_key = if let Some(first) = k.chars().next() {
18260                    let mut s = String::with_capacity(k.len());
18261                    s.extend(first.to_lowercase());
18262                    s.push_str(&k[first.len_utf8()..]);
18263                    s
18264                } else {
18265                    k
18266                };
18267                out.insert(new_key, lowercase_first_keys(v));
18268            }
18269            serde_json::Value::Object(out)
18270        }
18271        serde_json::Value::Array(arr) => {
18272            serde_json::Value::Array(arr.into_iter().map(lowercase_first_keys).collect())
18273        }
18274        other => other,
18275    }
18276}
18277
18278/// Synthesize the per-domain DNS validation record list for a
18279/// CFN-provisioned cert. Mirrors the runtime ACM path: each name gets
18280/// a SUCCESS record (since CFN-issued certs are auto-validated above)
18281/// and an `_amzn-validations.<domain>.` resource record so callers
18282/// that read DescribeCertificate see the same shape they'd expect
18283/// from a real ACM-issued cert.
18284fn synth_acm_domain_validation(
18285    domain_name: &str,
18286    sans: &[String],
18287    validation_method: &str,
18288) -> Vec<AcmDomainValidation> {
18289    let mut all = vec![domain_name.to_string()];
18290    for s in sans {
18291        if !all.contains(s) {
18292            all.push(s.clone());
18293        }
18294    }
18295    all.into_iter()
18296        .map(|name| AcmDomainValidation {
18297            domain_name: name.clone(),
18298            validation_status: "SUCCESS".to_string(),
18299            validation_method: validation_method.to_string(),
18300            resource_record_name: Some(format!("_amzn-validations.{name}.")),
18301            resource_record_type: Some("CNAME".to_string()),
18302            resource_record_value: Some(format!("{}.acm-validations.aws.", Uuid::new_v4())),
18303        })
18304        .collect()
18305}
18306
18307/// Convert CFN `Tags` array into the ACM crate's tag map form.
18308fn parse_acm_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
18309    let mut out = BTreeMap::new();
18310    if let Some(arr) = value.and_then(|v| v.as_array()) {
18311        for t in arr {
18312            if let (Some(k), Some(v)) = (
18313                t.get("Key").and_then(|v| v.as_str()),
18314                t.get("Value").and_then(|v| v.as_str()),
18315            ) {
18316                out.insert(k.to_string(), v.to_string());
18317            }
18318        }
18319    }
18320    out
18321}
18322
18323/// Convert CFN `Tags` array into the ECS `TagEntry` form.
18324fn parse_ecs_tags(value: Option<&serde_json::Value>) -> Vec<EcsTagEntry> {
18325    let Some(arr) = value.and_then(|v| v.as_array()) else {
18326        return Vec::new();
18327    };
18328    arr.iter()
18329        .filter_map(|t| {
18330            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
18331            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
18332            Some(EcsTagEntry { key, value })
18333        })
18334        .collect()
18335}
18336
18337/// Strip the cluster ARN prefix when CFN passes a Ref-resolved name or
18338/// a GetAtt-resolved ARN; ECS internal state keys clusters by name.
18339fn parse_ecs_cluster_name(input: &str) -> String {
18340    if let Some(after) = input.split(":cluster/").nth(1) {
18341        return after.to_string();
18342    }
18343    input.to_string()
18344}
18345
18346/// Pull `(family, revision)` out of a task definition ARN tail like
18347/// `task-definition/web:3`. Returns `(family, revision)` or
18348/// `(input, 1)` for unrecognised shapes.
18349fn parse_td_arn(input: &str) -> (String, i32) {
18350    let suffix = input.rsplit('/').next().unwrap_or(input);
18351    if let Some((family, rev)) = suffix.split_once(':') {
18352        if let Ok(revision) = rev.parse::<i32>() {
18353            return (family.to_string(), revision);
18354        }
18355    }
18356    (input.to_string(), 1)
18357}
18358
18359/// Pull `(cluster, service)` out of a service ARN like
18360/// `arn:aws:ecs:us-east-1:000000000000:service/<cluster>/<service>`.
18361fn parse_service_arn(input: &str) -> Option<(String, String)> {
18362    let after = input.split(":service/").nth(1)?;
18363    let mut parts = after.splitn(2, '/');
18364    let cluster = parts.next()?.to_string();
18365    let service = parts.next()?.to_string();
18366    Some((cluster, service))
18367}
18368
18369/// Parse CFN-shape Tags array into the RDS crate's tag form.
18370fn parse_rds_tags(value: Option<&serde_json::Value>) -> Vec<RdsTag> {
18371    let Some(arr) = value.and_then(|v| v.as_array()) else {
18372        return Vec::new();
18373    };
18374    arr.iter()
18375        .filter_map(|t| {
18376            let key = t.get("Key").and_then(|v| v.as_str())?.to_string();
18377            let value = t.get("Value").and_then(|v| v.as_str())?.to_string();
18378            Some(RdsTag { key, value })
18379        })
18380        .collect()
18381}
18382
18383/// Lazy-create an entry in the RDS `extras` bucket so the provisioner
18384/// doesn't have to re-implement the `BTreeMap::entry` boilerplate per
18385/// resource type.
18386fn rds_extras_mut<'a>(
18387    state: &'a mut fakecloud_rds::RdsState,
18388    category: &str,
18389) -> &'a mut BTreeMap<String, serde_json::Value> {
18390    state.extras.entry(category.to_string()).or_default()
18391}
18392
18393/// Parse a JSON array-of-strings property. Returns empty Vec when the
18394/// value is missing or shaped wrong; matches the tolerant input handling
18395/// used by the runtime Cognito service.
18396fn parse_cognito_string_array(value: Option<&serde_json::Value>) -> Vec<String> {
18397    value
18398        .and_then(|v| v.as_array())
18399        .map(|arr| {
18400            arr.iter()
18401                .filter_map(|v| v.as_str().map(|s| s.to_string()))
18402                .collect()
18403        })
18404        .unwrap_or_default()
18405}
18406
18407fn parse_cognito_password_policy(value: Option<&serde_json::Value>) -> PasswordPolicy {
18408    let Some(inner) = value
18409        .and_then(|v| v.get("PasswordPolicy"))
18410        .and_then(|v| v.as_object())
18411    else {
18412        return PasswordPolicy::default();
18413    };
18414    let mut p = PasswordPolicy::default();
18415    if let Some(n) = inner.get("MinimumLength").and_then(|v| v.as_i64()) {
18416        p.minimum_length = n;
18417    }
18418    if let Some(b) = inner.get("RequireUppercase").and_then(|v| v.as_bool()) {
18419        p.require_uppercase = b;
18420    }
18421    if let Some(b) = inner.get("RequireLowercase").and_then(|v| v.as_bool()) {
18422        p.require_lowercase = b;
18423    }
18424    if let Some(b) = inner.get("RequireNumbers").and_then(|v| v.as_bool()) {
18425        p.require_numbers = b;
18426    }
18427    if let Some(b) = inner.get("RequireSymbols").and_then(|v| v.as_bool()) {
18428        p.require_symbols = b;
18429    }
18430    if let Some(n) = inner
18431        .get("TemporaryPasswordValidityDays")
18432        .and_then(|v| v.as_i64())
18433    {
18434        p.temporary_password_validity_days = n;
18435    }
18436    p
18437}
18438
18439fn parse_cognito_schema_attribute(value: &serde_json::Value) -> Option<SchemaAttribute> {
18440    let name = value.get("Name").and_then(|v| v.as_str())?.to_string();
18441    Some(SchemaAttribute {
18442        name,
18443        attribute_data_type: value
18444            .get("AttributeDataType")
18445            .and_then(|v| v.as_str())
18446            .unwrap_or("String")
18447            .to_string(),
18448        developer_only_attribute: value
18449            .get("DeveloperOnlyAttribute")
18450            .and_then(|v| v.as_bool())
18451            .unwrap_or(false),
18452        mutable: value
18453            .get("Mutable")
18454            .and_then(|v| v.as_bool())
18455            .unwrap_or(true),
18456        required: value
18457            .get("Required")
18458            .and_then(|v| v.as_bool())
18459            .unwrap_or(false),
18460        string_attribute_constraints: None,
18461        number_attribute_constraints: None,
18462    })
18463}
18464
18465fn parse_cognito_tags(value: Option<&serde_json::Value>) -> BTreeMap<String, String> {
18466    let mut out = BTreeMap::new();
18467    if let Some(obj) = value.and_then(|v| v.as_object()) {
18468        for (k, v) in obj {
18469            if let Some(s) = v.as_str() {
18470                out.insert(k.clone(), s.to_string());
18471            }
18472        }
18473    }
18474    out
18475}
18476
18477fn parse_cognito_email_configuration(
18478    value: Option<&serde_json::Value>,
18479) -> Option<EmailConfiguration> {
18480    let inner = value?.as_object()?;
18481    Some(EmailConfiguration {
18482        source_arn: inner
18483            .get("SourceArn")
18484            .and_then(|v| v.as_str())
18485            .map(|s| s.to_string()),
18486        reply_to_email_address: inner
18487            .get("ReplyToEmailAddress")
18488            .and_then(|v| v.as_str())
18489            .map(|s| s.to_string()),
18490        email_sending_account: inner
18491            .get("EmailSendingAccount")
18492            .and_then(|v| v.as_str())
18493            .map(|s| s.to_string()),
18494        from_email_address: inner
18495            .get("From")
18496            .and_then(|v| v.as_str())
18497            .map(|s| s.to_string()),
18498        configuration_set: inner
18499            .get("ConfigurationSet")
18500            .and_then(|v| v.as_str())
18501            .map(|s| s.to_string()),
18502    })
18503}
18504
18505fn parse_cognito_sms_configuration(value: Option<&serde_json::Value>) -> Option<SmsConfiguration> {
18506    let inner = value?.as_object()?;
18507    Some(SmsConfiguration {
18508        sns_caller_arn: inner
18509            .get("SnsCallerArn")
18510            .and_then(|v| v.as_str())
18511            .map(|s| s.to_string()),
18512        external_id: inner
18513            .get("ExternalId")
18514            .and_then(|v| v.as_str())
18515            .map(|s| s.to_string()),
18516        sns_region: inner
18517            .get("SnsRegion")
18518            .and_then(|v| v.as_str())
18519            .map(|s| s.to_string()),
18520    })
18521}
18522
18523fn parse_cognito_admin_create_user_config(
18524    value: Option<&serde_json::Value>,
18525) -> Option<AdminCreateUserConfig> {
18526    let inner = value?.as_object()?;
18527    Some(AdminCreateUserConfig {
18528        allow_admin_create_user_only: inner
18529            .get("AllowAdminCreateUserOnly")
18530            .and_then(|v| v.as_bool()),
18531        invite_message_template: None,
18532        unused_account_validity_days: inner
18533            .get("UnusedAccountValidityDays")
18534            .and_then(|v| v.as_i64()),
18535    })
18536}
18537
18538fn parse_cognito_account_recovery(
18539    value: Option<&serde_json::Value>,
18540) -> Option<AccountRecoverySetting> {
18541    let arr = value?.get("RecoveryMechanisms")?.as_array()?;
18542    Some(AccountRecoverySetting {
18543        recovery_mechanisms: arr
18544            .iter()
18545            .filter_map(|m| {
18546                let name = m.get("Name").and_then(|v| v.as_str())?.to_string();
18547                let priority = m.get("Priority").and_then(|v| v.as_i64()).unwrap_or(1);
18548                Some(RecoveryOption { name, priority })
18549            })
18550            .collect(),
18551    })
18552}
18553
18554fn parse_firehose_s3_destination(value: &serde_json::Value) -> Result<S3Destination, String> {
18555    let role_arn = value
18556        .get("RoleARN")
18557        .and_then(|v| v.as_str())
18558        .ok_or("S3 destination requires RoleARN")?
18559        .to_string();
18560    let bucket_arn = value
18561        .get("BucketARN")
18562        .and_then(|v| v.as_str())
18563        .ok_or("S3 destination requires BucketARN")?
18564        .to_string();
18565    let prefix = value
18566        .get("Prefix")
18567        .and_then(|v| v.as_str())
18568        .map(|s| s.to_string());
18569    let error_output_prefix = value
18570        .get("ErrorOutputPrefix")
18571        .and_then(|v| v.as_str())
18572        .map(|s| s.to_string());
18573    let mut buffering_size_mb = None;
18574    let mut buffering_interval_seconds = None;
18575    if let Some(hints) = value.get("BufferingHints") {
18576        buffering_size_mb = hints.get("SizeInMBs").and_then(|v| v.as_i64());
18577        buffering_interval_seconds = hints.get("IntervalInSeconds").and_then(|v| v.as_i64());
18578    }
18579    let compression_format = value
18580        .get("CompressionFormat")
18581        .and_then(|v| v.as_str())
18582        .map(|s| s.to_string());
18583
18584    Ok(S3Destination {
18585        destination_id: "destination-1".to_string(),
18586        role_arn,
18587        bucket_arn,
18588        prefix,
18589        error_output_prefix,
18590        buffering_size_mb,
18591        buffering_interval_seconds,
18592        compression_format,
18593    })
18594}
18595
18596#[cfg(test)]
18597mod tests {
18598    use super::*;
18599    use parking_lot::RwLock;
18600
18601    fn make_provisioner() -> ResourceProvisioner {
18602        ResourceProvisioner {
18603            sqs_state: Arc::new(RwLock::new(
18604                fakecloud_core::multi_account::MultiAccountState::new(
18605                    "123456789012",
18606                    "us-east-1",
18607                    "http://localhost:4566",
18608                ),
18609            )),
18610            sns_state: Arc::new(RwLock::new(
18611                fakecloud_core::multi_account::MultiAccountState::new(
18612                    "123456789012",
18613                    "us-east-1",
18614                    "http://localhost:4566",
18615                ),
18616            )),
18617            ssm_state: Arc::new(RwLock::new(
18618                fakecloud_core::multi_account::MultiAccountState::new(
18619                    "123456789012",
18620                    "us-east-1",
18621                    "http://localhost:4566",
18622                ),
18623            )),
18624            iam_state: Arc::new(RwLock::new(
18625                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", "http://localhost:4566"),
18626            )),
18627            s3_state: Arc::new(RwLock::new(fakecloud_core::multi_account::MultiAccountState::new(
18628                "123456789012",
18629                "us-east-1", "",
18630            ))),
18631            eventbridge_state: Arc::new(RwLock::new(
18632                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18633            )),
18634            dynamodb_state: Arc::new(RwLock::new(fakecloud_core::multi_account::MultiAccountState::new(
18635                "123456789012",
18636                "us-east-1", "",
18637            ))),
18638            logs_state: Arc::new(RwLock::new(
18639                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18640            )),
18641            lambda_state: Arc::new(RwLock::new(
18642                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18643            )),
18644            secretsmanager_state: Arc::new(RwLock::new(
18645                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18646            )),
18647            kinesis_state: Arc::new(RwLock::new(
18648                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18649            )),
18650            kms_state: Arc::new(RwLock::new(
18651                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18652            )),
18653            ecr_state: Arc::new(RwLock::new(
18654                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18655            )),
18656            cloudwatch_state: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
18657            elbv2_state: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
18658            organizations_state: Arc::new(RwLock::new(None)),
18659            cognito_state: Arc::new(RwLock::new(
18660                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18661            )),
18662            rds_state: Arc::new(RwLock::new(
18663                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18664            )),
18665            ecs_state: Arc::new(RwLock::new(
18666                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18667            )),
18668            acm_state: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
18669            elasticache_state: Arc::new(RwLock::new(
18670                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18671            )),
18672            route53_state: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
18673            cloudfront_state: Arc::new(RwLock::new(
18674                fakecloud_cloudfront::CloudFrontAccounts::new(),
18675            )),
18676            cloudformation_state: Arc::new(RwLock::new(
18677                fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
18678            )),
18679            stepfunctions_state: Arc::new(RwLock::new(
18680                fakecloud_core::multi_account::MultiAccountState::new(
18681                    "123456789012",
18682                    "us-east-1",
18683                    "",
18684                ),
18685            )),
18686            wafv2_state: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
18687            apigateway_state: Arc::new(RwLock::new(
18688                fakecloud_core::multi_account::MultiAccountState::new(
18689                    "123456789012",
18690                    "us-east-1",
18691                    "",
18692                ),
18693            )),
18694            apigatewayv2_state: Arc::new(RwLock::new(
18695                fakecloud_core::multi_account::MultiAccountState::new(
18696                    "123456789012",
18697                    "us-east-1",
18698                    "",
18699                ),
18700            )),
18701            ses_state: Arc::new(RwLock::new(
18702                fakecloud_core::multi_account::MultiAccountState::new(
18703                    "123456789012",
18704                    "us-east-1",
18705                    "",
18706                ),
18707            )),
18708            app_autoscaling_state: Arc::new(parking_lot::RwLock::new(
18709                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
18710            )),
18711            athena_state: Arc::new(parking_lot::RwLock::new(
18712                fakecloud_athena::AthenaAccounts::new(),
18713            )),
18714            firehose_state: Arc::new(parking_lot::RwLock::new(
18715                fakecloud_firehose::FirehoseAccounts::new(),
18716            )),
18717            glue_state: Arc::new(parking_lot::RwLock::new(
18718                fakecloud_glue::GlueAccounts::new(),
18719            )),
18720            delivery: Arc::new(DeliveryBus::new()),
18721            account_id: "123456789012".to_string(),
18722            region: "us-east-1".to_string(),
18723            stack_id: "arn:aws:cloudformation:us-east-1:123456789012:stack/test/00000000-0000-0000-0000-000000000000".to_string(),
18724        }
18725    }
18726
18727    fn make_resource(
18728        resource_type: &str,
18729        logical_id: &str,
18730        props: serde_json::Value,
18731    ) -> ResourceDefinition {
18732        ResourceDefinition {
18733            logical_id: logical_id.to_string(),
18734            resource_type: resource_type.to_string(),
18735            properties: props,
18736        }
18737    }
18738
18739    #[test]
18740    fn sns_subscription_rejects_nonexistent_topic() {
18741        let prov = make_provisioner();
18742        let resource = make_resource(
18743            "AWS::SNS::Subscription",
18744            "MySub",
18745            serde_json::json!({
18746                "TopicArn": "arn:aws:sns:us-east-1:123456789012:NonExistent",
18747                "Protocol": "sqs",
18748                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
18749            }),
18750        );
18751        let result = prov.create_resource(&resource);
18752        assert!(result.is_err());
18753        assert!(result.unwrap_err().contains("does not exist"));
18754    }
18755
18756    #[test]
18757    fn sns_subscription_succeeds_when_topic_exists() {
18758        let prov = make_provisioner();
18759        // First create the topic
18760        let topic = make_resource(
18761            "AWS::SNS::Topic",
18762            "MyTopic",
18763            serde_json::json!({ "TopicName": "my-topic" }),
18764        );
18765        let topic_result = prov.create_resource(&topic);
18766        assert!(topic_result.is_ok());
18767        let topic_arn = topic_result.unwrap().physical_id;
18768
18769        // Now create subscription referencing that topic
18770        let sub = make_resource(
18771            "AWS::SNS::Subscription",
18772            "MySub",
18773            serde_json::json!({
18774                "TopicArn": topic_arn,
18775                "Protocol": "sqs",
18776                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
18777            }),
18778        );
18779        let result = prov.create_resource(&sub);
18780        assert!(result.is_ok());
18781    }
18782
18783    #[test]
18784    fn eventbridge_rule_arn_default_bus_omits_bus_name() {
18785        let prov = make_provisioner();
18786        let resource = make_resource(
18787            "AWS::Events::Rule",
18788            "MyRule",
18789            serde_json::json!({
18790                "Name": "my-rule",
18791                "ScheduleExpression": "rate(1 hour)"
18792            }),
18793        );
18794        let result = prov.create_resource(&resource).unwrap();
18795        // For default bus, ARN should be rule/<name> without /default/
18796        assert_eq!(
18797            result.physical_id,
18798            "arn:aws:events:us-east-1:123456789012:rule/my-rule"
18799        );
18800        assert!(!result.physical_id.contains("rule/default/"));
18801    }
18802
18803    #[test]
18804    fn eventbridge_rule_arn_custom_bus_includes_bus_name() {
18805        let prov = make_provisioner();
18806        // Create a custom bus first
18807        {
18808            let mut eb_accounts = prov.eventbridge_state.write();
18809            let state = eb_accounts.default_mut();
18810            state.buses.insert(
18811                "custom-bus".to_string(),
18812                fakecloud_eventbridge::EventBus {
18813                    name: "custom-bus".to_string(),
18814                    arn: "arn:aws:events:us-east-1:123456789012:event-bus/custom-bus".to_string(),
18815                    policy: None,
18816                    creation_time: Utc::now(),
18817                    last_modified_time: Utc::now(),
18818                    description: None,
18819                    kms_key_identifier: None,
18820                    dead_letter_config: None,
18821                    tags: std::collections::BTreeMap::new(),
18822                },
18823            );
18824        }
18825        let resource = make_resource(
18826            "AWS::Events::Rule",
18827            "MyRule",
18828            serde_json::json!({
18829                "Name": "my-rule",
18830                "EventBusName": "custom-bus",
18831                "ScheduleExpression": "rate(1 hour)"
18832            }),
18833        );
18834        let result = prov.create_resource(&resource).unwrap();
18835        assert_eq!(
18836            result.physical_id,
18837            "arn:aws:events:us-east-1:123456789012:rule/custom-bus/my-rule"
18838        );
18839    }
18840
18841    #[test]
18842    fn eventbridge_rule_rejects_nonexistent_bus() {
18843        let prov = make_provisioner();
18844        let resource = make_resource(
18845            "AWS::Events::Rule",
18846            "MyRule",
18847            serde_json::json!({
18848                "Name": "my-rule",
18849                "EventBusName": "nonexistent-bus",
18850                "ScheduleExpression": "rate(1 hour)"
18851            }),
18852        );
18853        let result = prov.create_resource(&resource);
18854        assert!(result.is_err());
18855        assert!(result.unwrap_err().contains("does not exist"));
18856    }
18857
18858    #[test]
18859    fn custom_resource_requires_service_token() {
18860        let prov = make_provisioner();
18861        let resource = make_resource(
18862            "Custom::MyResource",
18863            "MyCustom",
18864            serde_json::json!({
18865                "Foo": "bar"
18866            }),
18867        );
18868        let result = prov.create_resource(&resource);
18869        assert!(result.is_err());
18870        assert!(
18871            result.unwrap_err().contains("ServiceToken"),
18872            "Should require ServiceToken property"
18873        );
18874    }
18875
18876    #[test]
18877    fn custom_resource_succeeds_without_lambda_delivery() {
18878        // When no Lambda delivery is configured, custom resource creation
18879        // should still succeed (the invocation is silently skipped).
18880        let prov = make_provisioner();
18881        let resource = make_resource(
18882            "Custom::MyResource",
18883            "MyCustom",
18884            serde_json::json!({
18885                "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:my-func",
18886                "Foo": "bar"
18887            }),
18888        );
18889        let result = prov.create_resource(&resource);
18890        assert!(result.is_ok());
18891        let sr = result.unwrap();
18892        assert_eq!(sr.logical_id, "MyCustom");
18893        assert_eq!(sr.resource_type, "Custom::MyResource");
18894        assert!(sr.physical_id.starts_with("MyCustom-"));
18895    }
18896
18897    #[test]
18898    fn cloudformation_custom_resource_type_succeeds() {
18899        let prov = make_provisioner();
18900        let resource = make_resource(
18901            "AWS::CloudFormation::CustomResource",
18902            "MyCustom2",
18903            serde_json::json!({
18904                "ServiceToken": "arn:aws:lambda:us-east-1:123456789012:function:my-func",
18905                "Key": "value"
18906            }),
18907        );
18908        let result = prov.create_resource(&resource);
18909        assert!(result.is_ok());
18910        let sr = result.unwrap();
18911        assert_eq!(sr.resource_type, "AWS::CloudFormation::CustomResource");
18912    }
18913
18914    // ── Resource create/delete lifecycle tests ──
18915
18916    #[test]
18917    fn sqs_queue_create_and_delete() {
18918        let prov = make_provisioner();
18919        let res = make_resource(
18920            "AWS::SQS::Queue",
18921            "MyQ",
18922            serde_json::json!({"QueueName": "my-q"}),
18923        );
18924        let sr = prov.create_resource(&res).unwrap();
18925        assert!(sr.physical_id.contains("my-q"));
18926        assert_eq!(sr.resource_type, "AWS::SQS::Queue");
18927        prov.delete_resource(&sr).unwrap();
18928    }
18929
18930    #[test]
18931    fn sqs_queue_fifo_with_suffix() {
18932        let prov = make_provisioner();
18933        let res = make_resource(
18934            "AWS::SQS::Queue",
18935            "FifoQ",
18936            serde_json::json!({"QueueName": "my-fifo.fifo", "FifoQueue": true}),
18937        );
18938        let sr = prov.create_resource(&res).unwrap();
18939        assert!(sr.physical_id.contains(".fifo"));
18940    }
18941
18942    #[test]
18943    fn sns_topic_create_and_delete() {
18944        let prov = make_provisioner();
18945        let res = make_resource(
18946            "AWS::SNS::Topic",
18947            "MyTopic",
18948            serde_json::json!({"TopicName": "t1"}),
18949        );
18950        let sr = prov.create_resource(&res).unwrap();
18951        assert!(sr.physical_id.contains("t1"));
18952        prov.delete_resource(&sr).unwrap();
18953    }
18954
18955    #[test]
18956    fn ssm_parameter_create_and_delete() {
18957        let prov = make_provisioner();
18958        let res = make_resource(
18959            "AWS::SSM::Parameter",
18960            "MyParam",
18961            serde_json::json!({
18962                "Name": "/my/param",
18963                "Type": "String",
18964                "Value": "v1"
18965            }),
18966        );
18967        let sr = prov.create_resource(&res).unwrap();
18968        assert_eq!(sr.physical_id, "/my/param");
18969        prov.delete_resource(&sr).unwrap();
18970    }
18971
18972    #[test]
18973    fn iam_role_create_and_delete() {
18974        let prov = make_provisioner();
18975        let res = make_resource(
18976            "AWS::IAM::Role",
18977            "MyRole",
18978            serde_json::json!({
18979                "RoleName": "my-role",
18980                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
18981            }),
18982        );
18983        let sr = prov.create_resource(&res).unwrap();
18984        assert!(sr.physical_id.contains("my-role"));
18985        prov.delete_resource(&sr).unwrap();
18986    }
18987
18988    #[test]
18989    fn iam_policy_create_and_delete() {
18990        let prov = make_provisioner();
18991        let res = make_resource(
18992            "AWS::IAM::Policy",
18993            "MyPolicy",
18994            serde_json::json!({
18995                "PolicyName": "my-policy",
18996                "PolicyDocument": {"Version": "2012-10-17", "Statement": []}
18997            }),
18998        );
18999        let sr = prov.create_resource(&res).unwrap();
19000        assert!(sr.physical_id.contains("my-policy"));
19001        prov.delete_resource(&sr).unwrap();
19002    }
19003
19004    #[test]
19005    fn s3_bucket_create_and_delete() {
19006        let prov = make_provisioner();
19007        let res = make_resource(
19008            "AWS::S3::Bucket",
19009            "MyBucket",
19010            serde_json::json!({"BucketName": "my-bucket"}),
19011        );
19012        let sr = prov.create_resource(&res).unwrap();
19013        assert_eq!(sr.physical_id, "my-bucket");
19014        prov.delete_resource(&sr).unwrap();
19015    }
19016
19017    #[test]
19018    fn dynamodb_table_create_and_delete() {
19019        let prov = make_provisioner();
19020        let res = make_resource(
19021            "AWS::DynamoDB::Table",
19022            "MyTable",
19023            serde_json::json!({
19024                "TableName": "my-table",
19025                "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
19026                "AttributeDefinitions": [{"AttributeName": "pk", "AttributeType": "S"}],
19027                "BillingMode": "PAY_PER_REQUEST"
19028            }),
19029        );
19030        let sr = prov.create_resource(&res).unwrap();
19031        assert!(sr.physical_id.contains("my-table"));
19032        prov.delete_resource(&sr).unwrap();
19033    }
19034
19035    #[test]
19036    fn log_group_create_and_delete() {
19037        let prov = make_provisioner();
19038        let res = make_resource(
19039            "AWS::Logs::LogGroup",
19040            "MyLogs",
19041            serde_json::json!({"LogGroupName": "/app/logs"}),
19042        );
19043        let sr = prov.create_resource(&res).unwrap();
19044        assert!(sr.physical_id.contains("/app/logs"));
19045        prov.delete_resource(&sr).unwrap();
19046    }
19047
19048    #[test]
19049    fn lambda_function_create_and_delete() {
19050        let prov = make_provisioner();
19051        let res = make_resource(
19052            "AWS::Lambda::Function",
19053            "MyFn",
19054            serde_json::json!({
19055                "FunctionName": "my-fn",
19056                "Runtime": "nodejs20.x",
19057                "Role": "arn:aws:iam::123456789012:role/lambda-role",
19058                "Handler": "index.handler",
19059                "MemorySize": 256,
19060                "Timeout": 10,
19061                "Environment": {"Variables": {"FOO": "bar"}}
19062            }),
19063        );
19064        let sr = prov.create_resource(&res).unwrap();
19065        assert_eq!(sr.physical_id, "my-fn");
19066        assert_eq!(
19067            sr.attributes.get("Arn").map(String::as_str),
19068            Some("arn:aws:lambda:us-east-1:123456789012:function:my-fn")
19069        );
19070        // Verify it landed in lambda state
19071        {
19072            let lam = prov.lambda_state.read();
19073            let st = lam.get("123456789012").unwrap();
19074            let f = st.functions.get("my-fn").unwrap();
19075            assert_eq!(f.runtime, "nodejs20.x");
19076            assert_eq!(f.memory_size, 256);
19077            assert_eq!(f.environment.get("FOO").unwrap(), "bar");
19078        }
19079        prov.delete_resource(&sr).unwrap();
19080        let lam = prov.lambda_state.read();
19081        let st = lam.get("123456789012").unwrap();
19082        assert!(!st.functions.contains_key("my-fn"));
19083    }
19084
19085    #[test]
19086    fn unsupported_resource_type_fails() {
19087        let prov = make_provisioner();
19088        let res = make_resource("AWS::NonExistent::Thing", "X", serde_json::json!({}));
19089        assert!(prov.create_resource(&res).is_err());
19090    }
19091
19092    #[test]
19093    fn iam_role_with_inline_policies() {
19094        let prov = make_provisioner();
19095        let res = make_resource(
19096            "AWS::IAM::Role",
19097            "MyRole",
19098            serde_json::json!({
19099                "RoleName": "role-inline",
19100                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []},
19101                "Policies": [
19102                    {
19103                        "PolicyName": "inline-1",
19104                        "PolicyDocument": {"Version": "2012-10-17", "Statement": []}
19105                    }
19106                ]
19107            }),
19108        );
19109        let sr = prov.create_resource(&res).unwrap();
19110        assert!(sr.physical_id.contains("role-inline"));
19111    }
19112
19113    #[test]
19114    fn sqs_queue_auto_name() {
19115        let prov = make_provisioner();
19116        let res = make_resource("AWS::SQS::Queue", "AutoQ", serde_json::json!({}));
19117        let sr = prov.create_resource(&res).unwrap();
19118        // Generated queue name should exist
19119        assert!(!sr.physical_id.is_empty());
19120    }
19121
19122    #[test]
19123    fn sns_topic_auto_name() {
19124        let prov = make_provisioner();
19125        let res = make_resource("AWS::SNS::Topic", "AutoT", serde_json::json!({}));
19126        let sr = prov.create_resource(&res).unwrap();
19127        assert!(!sr.physical_id.is_empty());
19128    }
19129
19130    // ── additional resource types ──
19131
19132    #[test]
19133    fn unsupported_resource_type_errors() {
19134        let prov = make_provisioner();
19135        let res = make_resource("AWS::FooBar::Thing", "X", serde_json::json!({}));
19136        assert!(prov.create_resource(&res).is_err());
19137    }
19138
19139    #[test]
19140    fn sqs_queue_with_redrive_policy() {
19141        let prov = make_provisioner();
19142        // Create DLQ first
19143        let dlq = make_resource(
19144            "AWS::SQS::Queue",
19145            "DLQ",
19146            serde_json::json!({"QueueName": "dlq1"}),
19147        );
19148        let dlq_resource = prov.create_resource(&dlq).unwrap();
19149        let _ = dlq_resource.physical_id;
19150
19151        // Create source queue with redrive policy
19152        let src = make_resource(
19153            "AWS::SQS::Queue",
19154            "Src",
19155            serde_json::json!({
19156                "QueueName": "src1",
19157                "RedrivePolicy": {
19158                    "deadLetterTargetArn": "arn:aws:sqs:us-east-1:123456789012:dlq1",
19159                    "maxReceiveCount": 3
19160                }
19161            }),
19162        );
19163        let sr = prov.create_resource(&src).unwrap();
19164        assert!(!sr.physical_id.is_empty());
19165    }
19166
19167    #[test]
19168    fn sns_topic_with_display_name() {
19169        let prov = make_provisioner();
19170        let res = make_resource(
19171            "AWS::SNS::Topic",
19172            "WithName",
19173            serde_json::json!({"TopicName": "named-topic", "DisplayName": "Named"}),
19174        );
19175        let sr = prov.create_resource(&res).unwrap();
19176        assert!(sr.physical_id.contains("named-topic"));
19177    }
19178
19179    #[test]
19180    fn ssm_parameter_with_explicit_name() {
19181        let prov = make_provisioner();
19182        let res = make_resource(
19183            "AWS::SSM::Parameter",
19184            "Param",
19185            serde_json::json!({"Name": "/my/param", "Value": "v", "Type": "String"}),
19186        );
19187        let sr = prov.create_resource(&res).unwrap();
19188        assert!(sr.physical_id.contains("/my/param"));
19189    }
19190
19191    #[test]
19192    fn ssm_parameter_missing_name_errors() {
19193        let prov = make_provisioner();
19194        let res = make_resource(
19195            "AWS::SSM::Parameter",
19196            "AutoP",
19197            serde_json::json!({"Value": "v", "Type": "String"}),
19198        );
19199        assert!(prov.create_resource(&res).is_err());
19200    }
19201
19202    #[test]
19203    fn iam_managed_policy_auto_name() {
19204        let prov = make_provisioner();
19205        let res = make_resource(
19206            "AWS::IAM::Policy",
19207            "AutoPol",
19208            serde_json::json!({
19209                "PolicyName": "inline-pol",
19210                "PolicyDocument": {"Version": "2012-10-17", "Statement": []},
19211                "Users": []
19212            }),
19213        );
19214        let sr = prov.create_resource(&res).unwrap();
19215        assert!(!sr.physical_id.is_empty());
19216    }
19217
19218    #[test]
19219    fn delete_resource_works_for_queue() {
19220        let prov = make_provisioner();
19221        let res = make_resource(
19222            "AWS::SQS::Queue",
19223            "ToDel",
19224            serde_json::json!({"QueueName": "todel"}),
19225        );
19226        let sr = prov.create_resource(&res).unwrap();
19227        assert!(prov.delete_resource(&sr).is_ok());
19228    }
19229
19230    #[test]
19231    fn delete_resource_works_for_topic() {
19232        let prov = make_provisioner();
19233        let res = make_resource(
19234            "AWS::SNS::Topic",
19235            "DelT",
19236            serde_json::json!({"TopicName": "delt"}),
19237        );
19238        let sr = prov.create_resource(&res).unwrap();
19239        assert!(prov.delete_resource(&sr).is_ok());
19240    }
19241
19242    #[test]
19243    fn application_autoscaling_scalable_target_round_trip() {
19244        let prov = make_provisioner();
19245        let res = make_resource(
19246            "AWS::ApplicationAutoScaling::ScalableTarget",
19247            "Target",
19248            serde_json::json!({
19249                "ServiceNamespace": "ecs",
19250                "ResourceId": "service/my-cluster/my-service",
19251                "ScalableDimension": "ecs:service:DesiredCount",
19252                "MinCapacity": 1,
19253                "MaxCapacity": 10,
19254                "RoleARN": "arn:aws:iam::123456789012:role/my-role",
19255            }),
19256        );
19257        let sr = prov.create_resource(&res).unwrap();
19258        assert_eq!(sr.physical_id, "service/my-cluster/my-service");
19259        assert!(sr.attributes.contains_key("ScalableTargetARN"));
19260        assert!(prov.delete_resource(&sr).is_ok());
19261    }
19262
19263    #[test]
19264    fn application_autoscaling_scaling_policy_requires_target() {
19265        let prov = make_provisioner();
19266        let res = make_resource(
19267            "AWS::ApplicationAutoScaling::ScalingPolicy",
19268            "Policy",
19269            serde_json::json!({
19270                "PolicyName": "my-policy",
19271                "ServiceNamespace": "ecs",
19272                "ResourceId": "service/my-cluster/my-service",
19273                "ScalableDimension": "ecs:service:DesiredCount",
19274                "PolicyType": "TargetTrackingScaling",
19275                "TargetTrackingScalingPolicyConfiguration": {
19276                    "TargetValue": 50.0,
19277                    "PredefinedMetricSpecification": {
19278                        "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
19279                    }
19280                },
19281            }),
19282        );
19283        // Should fail because scalable target does not exist
19284        assert!(prov.create_resource(&res).is_err());
19285    }
19286
19287    #[test]
19288    fn application_autoscaling_scaling_policy_round_trip() {
19289        let prov = make_provisioner();
19290        let target = make_resource(
19291            "AWS::ApplicationAutoScaling::ScalableTarget",
19292            "Target",
19293            serde_json::json!({
19294                "ServiceNamespace": "ecs",
19295                "ResourceId": "service/my-cluster/my-service",
19296                "ScalableDimension": "ecs:service:DesiredCount",
19297                "MinCapacity": 1,
19298                "MaxCapacity": 10,
19299            }),
19300        );
19301        let sr = prov.create_resource(&target).unwrap();
19302
19303        let policy = make_resource(
19304            "AWS::ApplicationAutoScaling::ScalingPolicy",
19305            "Policy",
19306            serde_json::json!({
19307                "PolicyName": "my-policy",
19308                "ServiceNamespace": "ecs",
19309                "ResourceId": "service/my-cluster/my-service",
19310                "ScalableDimension": "ecs:service:DesiredCount",
19311                "PolicyType": "TargetTrackingScaling",
19312                "TargetTrackingScalingPolicyConfiguration": {
19313                    "TargetValue": 50.0,
19314                    "PredefinedMetricSpecification": {
19315                        "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
19316                    }
19317                },
19318            }),
19319        );
19320        let psr = prov.create_resource(&policy).unwrap();
19321        assert!(psr.physical_id.starts_with("arn:aws:autoscaling:"));
19322        assert!(prov.delete_resource(&psr).is_ok());
19323        assert!(prov.delete_resource(&sr).is_ok());
19324    }
19325
19326    #[test]
19327    fn sqs_queue_with_fifo_suffix() {
19328        let prov = make_provisioner();
19329        let res = make_resource(
19330            "AWS::SQS::Queue",
19331            "Fifo",
19332            serde_json::json!({"QueueName": "fq.fifo", "FifoQueue": true}),
19333        );
19334        let sr = prov.create_resource(&res).unwrap();
19335        assert!(sr.physical_id.ends_with(".fifo"));
19336    }
19337
19338    // ── get_att dispatch ──
19339
19340    #[test]
19341    fn getatt_s3_bucket_arn_returns_arn() {
19342        let prov = make_provisioner();
19343        let bucket = make_resource(
19344            "AWS::S3::Bucket",
19345            "MyBucket",
19346            serde_json::json!({"BucketName": "my-bucket"}),
19347        );
19348        let sr = prov.create_resource(&bucket).unwrap();
19349        assert_eq!(
19350            prov.get_att(&sr, "Arn"),
19351            Some("arn:aws:s3:::my-bucket".to_string())
19352        );
19353    }
19354
19355    #[test]
19356    fn getatt_s3_bucket_domain_name_returns_dns_name() {
19357        let prov = make_provisioner();
19358        let bucket = make_resource(
19359            "AWS::S3::Bucket",
19360            "MyBucket",
19361            serde_json::json!({"BucketName": "my-bucket"}),
19362        );
19363        let sr = prov.create_resource(&bucket).unwrap();
19364        assert_eq!(
19365            prov.get_att(&sr, "DomainName"),
19366            Some("my-bucket.s3.amazonaws.com".to_string())
19367        );
19368    }
19369
19370    #[test]
19371    fn getatt_lambda_function_arn_returns_function_arn() {
19372        let prov = make_provisioner();
19373        // Lambda needs an existing IAM role to validate the function role.
19374        let role = make_resource(
19375            "AWS::IAM::Role",
19376            "MyRole",
19377            serde_json::json!({
19378                "RoleName": "my-role",
19379                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
19380            }),
19381        );
19382        let role_sr = prov.create_resource(&role).unwrap();
19383        let fn_res = make_resource(
19384            "AWS::Lambda::Function",
19385            "MyFn",
19386            serde_json::json!({
19387                "FunctionName": "my-fn",
19388                "Runtime": "python3.11",
19389                "Handler": "index.handler",
19390                "Role": role_sr.physical_id,
19391                "Code": {"ZipFile": "def handler(e,c): return e"}
19392            }),
19393        );
19394        let fn_sr = prov.create_resource(&fn_res).unwrap();
19395        let arn = prov.get_att(&fn_sr, "Arn").expect("Arn should resolve");
19396        assert!(arn.starts_with("arn:aws:lambda:"));
19397        assert!(arn.contains(":function:my-fn"));
19398    }
19399
19400    #[test]
19401    fn getatt_iam_role_arn_returns_role_arn() {
19402        let prov = make_provisioner();
19403        let role = make_resource(
19404            "AWS::IAM::Role",
19405            "MyRole",
19406            serde_json::json!({
19407                "RoleName": "my-role",
19408                "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": []}
19409            }),
19410        );
19411        let sr = prov.create_resource(&role).unwrap();
19412        assert_eq!(
19413            prov.get_att(&sr, "Arn"),
19414            Some("arn:aws:iam::123456789012:role/my-role".to_string())
19415        );
19416        // RoleId is a real value generated at create time (FKIA-prefixed).
19417        let role_id = prov.get_att(&sr, "RoleId").expect("RoleId should resolve");
19418        assert!(role_id.starts_with("FKIA"));
19419    }
19420
19421    #[test]
19422    fn getatt_unknown_attribute_returns_none() {
19423        let prov = make_provisioner();
19424        let bucket = make_resource(
19425            "AWS::S3::Bucket",
19426            "MyBucket",
19427            serde_json::json!({"BucketName": "my-bucket"}),
19428        );
19429        let sr = prov.create_resource(&bucket).unwrap();
19430        // Fall-back behaviour: unknown attribute on a known type returns
19431        // None; the resolver in template.rs surfaces a "Logical.Attr"
19432        // placeholder so the existing template still builds.
19433        assert_eq!(prov.get_att(&sr, "NotARealAttr"), None);
19434    }
19435
19436    #[test]
19437    fn getatt_unknown_resource_type_returns_none() {
19438        let prov = make_provisioner();
19439        // Hand-crafted StackResource with a resource_type we don't dispatch
19440        // on at all. The captured attributes map is also empty, so there's
19441        // nothing to return.
19442        let stack_resource = StackResource {
19443            logical_id: "Mystery".to_string(),
19444            physical_id: "mystery-id".to_string(),
19445            resource_type: "AWS::Made::Up".to_string(),
19446            status: "CREATE_COMPLETE".to_string(),
19447            service_token: None,
19448            attributes: BTreeMap::new(),
19449        };
19450        assert_eq!(prov.get_att(&stack_resource, "Arn"), None);
19451    }
19452
19453    #[test]
19454    fn getatt_falls_back_to_captured_attributes() {
19455        let prov = make_provisioner();
19456        // Captured attributes always win — even if live-state dispatch
19457        // would return something different. This keeps `Fn::GetAtt`
19458        // deterministic for resources with cached attrs.
19459        let stack_resource = StackResource {
19460            logical_id: "MyTopic".to_string(),
19461            physical_id: "arn:aws:sns:us-east-1:123456789012:my-topic".to_string(),
19462            resource_type: "AWS::SNS::Topic".to_string(),
19463            status: "CREATE_COMPLETE".to_string(),
19464            service_token: None,
19465            attributes: {
19466                let mut m = BTreeMap::new();
19467                m.insert("TopicArn".to_string(), "captured-arn".to_string());
19468                m
19469            },
19470        };
19471        assert_eq!(
19472            prov.get_att(&stack_resource, "TopicArn"),
19473            Some("captured-arn".to_string())
19474        );
19475    }
19476
19477    #[test]
19478    fn getatt_secrets_manager_arn_resolves_via_live_state() {
19479        // Secrets create handler captures Id but not Arn; live-state
19480        // fallback fills in Arn.
19481        let prov = make_provisioner();
19482        let res = make_resource(
19483            "AWS::SecretsManager::Secret",
19484            "MySecret",
19485            serde_json::json!({"Name": "my-secret", "SecretString": "hunter2"}),
19486        );
19487        let sr = prov.create_resource(&res).unwrap();
19488        let arn = prov.get_att(&sr, "Arn").expect("Arn should resolve");
19489        assert!(arn.starts_with("arn:aws:secretsmanager:"));
19490        assert!(arn.ends_with(":secret:my-secret"));
19491    }
19492
19493    #[test]
19494    fn wafv2_web_acl_lifecycle() {
19495        let prov = make_provisioner();
19496        let res = make_resource(
19497            "AWS::WAFv2::WebACL",
19498            "MyAcl",
19499            serde_json::json!({
19500                "Name": "my-acl",
19501                "Scope": "REGIONAL",
19502                "DefaultAction": {"Allow": {}},
19503                "Rules": [{"Name": "rule1", "Priority": 1, "Statement": {}, "VisibilityConfig": {}}],
19504                "VisibilityConfig": {"SampledRequestsEnabled": true, "CloudWatchMetricsEnabled": true, "MetricName": "my-acl-metric"},
19505                "Capacity": 100,
19506            }),
19507        );
19508        let sr = prov.create_resource(&res).unwrap();
19509        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
19510        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
19511        assert_eq!(prov.get_att(&sr, "Name"), Some("my-acl".to_string()));
19512        assert!(prov.get_att(&sr, "Id").is_some());
19513        assert_eq!(prov.get_att(&sr, "Capacity"), Some("100".to_string()));
19514
19515        prov.delete_resource(&sr.clone()).unwrap();
19516        // Verify live-state fallback returns None by using a fresh resource
19517        // with empty attributes (captured attrs would still win).
19518        let fresh = StackResource {
19519            logical_id: "MyAcl".to_string(),
19520            physical_id: sr.physical_id.clone(),
19521            resource_type: "AWS::WAFv2::WebACL".to_string(),
19522            status: "CREATE_COMPLETE".to_string(),
19523            service_token: None,
19524            attributes: BTreeMap::new(),
19525        };
19526        assert_eq!(prov.get_att(&fresh, "Arn"), None);
19527    }
19528
19529    #[test]
19530    fn wafv2_ip_set_lifecycle() {
19531        let prov = make_provisioner();
19532        let res = make_resource(
19533            "AWS::WAFv2::IPSet",
19534            "MyIpSet",
19535            serde_json::json!({
19536                "Name": "my-ipset",
19537                "Scope": "REGIONAL",
19538                "IPAddressVersion": "IPV4",
19539                "Addresses": ["10.0.0.0/8"],
19540            }),
19541        );
19542        let sr = prov.create_resource(&res).unwrap();
19543        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
19544        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
19545        assert_eq!(prov.get_att(&sr, "Name"), Some("my-ipset".to_string()));
19546
19547        prov.delete_resource(&sr.clone()).unwrap();
19548        let fresh = StackResource {
19549            logical_id: "MyIpSet".to_string(),
19550            physical_id: sr.physical_id.clone(),
19551            resource_type: "AWS::WAFv2::IPSet".to_string(),
19552            status: "CREATE_COMPLETE".to_string(),
19553            service_token: None,
19554            attributes: BTreeMap::new(),
19555        };
19556        assert_eq!(prov.get_att(&fresh, "Arn"), None);
19557    }
19558
19559    #[test]
19560    fn wafv2_regex_pattern_set_lifecycle() {
19561        let prov = make_provisioner();
19562        let res = make_resource(
19563            "AWS::WAFv2::RegexPatternSet",
19564            "MyRegexSet",
19565            serde_json::json!({
19566                "Name": "my-regex",
19567                "Scope": "REGIONAL",
19568                "RegularExpressions": [{"RegexString": "^test"}],
19569            }),
19570        );
19571        let sr = prov.create_resource(&res).unwrap();
19572        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
19573        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
19574        assert_eq!(prov.get_att(&sr, "Name"), Some("my-regex".to_string()));
19575
19576        prov.delete_resource(&sr.clone()).unwrap();
19577        let fresh = StackResource {
19578            logical_id: "MyRegexSet".to_string(),
19579            physical_id: sr.physical_id.clone(),
19580            resource_type: "AWS::WAFv2::RegexPatternSet".to_string(),
19581            status: "CREATE_COMPLETE".to_string(),
19582            service_token: None,
19583            attributes: BTreeMap::new(),
19584        };
19585        assert_eq!(prov.get_att(&fresh, "Arn"), None);
19586    }
19587
19588    #[test]
19589    fn wafv2_rule_group_lifecycle() {
19590        let prov = make_provisioner();
19591        let res = make_resource(
19592            "AWS::WAFv2::RuleGroup",
19593            "MyRuleGroup",
19594            serde_json::json!({
19595                "Name": "my-rg",
19596                "Scope": "REGIONAL",
19597                "Capacity": 50,
19598                "Rules": [{"Name": "r1", "Priority": 1, "Statement": {}, "VisibilityConfig": {}}],
19599                "VisibilityConfig": {"SampledRequestsEnabled": true, "CloudWatchMetricsEnabled": true, "MetricName": "rg-metric"},
19600            }),
19601        );
19602        let sr = prov.create_resource(&res).unwrap();
19603        assert!(sr.physical_id.starts_with("arn:aws:wafv2:"));
19604        assert_eq!(prov.get_att(&sr, "Arn"), Some(sr.physical_id.clone()));
19605        assert_eq!(prov.get_att(&sr, "Name"), Some("my-rg".to_string()));
19606
19607        prov.delete_resource(&sr.clone()).unwrap();
19608        let fresh = StackResource {
19609            logical_id: "MyRuleGroup".to_string(),
19610            physical_id: sr.physical_id.clone(),
19611            resource_type: "AWS::WAFv2::RuleGroup".to_string(),
19612            status: "CREATE_COMPLETE".to_string(),
19613            service_token: None,
19614            attributes: BTreeMap::new(),
19615        };
19616        assert_eq!(prov.get_att(&fresh, "Arn"), None);
19617    }
19618
19619    #[test]
19620    fn wafv2_logging_configuration_lifecycle() {
19621        let prov = make_provisioner();
19622        let res = make_resource(
19623            "AWS::WAFv2::LoggingConfiguration",
19624            "MyLogConfig",
19625            serde_json::json!({
19626                "ResourceArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/abc",
19627                "LogDestinationConfigs": ["arn:aws:logs:us-east-1:123456789012:log-group:/aws/waf"],
19628            }),
19629        );
19630        let sr = prov.create_resource(&res).unwrap();
19631        assert_eq!(
19632            sr.physical_id,
19633            "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/abc"
19634        );
19635
19636        prov.delete_resource(&sr.clone()).unwrap();
19637    }
19638
19639    #[test]
19640    fn wafv2_web_acl_association_lifecycle() {
19641        let prov = make_provisioner();
19642        let res = make_resource(
19643            "AWS::WAFv2::WebACLAssociation",
19644            "MyAssoc",
19645            serde_json::json!({
19646                "ResourceArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188",
19647                "WebACLArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/my-acl/abc",
19648            }),
19649        );
19650        let sr = prov.create_resource(&res).unwrap();
19651        assert_eq!(sr.physical_id, "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-alb/50dc6c495c0c9188");
19652
19653        prov.delete_resource(&sr.clone()).unwrap();
19654    }
19655
19656    #[test]
19657    fn ses_configuration_set_lifecycle() {
19658        let prov = make_provisioner();
19659        let res = make_resource(
19660            "AWS::SES::ConfigurationSet",
19661            "MyConfigSet",
19662            serde_json::json!({
19663                "Name": "my-cs",
19664                "SendingOptions": {"SendingEnabled": true},
19665                "DeliveryOptions": {"TlsPolicy": "REQUIRE"},
19666            }),
19667        );
19668        let sr = prov.create_resource(&res).unwrap();
19669        assert_eq!(sr.physical_id, "my-cs");
19670        assert_eq!(prov.get_att(&sr, "Name"), Some("my-cs".to_string()));
19671
19672        prov.delete_resource(&sr.clone()).unwrap();
19673        let fresh = StackResource {
19674            logical_id: "MyConfigSet".to_string(),
19675            physical_id: "my-cs".to_string(),
19676            resource_type: "AWS::SES::ConfigurationSet".to_string(),
19677            status: "CREATE_COMPLETE".to_string(),
19678            service_token: None,
19679            attributes: BTreeMap::new(),
19680        };
19681        assert_eq!(prov.get_att(&fresh, "Name"), None);
19682    }
19683
19684    #[test]
19685    fn ses_email_identity_lifecycle() {
19686        let prov = make_provisioner();
19687        let res = make_resource(
19688            "AWS::SES::EmailIdentity",
19689            "MyIdentity",
19690            serde_json::json!({"EmailIdentity": "example.com"}),
19691        );
19692        let sr = prov.create_resource(&res).unwrap();
19693        assert_eq!(sr.physical_id, "example.com");
19694        assert_eq!(
19695            prov.get_att(&sr, "IdentityName"),
19696            Some("example.com".to_string())
19697        );
19698
19699        prov.delete_resource(&sr.clone()).unwrap();
19700        let fresh = StackResource {
19701            logical_id: "MyIdentity".to_string(),
19702            physical_id: "example.com".to_string(),
19703            resource_type: "AWS::SES::EmailIdentity".to_string(),
19704            status: "CREATE_COMPLETE".to_string(),
19705            service_token: None,
19706            attributes: BTreeMap::new(),
19707        };
19708        assert_eq!(prov.get_att(&fresh, "IdentityName"), None);
19709    }
19710
19711    #[test]
19712    fn ses_template_lifecycle() {
19713        let prov = make_provisioner();
19714        let res = make_resource(
19715            "AWS::SES::Template",
19716            "MyTemplate",
19717            serde_json::json!({
19718                "Template": {
19719                    "TemplateName": "my-tpl",
19720                    "SubjectPart": "Hello",
19721                    "HtmlPart": "<h1>Hi</h1>",
19722                    "TextPart": "Hi",
19723                },
19724            }),
19725        );
19726        let sr = prov.create_resource(&res).unwrap();
19727        assert_eq!(sr.physical_id, "my-tpl");
19728        assert_eq!(
19729            prov.get_att(&sr, "TemplateName"),
19730            Some("my-tpl".to_string())
19731        );
19732
19733        prov.delete_resource(&sr.clone()).unwrap();
19734        let fresh = StackResource {
19735            logical_id: "MyTemplate".to_string(),
19736            physical_id: "my-tpl".to_string(),
19737            resource_type: "AWS::SES::Template".to_string(),
19738            status: "CREATE_COMPLETE".to_string(),
19739            service_token: None,
19740            attributes: BTreeMap::new(),
19741        };
19742        assert_eq!(prov.get_att(&fresh, "TemplateName"), None);
19743    }
19744
19745    #[test]
19746    fn ses_contact_list_lifecycle() {
19747        let prov = make_provisioner();
19748        let res = make_resource(
19749            "AWS::SES::ContactList",
19750            "MyContactList",
19751            serde_json::json!({
19752                "ContactListName": "my-cl",
19753                "Description": "Test contacts",
19754                "Topics": [{"TopicName": "news", "DisplayName": "Newsletter", "Description": "Weekly news"}],
19755            }),
19756        );
19757        let sr = prov.create_resource(&res).unwrap();
19758        assert_eq!(sr.physical_id, "my-cl");
19759        assert_eq!(
19760            prov.get_att(&sr, "ContactListName"),
19761            Some("my-cl".to_string())
19762        );
19763
19764        prov.delete_resource(&sr.clone()).unwrap();
19765        let fresh = StackResource {
19766            logical_id: "MyContactList".to_string(),
19767            physical_id: "my-cl".to_string(),
19768            resource_type: "AWS::SES::ContactList".to_string(),
19769            status: "CREATE_COMPLETE".to_string(),
19770            service_token: None,
19771            attributes: BTreeMap::new(),
19772        };
19773        assert_eq!(prov.get_att(&fresh, "ContactListName"), None);
19774    }
19775
19776    #[test]
19777    fn ses_dedicated_ip_pool_lifecycle() {
19778        let prov = make_provisioner();
19779        let res = make_resource(
19780            "AWS::SES::DedicatedIpPool",
19781            "MyPool",
19782            serde_json::json!({"PoolName": "my-pool", "ScalingMode": "STANDARD"}),
19783        );
19784        let sr = prov.create_resource(&res).unwrap();
19785        assert_eq!(sr.physical_id, "my-pool");
19786        assert_eq!(prov.get_att(&sr, "PoolName"), Some("my-pool".to_string()));
19787
19788        prov.delete_resource(&sr.clone()).unwrap();
19789        let fresh = StackResource {
19790            logical_id: "MyPool".to_string(),
19791            physical_id: "my-pool".to_string(),
19792            resource_type: "AWS::SES::DedicatedIpPool".to_string(),
19793            status: "CREATE_COMPLETE".to_string(),
19794            service_token: None,
19795            attributes: BTreeMap::new(),
19796        };
19797        assert_eq!(prov.get_att(&fresh, "PoolName"), None);
19798    }
19799
19800    #[test]
19801    fn ses_receipt_rule_set_lifecycle() {
19802        let prov = make_provisioner();
19803        let res = make_resource(
19804            "AWS::SES::ReceiptRuleSet",
19805            "MyRuleSet",
19806            serde_json::json!({"RuleSetName": "my-rs"}),
19807        );
19808        let sr = prov.create_resource(&res).unwrap();
19809        assert_eq!(sr.physical_id, "my-rs");
19810        assert_eq!(prov.get_att(&sr, "RuleSetName"), Some("my-rs".to_string()));
19811
19812        prov.delete_resource(&sr.clone()).unwrap();
19813        let fresh = StackResource {
19814            logical_id: "MyRuleSet".to_string(),
19815            physical_id: "my-rs".to_string(),
19816            resource_type: "AWS::SES::ReceiptRuleSet".to_string(),
19817            status: "CREATE_COMPLETE".to_string(),
19818            service_token: None,
19819            attributes: BTreeMap::new(),
19820        };
19821        assert_eq!(prov.get_att(&fresh, "RuleSetName"), None);
19822    }
19823
19824    #[test]
19825    fn ses_receipt_rule_lifecycle() {
19826        let prov = make_provisioner();
19827        let rs = make_resource(
19828            "AWS::SES::ReceiptRuleSet",
19829            "MyRuleSet",
19830            serde_json::json!({"RuleSetName": "my-rs2"}),
19831        );
19832        prov.create_resource(&rs).unwrap();
19833
19834        let res = make_resource(
19835            "AWS::SES::ReceiptRule",
19836            "MyRule",
19837            serde_json::json!({
19838                "RuleSetName": "my-rs2",
19839                "Rule": {
19840                    "Name": "rule1",
19841                    "Priority": 1,
19842                    "Enabled": true,
19843                    "Actions": [{"S3Action": {"BucketName": "my-bucket"}}],
19844                },
19845            }),
19846        );
19847        let sr = prov.create_resource(&res).unwrap();
19848        assert_eq!(sr.physical_id, "my-rs2|rule1");
19849
19850        prov.delete_resource(&sr.clone()).unwrap();
19851    }
19852
19853    #[test]
19854    fn ses_receipt_filter_lifecycle() {
19855        let prov = make_provisioner();
19856        let res = make_resource(
19857            "AWS::SES::ReceiptFilter",
19858            "MyFilter",
19859            serde_json::json!({
19860                "Filter": {
19861                    "Name": "my-filter",
19862                    "IpFilter": {"Policy": "Block", "Cidr": "10.0.0.0/8"},
19863                },
19864            }),
19865        );
19866        let sr = prov.create_resource(&res).unwrap();
19867        assert_eq!(sr.physical_id, "my-filter");
19868
19869        prov.delete_resource(&sr.clone()).unwrap();
19870    }
19871
19872    #[test]
19873    fn ses_vdm_attributes_lifecycle() {
19874        let prov = make_provisioner();
19875        let res = make_resource(
19876            "AWS::SES::VdmAttributes",
19877            "MyVdm",
19878            serde_json::json!({
19879                "DashboardAttributes": {"EngagementMetrics": "ENABLED"},
19880                "GuardianAttributes": {"OptimizedSharedDelivery": "ENABLED"},
19881            }),
19882        );
19883        let sr = prov.create_resource(&res).unwrap();
19884        assert_eq!(sr.physical_id, "vdm-MyVdm");
19885
19886        prov.delete_resource(&sr.clone()).unwrap();
19887    }
19888
19889    #[test]
19890    fn athena_work_group_lifecycle() {
19891        let prov = make_provisioner();
19892        let res = make_resource(
19893            "AWS::Athena::WorkGroup",
19894            "MyWg",
19895            serde_json::json!({
19896                "Name": "my-wg",
19897                "Description": "test wg",
19898                "Configuration": {"EnforceWorkGroupConfiguration": true},
19899            }),
19900        );
19901        let sr = prov.create_resource(&res).unwrap();
19902        assert_eq!(sr.physical_id, "my-wg");
19903        assert_eq!(sr.attributes.get("Name"), Some(&"my-wg".to_string()));
19904        assert!(sr
19905            .attributes
19906            .get("Arn")
19907            .unwrap()
19908            .contains("workgroup/my-wg"));
19909
19910        assert_eq!(
19911            prov.get_att(
19912                &StackResource {
19913                    resource_type: "AWS::Athena::WorkGroup".to_string(),
19914                    physical_id: sr.physical_id.clone(),
19915                    logical_id: "MyWg".to_string(),
19916                    status: "CREATE_COMPLETE".to_string(),
19917                    service_token: None,
19918                    attributes: BTreeMap::new(),
19919                },
19920                "Name",
19921            ),
19922            Some("my-wg".to_string()),
19923        );
19924
19925        prov.delete_resource(&sr.clone()).unwrap();
19926    }
19927
19928    #[test]
19929    fn athena_data_catalog_lifecycle() {
19930        let prov = make_provisioner();
19931        let res = make_resource(
19932            "AWS::Athena::DataCatalog",
19933            "MyCatalog",
19934            serde_json::json!({
19935                "Name": "my-catalog",
19936                "Type": "GLUE",
19937                "Description": "test catalog",
19938            }),
19939        );
19940        let sr = prov.create_resource(&res).unwrap();
19941        assert_eq!(sr.physical_id, "my-catalog");
19942        assert_eq!(sr.attributes.get("Name"), Some(&"my-catalog".to_string()));
19943        assert!(sr
19944            .attributes
19945            .get("Arn")
19946            .unwrap()
19947            .contains("datacatalog/my-catalog"));
19948
19949        prov.delete_resource(&sr.clone()).unwrap();
19950    }
19951
19952    #[test]
19953    fn athena_named_query_lifecycle() {
19954        let prov = make_provisioner();
19955        let res = make_resource(
19956            "AWS::Athena::NamedQuery",
19957            "MyQuery",
19958            serde_json::json!({
19959                "Name": "my-query",
19960                "Database": "mydb",
19961                "QueryString": "SELECT 1",
19962                "WorkGroup": "primary",
19963            }),
19964        );
19965        let sr = prov.create_resource(&res).unwrap();
19966        assert!(!sr.physical_id.is_empty());
19967        assert_eq!(sr.attributes.get("NamedQueryId"), Some(&sr.physical_id));
19968
19969        prov.delete_resource(&sr.clone()).unwrap();
19970    }
19971
19972    #[test]
19973    fn athena_prepared_statement_lifecycle() {
19974        let prov = make_provisioner();
19975        let res = make_resource(
19976            "AWS::Athena::PreparedStatement",
19977            "MyPs",
19978            serde_json::json!({
19979                "StatementName": "my-ps",
19980                "WorkGroupName": "primary",
19981                "QueryStatement": "SELECT 1",
19982            }),
19983        );
19984        let sr = prov.create_resource(&res).unwrap();
19985        assert_eq!(sr.physical_id, "primary|my-ps");
19986
19987        prov.delete_resource(&sr.clone()).unwrap();
19988    }
19989}