Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/drift/drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ export async function detectStackDrift(
const driftResults = await cfn.describeStackResourceDrifts({
StackName: stackName,
});
let nextToken = driftResults.NextToken;
if (nextToken) {
driftResults.StackResourceDrifts = [...(driftResults.StackResourceDrifts ?? [])];
}
while (nextToken) {
const page = await cfn.describeStackResourceDrifts({
StackName: stackName,
NextToken: nextToken,
});
driftResults.StackResourceDrifts!.push(...(page.StackResourceDrifts ?? []));
nextToken = page.NextToken;
}
// All pages have been consumed; do not expose an obsolete continuation token.
delete driftResults.NextToken;

// Log warning for any resources with UNKNOWN status
const unknownResources = driftResults.StackResourceDrifts?.filter(
Expand Down
27 changes: 27 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/actions/drift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@ beforeEach(() => {
});

describe('drift', () => {
test('reports drift from later pages instead of marking the resource unchecked', async () => {
mockCloudFormationClient.on(DetectStackDriftCommand).resolves({ StackDriftDetectionId: '12345' });
mockCloudFormationClient.on(DescribeStackDriftDetectionStatusCommand).resolves({ DetectionStatus: 'DETECTION_COMPLETE' });
mockCloudFormationClient.on(DescribeStackResourceDriftsCommand)
.resolvesOnce({ StackResourceDrifts: [], NextToken: 'page-2' })
.resolvesOnce({
StackResourceDrifts: [{
StackId: 'some:stack:arn',
StackResourceDriftStatus: 'DELETED',
LogicalResourceId: 'MyBucketF68F3FF0',
PhysicalResourceId: 'physical-id-1',
ResourceType: 'AWS::S3::Bucket',
Timestamp: new Date(),
}],
});

const cx = await builderFixture(toolkit, 'stack-with-bucket');
const result = await toolkit.drift(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS } });

expect(result.Stack1.numResourcesWithDrift).toBe(1);
expect(result.Stack1.numResourcesUnchecked).toBe(0);
ioHost.expectMessage({ containing: 'Deleted Resources', level: 'info' });
expect(mockCloudFormationClient).toHaveReceivedCommandWith(DescribeStackResourceDriftsCommand, {
StackName: 'Stack1', NextToken: 'page-2',
});
});

test('if no drift is returned, warn user', async () => {
// GIVEN
mockCloudFormationClient.on(DetectStackDriftCommand).resolves({ StackDriftDetectionId: '12345' });
Expand Down
42 changes: 42 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/api/drift/drift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,48 @@ describe('detectStackDrift', () => {
}));
});

test.each([[], undefined])('follows continuation tokens through empty pages (%p)', async (emptyPage) => {
const inSync = {
StackId: 'stack-id',
LogicalResourceId: 'InSync',
ResourceType: 'AWS::SSM::Parameter',
StackResourceDriftStatus: 'IN_SYNC',
Timestamp: new Date(),
};
const deleted = { ...inSync, LogicalResourceId: 'Deleted', StackResourceDriftStatus: 'DELETED' };
const unknown = { ...inSync, LogicalResourceId: 'Unknown', StackResourceDriftStatus: 'UNKNOWN', DriftStatusReason: 'access denied' };
mockCfn.detectStackDrift.mockResolvedValue({ StackDriftDetectionId: 'detection-id' });
mockCfn.describeStackDriftDetectionStatus.mockResolvedValue({ DetectionStatus: 'DETECTION_COMPLETE' });
mockCfn.describeStackResourceDrifts
.mockResolvedValueOnce({ StackResourceDrifts: Array(100).fill(inSync), NextToken: 'page-2', $metadata: {} })
.mockResolvedValueOnce({ StackResourceDrifts: emptyPage, NextToken: 'page-3', $metadata: {} })
.mockResolvedValueOnce({ StackResourceDrifts: [deleted, unknown], $metadata: {} });

const result = await detectStackDrift(mockCfn, ioHelper, 'test-stack');

expect(result.StackResourceDrifts).toEqual([...Array(100).fill(inSync), deleted, unknown]);
expect(result.NextToken).toBeUndefined();
expect(mockCfn.describeStackResourceDrifts).toHaveBeenCalledTimes(3);
expect(mockCfn.describeStackResourceDrifts).toHaveBeenNthCalledWith(1, { StackName: 'test-stack' });
expect(mockCfn.describeStackResourceDrifts).toHaveBeenNthCalledWith(2, { StackName: 'test-stack', NextToken: 'page-2' });
expect(mockCfn.describeStackResourceDrifts).toHaveBeenNthCalledWith(3, { StackName: 'test-stack', NextToken: 'page-3' });
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('Unknown: access denied'),
level: 'trace',
}));
});

test('propagates errors from later drift result pages', async () => {
mockCfn.detectStackDrift.mockResolvedValue({ StackDriftDetectionId: 'detection-id' });
mockCfn.describeStackDriftDetectionStatus.mockResolvedValue({ DetectionStatus: 'DETECTION_COMPLETE' });
const error = new Error('pagination failed');
mockCfn.describeStackResourceDrifts
.mockResolvedValueOnce({ StackResourceDrifts: [], NextToken: 'page-2', $metadata: {} })
.mockRejectedValueOnce(error);

await expect(detectStackDrift(mockCfn, ioHelper, 'test-stack')).rejects.toBe(error);
});

test('throws error when drift detection takes too long', async () => {
// GIVEN
const stackName = 'test-stack';
Expand Down
Loading