diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index dbe43118c..d034ac9e8 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -132,6 +132,7 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu | `CDK_TOOLKIT_I9000` | Provides bootstrap times | `info` | {@link Duration} | | `CDK_TOOLKIT_I9100` | Bootstrap progress | `info` | {@link BootstrapEnvironmentProgress} | | `CDK_TOOLKIT_I9210` | Confirm the deletion of a batch of assets | `info` | {@link AssetBatchDeletionRequest} | +| `CDK_TOOLKIT_I9211` | Confirm skipping unauthorized stacks during garbage collection | `info` | {@link UnauthorizedStacksRequest} | | `CDK_TOOLKIT_I9900` | Bootstrap results on success | `result` | [cxapi.Environment](https://docs.aws.amazon.com/cdk/api/v2/docs/@aws-cdk_cx-api.Environment.html) | | `CDK_TOOLKIT_E9900` | Bootstrap failed | `error` | {@link ErrorPayload} | | `CDK_TOOLKIT_I9300` | Confirm the feature flag configuration changes | `info` | {@link FeatureFlagChangeRequest} | diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/README.md b/packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/README.md new file mode 100644 index 000000000..239dfd351 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/README.md @@ -0,0 +1,78 @@ +# CDK Garbage Collection - Skip Unauthorized Native CloudFormation Stacks + +This document describes the `--unauth-native-cfn-stacks-to-skip` option that allows users to provide patterns to automatically skip unauthorized native CloudFormation stacks. + +## Overview + +When CDK Garbage Collection scans CloudFormation stacks to determine which assets are still in use, it may encounter stacks that it cannot access due to insufficient permissions. + +**Without skip patterns configured:** +1. **Prompt the user** asking whether to skip the unauthorized stacks +2. **Default to 'no'** - the operation will be cancelled unless the user explicitly chooses to skip +3. **List the unauthorized stacks** that were found + +**With skip patterns configured:** +- Stacks matching the patterns are automatically skipped without prompting +- Only non-matching unauthorized stacks will prompt the user + +The user needs to ensure that the stacks they intend to skip are native CloudFormation stacks (not CDK-managed). The option does NOT check this. Attempting to skip CDK stacks during gc can be hazardous + +Example prompt: +``` +Found 3 unauthorized stack(s): Legacy-App-Stack, +Legacy-DB-Stack, +ThirdParty-Service +Do you want to skip all these stacks? Default is 'no' [y]es/[n]o +``` + +## Skip Patterns Configuration + +Users can provide glob patterns to automatically skip unauthorized stacks using the `--unauth-native-cfn-stacks-to-skip` option: + +```bash +cdk gc --unstable=gc --unauth-native-cfn-stacks-to-skip "Legacy-*" "ThirdParty-*" +``` + +**How it works:** +- Patterns are checked against unauthorized stack names +- Matching stacks are automatically skipped +- Non-matching unauthorized stacks still prompt the user with default 'no' + +### Pattern Matching + +- Supports glob patterns (`*`, `**`) +- Extracts stack names from ARNs automatically +- Case-sensitive matching + +Examples: +- `Legacy-*` matches `Legacy-App-Stack`, `Legacy-DB-Stack` +- `*-Prod` matches `MyApp-Prod`, `Database-Prod` +- `ThirdParty-*` matches `ThirdParty-Service`, `ThirdParty-API` + +## Security Considerations + +The default behavior of requiring explicit user confirmation to skip stacks helps prevent: + +- Accidentally skipping important stacks +- Missing assets that might be referenced by inaccessible stacks +- Unintended deletion of assets in shared environments + +## CI/CD Environments + +In CI/CD environments where user interaction is not possible: + +- The default 'no' response will cause the operation to fail +- Consider implementing proper IAM permissions instead of skipping stacks + + +## Implementation Details + +The skip patterns feature is implemented in `stack-refresh.ts`: + +1. Attempt to access each stack template +2. Catch `AccessDenied` errors +3. Check if stack name matches any user-provided skip patterns +4. **If pattern matches:** automatically skip without prompting +5. **If no pattern matches:** prompt user whether to skip (defaults to 'no') + +This ensures that only stacks matching user-specified patterns are skipped automatically, maintaining security by default. \ No newline at end of file diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/garbage-collector.ts b/packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/garbage-collector.ts index 84f13239e..c1859d350 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/garbage-collector.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/garbage-collector.ts @@ -178,6 +178,14 @@ interface GarbageCollectorProps { * @default true */ readonly confirm?: boolean; + + /** + * Native CloudFormation stack names or glob patterns to skip when encountering unauthorized access errors during garbage collection. + * You must explicitly specify native CloudFormation stack names + * + * @default undefined + */ + readonly unauthNativeCfnStacksToSkip?: string[]; } /** @@ -191,6 +199,7 @@ export class GarbageCollector { private bootstrapStackName: string; private confirm: boolean; private ioHelper: IoHelper; + private unauthNativeCfnStacksToSkip?: string[]; public constructor(readonly props: GarbageCollectorProps) { this.ioHelper = props.ioHelper; @@ -201,6 +210,7 @@ export class GarbageCollector { this.permissionToDelete = ['delete-tagged', 'full'].includes(props.action); this.permissionToTag = ['tag', 'full'].includes(props.action); this.confirm = props.confirm ?? true; + this.unauthNativeCfnStacksToSkip = props.unauthNativeCfnStacksToSkip; this.bootstrapStackName = props.bootstrapStackName ?? DEFAULT_TOOLKIT_STACK_NAME; } @@ -224,6 +234,7 @@ export class GarbageCollector { ioHelper: this.ioHelper, activeAssets, qualifier, + unauthNativeCfnStacksToSkip: this.unauthNativeCfnStacksToSkip, }); // Start the background refresh const backgroundStackRefresh = new BackgroundStackRefresh({ @@ -231,6 +242,7 @@ export class GarbageCollector { ioHelper: this.ioHelper, activeAssets, qualifier, + unauthNativeCfnStacksToSkip: this.unauthNativeCfnStacksToSkip, }); backgroundStackRefresh.start(); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/stack-refresh.ts b/packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/stack-refresh.ts index 83e13ba86..4dfa27dea 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/stack-refresh.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/stack-refresh.ts @@ -1,7 +1,9 @@ import type { ParameterDeclaration } from '@aws-sdk/client-cloudformation'; +import { minimatch } from 'minimatch'; import { ToolkitError } from '../../toolkit/toolkit-error'; import type { ICloudFormationClient } from '../aws-auth/private'; import type { IoHelper } from '../io/private'; +import { IO } from '../io/private/messages'; export class ActiveAssetCache { private readonly stacks: Set = new Set(); @@ -11,6 +13,9 @@ export class ActiveAssetCache { } public contains(asset: string): boolean { + // To reduce computation if asset is empty + if (asset=='') return false; + for (const stack of this.stacks) { if (stack.includes(asset)) { return true; @@ -20,6 +25,23 @@ export class ActiveAssetCache { } } +/** + * Check if a stack name matches any of the skip patterns using glob matching + */ +function shouldSkipStack(stackName: string, skipPatterns?: string[]): boolean { + if (!skipPatterns || skipPatterns.length === 0) { + return false; + } + + // Extract stack name from ARN if entire path is passed + // fetchAllStackTemplates can return either stack name or id so we handle both + const extractedStackName = stackName.includes(':cloudformation:') && stackName.includes(':stack/') + ? stackName.split('/')[1] || stackName + : stackName; + + return skipPatterns.some(pattern => minimatch(extractedStackName, pattern)); +} + async function paginateSdkCall(cb: (nextToken?: string) => Promise) { let finished = false; let nextToken: string | undefined; @@ -31,12 +53,51 @@ async function paginateSdkCall(cb: (nextToken?: string) => Promise { + if (unauthorizedStacks.length === 0) { + return; + } + + try { + // Ask user if they want to proceed. Default is no + // In CI environments, IoHelper automatically accepts the default response + const response = await ioHelper.requestResponse( + IO.CDK_TOOLKIT_I9211.req(`Found ${unauthorizedStacks.length} unauthorized stack(s): ${unauthorizedStacks.join(',\n')}\nDo you want to skip all these stacks? Default is 'no'`, { + stacks: unauthorizedStacks, + count: unauthorizedStacks.length, + responseDescription: '[y]es/[n]o', + }, 'n'), // To account for ci/cd environments, default remains no until a --yes flag is implemented for cdk-cli + ); + + // Throw error if user response is not yes or y + if (!response || !['y', 'yes'].includes(response.toLowerCase())) { + throw new ToolkitError('Operation cancelled by user due to unauthorized stacks'); + } + + await ioHelper.defaults.info(`Skipping ${unauthorizedStacks.length} unauthorized stack(s)`); + } catch (error) { + if (error instanceof ToolkitError) { + throw error; + } + throw new ToolkitError(`Failed to handle unauthorized stacks: ${error}`); + } +} + /** * Fetches all relevant stack templates from CloudFormation. It ignores the following stacks: * - stacks in DELETE_COMPLETE or DELETE_IN_PROGRESS stage * - stacks that are using a different bootstrap qualifier + * - unauthorized stacks that match the skip patterns (when specified) */ -async function fetchAllStackTemplates(cfn: ICloudFormationClient, ioHelper: IoHelper, qualifier?: string) { +async function fetchAllStackTemplates( + cfn: ICloudFormationClient, + ioHelper: IoHelper, + qualifier?: string, + unauthNativeCfnStacksToSkip?: string[], +) { const stackNames: string[] = []; await paginateSdkCall(async (nextToken) => { const stacks = await cfn.listStacks({ NextToken: nextToken }); @@ -55,24 +116,46 @@ async function fetchAllStackTemplates(cfn: ICloudFormationClient, ioHelper: IoHe await ioHelper.defaults.debug(`Parsing through ${stackNames.length} stacks`); const templates: string[] = []; + const unauthorizedStacks: string[] = []; + for (const stack of stackNames) { - let summary; - summary = await cfn.getTemplateSummary({ - StackName: stack, - }); + try { + let summary; + summary = await cfn.getTemplateSummary({ + StackName: stack, + }); + + if (bootstrapFilter(summary.Parameters, qualifier)) { + // This stack is definitely bootstrapped to a different qualifier so we can safely ignore it + continue; + } - if (bootstrapFilter(summary.Parameters, qualifier)) { - // This stack is definitely bootstrapped to a different qualifier so we can safely ignore it - continue; - } else { const template = await cfn.getTemplate({ StackName: stack, }); templates.push((template.TemplateBody ?? '') + JSON.stringify(summary?.Parameters)); + } catch (error: any) { + // Check if this is a CloudFormation access denied error + if (error.name === 'AccessDenied') { + if (shouldSkipStack(stack, unauthNativeCfnStacksToSkip)) { + unauthorizedStacks.push(stack); + continue; + } + + throw new ToolkitError( + `Access denied when trying to access stack '${stack}'. ` + + 'If this is a native CloudFormation stack that you want to skip, add it to --unauth-native-cfn-stacks-to-skip.', + ); + } + + // Re-throw the error if it's not handled + throw error; } } + await handleUnauthorizedStacks(unauthorizedStacks, ioHelper); + await ioHelper.defaults.debug('Done parsing through stacks'); return templates; @@ -102,11 +185,17 @@ export interface RefreshStacksProps { readonly ioHelper: IoHelper; readonly activeAssets: ActiveAssetCache; readonly qualifier?: string; + readonly unauthNativeCfnStacksToSkip?: string[]; } export async function refreshStacks(props: RefreshStacksProps) { try { - const stacks = await fetchAllStackTemplates(props.cfn, props.ioHelper, props.qualifier); + const stacks = await fetchAllStackTemplates( + props.cfn, + props.ioHelper, + props.qualifier, + props.unauthNativeCfnStacksToSkip, + ); for (const stack of stacks) { props.activeAssets.rememberStack(stack); } @@ -138,6 +227,11 @@ export interface BackgroundStackRefreshProps { * Stack bootstrap qualifier */ readonly qualifier?: string; + + /** + * Native CloudFormation stack names or glob patterns to skip when encountering unauthorized access errors + */ + readonly unauthNativeCfnStacksToSkip?: string[]; } /** @@ -166,6 +260,7 @@ export class BackgroundStackRefresh { ioHelper: this.props.ioHelper, activeAssets: this.props.activeAssets, qualifier: this.props.qualifier, + unauthNativeCfnStacksToSkip: this.props.unauthNativeCfnStacksToSkip, }); this.justRefreshedStacks(); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index 82435c09a..d22d4eae1 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -8,7 +8,7 @@ import type { BuildAsset, DeployConfirmationRequest, PublishAsset, StackDeployPr import type { StackDestroy, StackDestroyProgress } from '../../../payloads/destroy'; import type { DriftResultPayload } from '../../../payloads/drift'; import type { FeatureFlagChangeRequest } from '../../../payloads/flags'; -import type { AssetBatchDeletionRequest } from '../../../payloads/gc'; +import type { AssetBatchDeletionRequest, UnauthorizedStacksRequest } from '../../../payloads/gc'; import type { HotswapDeploymentDetails, HotswapDeploymentAttempt, HotswappableChange, HotswapResult } from '../../../payloads/hotswap'; import type { ResourceIdentificationRequest, ResourceImportRequest } from '../../../payloads/import'; import type { StackDetailsPayload } from '../../../payloads/list'; @@ -419,6 +419,11 @@ export const IO = { description: 'Confirm the deletion of a batch of assets', interface: 'AssetBatchDeletionRequest', }), + CDK_TOOLKIT_I9211: make.question({ + code: 'CDK_TOOLKIT_I9211', + description: 'Confirm skipping unauthorized stacks during garbage collection', + interface: 'UnauthorizedStacksRequest', + }), CDK_TOOLKIT_I9900: make.result<{ environment: cxapi.Environment }>({ code: 'CDK_TOOLKIT_I9900', diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/gc.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/gc.ts index b807745ff..23f7af88c 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/gc.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/gc.ts @@ -11,3 +11,11 @@ export interface AssetBatchDeletionRequest extends DataRequest { readonly createdBufferDays: number; }; } + +/** + * Request to confirm skipping unauthorized stacks during garbage collection. + */ +export interface UnauthorizedStacksRequest extends DataRequest { + readonly stacks: string[]; + readonly count: number; +} diff --git a/packages/@aws-cdk/toolkit-lib/test/api/aws-auth/__snapshots__/sdk-logger.test.ts.snap b/packages/@aws-cdk/toolkit-lib/test/api/aws-auth/__snapshots__/sdk-logger.test.ts.snap index 8fb0d999e..467b9b46b 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/aws-auth/__snapshots__/sdk-logger.test.ts.snap +++ b/packages/@aws-cdk/toolkit-lib/test/api/aws-auth/__snapshots__/sdk-logger.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`formatting a failing SDK call looks broadly reasonable 1`] = `"[2 attempts, 30ms retry] S3.GetBucketLocation({"Bucket":"....."}) -> Error: it failed"`; diff --git a/packages/@aws-cdk/toolkit-lib/test/api/garbage-collection/garbage-collection.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/garbage-collection/garbage-collection.test.ts index 242d66571..baa890dbc 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/garbage-collection/garbage-collection.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/garbage-collection/garbage-collection.test.ts @@ -735,6 +735,156 @@ describe('CloudFormation API calls', () => { }, }); }); + + test('skip stacks using glob patterns when unauthorized', async () => { + mockTheToolkitInfo({ Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '999' }] }); + + cfnClient.on(ListStacksCommand).resolves({ + StackSummaries: [ + { StackName: 'CDKStack1', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }, + { StackName: 'Legacy-App-Stack', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }, + { StackName: 'Legacy-DB-Stack', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }, + { StackName: 'ThirdParty-Service', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }, + ], + }); + + cfnClient.on(GetTemplateSummaryCommand, { StackName: 'CDKStack1' }).resolves({ + Parameters: [{ ParameterKey: 'BootstrapVersion', DefaultValue: '/cdk-bootstrap/abcde/version' }], + }); + cfnClient.on(GetTemplateCommand, { StackName: 'CDKStack1' }).resolves({ TemplateBody: 'cdk-template' }); + + const accessDeniedError = new Error('Access Denied'); + accessDeniedError.name = 'AccessDenied'; + + ['Legacy-App-Stack', 'Legacy-DB-Stack', 'ThirdParty-Service'].forEach(stackName => { + cfnClient.on(GetTemplateSummaryCommand, { StackName: stackName }).resolves({ Parameters: [] }); + cfnClient.on(GetTemplateCommand, { StackName: stackName }).rejects(accessDeniedError); + }); + + // Mock user response - IoHelper will use default 'n' in CI environments, so we need to explicitly say 'y' + ioHost.requestSpy.mockResolvedValue('y'); + + garbageCollector = new GarbageCollector({ + sdkProvider: new MockSdkProvider(), + ioHelper: ioHost.asHelper('gc'), + action: 'full', + resolvedEnvironment: { account: '123456789012', region: 'us-east-1', name: 'mock' }, + bootstrapStackName: 'GarbageStack', + rollbackBufferDays: 0, + createdBufferDays: 0, + type: 's3', + confirm: false, + unauthNativeCfnStacksToSkip: ['Legacy-*', 'ThirdParty-*'], + }); + + await garbageCollector.garbageCollect(); + + expect(cfnClient).toHaveReceivedCommandWith(GetTemplateCommand, { StackName: 'CDKStack1' }); + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ level: 'info', message: expect.stringContaining('Skipping 3 unauthorized stack(s)') }), + ); + }); + + test('fail on unauthorized stack not matching skip patterns', async () => { + mockTheToolkitInfo({ Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '999' }] }); + + cfnClient.on(ListStacksCommand).resolves({ + StackSummaries: [{ StackName: 'UnauthorizedStack', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }], + }); + + const accessDeniedError = new Error('Access Denied'); + accessDeniedError.name = 'AccessDenied'; + cfnClient.on(GetTemplateSummaryCommand, { StackName: 'UnauthorizedStack' }).resolves({ Parameters: [] }); + cfnClient.on(GetTemplateCommand, { StackName: 'UnauthorizedStack' }).rejects(accessDeniedError); + + garbageCollector = new GarbageCollector({ + sdkProvider: new MockSdkProvider(), + ioHelper: ioHost.asHelper('gc'), + action: 'full', + resolvedEnvironment: { account: '123456789012', region: 'us-east-1', name: 'mock' }, + bootstrapStackName: 'GarbageStack', + rollbackBufferDays: 0, + createdBufferDays: 0, + type: 's3', + confirm: false, + unauthNativeCfnStacksToSkip: ['Legacy-*'], + }); + + await expect(garbageCollector.garbageCollect()).rejects.toThrow( + "Access denied when trying to access stack 'UnauthorizedStack'", + ); + }); + + test('extract stack name from ARN for pattern matching', async () => { + mockTheToolkitInfo({ Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '999' }] }); + + const stackArn = 'arn:aws:cloudformation:us-east-1:123456789012:stack/Legacy-App-Stack/12345'; + cfnClient.on(ListStacksCommand).resolves({ + StackSummaries: [{ StackName: stackArn, StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }], + }); + + const accessDeniedError = new Error('Access Denied'); + accessDeniedError.name = 'AccessDenied'; + cfnClient.on(GetTemplateSummaryCommand, { StackName: stackArn }).resolves({ Parameters: [] }); + cfnClient.on(GetTemplateCommand, { StackName: stackArn }).rejects(accessDeniedError); + + // Mock user response - IoHelper will use default 'n' in CI environments, so we need to explicitly say 'y' + ioHost.requestSpy.mockResolvedValue('y'); + + garbageCollector = new GarbageCollector({ + sdkProvider: new MockSdkProvider(), + ioHelper: ioHost.asHelper('gc'), + action: 'full', + resolvedEnvironment: { account: '123456789012', region: 'us-east-1', name: 'mock' }, + bootstrapStackName: 'GarbageStack', + rollbackBufferDays: 0, + createdBufferDays: 0, + type: 's3', + confirm: false, + unauthNativeCfnStacksToSkip: ['Legacy-*'], + }); + + await garbageCollector.garbageCollect(); + + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ level: 'info', message: expect.stringContaining('Skipping 1 unauthorized stack(s)') }), + ); + }); + + test('user can decline to skip unauthorized stacks', async () => { + mockTheToolkitInfo({ Outputs: [{ OutputKey: 'BootstrapVersion', OutputValue: '999' }] }); + + cfnClient.on(ListStacksCommand).resolves({ + StackSummaries: [ + { StackName: 'Legacy-App-Stack', StackStatus: 'CREATE_COMPLETE', CreationTime: new Date() }, + ], + }); + + const accessDeniedError = new Error('Access Denied'); + accessDeniedError.name = 'AccessDenied'; + cfnClient.on(GetTemplateSummaryCommand, { StackName: 'Legacy-App-Stack' }).resolves({ Parameters: [] }); + cfnClient.on(GetTemplateCommand, { StackName: 'Legacy-App-Stack' }).rejects(accessDeniedError); + + // Mock user declining to skip + ioHost.requestSpy.mockResolvedValue('n'); + + garbageCollector = new GarbageCollector({ + sdkProvider: new MockSdkProvider(), + ioHelper: ioHost.asHelper('gc'), + action: 'full', + resolvedEnvironment: { account: '123456789012', region: 'us-east-1', name: 'mock' }, + bootstrapStackName: 'GarbageStack', + rollbackBufferDays: 0, + createdBufferDays: 0, + type: 's3', + confirm: false, + unauthNativeCfnStacksToSkip: ['Legacy-*'], + }); + + await expect(garbageCollector.garbageCollect()).rejects.toThrow( + 'Operation cancelled by user due to unauthorized stacks', + ); + }); }); function prepareDefaultCfnMock() { diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 19e548e86..08935d503 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -1136,6 +1136,7 @@ export class CdkToolkit { action: options.action ?? 'full', type: options.type ?? 'all', confirm: options.confirm ?? true, + unauthNativeCfnStacksToSkip: options.unauthNativeCfnStacksToSkip, }); await gc.garbageCollect(); } @@ -1921,6 +1922,14 @@ export interface GarbageCollectionOptions { * @default false */ readonly confirm?: boolean; + + /** + * 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. + * + * @default undefined + */ + readonly unauthNativeCfnStacksToSkip?: string[]; } export interface MigrateOptions { /** diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index fbe1aefcb..b46222cdc 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -114,6 +114,7 @@ export async function makeConfig(): Promise { 'confirm': { type: 'boolean', desc: 'Confirm via manual prompt before deletion', default: true }, 'toolkit-stack-name': { type: 'string', desc: 'The name of the CDK toolkit stack, if different from the default "CDKToolkit"', requiresArg: true, conflicts: 'bootstrap-stack-name' }, 'bootstrap-stack-name': { type: 'string', desc: 'The name of the CDK toolkit stack, if different from the default "CDKToolkit" (deprecated, use --toolkit-stack-name)', deprecated: 'use --toolkit-stack-name', requiresArg: true, conflicts: 'toolkit-stack-name' }, // TODO: remove when garbage collection is GA + 'unauth-native-cfn-stacks-to-skip': { type: 'array', 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.', default: [], requiresArg: true }, }, }, 'flags': { diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index 3d7cbb488..57335cbef 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -338,6 +338,12 @@ "deprecated": "use --toolkit-stack-name", "requiresArg": true, "conflicts": "toolkit-stack-name" + }, + "unauth-native-cfn-stacks-to-skip": { + "type": "array", + "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.", + "default": [], + "requiresArg": true } } }, diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index b60fc69ec..f978e8c1e 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -482,6 +482,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { deprecated: 'use --toolkit-stack-name', requiresArg: true, conflicts: 'toolkit-stack-name', + }) + .option('unauth-native-cfn-stacks-to-skip', { + type: 'array', + 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.', + default: [], + requiresArg: true, + nargs: 1, }), ) .command('flags [FLAGNAME..]', 'View and toggle feature flags.', (yargs: Argv) => diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 6a18b5873..e9b57c93f 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -618,6 +618,13 @@ export interface GcOptions { */ readonly bootstrapStackName?: string; + /** + * 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. + * + * @default - [] + */ + readonly unauthNativeCfnStacksToSkip?: Array; + /** * Positional argument for gc */