Skip to content

Commit 65b3bf1

Browse files
committed
feat: add --skip-unauthorized-stacks and --ignore-stacks support to garbage collection
- Modify bootstrap lookup methods to handle AccessDenied errors - Add error handling for CloudFormation listStacks and individual stacks - Add null checks in garbage collection methods to skip S3/ECR when bootstrap access fails
1 parent 2d8d734 commit 65b3bf1

File tree

5 files changed

+172
-33
lines changed

5 files changed

+172
-33
lines changed

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

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,20 @@ interface GarbageCollectorProps {
178178
* @default true
179179
*/
180180
readonly confirm?: boolean;
181+
182+
/**
183+
* Stack names to ignore during garbage collection
184+
*
185+
* @default []
186+
*/
187+
readonly ignoreNonCdkStacks?: string[];
188+
189+
/**
190+
* Skip stacks that return AccessDenied errors
191+
*
192+
* @default false
193+
*/
194+
readonly skipUnauthorizedStacks?: boolean;
181195
}
182196

183197
/**
@@ -211,6 +225,13 @@ export class GarbageCollector {
211225
public async garbageCollect() {
212226
await this.ioHelper.defaults.debug(`${this.garbageCollectS3Assets} ${this.garbageCollectEcrAssets}`);
213227

228+
// Display warning if ignoring stacks
229+
if (this.props.ignoreNonCdkStacks && this.props.ignoreNonCdkStacks.length > 0) {
230+
await this.ioHelper.defaults.warn(
231+
'WARNING: Ignoring stacks during garbage collection. Only use this for non-CDK stacks to avoid accidental asset deletion.',
232+
);
233+
}
234+
214235
// SDKs
215236
const sdk = (await this.props.sdkProvider.forEnvironment(this.props.resolvedEnvironment, Mode.ForWriting)).sdk;
216237
const cfn = sdk.cloudFormation();
@@ -224,13 +245,17 @@ export class GarbageCollector {
224245
ioHelper: this.ioHelper,
225246
activeAssets,
226247
qualifier,
248+
ignoreNonCdkStacks: this.props.ignoreNonCdkStacks,
249+
skipUnauthorizedStacks: this.props.skipUnauthorizedStacks,
227250
});
228251
// Start the background refresh
229252
const backgroundStackRefresh = new BackgroundStackRefresh({
230253
cfn,
231254
ioHelper: this.ioHelper,
232255
activeAssets,
233256
qualifier,
257+
ignoreNonCdkStacks: this.props.ignoreNonCdkStacks,
258+
skipUnauthorizedStacks: this.props.skipUnauthorizedStacks,
234259
});
235260
backgroundStackRefresh.start();
236261

@@ -255,6 +280,8 @@ export class GarbageCollector {
255280
public async garbageCollectEcr(sdk: SDK, activeAssets: ActiveAssetCache, backgroundStackRefresh: BackgroundStackRefresh) {
256281
const ecr = sdk.ecr();
257282
const repo = await this.bootstrapRepositoryName(sdk, this.bootstrapStackName);
283+
// Null check to prevent an early error being thrown and invalidate skipUnauthorizedStacks functionality
284+
if (!repo) return;
258285
const numImages = await this.numImagesInRepo(ecr, repo);
259286
const printer = new ProgressPrinter(this.ioHelper, numImages, 1000);
260287

@@ -329,6 +356,8 @@ export class GarbageCollector {
329356
public async garbageCollectS3(sdk: SDK, activeAssets: ActiveAssetCache, backgroundStackRefresh: BackgroundStackRefresh) {
330357
const s3 = sdk.s3();
331358
const bucket = await this.bootstrapBucketName(sdk, this.bootstrapStackName);
359+
// Null check to prevent an early error being thrown and invalidate skipUnauthorizedStack functionality
360+
if (!bucket) return;
332361
const numObjects = await this.numObjectsInBucket(s3, bucket);
333362
const printer = new ProgressPrinter(this.ioHelper, numObjects, 1000);
334363

@@ -582,19 +611,46 @@ export class GarbageCollector {
582611
}
583612
}
584613

585-
private async bootstrapBucketName(sdk: SDK, bootstrapStackName: string): Promise<string> {
586-
const toolkitInfo = await ToolkitInfo.lookup(this.props.resolvedEnvironment, sdk, this.ioHelper, bootstrapStackName);
587-
return toolkitInfo.bucketName;
614+
private async bootstrapBucketName(sdk: SDK, bootstrapStackName: string): Promise<string | null> {
615+
try {
616+
const toolkitInfo = await ToolkitInfo.lookup(this.props.resolvedEnvironment, sdk, this.ioHelper, bootstrapStackName);
617+
return toolkitInfo.bucketName;
618+
} catch (error: any) {
619+
// Conditional error handling to return null if skipUnauthorizedStacks is enabled instead of crashing
620+
if (this.props.skipUnauthorizedStacks && error.message?.includes('AccessDenied')) {
621+
await this.ioHelper.defaults.warn(`Skipping S3 garbage collection due to insufficient permissions to access bootstrap stack '${bootstrapStackName}'`);
622+
return null;
623+
}
624+
throw error;
625+
}
588626
}
589627

590-
private async bootstrapRepositoryName(sdk: SDK, bootstrapStackName: string): Promise<string> {
591-
const toolkitInfo = await ToolkitInfo.lookup(this.props.resolvedEnvironment, sdk, this.ioHelper, bootstrapStackName);
592-
return toolkitInfo.repositoryName;
628+
private async bootstrapRepositoryName(sdk: SDK, bootstrapStackName: string): Promise<string | null> {
629+
try {
630+
const toolkitInfo = await ToolkitInfo.lookup(this.props.resolvedEnvironment, sdk, this.ioHelper, bootstrapStackName);
631+
return toolkitInfo.repositoryName;
632+
} catch (error: any) {
633+
// Conditional error handling to return null if skipUnauthorizedStacks is enabled instead of crashing
634+
if (this.props.skipUnauthorizedStacks && error.message?.includes('AccessDenied')) {
635+
await this.ioHelper.defaults.warn(`Skipping ECR garbage collection due to insufficient permissions to access bootstrap stack '${bootstrapStackName}'`);
636+
return null;
637+
}
638+
throw error;
639+
}
593640
}
594641

595642
private async bootstrapQualifier(sdk: SDK, bootstrapStackName: string): Promise<string | undefined> {
596-
const toolkitInfo = await ToolkitInfo.lookup(this.props.resolvedEnvironment, sdk, this.ioHelper, bootstrapStackName);
597-
return toolkitInfo.bootstrapStack.parameters.Qualifier;
643+
try {
644+
const toolkitInfo = await ToolkitInfo.lookup(this.props.resolvedEnvironment, sdk, this.ioHelper, bootstrapStackName);
645+
return toolkitInfo.bootstrapStack.parameters.Qualifier;
646+
} catch (error: any) {
647+
// Return undefined if skipUnauthorizedStacks is enabled instead of crashing. Maintains the original return type
648+
if (this.props.skipUnauthorizedStacks && error.message?.includes('AccessDenied')) {
649+
await this.ioHelper.defaults.warn('Skipping bootstrap stack lookup due to AccessDenied error');
650+
return undefined;
651+
}
652+
throw error;
653+
}
598654
}
599655

600656
private async numObjectsInBucket(s3: IS3Client, bucket: string): Promise<number> {

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

Lines changed: 78 additions & 25 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,40 +37,72 @@ 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+
ignoreNonCdkStacks?: string[],
45+
skipUnauthorizedStacks?: boolean,
46+
) {
4047
const stackNames: string[] = [];
41-
await paginateSdkCall(async (nextToken) => {
42-
const stacks = await cfn.listStacks({ NextToken: nextToken });
43-
44-
// We ignore stacks with these statuses because their assets are no longer live
45-
const ignoredStatues = ['CREATE_FAILED', 'DELETE_COMPLETE', 'DELETE_IN_PROGRESS', 'DELETE_FAILED', 'REVIEW_IN_PROGRESS'];
46-
stackNames.push(
47-
...(stacks.StackSummaries ?? [])
48-
.filter((s: any) => !ignoredStatues.includes(s.StackStatus))
49-
.map((s: any) => s.StackId ?? s.StackName),
50-
);
48+
// Handle the error if the user is unable to list stacks due to insufficient permissions
49+
try {
50+
await paginateSdkCall(async (nextToken) => {
51+
const stacks = await cfn.listStacks({ NextToken: nextToken });
52+
53+
// We ignore stacks with these statuses because their assets are no longer live
54+
const ignoredStatues = ['CREATE_FAILED', 'DELETE_COMPLETE', 'DELETE_IN_PROGRESS', 'DELETE_FAILED', 'REVIEW_IN_PROGRESS'];
55+
stackNames.push(
56+
...(stacks.StackSummaries ?? [])
57+
.filter((s: any) => !ignoredStatues.includes(s.StackStatus))
58+
.map((s: any) => s.StackId ?? s.StackName),
59+
);
5160

52-
return stacks.NextToken;
53-
});
61+
return stacks.NextToken;
62+
});
63+
} catch (error: any) {
64+
if (error.name == 'AccessDenied' && skipUnauthorizedStacks) {
65+
await ioHelper.defaults.warn('Skipping all stack processing due to AccessDenied on listStacks');
66+
return [];
67+
}
68+
throw error;
69+
}
5470

5571
await ioHelper.defaults.debug(`Parsing through ${stackNames.length} stacks`);
5672

5773
const templates: string[] = [];
5874
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({
75+
// Skip stacks matching ignore patterns
76+
if (ignoreNonCdkStacks && ignoreNonCdkStacks.length > 0) {
77+
const shouldSkip = ignoreNonCdkStacks.some(pattern => minimatch(stack, pattern));
78+
if (shouldSkip) {
79+
await ioHelper.defaults.debug(`Skipping stack ${stack}`);
80+
continue;
81+
}
82+
}
83+
// Allows users to skip a stack if they can't access it instead of failing
84+
try {
85+
let summary;
86+
summary = await cfn.getTemplateSummary({
6987
StackName: stack,
7088
});
7189

72-
templates.push((template.TemplateBody ?? '') + JSON.stringify(summary?.Parameters));
90+
if (bootstrapFilter(summary.Parameters, qualifier)) {
91+
// This stack is definitely bootstrapped to a different qualifier so we can safely ignore it
92+
continue;
93+
} else {
94+
const template = await cfn.getTemplate({
95+
StackName: stack,
96+
});
97+
98+
templates.push((template.TemplateBody ?? '') + JSON.stringify(summary?.Parameters));
99+
}
100+
} catch (error: any) {
101+
if (error.name === 'AccessDenied' && skipUnauthorizedStacks) {
102+
await ioHelper.defaults.warn(`Skipping stack ${stack} due to AccessDenied error`);
103+
continue;
104+
}
105+
throw error;
73106
}
74107
}
75108

@@ -102,11 +135,19 @@ export interface RefreshStacksProps {
102135
readonly ioHelper: IoHelper;
103136
readonly activeAssets: ActiveAssetCache;
104137
readonly qualifier?: string;
138+
readonly ignoreNonCdkStacks?: string[];
139+
readonly skipUnauthorizedStacks?: boolean;
105140
}
106141

107142
export async function refreshStacks(props: RefreshStacksProps) {
108143
try {
109-
const stacks = await fetchAllStackTemplates(props.cfn, props.ioHelper, props.qualifier);
144+
const stacks = await fetchAllStackTemplates(
145+
props.cfn,
146+
props.ioHelper,
147+
props.qualifier,
148+
props.ignoreNonCdkStacks,
149+
props.skipUnauthorizedStacks,
150+
);
110151
for (const stack of stacks) {
111152
props.activeAssets.rememberStack(stack);
112153
}
@@ -138,6 +179,16 @@ export interface BackgroundStackRefreshProps {
138179
* Stack bootstrap qualifier
139180
*/
140181
readonly qualifier?: string;
182+
183+
/**
184+
* Stack names to ignore during garbage collection
185+
*/
186+
readonly ignoreNonCdkStacks?: string[];
187+
188+
/**
189+
* Skip stacks that return AccessDenied errors
190+
*/
191+
readonly skipUnauthorizedStacks?: boolean;
141192
}
142193

143194
/**
@@ -166,6 +217,8 @@ export class BackgroundStackRefresh {
166217
ioHelper: this.props.ioHelper,
167218
activeAssets: this.props.activeAssets,
168219
qualifier: this.props.qualifier,
220+
ignoreNonCdkStacks: this.props.ignoreNonCdkStacks,
221+
skipUnauthorizedStacks: this.props.skipUnauthorizedStacks,
169222
});
170223
this.justRefreshedStacks();
171224

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,8 @@ export class CdkToolkit {
11331133
action: options.action ?? 'full',
11341134
type: options.type ?? 'all',
11351135
confirm: options.confirm ?? true,
1136+
ignoreNonCdkStacks: options.ignoreNonCdkStacks,
1137+
skipUnauthorizedStacks: options.skipUnauthorizedStacks,
11361138
});
11371139
await gc.garbageCollect();
11381140
}
@@ -1918,6 +1920,20 @@ export interface GarbageCollectionOptions {
19181920
* @default false
19191921
*/
19201922
readonly confirm?: boolean;
1923+
1924+
/**
1925+
* Stack names to ignore during garbage collection
1926+
*
1927+
* @default []
1928+
*/
1929+
readonly ignoreNonCdkStacks?: string[];
1930+
1931+
/**
1932+
* Skip stacks that return AccessDenied errors
1933+
*
1934+
* @default false
1935+
*/
1936+
readonly skipUnauthorizedStacks?: boolean;
19211937
}
19221938
export interface MigrateOptions {
19231939
/**

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+
ignoreNonCdkStacks: arrayFromYargs(args['ignore-non-cdk-stacks']),
491+
skipUnauthorizedStacks: args['skip-unauthorized-stacks'],
490492
});
491493

492494
case 'flags':

packages/aws-cdk/lib/cli/parse-command-line-arguments.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,18 @@ export function parseCommandLineArguments(args: Array<string>): any {
364364
deprecated: 'use --toolkit-stack-name',
365365
requiresArg: true,
366366
conflicts: 'toolkit-stack-name',
367+
})
368+
.option('ignore-non-cdk-stacks', {
369+
type: 'array',
370+
desc: 'Stack names to ignore during garbage collection (WARNING: Only use this for non-CDK stacks to avoid accidentally deleting assets)',
371+
default: [],
372+
nargs: 1,
373+
requiresArg: true,
374+
})
375+
.option('skip-unauthorized-stacks', {
376+
default: false,
377+
type: 'boolean',
378+
desc: 'Skip stacks that return AccessDenied errors',
367379
}),
368380
)
369381
.command('flags [FLAGNAME..]', 'View and toggle feature flags.', (yargs: Argv) =>

0 commit comments

Comments
 (0)