Skip to content

Commit 7876c08

Browse files
author
Benjamin Pace
committed
fix(cli-internal): harden drift detection against false negatives from CR review
Safety-critical (false negatives in rollback gate): 1. CloudFormation::Stack changes without ChangeSetId tracked as incomplete 2. Recoverable failures with empty Changes return skipped:true 3. DELETE_COMPLETE/DELETE_FAILED nested changesets tracked as incomplete Robustness: 4. extractStackNameFromArn in catch block wrapped in try-catch 5. validateLockRollbackDrift catch block properly typed 6. DELETE_FAILED added to terminal statuses for polling
1 parent aa2522e commit 7876c08

File tree

2 files changed

+56
-13
lines changed

2 files changed

+56
-13
lines changed

packages/amplify-cli/src/commands/drift-detection/detect-template-drift.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,17 @@ async function analyzeChangeSet(
261261
return result;
262262
}
263263

264-
// EarlyValidation failures still have Changes populated — fall through to process them
264+
// Recoverable failures (EarlyValidation, Template format errors) still have Changes populated.
265+
// However, if Changes is empty despite the recoverable classification, treat as incomplete.
265266
if (isRecoverableFailure(changeSet.StatusReason)) {
267+
if (!changeSet.Changes || changeSet.Changes.length === 0) {
268+
print.warn(`Recoverable failure but no Changes populated: ${changeSet.StatusReason}`);
269+
return {
270+
changes: [],
271+
skipped: true,
272+
skipReason: `Changeset failed with no usable changes: ${changeSet.StatusReason}`,
273+
};
274+
}
266275
print.warn(`Nested changeset FAILED (recoverable): ${changeSet.StatusReason}`);
267276
} else if (isRoot) {
268277
// Root changeset FAILED because a nested changeset failed. The root's StatusReason
@@ -299,6 +308,16 @@ async function analyzeChangeSet(
299308
const changeInfo: ResourceChangeWithNested = { ...rc };
300309

301310
// Check if this is a nested stack with its own changeset
311+
if (rc.ResourceType === 'AWS::CloudFormation::Stack' && !rc.ChangeSetId) {
312+
// Stack change without a nested changeset — can't inspect its contents.
313+
// This happens when IncludeNestedStacks creates the root changeset but the
314+
// nested changeset failed before being created. Track as incomplete.
315+
const stackLabel = rc.LogicalResourceId || rc.PhysicalResourceId || 'unknown';
316+
print.warn(`Nested stack ${stackLabel} has no changeset — tracking as incomplete`);
317+
skippedStacks.push(stackLabel);
318+
result.changes.push(changeInfo);
319+
continue;
320+
}
302321
if (rc.ResourceType === 'AWS::CloudFormation::Stack' && rc.ChangeSetId && rc.PhysicalResourceId) {
303322
try {
304323
// Extract stack name and changeset name from ARNs using parseArn utility
@@ -317,25 +336,34 @@ async function analyzeChangeSet(
317336
ChangeSetName: changeSetName,
318337
}),
319338
);
320-
const terminalStatuses = ['CREATE_COMPLETE', 'FAILED', 'DELETE_COMPLETE'];
321-
const maxPollAttempts = 30;
322-
for (let attempt = 0; !terminalStatuses.includes(nestedChangeSet.Status!) && attempt < maxPollAttempts; attempt++) {
339+
const terminalStatuses = ['CREATE_COMPLETE', 'FAILED', 'DELETE_COMPLETE', 'DELETE_FAILED'];
340+
const NESTED_POLL_MAX_ATTEMPTS = 30;
341+
const NESTED_POLL_INTERVAL_MS = 2000;
342+
for (let attempt = 0; !terminalStatuses.includes(nestedChangeSet.Status ?? '') && attempt < NESTED_POLL_MAX_ATTEMPTS; attempt++) {
323343
print.debug(`Nested changeset ${stackName} is ${nestedChangeSet.Status}, waiting...`);
324-
await new Promise((resolve) => setTimeout(resolve, 2000));
344+
await new Promise((resolve) => setTimeout(resolve, NESTED_POLL_INTERVAL_MS));
325345
nestedChangeSet = await cfn.send(
326346
new DescribeChangeSetCommand({
327347
StackName: stackName,
328348
ChangeSetName: changeSetName,
329349
}),
330350
);
331351
}
332-
if (!terminalStatuses.includes(nestedChangeSet.Status!)) {
352+
if (!terminalStatuses.includes(nestedChangeSet.Status ?? '')) {
333353
print.warn(`Nested changeset ${stackName} did not reach terminal status (${nestedChangeSet.Status})`);
334354
skippedStacks.push(stackName);
335355
result.changes.push(changeInfo);
336356
continue;
337357
}
338358

359+
// Deleted or failed-to-delete changesets can't be analyzed — track as incomplete
360+
if (nestedChangeSet.Status === 'DELETE_COMPLETE' || nestedChangeSet.Status === 'DELETE_FAILED') {
361+
print.warn(`Nested changeset ${stackName} was deleted (${nestedChangeSet.Status}) — tracking as incomplete`);
362+
skippedStacks.push(stackName);
363+
result.changes.push(changeInfo);
364+
continue;
365+
}
366+
339367
// Print nested changeset details
340368
if (nestedChangeSet.Changes && nestedChangeSet.Changes.length > 0) {
341369
print.debug(`Nested Stack: ${stackName}`);
@@ -371,11 +399,16 @@ async function analyzeChangeSet(
371399
print.debug(`Processed ${nestedResult.changes.length} nested changes`);
372400
}
373401
} catch (error: any) {
374-
// Log error and track as incomplete
402+
// Log error and track as incomplete. Use LogicalResourceId as fallback
403+
// since extractStackNameFromArn could throw on malformed ARNs.
375404
print.warn(`⚠ Could not fetch nested changeset for ${rc.LogicalResourceId}: ${error.message}`);
376405
print.debug(`Stack ARN: ${rc.PhysicalResourceId}`);
377406
print.debug(`ChangeSet ID: ${rc.ChangeSetId}`);
378-
skippedStacks.push(extractStackNameFromArn(rc.PhysicalResourceId));
407+
try {
408+
skippedStacks.push(extractStackNameFromArn(rc.PhysicalResourceId));
409+
} catch {
410+
skippedStacks.push(rc.LogicalResourceId || 'unknown');
411+
}
379412
}
380413
}
381414

packages/amplify-cli/src/commands/gen2-migration/lock.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,20 @@ const isExpectedLockDrift = (change: ResourceChangeWithNested): boolean => {
6969
if (change.Scope?.length === 1 && change.Scope[0] === 'DeletionPolicy') return true;
7070

7171
// Cascading IAM Policy change caused by DeletionPolicy modification on a referenced resource.
72-
// All Details must be Dynamic ResourceAttribute re-evaluations (no static/direct changes).
73-
if (change.ResourceType === 'AWS::IAM::Policy' && change.Details?.length) {
72+
// Must be: Properties-only scope, all Details are Dynamic ResourceAttribute re-evaluations
73+
// with CausingEntity referencing a table attribute (e.g., TodoTable.Arn, TodoTable.StreamArn).
74+
if (
75+
change.ResourceType === 'AWS::IAM::Policy' &&
76+
change.Scope?.length === 1 &&
77+
change.Scope[0] === 'Properties' &&
78+
change.Details?.length
79+
) {
7480
return change.Details.every(
75-
(d) => d.ChangeSource === 'ResourceAttribute' && d.Evaluation === 'Dynamic' && d.Target?.RequiresRecreation === 'Never',
81+
(d) =>
82+
d.ChangeSource === 'ResourceAttribute' &&
83+
d.Evaluation === 'Dynamic' &&
84+
d.Target?.RequiresRecreation === 'Never' &&
85+
/Table\.(Arn|StreamArn)$/.test(d.CausingEntity ?? ''),
7686
);
7787
}
7888

@@ -310,8 +320,8 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep {
310320
}
311321

312322
return { valid: true };
313-
} catch (e) {
314-
return { valid: false, report: e.message };
323+
} catch (e: any) {
324+
return { valid: false, report: e?.message ?? String(e) };
315325
}
316326
}
317327

0 commit comments

Comments
 (0)