Skip to content

Commit 248ad08

Browse files
committed
feat(cli): add skip-unauthorized-stacks-when-noncdk option for garbage collection
- Removed old functionality on ignore-stacks & skip-unauthorized-stacks - Add --skip-unauthorized-stacks-when-noncdk CLI option to handle AccessDenied errors for non-cdk stack names entered by user - Update interfaces and constructors with new parameter
1 parent 2f0cfc4 commit 248ad08

File tree

6 files changed

+203
-10
lines changed

6 files changed

+203
-10
lines changed

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ interface GarbageCollectorProps {
178178
* @default true
179179
*/
180180
readonly confirm?: boolean;
181+
182+
/**
183+
* Non-CDK stack names or glob patterns to skip when encountering unauthorized access errors during garbage collection.
184+
* You must explicitly specify non-CDK stack names - CDK stacks will be rejected to prevent accidental asset deletion.
185+
*
186+
* @default undefined
187+
*/
188+
readonly skipUnauthorizedStacksWhenNonCdk?: string[];
181189
}
182190

183191
/**
@@ -191,6 +199,7 @@ export class GarbageCollector {
191199
private bootstrapStackName: string;
192200
private confirm: boolean;
193201
private ioHelper: IoHelper;
202+
private skipUnauthorizedStacksWhenNonCdk?: string[];
194203

195204
public constructor(readonly props: GarbageCollectorProps) {
196205
this.ioHelper = props.ioHelper;
@@ -201,6 +210,7 @@ export class GarbageCollector {
201210
this.permissionToDelete = ['delete-tagged', 'full'].includes(props.action);
202211
this.permissionToTag = ['tag', 'full'].includes(props.action);
203212
this.confirm = props.confirm ?? true;
213+
this.skipUnauthorizedStacksWhenNonCdk = props.skipUnauthorizedStacksWhenNonCdk;
204214

205215
this.bootstrapStackName = props.bootstrapStackName ?? DEFAULT_TOOLKIT_STACK_NAME;
206216
}
@@ -224,13 +234,15 @@ export class GarbageCollector {
224234
ioHelper: this.ioHelper,
225235
activeAssets,
226236
qualifier,
237+
skipUnauthorizedStacksWhenNonCdk: this.skipUnauthorizedStacksWhenNonCdk,
227238
});
228239
// Start the background refresh
229240
const backgroundStackRefresh = new BackgroundStackRefresh({
230241
cfn,
231242
ioHelper: this.ioHelper,
232243
activeAssets,
233244
qualifier,
245+
skipUnauthorizedStacksWhenNonCdk: this.skipUnauthorizedStacksWhenNonCdk,
234246
});
235247
backgroundStackRefresh.start();
236248

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

Lines changed: 66 additions & 10 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';
@@ -20,6 +21,23 @@ export class ActiveAssetCache {
2021
}
2122
}
2223

24+
/**
25+
* Check if a stack name matches any of the skip patterns using glob matching
26+
*/
27+
function shouldSkipStack(stackName: string, skipPatterns?: string[]): boolean {
28+
if (!skipPatterns || skipPatterns.length === 0) {
29+
return false;
30+
}
31+
32+
// Extract stack name from ARN if entire path is passed
33+
// fetchAllStackTemplates can return either stack name or id so we handle both
34+
const extractedStackName = stackName.includes(':cloudformation:') && stackName.includes(':stack/')
35+
? stackName.split('/')[1] || stackName
36+
: stackName;
37+
38+
return skipPatterns.some(pattern => minimatch(extractedStackName, pattern));
39+
}
40+
2341
async function paginateSdkCall(cb: (nextToken?: string) => Promise<string | undefined>) {
2442
let finished = false;
2543
let nextToken: string | undefined;
@@ -35,8 +53,14 @@ async function paginateSdkCall(cb: (nextToken?: string) => Promise<string | unde
3553
* Fetches all relevant stack templates from CloudFormation. It ignores the following stacks:
3654
* - stacks in DELETE_COMPLETE or DELETE_IN_PROGRESS stage
3755
* - stacks that are using a different bootstrap qualifier
56+
* - unauthorized stacks that match the skip patterns (when specified)
3857
*/
39-
async function fetchAllStackTemplates(cfn: ICloudFormationClient, ioHelper: IoHelper, qualifier?: string) {
58+
async function fetchAllStackTemplates(
59+
cfn: ICloudFormationClient,
60+
ioHelper: IoHelper,
61+
qualifier?: string,
62+
skipUnauthorizedStacksWhenNonCdk?: string[],
63+
) {
4064
const stackNames: string[] = [];
4165
await paginateSdkCall(async (nextToken) => {
4266
const stacks = await cfn.listStacks({ NextToken: nextToken });
@@ -56,20 +80,40 @@ async function fetchAllStackTemplates(cfn: ICloudFormationClient, ioHelper: IoHe
5680

5781
const templates: string[] = [];
5882
for (const stack of stackNames) {
59-
let summary;
60-
summary = await cfn.getTemplateSummary({
61-
StackName: stack,
62-
});
83+
try {
84+
let summary;
85+
summary = await cfn.getTemplateSummary({
86+
StackName: stack,
87+
});
88+
89+
if (bootstrapFilter(summary.Parameters, qualifier)) {
90+
// This stack is definitely bootstrapped to a different qualifier so we can safely ignore it
91+
continue;
92+
}
6393

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 {
6894
const template = await cfn.getTemplate({
6995
StackName: stack,
7096
});
7197

7298
templates.push((template.TemplateBody ?? '') + JSON.stringify(summary?.Parameters));
99+
} catch (error: any) {
100+
// Check if this is a CloudFormation access denied error
101+
if (error.name === 'AccessDenied') {
102+
if (shouldSkipStack(stack, skipUnauthorizedStacksWhenNonCdk)) {
103+
await ioHelper.defaults.warn(
104+
`Skipping unauthorized stack '${stack}' as specified in --skip-unauthorized-stacks-when-noncdk`,
105+
);
106+
continue;
107+
}
108+
109+
throw new ToolkitError(
110+
`Access denied when trying to access stack '${stack}'. ` +
111+
'If this is a non-CDK stack that you want to skip, add it to --skip-unauthorized-stacks-when-noncdk.',
112+
);
113+
}
114+
115+
// Re-throw the error if it's not handled
116+
throw error;
73117
}
74118
}
75119

@@ -102,11 +146,17 @@ export interface RefreshStacksProps {
102146
readonly ioHelper: IoHelper;
103147
readonly activeAssets: ActiveAssetCache;
104148
readonly qualifier?: string;
149+
readonly skipUnauthorizedStacksWhenNonCdk?: string[];
105150
}
106151

107152
export async function refreshStacks(props: RefreshStacksProps) {
108153
try {
109-
const stacks = await fetchAllStackTemplates(props.cfn, props.ioHelper, props.qualifier);
154+
const stacks = await fetchAllStackTemplates(
155+
props.cfn,
156+
props.ioHelper,
157+
props.qualifier,
158+
props.skipUnauthorizedStacksWhenNonCdk,
159+
);
110160
for (const stack of stacks) {
111161
props.activeAssets.rememberStack(stack);
112162
}
@@ -138,6 +188,11 @@ export interface BackgroundStackRefreshProps {
138188
* Stack bootstrap qualifier
139189
*/
140190
readonly qualifier?: string;
191+
192+
/**
193+
* Non-CDK stack names or glob patterns to skip when encountering unauthorized access errors
194+
*/
195+
readonly skipUnauthorizedStacksWhenNonCdk?: string[];
141196
}
142197

143198
/**
@@ -166,6 +221,7 @@ export class BackgroundStackRefresh {
166221
ioHelper: this.props.ioHelper,
167222
activeAssets: this.props.activeAssets,
168223
qualifier: this.props.qualifier,
224+
skipUnauthorizedStacksWhenNonCdk: this.props.skipUnauthorizedStacksWhenNonCdk,
169225
});
170226
this.justRefreshedStacks();
171227

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

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,115 @@ describe('CloudFormation API calls', () => {
735735
},
736736
});
737737
});
738+
739+
test('skip stacks using glob patterns when unauthorized', async () => {
740+
mockTheToolkitInfo({ Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '999' }] });
741+
742+
cfnClient.on(ListStacksCommand).resolves({
743+
StackSummaries: [
744+
{ StackName: 'CDKStack1', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() },
745+
{ StackName: 'Legacy-App-Stack', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() },
746+
{ StackName: 'Legacy-DB-Stack', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() },
747+
{ StackName: 'ThirdParty-Service', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() },
748+
],
749+
});
750+
751+
cfnClient.on(GetTemplateSummaryCommand, { StackName: 'CDKStack1' }).resolves({
752+
Parameters: [{ ParameterKey: 'BootstrapVersion', DefaultValue: '/cdk-bootstrap/abcde/version' }],
753+
});
754+
cfnClient.on(GetTemplateCommand, { StackName: 'CDKStack1' }).resolves({ TemplateBody: 'cdk-template' });
755+
756+
const accessDeniedError = new Error('Access Denied');
757+
accessDeniedError.name = 'AccessDenied';
758+
759+
['Legacy-App-Stack', 'Legacy-DB-Stack', 'ThirdParty-Service'].forEach(stackName => {
760+
cfnClient.on(GetTemplateSummaryCommand, { StackName: stackName }).resolves({ Parameters: [] });
761+
cfnClient.on(GetTemplateCommand, { StackName: stackName }).rejects(accessDeniedError);
762+
});
763+
764+
garbageCollector = new GarbageCollector({
765+
sdkProvider: new MockSdkProvider(),
766+
ioHelper: ioHost.asHelper('gc'),
767+
action: 'full',
768+
resolvedEnvironment: { account: '123456789012', region: 'us-east-1', name: 'mock' },
769+
bootstrapStackName: 'GarbageStack',
770+
rollbackBufferDays: 0,
771+
createdBufferDays: 0,
772+
type: 's3',
773+
confirm: false,
774+
skipUnauthorizedStacksWhenNonCdk: ['Legacy-*', 'ThirdParty-*'],
775+
});
776+
777+
await garbageCollector.garbageCollect();
778+
779+
expect(cfnClient).toHaveReceivedCommandWith(GetTemplateCommand, { StackName: 'CDKStack1' });
780+
expect(ioHost.notifySpy).toHaveBeenCalledWith(
781+
expect.objectContaining({ level: 'warn', message: expect.stringContaining("Skipping unauthorized stack 'Legacy-App-Stack'") }),
782+
);
783+
});
784+
785+
test('fail on unauthorized stack not matching skip patterns', async () => {
786+
mockTheToolkitInfo({ Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '999' }] });
787+
788+
cfnClient.on(ListStacksCommand).resolves({
789+
StackSummaries: [{ StackName: 'UnauthorizedStack', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }],
790+
});
791+
792+
const accessDeniedError = new Error('Access Denied');
793+
accessDeniedError.name = 'AccessDenied';
794+
cfnClient.on(GetTemplateSummaryCommand, { StackName: 'UnauthorizedStack' }).resolves({ Parameters: [] });
795+
cfnClient.on(GetTemplateCommand, { StackName: 'UnauthorizedStack' }).rejects(accessDeniedError);
796+
797+
garbageCollector = new GarbageCollector({
798+
sdkProvider: new MockSdkProvider(),
799+
ioHelper: ioHost.asHelper('gc'),
800+
action: 'full',
801+
resolvedEnvironment: { account: '123456789012', region: 'us-east-1', name: 'mock' },
802+
bootstrapStackName: 'GarbageStack',
803+
rollbackBufferDays: 0,
804+
createdBufferDays: 0,
805+
type: 's3',
806+
confirm: false,
807+
skipUnauthorizedStacksWhenNonCdk: ['Legacy-*'],
808+
});
809+
810+
await expect(garbageCollector.garbageCollect()).rejects.toThrow(
811+
"Access denied when trying to access stack 'UnauthorizedStack'",
812+
);
813+
});
814+
815+
test('extract stack name from ARN for pattern matching', async () => {
816+
mockTheToolkitInfo({ Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '999' }] });
817+
818+
const stackArn = 'arn:aws:cloudformation:us-east-1:123456789012:stack/Legacy-App-Stack/12345';
819+
cfnClient.on(ListStacksCommand).resolves({
820+
StackSummaries: [{ StackName: stackArn, StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }],
821+
});
822+
823+
const accessDeniedError = new Error('Access Denied');
824+
accessDeniedError.name = 'AccessDenied';
825+
cfnClient.on(GetTemplateSummaryCommand, { StackName: stackArn }).resolves({ Parameters: [] });
826+
cfnClient.on(GetTemplateCommand, { StackName: stackArn }).rejects(accessDeniedError);
827+
828+
garbageCollector = new GarbageCollector({
829+
sdkProvider: new MockSdkProvider(),
830+
ioHelper: ioHost.asHelper('gc'),
831+
action: 'full',
832+
resolvedEnvironment: { account: '123456789012', region: 'us-east-1', name: 'mock' },
833+
bootstrapStackName: 'GarbageStack',
834+
rollbackBufferDays: 0,
835+
createdBufferDays: 0,
836+
type: 's3',
837+
confirm: false,
838+
skipUnauthorizedStacksWhenNonCdk: ['Legacy-*'],
839+
});
840+
841+
await garbageCollector.garbageCollect();
842+
843+
expect(ioHost.notifySpy).toHaveBeenCalledWith(
844+
expect.objectContaining({ level: 'warn', message: expect.stringContaining('Skipping unauthorized stack') }),
845+
);
846+
});
738847
});
739848

740849
function prepareDefaultCfnMock() {

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,7 @@ export class CdkToolkit {
11331133
action: options.action ?? 'full',
11341134
type: options.type ?? 'all',
11351135
confirm: options.confirm ?? true,
1136+
skipUnauthorizedStacksWhenNonCdk: options.skipUnauthorizedStacksWhenNonCdk,
11361137
});
11371138
await gc.garbageCollect();
11381139
}
@@ -1918,6 +1919,14 @@ export interface GarbageCollectionOptions {
19181919
* @default false
19191920
*/
19201921
readonly confirm?: boolean;
1922+
1923+
/**
1924+
* Non-CDK stack names or glob patterns to skip when encountering unauthorized access errors during garbage collection.
1925+
* You must explicitly specify non-CDK stack names - CDK stacks will be rejected to prevent accidental asset deletion.
1926+
*
1927+
* @default undefined
1928+
*/
1929+
readonly skipUnauthorizedStacksWhenNonCdk?: string[];
19211930
}
19221931
export interface MigrateOptions {
19231932
/**

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ 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+
skipUnauthorizedStacksWhenNonCdk: arrayFromYargs(args['skip-unauthorized-stacks-when-noncdk']),
490491
});
491492

492493
case 'flags':

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,12 @@ export function parseCommandLineArguments(args: Array<string>): any {
364364
deprecated: 'use --toolkit-stack-name',
365365
requiresArg: true,
366366
conflicts: 'toolkit-stack-name',
367+
})
368+
.option('skip-unauthorized-stacks-when-noncdk', {
369+
type: 'array',
370+
desc: 'Non-CDK stack names or glob patterns to skip when encountering unauthorized access errors during garbage collection. You must explicitly specify non-CDK stack names - CDK stacks will be rejected.',
371+
default: [],
372+
requiresArg: true,
367373
}),
368374
)
369375
.command('flags [FLAGNAME..]', 'View and toggle feature flags.', (yargs: Argv) =>

0 commit comments

Comments
 (0)