Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@aws-cdk/toolkit-lib/docs/message-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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} |
Expand Down
78 changes: 78 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/garbage-collection/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -224,13 +234,15 @@ export class GarbageCollector {
ioHelper: this.ioHelper,
activeAssets,
qualifier,
unauthNativeCfnStacksToSkip: this.unauthNativeCfnStacksToSkip,
});
// Start the background refresh
const backgroundStackRefresh = new BackgroundStackRefresh({
cfn,
ioHelper: this.ioHelper,
activeAssets,
qualifier,
unauthNativeCfnStacksToSkip: this.unauthNativeCfnStacksToSkip,
});
backgroundStackRefresh.start();

Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> = new Set();
Expand All @@ -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;
Expand All @@ -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<string | undefined>) {
let finished = false;
let nextToken: string | undefined;
Expand All @@ -31,12 +53,51 @@ async function paginateSdkCall(cb: (nextToken?: string) => Promise<string | unde
}
}

/**
* Handle unauthorized stacks by asking user if they want to skip them all
*/
async function handleUnauthorizedStacks(unauthorizedStacks: string[], ioHelper: IoHelper): Promise<void> {
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 });
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -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();

Expand Down
7 changes: 6 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -419,6 +419,11 @@ export const IO = {
description: 'Confirm the deletion of a batch of assets',
interface: 'AssetBatchDeletionRequest',
}),
CDK_TOOLKIT_I9211: make.question<UnauthorizedStacksRequest>({
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',
Expand Down
8 changes: 8 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/payloads/gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading