Skip to content

Commit 3dfa0b2

Browse files
committed
fix(cli-internal): handle root changeset failures, template format errors, and cascading IAM drift
Three fixes discovered during E2E lock + rollback testing: 1. Root changeset fall-through: Root changeset FAILED status references nested failures without containing "EarlyValidation". Add isRoot parameter so root always falls through to classify nested changesets individually. 2. Template format error as recoverable: IncludeNestedStacks fails on Amplify AppSync API stacks where model sub-stacks export names via Fn::Join. CFN reports "duplicate Export names". Changes are still populated — treat as recoverable like EarlyValidation. 3. Cascading IAM Policy changes: When DeletionPolicy changes on a DynamoDB table, CFN flags IAM policies referencing TableName.Arn as Dynamic ResourceAttribute re-evaluations. These are harmless and must be filtered alongside direct DeletionPolicy drift in lock rollback validation.
1 parent 15975c5 commit 3dfa0b2

3 files changed

Lines changed: 203 additions & 34 deletions

File tree

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

Lines changed: 157 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -535,18 +535,22 @@ describe('AmplifyMigrationLockStep', () => {
535535

536536
it('should fail validation when nested changes contain real drift at leaf level', async () => {
537537
mockDetectTemplateDrift.mockResolvedValueOnce({
538-
changes: [{
539-
Action: 'Modify',
540-
LogicalResourceId: 'authStack',
541-
ResourceType: 'AWS::CloudFormation::Stack',
542-
Scope: ['Properties'],
543-
nestedChanges: [{
538+
changes: [
539+
{
544540
Action: 'Modify',
545-
LogicalResourceId: 'UserPool',
546-
ResourceType: 'AWS::Cognito::UserPool',
541+
LogicalResourceId: 'authStack',
542+
ResourceType: 'AWS::CloudFormation::Stack',
547543
Scope: ['Properties'],
548-
}],
549-
}],
544+
nestedChanges: [
545+
{
546+
Action: 'Modify',
547+
LogicalResourceId: 'UserPool',
548+
ResourceType: 'AWS::Cognito::UserPool',
549+
Scope: ['Properties'],
550+
},
551+
],
552+
},
553+
],
550554
skipped: false,
551555
});
552556

@@ -569,21 +573,157 @@ describe('AmplifyMigrationLockStep', () => {
569573
expect(valid).toBe(false);
570574
});
571575

576+
it('should pass validation when only cascading IAM Policy drift from DeletionPolicy change exists', async () => {
577+
mockDetectTemplateDrift.mockResolvedValueOnce({
578+
changes: [
579+
{
580+
Action: 'Modify',
581+
LogicalResourceId: 'TodoTable',
582+
ResourceType: 'AWS::DynamoDB::Table',
583+
Scope: ['DeletionPolicy'],
584+
Replacement: 'False',
585+
},
586+
{
587+
Action: 'Modify',
588+
LogicalResourceId: 'TodoIAMRoleDefaultPolicy7BBBF45B',
589+
ResourceType: 'AWS::IAM::Policy',
590+
Scope: ['Properties'],
591+
Details: [
592+
{
593+
ChangeSource: 'ResourceAttribute',
594+
Evaluation: 'Dynamic',
595+
Target: { Attribute: 'Properties', Name: 'PolicyDocument', RequiresRecreation: 'Never' },
596+
CausingEntity: 'TodoTable.Arn',
597+
},
598+
],
599+
},
600+
],
601+
skipped: false,
602+
});
603+
604+
const plan = await lockStep.rollback();
605+
const valid = await plan.validate();
606+
607+
expect(valid).toBe(true);
608+
});
609+
610+
it('should fail validation when IAM Policy has a static/direct change mixed with dynamic', async () => {
611+
mockDetectTemplateDrift.mockResolvedValueOnce({
612+
changes: [
613+
{
614+
Action: 'Modify',
615+
LogicalResourceId: 'TodoIAMRoleDefaultPolicy7BBBF45B',
616+
ResourceType: 'AWS::IAM::Policy',
617+
Scope: ['Properties'],
618+
Details: [
619+
{
620+
ChangeSource: 'ResourceAttribute',
621+
Evaluation: 'Dynamic',
622+
Target: { Attribute: 'Properties', Name: 'PolicyDocument', RequiresRecreation: 'Never' },
623+
CausingEntity: 'TodoTable.Arn',
624+
},
625+
{
626+
ChangeSource: 'DirectModification',
627+
Evaluation: 'Static',
628+
Target: { Attribute: 'Properties', Name: 'PolicyDocument', RequiresRecreation: 'Never' },
629+
},
630+
],
631+
},
632+
],
633+
skipped: false,
634+
});
635+
636+
const plan = await lockStep.rollback();
637+
const valid = await plan.validate();
638+
639+
expect(valid).toBe(false);
640+
});
641+
642+
it('should fail validation when IAM Policy change requires recreation', async () => {
643+
mockDetectTemplateDrift.mockResolvedValueOnce({
644+
changes: [
645+
{
646+
Action: 'Modify',
647+
LogicalResourceId: 'SomePolicy',
648+
ResourceType: 'AWS::IAM::Policy',
649+
Scope: ['Properties'],
650+
Details: [
651+
{
652+
ChangeSource: 'ResourceAttribute',
653+
Evaluation: 'Dynamic',
654+
Target: { Attribute: 'Properties', Name: 'PolicyDocument', RequiresRecreation: 'Conditionally' },
655+
CausingEntity: 'TodoTable.Arn',
656+
},
657+
],
658+
},
659+
],
660+
skipped: false,
661+
});
662+
663+
const plan = await lockStep.rollback();
664+
const valid = await plan.validate();
665+
666+
expect(valid).toBe(false);
667+
});
668+
669+
it('should pass validation when nested tree has only DeletionPolicy and cascading IAM drift', async () => {
670+
mockDetectTemplateDrift.mockResolvedValueOnce({
671+
changes: [
672+
{
673+
Action: 'Modify',
674+
LogicalResourceId: 'apiStack',
675+
ResourceType: 'AWS::CloudFormation::Stack',
676+
Scope: ['Properties'],
677+
nestedChanges: [
678+
{
679+
Action: 'Modify',
680+
LogicalResourceId: 'TodoTable',
681+
ResourceType: 'AWS::DynamoDB::Table',
682+
Scope: ['DeletionPolicy'],
683+
},
684+
{
685+
Action: 'Modify',
686+
LogicalResourceId: 'TodoIAMRoleDefaultPolicy',
687+
ResourceType: 'AWS::IAM::Policy',
688+
Scope: ['Properties'],
689+
Details: [
690+
{
691+
ChangeSource: 'ResourceAttribute',
692+
Evaluation: 'Dynamic',
693+
Target: { Attribute: 'Properties', RequiresRecreation: 'Never' },
694+
CausingEntity: 'TodoTable.Arn',
695+
},
696+
],
697+
},
698+
],
699+
},
700+
],
701+
skipped: false,
702+
});
703+
704+
const plan = await lockStep.rollback();
705+
const valid = await plan.validate();
706+
707+
expect(valid).toBe(true);
708+
});
709+
572710
it('should pass validation when CloudFormation::Stack wrapper has no nestedChanges', async () => {
573711
mockDetectTemplateDrift.mockResolvedValueOnce({
574-
changes: [{
575-
Action: 'Modify',
576-
LogicalResourceId: 'apiStack',
577-
ResourceType: 'AWS::CloudFormation::Stack',
578-
Scope: ['Properties'],
579-
}],
712+
changes: [
713+
{
714+
Action: 'Modify',
715+
LogicalResourceId: 'apiStack',
716+
ResourceType: 'AWS::CloudFormation::Stack',
717+
Scope: ['Properties'],
718+
},
719+
],
580720
skipped: false,
581721
});
582722

583723
const plan = await lockStep.rollback();
584724
const valid = await plan.validate();
585725

586-
expect(valid).toBe(true)
726+
expect(valid).toBe(true);
587727
});
588728
});
589729
});

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

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,15 @@ function extractChangeSetNameFromArn(changeSetArn: string): string {
4848
return resource.split('/')[1];
4949
}
5050

51-
function isEarlyValidationFailure(reason?: string): boolean {
52-
return !!reason?.includes('EarlyValidation');
51+
function isRecoverableFailure(reason?: string): boolean {
52+
if (!reason) return false;
53+
// EarlyValidation failures (e.g., ResourceExistenceCheck) — Changes are populated
54+
if (reason.includes('EarlyValidation')) return true;
55+
// Template format errors (e.g., duplicate Export names from Fn::Join in nested stacks) —
56+
// Changes are partially populated. CFN validates exports after building the change list,
57+
// so some changes are available even though the changeset ultimately failed.
58+
if (reason.includes('Template format error')) return true;
59+
return false;
5360
}
5461

5562
const CHANGESET_PREFIX = 'amplify-drift-detection-';
@@ -220,7 +227,7 @@ export async function detectTemplateDrift(
220227
}
221228

222229
// Changeset is kept for user inspection via console URL — cleaned up on next run
223-
const result = await analyzeChangeSet(cfn, changeSet, print);
230+
const result = await analyzeChangeSet(cfn, changeSet, print, true);
224231
result.changeSetId = changeSet.ChangeSetId;
225232
return result;
226233
} catch (error: any) {
@@ -236,6 +243,7 @@ async function analyzeChangeSet(
236243
cfn: CloudFormationClient,
237244
changeSet: DescribeChangeSetCommandOutput,
238245
print: SpinningLogger,
246+
isRoot = false,
239247
): Promise<TemplateDriftResults> {
240248
const result: TemplateDriftResults = {
241249
changes: [],
@@ -254,10 +262,16 @@ async function analyzeChangeSet(
254262
}
255263

256264
// EarlyValidation failures still have Changes populated — fall through to process them
257-
if (isEarlyValidationFailure(changeSet.StatusReason)) {
258-
print.warn(`Nested changeset FAILED (EarlyValidation): ${changeSet.StatusReason}`);
265+
if (isRecoverableFailure(changeSet.StatusReason)) {
266+
print.warn(`Nested changeset FAILED (recoverable): ${changeSet.StatusReason}`);
267+
} else if (isRoot) {
268+
// Root changeset FAILED because a nested changeset failed. The root's StatusReason
269+
// references the first failing nested changeset (e.g., "Nested change set <ARN> was
270+
// not successfully created: Currently in FAILED.") but does NOT contain "EarlyValidation".
271+
// Classification must happen per-nested-stack, so fall through to process Changes.
272+
print.debug(`Root changeset FAILED due to nested failure — falling through to analyze nested changesets`);
259273
} else {
260-
// Unknown failure — treat as genuine error, skip this stack
274+
// Unknown failure on a nested stack — treat as genuine error, skip this stack
261275
print.warn(`ChangeSet failed with unexpected reason: ${changeSet.StatusReason || 'No reason provided'}`);
262276
return {
263277
changes: [],

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

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,32 @@ const ALLOW_ALL_POLICY = {
5252
/**
5353
* Identifies changeset changes that are expected DeletionPolicy drift from the lock step.
5454
*
55-
* The lock step adds `DeletionPolicy: Retain` to stateful resources. These show up as
56-
* Modify changes in changesets with Scope limited to `['DeletionPolicy']`. For lock rollback
57-
* to determine whether the environment is safe to revert, these expected changes must be
58-
* filtered out so only real drift blocks the rollback.
55+
* The lock step adds `DeletionPolicy: Retain` to stateful resources. These show up as:
56+
* 1. Direct DeletionPolicy changes — Modify with Scope exactly `['DeletionPolicy']`
57+
* 2. Cascading IAM Policy changes — CFN flags IAM policies that reference the modified
58+
* table's attributes (e.g., `TodoTable.Arn` in PolicyDocument) as Dynamic re-evaluations.
59+
* These have `ChangeSource: ResourceAttribute`, `Evaluation: Dynamic`, and
60+
* `RequiresRecreation: Never` — they are harmless re-evaluations, not actual changes.
61+
*
62+
* For lock rollback to determine whether the environment is safe to revert, these expected
63+
* changes must be filtered out so only real drift blocks the rollback.
5964
*/
60-
const isExpectedLockDrift = (change: ResourceChangeWithNested): boolean =>
61-
change.Action === 'Modify' && change.Scope?.length === 1 && change.Scope[0] === 'DeletionPolicy';
65+
const isExpectedLockDrift = (change: ResourceChangeWithNested): boolean => {
66+
if (change.Action !== 'Modify') return false;
67+
68+
// Direct DeletionPolicy change on a resource
69+
if (change.Scope?.length === 1 && change.Scope[0] === 'DeletionPolicy') return true;
70+
71+
// 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) {
74+
return change.Details.every(
75+
(d) => d.ChangeSource === 'ResourceAttribute' && d.Evaluation === 'Dynamic' && d.Target?.RequiresRecreation === 'Never',
76+
);
77+
}
78+
79+
return false;
80+
};
6281

6382
/**
6483
* Recursively walks the change tree to find any leaf resource changes that are
@@ -270,11 +289,7 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep {
270289
*/
271290
private async validateLockRollbackDrift(): Promise<ValidationResult> {
272291
try {
273-
const driftResults = await detectTemplateDrift(
274-
this.gen1App.rootStackName,
275-
this.logger,
276-
this.gen1App.clients.cloudFormation,
277-
);
292+
const driftResults = await detectTemplateDrift(this.gen1App.rootStackName, this.logger, this.gen1App.clients.cloudFormation);
278293

279294
if (driftResults.skipped) {
280295
return { valid: false, report: `Template drift detection was skipped: ${driftResults.skipReason}` };

0 commit comments

Comments
 (0)