Skip to content

Commit cb3ecfe

Browse files
authored
fix(cli): garbage collection ignores review_in_progress stacks (#31906)
Calling this a feat because I believe technically we are updating the functionality of gc. Previously we were waiting for stacks in `REVIEW_IN_PROGRESS` to land, because that is the one CFN state that you cannot retrieve a template for (because it doesn't exist yet). However in environments where we are constantly deploying new stacks (like our test environments), we may never get to a state in the allotted time where no stacks are `REVIEW_IN_PROGRESS`. Instead, we are going to ignore `REVIEW_IN_PROGRESS` stacks. This will introduce a subtle race condition where a previously isolated asset becomes in-use by the `REVIEW_IN_PROGRESS` stack before it turns into a `CREATE_IN_PROGRESS` stack and we can reference its stack. If garbage collection happens to come across the isolated asset while the stack is `REVIEW_IN_PROGRESS` (aka before it is `CREATE_IN_PROGRESS` but after CDK has verified that the assets exist) we will garbage collect the asset. However, we don't expect this to become a big problem in practice. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent e0615fe commit cb3ecfe

File tree

3 files changed

+7
-136
lines changed

3 files changed

+7
-136
lines changed

packages/aws-cdk/lib/api/garbage-collection/garbage-collector.ts

+1-11
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,6 @@ interface GarbageCollectorProps {
110110
*/
111111
readonly bootstrapStackName?: string;
112112

113-
/**
114-
* Max wait time for retries in milliseconds (for testing purposes).
115-
*
116-
* @default 60000
117-
*/
118-
readonly maxWaitTime?: number;
119-
120113
/**
121114
* Confirm with the user before actual deletion happens
122115
*
@@ -134,7 +127,6 @@ export class GarbageCollector {
134127
private permissionToDelete: boolean;
135128
private permissionToTag: boolean;
136129
private bootstrapStackName: string;
137-
private maxWaitTime: number;
138130
private confirm: boolean;
139131

140132
public constructor(readonly props: GarbageCollectorProps) {
@@ -145,7 +137,6 @@ export class GarbageCollector {
145137

146138
this.permissionToDelete = ['delete-tagged', 'full'].includes(props.action);
147139
this.permissionToTag = ['tag', 'full'].includes(props.action);
148-
this.maxWaitTime = props.maxWaitTime ?? 60000;
149140
this.confirm = props.confirm ?? true;
150141

151142
this.bootstrapStackName = props.bootstrapStackName ?? DEFAULT_TOOLKIT_STACK_NAME;
@@ -181,13 +172,12 @@ export class GarbageCollector {
181172
const activeAssets = new ActiveAssetCache();
182173

183174
// Grab stack templates first
184-
await refreshStacks(cfn, activeAssets, this.maxWaitTime, qualifier);
175+
await refreshStacks(cfn, activeAssets, qualifier);
185176
// Start the background refresh
186177
const backgroundStackRefresh = new BackgroundStackRefresh({
187178
cfn,
188179
activeAssets,
189180
qualifier,
190-
maxWaitTime: this.maxWaitTime,
191181
});
192182
backgroundStackRefresh.start();
193183

packages/aws-cdk/lib/api/garbage-collection/stack-refresh.ts

+6-37
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { CloudFormation } from 'aws-sdk';
2-
import { sleep } from '../../../test/util';
32
import { debug } from '../../logging';
43

54
export class ActiveAssetCache {
@@ -30,41 +29,18 @@ async function paginateSdkCall(cb: (nextToken?: string) => Promise<string | unde
3029
}
3130
}
3231

33-
/** We cannot operate on REVIEW_IN_PROGRESS stacks because we do not know what the template looks like in this case
34-
* If we encounter this status, we will wait up to the maxWaitTime before erroring out
35-
*/
36-
async function listStacksNotBeingReviewed(cfn: CloudFormation, maxWaitTime: number, nextToken: string | undefined) {
37-
let sleepMs = 500;
38-
const deadline = Date.now() + maxWaitTime;
39-
40-
while (Date.now() <= deadline) {
41-
let stacks = await cfn.listStacks({ NextToken: nextToken }).promise();
42-
if (!stacks.StackSummaries?.some(s => s.StackStatus == 'REVIEW_IN_PROGRESS')) {
43-
return stacks;
44-
}
45-
await sleep(Math.floor(Math.random() * sleepMs));
46-
sleepMs = sleepMs * 2;
47-
}
48-
49-
throw new Error(`Stacks still in REVIEW_IN_PROGRESS state after waiting for ${maxWaitTime} ms.`);
50-
}
51-
5232
/**
5333
* Fetches all relevant stack templates from CloudFormation. It ignores the following stacks:
5434
* - stacks in DELETE_COMPLETE or DELETE_IN_PROGRESS stage
5535
* - stacks that are using a different bootstrap qualifier
56-
*
57-
* It fails on the following stacks because we cannot get the template and therefore have an imcomplete
58-
* understanding of what assets are being used.
59-
* - stacks in REVIEW_IN_PROGRESS stage
6036
*/
61-
async function fetchAllStackTemplates(cfn: CloudFormation, maxWaitTime: number, qualifier?: string) {
37+
async function fetchAllStackTemplates(cfn: CloudFormation, qualifier?: string) {
6238
const stackNames: string[] = [];
6339
await paginateSdkCall(async (nextToken) => {
64-
const stacks = await listStacksNotBeingReviewed(cfn, maxWaitTime, nextToken);
40+
const stacks = await cfn.listStacks({ NextToken: nextToken }).promise();
6541

6642
// We ignore stacks with these statuses because their assets are no longer live
67-
const ignoredStatues = ['CREATE_FAILED', 'DELETE_COMPLETE', 'DELETE_IN_PROGRESS', 'DELETE_FAILED'];
43+
const ignoredStatues = ['CREATE_FAILED', 'DELETE_COMPLETE', 'DELETE_IN_PROGRESS', 'DELETE_FAILED', 'REVIEW_IN_PROGRESS'];
6844
stackNames.push(
6945
...(stacks.StackSummaries ?? [])
7046
.filter(s => !ignoredStatues.includes(s.StackStatus))
@@ -119,9 +95,9 @@ function bootstrapFilter(parameters?: CloudFormation.ParameterDeclarations, qual
11995
splitBootstrapVersion[2] != qualifier);
12096
}
12197

122-
export async function refreshStacks(cfn: CloudFormation, activeAssets: ActiveAssetCache, maxWaitTime: number, qualifier?: string) {
98+
export async function refreshStacks(cfn: CloudFormation, activeAssets: ActiveAssetCache, qualifier?: string) {
12399
try {
124-
const stacks = await fetchAllStackTemplates(cfn, maxWaitTime, qualifier);
100+
const stacks = await fetchAllStackTemplates(cfn, qualifier);
125101
for (const stack of stacks) {
126102
activeAssets.rememberStack(stack);
127103
}
@@ -148,13 +124,6 @@ export interface BackgroundStackRefreshProps {
148124
* Stack bootstrap qualifier
149125
*/
150126
readonly qualifier?: string;
151-
152-
/**
153-
* Maximum wait time when waiting for stacks to leave REVIEW_IN_PROGRESS stage.
154-
*
155-
* @default 60000
156-
*/
157-
readonly maxWaitTime?: number;
158127
}
159128

160129
/**
@@ -178,7 +147,7 @@ export class BackgroundStackRefresh {
178147
private async refresh() {
179148
const startTime = Date.now();
180149

181-
await refreshStacks(this.props.cfn, this.props.activeAssets, this.props.maxWaitTime ?? 60000, this.props.qualifier);
150+
await refreshStacks(this.props.cfn, this.props.activeAssets, this.props.qualifier);
182151
this.justRefreshedStacks();
183152

184153
// If the last invocation of refreshStacks takes <5 minutes, the next invocation starts 5 minutes after the last one started.

packages/aws-cdk/test/api/garbage-collection.test.ts

-88
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ function gc(props: {
3333
rollbackBufferDays?: number;
3434
createdAtBufferDays?: number;
3535
action: 'full' | 'print' | 'tag' | 'delete-tagged';
36-
maxWaitTime?: number;
3736
}): GarbageCollector {
3837
return new GarbageCollector({
3938
sdkProvider: sdk,
@@ -47,7 +46,6 @@ function gc(props: {
4746
rollbackBufferDays: props.rollbackBufferDays ?? 0,
4847
createdBufferDays: props.createdAtBufferDays ?? 0,
4948
type: props.type,
50-
maxWaitTime: props.maxWaitTime,
5149
confirm: false,
5250
});
5351
}
@@ -435,91 +433,6 @@ describe('Garbage Collection', () => {
435433
},
436434
});
437435
});
438-
439-
test('stackStatus in REVIEW_IN_PROGRESS means we wait until it changes', async () => {
440-
mockTheToolkitInfo({
441-
Outputs: [
442-
{
443-
OutputKey: 'BootstrapVersion',
444-
OutputValue: '999',
445-
},
446-
],
447-
});
448-
449-
// Mock the listStacks call
450-
const mockListStacksStatus = jest.fn()
451-
.mockResolvedValueOnce({
452-
StackSummaries: [
453-
{ StackName: 'Stack1', StackStatus: 'REVIEW_IN_PROGRESS' },
454-
{ StackName: 'Stack2', StackStatus: 'UPDATE_COMPLETE' },
455-
],
456-
})
457-
.mockResolvedValueOnce({
458-
StackSummaries: [
459-
{ StackName: 'Stack1', StackStatus: 'UPDATE_COMPLETE' },
460-
{ StackName: 'Stack2', StackStatus: 'UPDATE_COMPLETE' },
461-
],
462-
});
463-
464-
sdk.stubCloudFormation({
465-
listStacks: mockListStacksStatus,
466-
getTemplateSummary: mockGetTemplateSummary,
467-
getTemplate: mockGetTemplate,
468-
});
469-
470-
garbageCollector = garbageCollector = gc({
471-
type: 's3',
472-
rollbackBufferDays: 3,
473-
action: 'full',
474-
});
475-
await garbageCollector.garbageCollect();
476-
477-
// list are called as expected
478-
expect(mockListStacksStatus).toHaveBeenCalledTimes(2);
479-
480-
// everything else runs as expected:
481-
// assets tagged
482-
expect(mockGetObjectTagging).toHaveBeenCalledTimes(3);
483-
expect(mockPutObjectTagging).toHaveBeenCalledTimes(2); // one object already has the tag
484-
485-
// no deleting
486-
expect(mockDeleteObjects).toHaveBeenCalledTimes(0);
487-
}, 60000);
488-
489-
test('fails when stackStatus stuck in REVIEW_IN_PROGRESS', async () => {
490-
mockTheToolkitInfo({
491-
Outputs: [
492-
{
493-
OutputKey: 'BootstrapVersion',
494-
OutputValue: '999',
495-
},
496-
],
497-
});
498-
499-
// Mock the listStacks call
500-
const mockListStacksStatus = jest.fn()
501-
.mockResolvedValue({
502-
StackSummaries: [
503-
{ StackName: 'Stack1', StackStatus: 'REVIEW_IN_PROGRESS' },
504-
{ StackName: 'Stack2', StackStatus: 'UPDATE_COMPLETE' },
505-
],
506-
});
507-
508-
sdk.stubCloudFormation({
509-
listStacks: mockListStacksStatus,
510-
getTemplateSummary: mockGetTemplateSummary,
511-
getTemplate: mockGetTemplate,
512-
});
513-
514-
garbageCollector = garbageCollector = gc({
515-
type: 's3',
516-
rollbackBufferDays: 3,
517-
action: 'full',
518-
maxWaitTime: 600, // Wait only 600 ms in tests
519-
});
520-
521-
await expect(garbageCollector.garbageCollect()).rejects.toThrow(/Stacks still in REVIEW_IN_PROGRESS state after waiting/);
522-
}, 60000);
523436
});
524437

525438
let mockListObjectsV2Large: (params: AWS.S3.Types.ListObjectsV2Request) => AWS.S3.Types.ListObjectsV2Output;
@@ -680,7 +593,6 @@ describe('BackgroundStackRefresh', () => {
680593
refreshProps = {
681594
cfn: sdk.mockSdk.cloudFormation(),
682595
activeAssets: new ActiveAssetCache(),
683-
maxWaitTime: 60000, // 1 minute
684596
};
685597

686598
backgroundRefresh = new BackgroundStackRefresh(refreshProps);

0 commit comments

Comments
 (0)