Skip to content

Commit 23ba989

Browse files
authored
fix(cloudformation): apply in-place UpdateStack changes for common resource types (bug-hunt) (#2313)
2 parents 69a48fe + 1c73a95 commit 23ba989

12 files changed

Lines changed: 1079 additions & 1 deletion

File tree

crates/fakecloud-cloudformation/src/resource_provisioner/cognito.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,73 @@ impl ResourceProvisioner {
140140
.with("UserPoolId", pool_id))
141141
}
142142

143+
/// Apply a CFN property update to an existing Cognito user pool in place.
144+
/// Mirrors the property extraction in `create_cognito_user_pool` for the
145+
/// fields that update without replacement (policies, MFA, tier, deletion
146+
/// protection, tags, email/SMS config, admin-create config, recovery
147+
/// settings, auto-verified attributes) so a stack update reaches the pool
148+
/// and `DescribeUserPool` reflects the new config instead of the stale one.
149+
/// The pool id/ARN, creation date and signing keys are preserved.
150+
pub(super) fn update_cognito_user_pool(
151+
&self,
152+
existing: &StackResource,
153+
resource: &ResourceDefinition,
154+
) -> Result<ProvisionResult, String> {
155+
let props = &resource.properties;
156+
let pool_id = &existing.physical_id;
157+
158+
let mut accounts = self.cognito_state.write();
159+
let state = accounts.get_or_create(&self.account_id);
160+
let pool = state
161+
.user_pools
162+
.get_mut(pool_id)
163+
.ok_or_else(|| format!("User pool {pool_id} not yet provisioned"))?;
164+
165+
if let Some(pool_name) = props.get("PoolName").and_then(|v| v.as_str()) {
166+
pool.name = pool_name.to_string();
167+
}
168+
pool.policies.password_policy = parse_cognito_password_policy(props.get("Policies"));
169+
pool.auto_verified_attributes =
170+
parse_cognito_string_array(props.get("AutoVerifiedAttributes"));
171+
if let Some(mfa) = props.get("MfaConfiguration").and_then(|v| v.as_str()) {
172+
pool.mfa_configuration = mfa.to_string();
173+
}
174+
if let Some(tier) = props.get("UserPoolTier").and_then(|v| v.as_str()) {
175+
pool.user_pool_tier = tier.to_string();
176+
}
177+
if let Some(dp) = props.get("DeletionProtection").and_then(|v| v.as_str()) {
178+
pool.deletion_protection = Some(dp.to_string());
179+
}
180+
if props.get("UserPoolTags").is_some() {
181+
pool.user_pool_tags = parse_cognito_tags(props.get("UserPoolTags"));
182+
}
183+
if props.get("EmailConfiguration").is_some() {
184+
pool.email_configuration =
185+
parse_cognito_email_configuration(props.get("EmailConfiguration"));
186+
}
187+
if props.get("SmsConfiguration").is_some() {
188+
pool.sms_configuration = parse_cognito_sms_configuration(props.get("SmsConfiguration"));
189+
}
190+
if props.get("AdminCreateUserConfig").is_some() {
191+
pool.admin_create_user_config =
192+
parse_cognito_admin_create_user_config(props.get("AdminCreateUserConfig"));
193+
}
194+
if props.get("AccountRecoverySetting").is_some() {
195+
pool.account_recovery_setting =
196+
parse_cognito_account_recovery(props.get("AccountRecoverySetting"));
197+
}
198+
pool.last_modified_date = Utc::now();
199+
200+
let arn = pool.arn.clone();
201+
let provider_name = format!("cognito-idp.{}.amazonaws.com/{}", self.region, pool_id);
202+
let provider_url = format!("https://{provider_name}");
203+
Ok(ProvisionResult::new(pool_id.clone())
204+
.with("Arn", arn)
205+
.with("ProviderName", provider_name)
206+
.with("ProviderURL", provider_url)
207+
.with("UserPoolId", pool_id.clone()))
208+
}
209+
143210
pub(super) fn delete_cognito_user_pool(&self, physical_id: &str) -> Result<(), String> {
144211
let mut accounts = self.cognito_state.write();
145212
let state = accounts.get_or_create(&self.account_id);
@@ -278,6 +345,83 @@ impl ResourceProvisioner {
278345
Ok(result)
279346
}
280347

348+
/// Apply a CFN property update to an existing Cognito user pool client in
349+
/// place. Mirrors the property extraction in `create_cognito_user_pool_client`
350+
/// for the fields that update without replacement (auth flows, token
351+
/// validity, callback/logout URLs, OAuth config, read/write attributes,
352+
/// etc.) so a stack update reaches the client and `DescribeUserPoolClient`
353+
/// reflects the new config. The client id/secret, pool id and creation date
354+
/// are preserved (`GenerateSecret` is create-only).
355+
pub(super) fn update_cognito_user_pool_client(
356+
&self,
357+
existing: &StackResource,
358+
resource: &ResourceDefinition,
359+
) -> Result<ProvisionResult, String> {
360+
let props = &resource.properties;
361+
let client_id = &existing.physical_id;
362+
363+
let mut accounts = self.cognito_state.write();
364+
let state = accounts.get_or_create(&self.account_id);
365+
let client = state
366+
.user_pool_clients
367+
.get_mut(client_id)
368+
.ok_or_else(|| format!("User pool client {client_id} not yet provisioned"))?;
369+
370+
if let Some(name) = props.get("ClientName").and_then(|v| v.as_str()) {
371+
client.client_name = name.to_string();
372+
}
373+
client.explicit_auth_flows = parse_cognito_string_array(props.get("ExplicitAuthFlows"));
374+
if let Some(v) = props.get("AccessTokenValidity").and_then(|v| v.as_i64()) {
375+
client.access_token_validity = Some(v);
376+
}
377+
if let Some(v) = props.get("IdTokenValidity").and_then(|v| v.as_i64()) {
378+
client.id_token_validity = Some(v);
379+
}
380+
if let Some(v) = props.get("RefreshTokenValidity").and_then(|v| v.as_i64()) {
381+
client.refresh_token_validity = Some(v);
382+
}
383+
client.callback_urls = parse_cognito_string_array(props.get("CallbackURLs"));
384+
client.logout_urls = parse_cognito_string_array(props.get("LogoutURLs"));
385+
client.supported_identity_providers =
386+
parse_cognito_string_array(props.get("SupportedIdentityProviders"));
387+
client.allowed_o_auth_flows = parse_cognito_string_array(props.get("AllowedOAuthFlows"));
388+
client.allowed_o_auth_scopes = parse_cognito_string_array(props.get("AllowedOAuthScopes"));
389+
if let Some(b) = props
390+
.get("AllowedOAuthFlowsUserPoolClient")
391+
.and_then(|v| v.as_bool())
392+
{
393+
client.allowed_o_auth_flows_user_pool_client = b;
394+
}
395+
if let Some(s) = props
396+
.get("PreventUserExistenceErrors")
397+
.and_then(|v| v.as_str())
398+
{
399+
client.prevent_user_existence_errors = Some(s.to_string());
400+
}
401+
client.read_attributes = parse_cognito_string_array(props.get("ReadAttributes"));
402+
client.write_attributes = parse_cognito_string_array(props.get("WriteAttributes"));
403+
if let Some(b) = props.get("EnableTokenRevocation").and_then(|v| v.as_bool()) {
404+
client.enable_token_revocation = b;
405+
}
406+
if let Some(v) = props.get("AuthSessionValidity").and_then(|v| v.as_i64()) {
407+
client.auth_session_validity = Some(v);
408+
}
409+
if let Some(b) = props
410+
.get("EnablePropagateAdditionalUserContextData")
411+
.and_then(|v| v.as_bool())
412+
{
413+
client.enable_propagate_additional_user_context_data = b;
414+
}
415+
if let Some(v) = props.get("AnalyticsConfiguration") {
416+
client.analytics_configuration = if v.is_null() { None } else { Some(v.clone()) };
417+
}
418+
client.last_modified_date = Utc::now();
419+
420+
Ok(ProvisionResult::new(client_id.clone())
421+
.with("ClientId", client_id.clone())
422+
.with("Name", client_id.clone()))
423+
}
424+
281425
pub(super) fn delete_cognito_user_pool_client(&self, physical_id: &str) -> Result<(), String> {
282426
let mut accounts = self.cognito_state.write();
283427
let state = accounts.get_or_create(&self.account_id);

crates/fakecloud-cloudformation/src/resource_provisioner/cwlogs.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,48 @@ impl ResourceProvisioner {
5050
Ok(ProvisionResult::new(arn.clone()).with("Arn", arn))
5151
}
5252

53+
/// Apply a CFN property update to an existing CloudWatch Logs log group in
54+
/// place. `RetentionInDays` and `KmsKeyId` are updatable-without-replacement
55+
/// in real CloudFormation, so a stack update must reach the log group and be
56+
/// reflected by `DescribeLogGroups` instead of being silently dropped.
57+
/// Omitting `RetentionInDays` clears retention (never expire), matching the
58+
/// direct `DeleteRetentionPolicy` semantics CFN applies when the property
59+
/// is removed.
60+
pub(crate) fn update_log_group(
61+
&self,
62+
existing: &StackResource,
63+
resource: &ResourceDefinition,
64+
) -> Result<ProvisionResult, String> {
65+
let props = &resource.properties;
66+
let arn = &existing.physical_id;
67+
68+
let retention_in_days = props
69+
.get("RetentionInDays")
70+
.and_then(|v| v.as_i64())
71+
.map(|v| v as i32);
72+
let kms_key_id = props
73+
.get("KmsKeyId")
74+
.and_then(|v| v.as_str())
75+
.map(String::from);
76+
77+
let mut logs_accounts = self.logs_state.write();
78+
let state = logs_accounts.get_or_create(&self.account_id);
79+
let group = state
80+
.log_groups
81+
.values_mut()
82+
.find(|g| g.arn == *arn)
83+
.ok_or_else(|| format!("Log group {arn} not yet provisioned"))?;
84+
group.retention_in_days = retention_in_days;
85+
if kms_key_id.is_some() {
86+
group.kms_key_id = kms_key_id;
87+
}
88+
if let Some(class) = props.get("LogGroupClass").and_then(|v| v.as_str()) {
89+
group.log_group_class = Some(class.to_string());
90+
}
91+
92+
Ok(ProvisionResult::new(arn.clone()).with("Arn", arn.clone()))
93+
}
94+
5395
/// Look up an S3 object's bytes from the in-process S3 state. Used by
5496
/// the Lambda function provisioner to hydrate `Code.S3Bucket` /
5597
/// `Code.S3Key` references into real ZIP content. Returns an error

crates/fakecloud-cloudformation/src/resource_provisioner/dynamodb.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,107 @@ impl ResourceProvisioner {
239239
Ok(result)
240240
}
241241

242+
/// Apply a CFN property update to an existing DynamoDB table in place.
243+
/// `BillingMode`, `ProvisionedThroughput`, `GlobalSecondaryIndexes`,
244+
/// `OnDemandThroughput`, `DeletionProtectionEnabled`, `TableClass` and
245+
/// `SSESpecification` are all update-without-replacement in real
246+
/// CloudFormation, so a stack update must reach the table and be reflected
247+
/// by `DescribeTable` instead of being silently dropped. Key schema and
248+
/// attribute definitions are replacement-only and left untouched here.
249+
pub(super) fn update_dynamodb_table(
250+
&self,
251+
existing: &StackResource,
252+
resource: &ResourceDefinition,
253+
) -> Result<ProvisionResult, String> {
254+
let props = &resource.properties;
255+
let arn = &existing.physical_id;
256+
257+
let mut __ddb_mas = self.dynamodb_state.write();
258+
let state = __ddb_mas.get_or_create(&self.account_id);
259+
let table = state
260+
.tables
261+
.values_mut()
262+
.find(|t| &t.arn == arn)
263+
.ok_or_else(|| format!("DynamoDB table {arn} not yet provisioned"))?;
264+
265+
if let Some(billing_mode) = props.get("BillingMode").and_then(|v| v.as_str()) {
266+
table.billing_mode = billing_mode.to_string();
267+
}
268+
// Provisioned throughput only applies under PROVISIONED billing; when
269+
// switching to PAY_PER_REQUEST the units go to zero, matching Describe.
270+
if table.billing_mode == "PROVISIONED" {
271+
if let Some(pt) = props.get("ProvisionedThroughput") {
272+
table.provisioned_throughput = ProvisionedThroughput {
273+
read_capacity_units: pt
274+
.get("ReadCapacityUnits")
275+
.and_then(|v| v.as_i64())
276+
.unwrap_or(table.provisioned_throughput.read_capacity_units),
277+
write_capacity_units: pt
278+
.get("WriteCapacityUnits")
279+
.and_then(|v| v.as_i64())
280+
.unwrap_or(table.provisioned_throughput.write_capacity_units),
281+
};
282+
}
283+
} else {
284+
table.provisioned_throughput = ProvisionedThroughput {
285+
read_capacity_units: 0,
286+
write_capacity_units: 0,
287+
};
288+
}
289+
if let Some(gsi) = props.get("GlobalSecondaryIndexes") {
290+
table.gsi = fakecloud_dynamodb::parse_gsi(gsi, &table.billing_mode);
291+
}
292+
if let Some(odt) = props.get("OnDemandThroughput") {
293+
table.on_demand_throughput = Some(OnDemandThroughput {
294+
max_read_request_units: odt
295+
.get("MaxReadRequestUnits")
296+
.and_then(|v| v.as_i64())
297+
.unwrap_or(-1),
298+
max_write_request_units: odt
299+
.get("MaxWriteRequestUnits")
300+
.and_then(|v| v.as_i64())
301+
.unwrap_or(-1),
302+
});
303+
}
304+
if let Some(dp) = props
305+
.get("DeletionProtectionEnabled")
306+
.and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true")))
307+
{
308+
table.deletion_protection_enabled = dp;
309+
}
310+
if let Some(class) = props.get("TableClass").and_then(|v| v.as_str()) {
311+
table.table_class = class.to_string();
312+
}
313+
if let Some(sse_spec) = props.get("SSESpecification") {
314+
let enabled = sse_spec
315+
.get("SSEEnabled")
316+
.and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true")))
317+
.unwrap_or(false);
318+
if enabled {
319+
table.sse_type = Some(
320+
sse_spec
321+
.get("SSEType")
322+
.and_then(|v| v.as_str())
323+
.unwrap_or("KMS")
324+
.to_string(),
325+
);
326+
table.sse_kms_key_arn = sse_spec
327+
.get("KMSMasterKeyId")
328+
.and_then(|v| v.as_str())
329+
.map(|s| s.to_string());
330+
} else {
331+
table.sse_type = None;
332+
table.sse_kms_key_arn = None;
333+
}
334+
}
335+
336+
let mut result = ProvisionResult::new(arn.clone()).with("Arn", arn.clone());
337+
if let Some(stream_arn) = table.stream_arn.clone() {
338+
result = result.with("StreamArn", stream_arn);
339+
}
340+
Ok(result)
341+
}
342+
242343
pub(super) fn delete_dynamodb_table(&self, physical_id: &str) -> Result<(), String> {
243344
let mut __ddb_mas = self.dynamodb_state.write();
244345
let state = __ddb_mas.get_or_create(&self.account_id);

crates/fakecloud-cloudformation/src/resource_provisioner/eventbridge.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,67 @@ impl ResourceProvisioner {
9696
Ok(ProvisionResult::new(arn.clone()).with("Arn", arn))
9797
}
9898

99+
/// Apply a CFN property update to an existing EventBridge rule in place.
100+
/// Mirrors the property extraction in `create_eventbridge_rule` so a stack
101+
/// update re-applies ScheduleExpression / EventPattern / State / Targets /
102+
/// Description / RoleArn — all update-without-replacement in real
103+
/// CloudFormation — instead of being silently dropped. Without this a
104+
/// `cdk deploy` that changes a schedule or its targets reports success but
105+
/// the rule keeps firing on the stale schedule into the old targets.
106+
pub(super) fn update_eventbridge_rule(
107+
&self,
108+
existing: &StackResource,
109+
resource: &ResourceDefinition,
110+
) -> Result<ProvisionResult, String> {
111+
let props = &resource.properties;
112+
let arn = &existing.physical_id;
113+
114+
let mut eb_accounts = self.eventbridge_state.write();
115+
let state = eb_accounts.get_or_create(&self.account_id);
116+
let rule = state
117+
.rules
118+
.values_mut()
119+
.find(|r| &r.arn == arn)
120+
.ok_or_else(|| format!("EventBridge rule {arn} not yet provisioned"))?;
121+
122+
rule.event_pattern = props.get("EventPattern").map(|v| {
123+
if v.is_string() {
124+
v.as_str().unwrap_or_default().to_string()
125+
} else {
126+
serde_json::to_string(v).unwrap_or_default()
127+
}
128+
});
129+
rule.schedule_expression = props
130+
.get("ScheduleExpression")
131+
.and_then(|v| v.as_str())
132+
.map(|s| s.to_string());
133+
rule.state = props
134+
.get("State")
135+
.and_then(|v| v.as_str())
136+
.unwrap_or("ENABLED")
137+
.to_string();
138+
rule.description = props
139+
.get("Description")
140+
.and_then(|v| v.as_str())
141+
.map(|s| s.to_string());
142+
rule.role_arn = props
143+
.get("RoleArn")
144+
.and_then(|v| v.as_str())
145+
.map(|s| s.to_string());
146+
rule.targets = props
147+
.get("Targets")
148+
.and_then(|v| v.as_array())
149+
.map(|arr| {
150+
arr.iter()
151+
.filter(|t| t.get("Arn").and_then(|v| v.as_str()).is_some())
152+
.map(fakecloud_eventbridge::parse_target)
153+
.collect()
154+
})
155+
.unwrap_or_default();
156+
157+
Ok(ProvisionResult::new(arn.clone()).with("Arn", arn.clone()))
158+
}
159+
99160
pub(super) fn delete_eventbridge_rule(&self, physical_id: &str) -> Result<(), String> {
100161
let mut eb_accounts = self.eventbridge_state.write();
101162
let state = eb_accounts.default_mut();

0 commit comments

Comments
 (0)