Skip to content

Commit f00d0f1

Browse files
committed
feat: add ignore-stacks and skip-unauthorized-stacks options to gc command
1 parent 4f996e8 commit f00d0f1

File tree

5 files changed

+121
-17
lines changed

5 files changed

+121
-17
lines changed

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,18 @@ interface GarbageCollectorProps {
178178
* @default true
179179
*/
180180
readonly confirm?: boolean;
181+
182+
/**
183+
*
184+
* @default []
185+
*/
186+
readonly ignoreStacks?: string[];
187+
188+
/**
189+
*
190+
* @default false
191+
*/
192+
readonly skipUnauthorizedStacks?: boolean;
181193
}
182194

183195
/**
@@ -219,7 +231,7 @@ export class GarbageCollector {
219231
const activeAssets = new ActiveAssetCache();
220232

221233
// Grab stack templates first
222-
await refreshStacks(cfn, this.ioHelper, activeAssets, qualifier);
234+
await refreshStacks(cfn, this.ioHelper, activeAssets, qualifier, this.props.ignoreStacks, this.props.skipUnauthorizedStacks);
223235
// Start the background refresh
224236
const backgroundStackRefresh = new BackgroundStackRefresh({
225237
cfn,

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

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ParameterDeclaration } from '@aws-sdk/client-cloudformation';
2+
import { minimatch } from 'minimatch';
23
import { ToolkitError } from '../../toolkit/toolkit-error';
34
import type { ICloudFormationClient } from '../aws-auth/private';
45
import type { IoHelper } from '../io/private';
@@ -36,7 +37,13 @@ async function paginateSdkCall(cb: (nextToken?: string) => Promise<string | unde
3637
* - stacks in DELETE_COMPLETE or DELETE_IN_PROGRESS stage
3738
* - stacks that are using a different bootstrap qualifier
3839
*/
39-
async function fetchAllStackTemplates(cfn: ICloudFormationClient, ioHelper: IoHelper, qualifier?: string) {
40+
async function fetchAllStackTemplates(
41+
cfn: ICloudFormationClient,
42+
ioHelper: IoHelper,
43+
qualifier?: string,
44+
ignoreStacks?: string[],
45+
skipUnauthorizedStacks?: boolean,
46+
) {
4047
const stackNames: string[] = [];
4148
await paginateSdkCall(async (nextToken) => {
4249
const stacks = await cfn.listStacks({ NextToken: nextToken });
@@ -56,25 +63,41 @@ async function fetchAllStackTemplates(cfn: ICloudFormationClient, ioHelper: IoHe
5663

5764
const templates: string[] = [];
5865
for (const stack of stackNames) {
59-
let summary;
60-
summary = await cfn.getTemplateSummary({
61-
StackName: stack,
62-
});
63-
64-
if (bootstrapFilter(summary.Parameters, qualifier)) {
65-
// This stack is definitely bootstrapped to a different qualifier so we can safely ignore it
66-
continue;
67-
} else {
68-
const template = await cfn.getTemplate({
66+
// Skip stacks matching user-inputted ignore patterns
67+
if (ignoreStacks && ignoreStacks.length>0) {
68+
const shouldSkip = ignoreStacks.some(pattern => minimatch(stack, pattern));
69+
if (shouldSkip) {
70+
await ioHelper.defaults.debug(`Skipping stack ${stack}`);
71+
continue;
72+
}
73+
}
74+
try {
75+
let summary;
76+
summary = await cfn.getTemplateSummary({
6977
StackName: stack,
7078
});
7179

72-
templates.push((template.TemplateBody ?? '') + JSON.stringify(summary?.Parameters));
80+
if (bootstrapFilter(summary.Parameters, qualifier)) {
81+
// This stack is definitely bootstrapped to a different qualifier so we can safely ignore it
82+
continue;
83+
} else {
84+
const template = await cfn.getTemplate({
85+
StackName: stack,
86+
});
87+
88+
templates.push((template.TemplateBody ?? '') + JSON.stringify(summary?.Parameters));
89+
}
90+
} catch (error: any) {
91+
// Skip stacks with access denied errors whenever the flag is enabled
92+
if ((error.name == 'AccessDenied') && skipUnauthorizedStacks) {
93+
await ioHelper.defaults.warn(`Skipping unauthorized stack: ${stack}`);
94+
continue;
95+
}
96+
// To account for non-access related errors
97+
throw error;
7398
}
7499
}
75-
76100
await ioHelper.defaults.debug('Done parsing through stacks');
77-
78101
return templates;
79102
}
80103

@@ -97,9 +120,16 @@ function bootstrapFilter(parameters?: ParameterDeclaration[], qualifier?: string
97120
splitBootstrapVersion[2] != qualifier);
98121
}
99122

100-
export async function refreshStacks(cfn: ICloudFormationClient, ioHelper: IoHelper, activeAssets: ActiveAssetCache, qualifier?: string) {
123+
export async function refreshStacks(
124+
cfn: ICloudFormationClient,
125+
ioHelper: IoHelper,
126+
activeAssets: ActiveAssetCache,
127+
qualifier?: string,
128+
ignoreStacks?: string[],
129+
skipUnauthorizedStacks?: boolean,
130+
) {
101131
try {
102-
const stacks = await fetchAllStackTemplates(cfn, ioHelper, qualifier);
132+
const stacks = await fetchAllStackTemplates(cfn, ioHelper, qualifier, ignoreStacks, skipUnauthorizedStacks);
103133
for (const stack of stacks) {
104134
activeAssets.rememberStack(stack);
105135
}

packages/@aws-cdk/toolkit-lib/test/api/garbage-collection/garbage-collection.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
BackgroundStackRefresh,
2828
ProgressPrinter,
2929
} from '../../../lib/api/garbage-collection';
30+
import { refreshStacks } from '../../../lib/api/garbage-collection/stack-refresh';
3031
import { mockBootstrapStack, mockCloudFormationClient, mockECRClient, mockS3Client, MockSdk, MockSdkProvider } from '../../_helpers/mock-sdk';
3132
import { TestIoHost } from '../../_helpers/test-io-host';
3233

@@ -1048,3 +1049,50 @@ function yearsInTheFuture(years: number): Date {
10481049
d.setFullYear(d.getFullYear() + years);
10491050
return d;
10501051
}
1052+
1053+
describe('Skip & Ignore Stacks', () => {
1054+
const mockIoHelper = { defaults: { debug: jest.fn(), warn: jest.fn() } };
1055+
test('skips stacks matching ignore patterns by the user', async () => {
1056+
// One stack matches the ignore pattern and one does not
1057+
const mockCfn = {
1058+
listStacks: jest.fn().mockResolvedValue({
1059+
StackSummaries: [
1060+
{ StackName: 'StackSet-DGApp-123', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }, // Will be filtered out
1061+
{ StackName: 'DGAppStack2', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }, // Will be processed
1062+
],
1063+
}),
1064+
getTemplateSummary: jest.fn().mockResolvedValue({ Parameters: [] }),
1065+
getTemplate: jest.fn().mockResolvedValue({ TemplateBody: '{}' }),
1066+
};
1067+
1068+
const mockActiveAssets = { rememberStack: jest.fn() };
1069+
1070+
// Apply ignore pattern 'StackSet-*'
1071+
await refreshStacks(mockCfn as any, mockIoHelper as any, mockActiveAssets as any, undefined, ['StackSet-*']);
1072+
1073+
// Make sure only the non-matched stack is processed
1074+
expect(mockCfn.getTemplateSummary).toHaveBeenCalledTimes(1);
1075+
expect(mockCfn.getTemplateSummary).toHaveBeenCalledWith({ StackName: 'DGAppStack2' });
1076+
});
1077+
1078+
test('skips unauthorized stacks when the flag is true', async () => {
1079+
// Mock stack which throws an AccessDenied error
1080+
const mockCfn = {
1081+
listStacks: jest.fn().mockResolvedValue({
1082+
StackSummaries: [
1083+
{ StackName: 'UnauthorizedStack', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() },
1084+
],
1085+
}),
1086+
getTemplateSummary: jest.fn().mockRejectedValue({ name: 'AccessDenied' }),
1087+
};
1088+
1089+
const mockActiveAssets = { rememberStack: jest.fn() };
1090+
1091+
await expect(
1092+
refreshStacks(mockCfn as any, mockIoHelper as any, mockActiveAssets as any, undefined, [], true),
1093+
).resolves.not.toThrow();
1094+
1095+
expect(mockCfn.getTemplateSummary).toHaveBeenCalledWith({ StackName: 'UnauthorizedStack' });
1096+
expect(mockIoHelper.defaults.warn).toHaveBeenCalledWith('Skipping unauthorized stack: UnauthorizedStack');
1097+
});
1098+
});

packages/aws-cdk/lib/cli/cdk-toolkit.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1918,6 +1918,18 @@ export interface GarbageCollectionOptions {
19181918
* @default false
19191919
*/
19201920
readonly confirm?: boolean;
1921+
1922+
/**
1923+
*
1924+
* @default []
1925+
*/
1926+
readonly ignoreStacks?: string[];
1927+
1928+
/**
1929+
*
1930+
* @default false
1931+
*/
1932+
readonly skipUnauthorizedStacks?: boolean;
19211933
}
19221934
export interface MigrateOptions {
19231935
/**

packages/aws-cdk/lib/cli/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,8 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
487487
createdBufferDays: args['created-buffer-days'],
488488
bootstrapStackName: args.toolkitStackName ?? args.bootstrapStackName,
489489
confirm: args.confirm,
490+
ignoreStacks: args['ignore-stacks'],
491+
skipUnauthorizedStacks: args['skip-unauthorized-stacks'],
490492
});
491493

492494
case 'flags':

0 commit comments

Comments
 (0)