diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/metadata-schema.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/metadata-schema.ts index 2896e42eb..f2b6affb7 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/metadata-schema.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/metadata-schema.ts @@ -312,6 +312,11 @@ export enum ArtifactMetadataEntryType { * Represents tags of a stack. */ STACK_TAGS = 'aws:cdk:stack-tags', + + /** + * Whether the resource should be excluded during refactoring. + */ + DO_NOT_REFACTOR = 'aws:cdk:do-not-refactor', } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/refactor/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/refactor/index.ts index c948cb652..8fc9f06fe 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/refactor/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/refactor/index.ts @@ -14,4 +14,17 @@ export interface RefactorOptions { * @default - all stacks */ stacks?: StackSelector; + + /** + * A list of resources that will not be part of the refactor. + * Elements of this list must be the _destination_ locations + * that should be excluded, i.e., the location to which a + * resource would be moved if the refactor were to happen. + * + * The format of the locations in the file can be either: + * + * - Stack name and logical ID (e.g. `Stack1.MyQueue`) + * - A construct path (e.g. `Stack1/Foo/Bar/Resource`). + */ + exclude?: string[]; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/cloudformation.ts b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/cloudformation.ts index 949171efc..6828996df 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/cloudformation.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/cloudformation.ts @@ -1,3 +1,4 @@ +import type { TypedMapping } from '@aws-cdk/cloudformation-diff'; import type * as cxapi from '@aws-cdk/cx-api'; export interface CloudFormationTemplate { @@ -15,3 +16,54 @@ export interface CloudFormationStack { readonly stackName: string; readonly template: CloudFormationTemplate; } + +/** + * This class mirrors the `ResourceLocation` interface from CloudFormation, + * but is richer, since it has a reference to the stack object, rather than + * merely the stack name. + */ +export class ResourceLocation { + constructor(public readonly stack: CloudFormationStack, public readonly logicalResourceId: string) { + } + + public toPath(): string { + const stack = this.stack; + const resource = stack.template.Resources?.[this.logicalResourceId]; + const result = resource?.Metadata?.['aws:cdk:path']; + + if (result != null) { + return result; + } + + // If the path is not available, we can use stack name and logical ID + return `${stack.stackName}.${this.logicalResourceId}`; + } + + public getType(): string { + const resource = this.stack.template.Resources?.[this.logicalResourceId ?? '']; + return resource?.Type ?? 'Unknown'; + } + + public equalTo(other: ResourceLocation): boolean { + return this.logicalResourceId === other.logicalResourceId && this.stack.stackName === other.stack.stackName; + } +} + +/** + * A mapping between a source and a destination location. + */ +export class ResourceMapping { + constructor(public readonly source: ResourceLocation, public readonly destination: ResourceLocation) { + } + + public toTypedMapping(): TypedMapping { + return { + // the type is the same in both source and destination, + // so we can use either one + type: this.source.getType(), + sourcePath: this.source.toPath(), + destinationPath: this.destination.toPath(), + }; + } +} + diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/exclude.ts b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/exclude.ts new file mode 100644 index 000000000..72a88859d --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/exclude.ts @@ -0,0 +1,112 @@ +import type { AssemblyManifest } from '@aws-cdk/cloud-assembly-schema'; +import { ArtifactMetadataEntryType, ArtifactType } from '@aws-cdk/cloud-assembly-schema'; +import type { ResourceLocation as CfnResourceLocation } from '@aws-sdk/client-cloudformation'; +import type { ResourceLocation } from './cloudformation'; + +export interface ExcludeList { + isExcluded(location: ResourceLocation): boolean; +} + +export class ManifestExcludeList implements ExcludeList { + private readonly excludedLocations: CfnResourceLocation[]; + + constructor(manifest: AssemblyManifest) { + this.excludedLocations = this.getExcludedLocations(manifest); + } + + private getExcludedLocations(asmManifest: AssemblyManifest): CfnResourceLocation[] { + // First, we need to filter the artifacts to only include CloudFormation stacks + const stackManifests = Object.entries(asmManifest.artifacts ?? {}).filter( + ([_, manifest]) => manifest.type === ArtifactType.AWS_CLOUDFORMATION_STACK, + ); + + const result: CfnResourceLocation[] = []; + for (let [stackName, manifest] of stackManifests) { + const locations = Object.values(manifest.metadata ?? {}) + // Then pick only the resources in each stack marked with DO_NOT_REFACTOR + .filter((entries) => + entries.some((entry) => entry.type === ArtifactMetadataEntryType.DO_NOT_REFACTOR && entry.data === true), + ) + // Finally, get the logical ID of each resource + .map((entries) => { + const logicalIdEntry = entries.find((entry) => entry.type === ArtifactMetadataEntryType.LOGICAL_ID); + const location: CfnResourceLocation = { + StackName: stackName, + LogicalResourceId: logicalIdEntry!.data! as string, + }; + return location; + }); + result.push(...locations); + } + return result; + } + + isExcluded(location: ResourceLocation): boolean { + return this.excludedLocations.some( + (loc) => loc.StackName === location.stack.stackName && loc.LogicalResourceId === location.logicalResourceId, + ); + } +} + +export class InMemoryExcludeList implements ExcludeList { + private readonly excludedLocations: CfnResourceLocation[]; + private readonly excludedPaths: string[]; + + constructor(items: string[]) { + this.excludedLocations = []; + this.excludedPaths = []; + + if (items.length === 0) { + return; + } + + const locationRegex = /^[A-Za-z0-9]+\.[A-Za-z0-9]+$/; + + items.forEach((item: string) => { + if (locationRegex.test(item)) { + const [stackName, logicalId] = item.split('.'); + this.excludedLocations.push({ + StackName: stackName, + LogicalResourceId: logicalId, + }); + } else { + this.excludedPaths.push(item); + } + }); + } + + isExcluded(location: ResourceLocation): boolean { + const containsLocation = this.excludedLocations.some((loc) => { + return loc.StackName === location.stack.stackName && loc.LogicalResourceId === location.logicalResourceId; + }); + + const containsPath = this.excludedPaths.some((path) => location.toPath() === path); + return containsLocation || containsPath; + } +} + +export class UnionExcludeList implements ExcludeList { + constructor(private readonly excludeLists: ExcludeList[]) { + } + + isExcluded(location: ResourceLocation): boolean { + return this.excludeLists.some((excludeList) => excludeList.isExcluded(location)); + } +} + +export class NeverExclude implements ExcludeList { + isExcluded(_location: ResourceLocation): boolean { + return false; + } +} + +export class AlwaysExclude implements ExcludeList { + isExcluded(_location: ResourceLocation): boolean { + return true; + } +} + +export function fromManifestAndExclusionList(manifest: AssemblyManifest, exclude?: string[]): ExcludeList { + return new UnionExcludeList([new ManifestExcludeList(manifest), new InMemoryExcludeList(exclude ?? [])]); +} + diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/index.ts b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/index.ts index 02f17a073..98e2b15ea 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/index.ts @@ -10,7 +10,11 @@ import type { SdkProvider } from '../aws-auth/private'; import { Mode } from '../plugin'; import { StringWriteStream } from '../streams'; import type { CloudFormationStack } from './cloudformation'; +import { ResourceMapping, ResourceLocation } from './cloudformation'; import { computeResourceDigests, hashObject } from './digest'; +import { NeverExclude, type ExcludeList } from './exclude'; + +export * from './exclude'; /** * Represents a set of possible movements of a resource from one location @@ -33,56 +37,6 @@ export class AmbiguityError extends Error { } } -/** - * This class mirrors the `ResourceLocation` interface from CloudFormation, - * but is richer, since it has a reference to the stack object, rather than - * merely the stack name. - */ -export class ResourceLocation { - constructor(public readonly stack: CloudFormationStack, public readonly logicalResourceId: string) { - } - - public toPath(): string { - const stack = this.stack; - const resource = stack.template.Resources?.[this.logicalResourceId]; - const result = resource?.Metadata?.['aws:cdk:path']; - - if (result != null) { - return result; - } - - // If the path is not available, we can use stack name and logical ID - return `${stack.stackName}.${this.logicalResourceId}`; - } - - public getType(): string { - const resource = this.stack.template.Resources?.[this.logicalResourceId ?? '']; - return resource?.Type ?? 'Unknown'; - } - - public equalTo(other: ResourceLocation): boolean { - return this.logicalResourceId === other.logicalResourceId && this.stack.stackName === other.stack.stackName; - } -} - -/** - * A mapping between a source and a destination location. - */ -export class ResourceMapping { - constructor(public readonly source: ResourceLocation, public readonly destination: ResourceLocation) { - } - - public toTypedMapping(): TypedMapping { - return { - // the type is the same in both source and destination, - // so we can use either one - type: this.source.getType(), - sourcePath: this.source.toPath(), - destinationPath: this.destination.toPath(), - }; - } -} - function groupByKey(entries: [string, A][]): Record { const result: Record = {}; @@ -118,25 +72,27 @@ export function ambiguousMovements(movements: ResourceMovement[]) { * Converts a list of unambiguous resource movements into a list of resource mappings. * */ -export function resourceMappings(movements: ResourceMovement[], stacks?: CloudFormationStack[]): ResourceMapping[] { - const predicate = stacks == null - ? () => true - : (m: ResourceMapping) => { - // Any movement that involves one of the selected stacks (either moving from or to) - // is considered a candidate for refactoring. - const stackNames = [m.source.stack.stackName, m.destination.stack.stackName]; - return stacks.some((stack) => stackNames.includes(stack.stackName)); - }; +export function resourceMappings( + movements: ResourceMovement[], + stacks?: CloudFormationStack[], +): ResourceMapping[] { + const stacksPredicate = + stacks == null + ? () => true + : (m: ResourceMapping) => { + // Any movement that involves one of the selected stacks (either moving from or to) + // is considered a candidate for refactoring. + const stackNames = [m.source.stack.stackName, m.destination.stack.stackName]; + return stacks.some((stack) => stackNames.includes(stack.stackName)); + }; return movements .filter(([pre, post]) => pre.length === 1 && post.length === 1 && !pre[0].equalTo(post[0])) .map(([pre, post]) => new ResourceMapping(pre[0], post[0])) - .filter(predicate); + .filter(stacksPredicate); } -function removeUnmovedResources( - m: Record, -): Record { +function removeUnmovedResources(m: Record): Record { const result: Record = {}; for (const [hash, [before, after]] of Object.entries(m)) { const common = before.filter((b) => after.some((a) => a.equalTo(b))); @@ -196,6 +152,7 @@ function resourceDigests(stack: CloudFormationStack): [string, ResourceLocation] export async function findResourceMovements( stacks: CloudFormationStack[], sdkProvider: SdkProvider, + exclude: ExcludeList = new NeverExclude(), ): Promise { const stackGroups: Map = new Map(); @@ -216,7 +173,11 @@ export async function findResourceMovements( for (const [_, [before, after]] of stackGroups) { result.push(...resourceMovements(before, after)); } - return result; + + return result.filter(mov => { + const after = mov[1]; + return after.every(l => !exclude.isExcluded(l)); + }); } async function getDeployedStacks( diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 245266d85..b656b6136 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -48,7 +48,7 @@ import type { IIoHost, IoMessageLevel } from '../api/io'; import type { IoHelper } from '../api/io/private'; import { asIoHelper, asSdkLogger, IO, SPAN, withoutColor, withoutEmojis, withTrimmedWhitespace } from '../api/io/private'; import { CloudWatchLogEventMonitor, findCloudWatchLogGroups } from '../api/logs-monitor'; -import { AmbiguityError, ambiguousMovements, findResourceMovements, formatAmbiguousMappings, formatTypedMappings, resourceMappings } from '../api/refactoring'; +import { AmbiguityError, ambiguousMovements, findResourceMovements, formatAmbiguousMappings, formatTypedMappings, fromManifestAndExclusionList, resourceMappings } from '../api/refactoring'; import { ResourceMigrator } from '../api/resource-import'; import type { AssemblyData, StackDetails, SuccessfulDeployStackResult, ToolkitAction } from '../api/shared-public'; import { PermissionChangeType, PluginHost, ToolkitError } from '../api/shared-public'; @@ -223,7 +223,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { const bootstrapSpan = await ioHelper.span(SPAN.BOOTSTRAP_SINGLE) .begin(`${chalk.bold(environment.name)}: bootstrapping...`, { total: bootstrapEnvironments.length, - current: currentIdx+1, + current: currentIdx + 1, environment, }); @@ -317,7 +317,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { /** * Diff Action */ - public async diff(cx: ICloudAssemblySource, options: DiffOptions): Promise<{ [name: string]: TemplateDiff}> { + public async diff(cx: ICloudAssemblySource, options: DiffOptions): Promise<{ [name: string]: TemplateDiff }> { const ioHelper = asIoHelper(this.ioHost, 'diff'); const selectStacks = options.stacks ?? ALL_STACKS; const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: selectStacks }); @@ -603,7 +603,10 @@ export class Toolkit extends CloudAssemblySourceBuilder { // Perform a rollback await this._rollback(assembly, action, { - stacks: { patterns: [stack.hierarchicalId], strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE }, + stacks: { + patterns: [stack.hierarchicalId], + strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE, + }, orphanFailedResources: options.orphanFailedResourcesDuringRollback, }); @@ -743,7 +746,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { if (options.include === undefined && options.exclude === undefined) { throw new ToolkitError( "Cannot use the 'watch' command without specifying at least one directory to monitor. " + - 'Make sure to add a "watch" key to your cdk.json', + 'Make sure to add a "watch" key to your cdk.json', ); } @@ -964,11 +967,13 @@ export class Toolkit extends CloudAssemblySourceBuilder { const stacks = await assembly.selectStacksV2(ALL_STACKS); const sdkProvider = await this.sdkProvider('refactor'); - const movements = await findResourceMovements(stacks.stackArtifacts, sdkProvider); + const exclude = fromManifestAndExclusionList(assembly.cloudAssembly.manifest, options.exclude); + const movements = await findResourceMovements(stacks.stackArtifacts, sdkProvider, exclude); const ambiguous = ambiguousMovements(movements); if (ambiguous.length === 0) { const filteredStacks = await assembly.selectStacksV2(options.stacks ?? ALL_STACKS); - const typedMappings = resourceMappings(movements, filteredStacks.stackArtifacts).map(m => m.toTypedMapping()); + const mappings = resourceMappings(movements, filteredStacks.stackArtifacts); + const typedMappings = mappings.map(m => m.toTypedMapping()); await ioHelper.notify(IO.CDK_TOOLKIT_I8900.msg(formatTypedMappings(typedMappings), { typedMappings, })); @@ -1063,9 +1068,12 @@ export class Toolkit extends CloudAssemblySourceBuilder { private async validateStacksMetadata(stacks: StackCollection, ioHost: IoHelper) { const builder = (level: IoMessageLevel) => { switch (level) { - case 'error': return IO.CDK_ASSEMBLY_E9999; - case 'warn': return IO.CDK_ASSEMBLY_W9999; - default: return IO.CDK_ASSEMBLY_I9999; + case 'error': + return IO.CDK_ASSEMBLY_E9999; + case 'warn': + return IO.CDK_ASSEMBLY_W9999; + default: + return IO.CDK_ASSEMBLY_I9999; } }; await stacks.validateMetadata( diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/exclude-refactor/index.ts b/packages/@aws-cdk/toolkit-lib/test/_fixtures/exclude-refactor/index.ts new file mode 100644 index 000000000..6dbed6915 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/exclude-refactor/index.ts @@ -0,0 +1,10 @@ +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as core from 'aws-cdk-lib/core'; + +export default async () => { + const app = new core.App({ autoSynth: false }); + const stack = new core.Stack(app, 'Stack1'); + const bucket = new s3.Bucket(stack, 'MyBucket'); + bucket.node.defaultChild?.node.addMetadata('aws:cdk:do-not-refactor', true); + return app.synth(); +}; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/refactor.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/refactor.test.ts index 28e67190b..bf50d87bf 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/refactor.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/refactor.test.ts @@ -247,3 +247,55 @@ test('filters stacks when stack selector is passed', async () => { }), ); }); + +test('resource is marked to be excluded for refactoring in the cloud assembly', async () => { + // GIVEN + mockCloudFormationClient.on(ListStacksCommand).resolves({ + StackSummaries: [ + { + StackName: 'Stack1', + StackId: 'arn:aws:cloudformation:us-east-1:999999999999:stack/Stack1', + StackStatus: 'CREATE_COMPLETE', + CreationTime: new Date(), + }, + ], + }); + + mockCloudFormationClient + .on(GetTemplateCommand, { + StackName: 'Stack1', + }) + .resolves({ + TemplateBody: JSON.stringify({ + Resources: { + // This would have caused a refactor to be detected, + // but the resource is marked to be excluded... + OldLogicalID: { + Type: 'AWS::S3::Bucket', + UpdateReplacePolicy: 'Retain', + DeletionPolicy: 'Retain', + Metadata: { + 'aws:cdk:path': 'Stack1/OldLogicalID/Resource', + }, + }, + }, + }), + }); + + // WHEN + const cx = await builderFixture(toolkit, 'exclude-refactor'); + await toolkit.refactor(cx, { + dryRun: true, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'refactor', + level: 'result', + code: 'CDK_TOOLKIT_I8900', + // ...so we don't see it in the output + message: expect.stringMatching(/Nothing to refactor/), + }), + ); +}); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/refactoring/exclude.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/refactoring/exclude.test.ts new file mode 100644 index 000000000..b658ebc80 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/refactoring/exclude.test.ts @@ -0,0 +1,131 @@ +import { ArtifactMetadataEntryType, ArtifactType } from '@aws-cdk/cloud-assembly-schema'; +import { + AlwaysExclude, + InMemoryExcludeList, + ManifestExcludeList, + NeverExclude, + UnionExcludeList, +} from '../../../lib/api/refactoring'; +import type { CloudFormationStack } from '../../../lib/api/refactoring/cloudformation'; +import { ResourceLocation } from '../../../lib/api/refactoring/cloudformation'; + +const environment = { + name: 'prod', + account: '123456789012', + region: 'us-east-1', +}; + +const stack1: CloudFormationStack = { + stackName: 'Stack1', + environment, + template: {}, +}; +const stack2: CloudFormationStack = { + stackName: 'Stack2', + environment, + template: { + Resources: { + Resource3: { + Type: 'AWS::S3::Bucket', + Metadata: { + 'aws:cdk:path': 'Stack2/Resource3', + }, + }, + }, + }, +}; + +const resource1 = new ResourceLocation(stack1, 'Resource1'); +const resource2 = new ResourceLocation(stack2, 'Resource2'); +const resource3 = new ResourceLocation(stack2, 'Resource3'); + +describe('ManifestExcludeList', () => { + test('locations marked with DO_NOT_REFACTOR in the manifest are excluded', () => { + const manifest = { + artifacts: { + 'Stack1': { + type: ArtifactType.AWS_CLOUDFORMATION_STACK, + metadata: { + LogicalId1: [ + { type: ArtifactMetadataEntryType.DO_NOT_REFACTOR, data: true }, + { type: ArtifactMetadataEntryType.LOGICAL_ID, data: 'Resource1' }, + ], + }, + }, + 'Stack2': { + type: ArtifactType.AWS_CLOUDFORMATION_STACK, + metadata: { + LogicalId2: [ + { type: ArtifactMetadataEntryType.DO_NOT_REFACTOR, data: true }, + { type: ArtifactMetadataEntryType.LOGICAL_ID, data: 'Resource2' }, + ], + }, + }, + 'Stack1.assets': { + type: 'cdk:asset-manifest', + properties: { + file: 'Stack1.assets.json', + requiresBootstrapStackVersion: 6, + bootstrapStackVersionSsmParameter: '/cdk-bootstrap/hnb659fds/version', + }, + }, + }, + }; + + const excludeList = new ManifestExcludeList(manifest as any); + + expect(excludeList.isExcluded(resource1)).toBe(true); + expect(excludeList.isExcluded(resource2)).toBe(true); + expect(excludeList.isExcluded(resource3)).toBe(false); + }); + + test('nothing is excluded if no DO_NOT_REFACTOR entries exist', () => { + const manifest = { + artifacts: { + Stack1: { + type: ArtifactType.AWS_CLOUDFORMATION_STACK, + metadata: { + LogicalId1: [{ type: ArtifactMetadataEntryType.LOGICAL_ID, data: 'Resource1' }], + }, + }, + }, + }; + + const excludeList = new ManifestExcludeList(manifest as any); + expect(excludeList.isExcluded(resource1)).toBe(false); + }); +}); + +describe('InMemoryexcludeList', () => { + test('valid resources on a valid list are excluded', () => { + const excludeList = new InMemoryExcludeList(['Stack1.Resource1', 'Stack2/Resource3']); + expect(excludeList.isExcluded(resource1)).toBe(true); + expect(excludeList.isExcluded(resource2)).toBe(false); + expect(excludeList.isExcluded(resource3)).toBe(true); + }); + + test('nothing is excluded if no file path is provided', () => { + const excludeList = new InMemoryExcludeList([]); + expect(excludeList.isExcluded(resource1)).toBe(false); + expect(excludeList.isExcluded(resource2)).toBe(false); + expect(excludeList.isExcluded(resource3)).toBe(false); + }); +}); + +describe('UnionexcludeList', () => { + test('excludes a resource if at least one underlying list excludes', () => { + const excludeList1 = new AlwaysExclude(); + const excludeList2 = new NeverExclude(); + + const unionexcludeList = new UnionExcludeList([excludeList1, excludeList2]); + expect(unionexcludeList.isExcluded(resource1)).toBe(true); + }); + + test('does not exclude a resource if all underlying lists do not exclude', () => { + const excludeList1 = new NeverExclude(); + const excludeList2 = new NeverExclude(); + + const unionExcludeList = new UnionExcludeList([excludeList1, excludeList2]); + expect(unionExcludeList.isExcluded(resource1)).toBe(false); + }); +}); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts index 14cf37639..899c4ecfa 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts @@ -7,21 +7,21 @@ import type { ResourceLocation as CfnResourceLocation, ResourceMapping as CfnResourceMapping, } from '@aws-sdk/client-cloudformation'; -import { - GetTemplateCommand, - ListStacksCommand, -} from '@aws-sdk/client-cloudformation'; +import { GetTemplateCommand, ListStacksCommand } from '@aws-sdk/client-cloudformation'; import { expect } from '@jest/globals'; -import type { - ResourceLocation, - ResourceMapping, -} from '../../../lib/api/refactoring'; +import type { ExcludeList } from '../../../lib/api/refactoring'; import { + AlwaysExclude, ambiguousMovements, findResourceMovements, resourceMappings, resourceMovements, } from '../../../lib/api/refactoring'; +import type { + ResourceLocation, + ResourceMapping, + CloudFormationStack, +} from '../../../lib/api/refactoring/cloudformation'; import { computeResourceDigests } from '../../../lib/api/refactoring/digest'; import { mockCloudFormationClient, MockSdkProvider } from '../../_helpers/mock-sdk'; @@ -1240,13 +1240,7 @@ describe('environment grouping', () => { }), }); - const provider = new MockSdkProvider(); - provider.returnsDefaultAccounts(environment.account); - - const movements = await findResourceMovements([stack1, stack2], provider); - expect(ambiguousMovements(movements)).toEqual([]); - - expect(resourceMappings(movements).map(toCfnMapping)).toEqual([ + expect(await mappings([stack1, stack2])).toEqual([ { Destination: { LogicalResourceId: 'Bucket', @@ -1258,6 +1252,15 @@ describe('environment grouping', () => { }, }, ]); + + expect(await mappings([stack1, stack2], new AlwaysExclude())).toEqual([]); + + async function mappings(stacks: CloudFormationStack[], excludeList?: ExcludeList) { + const provider = new MockSdkProvider(); + provider.returnsDefaultAccounts(environment.account); + const movements2 = await findResourceMovements(stacks, provider, excludeList); + return resourceMappings(movements2).map(toCfnMapping); + } }); test('does not produce cross-environment mappings', async () => { diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index bfbd7ae04..07b70dfce 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -11,24 +11,25 @@ The AWS CDK Toolkit provides the `cdk` command-line interface that can be used to work with AWS CDK applications. -| Command | Description | -| ------------------------------------- | ---------------------------------------------------------------------------------- | -| [`cdk docs`](#cdk-docs) | Access the online documentation | -| [`cdk init`](#cdk-init) | Start a new CDK project (app or library) | -| [`cdk list`](#cdk-list) | List stacks and their dependencies in an application | -| [`cdk synth`](#cdk-synthesize) | Synthesize a CDK app to CloudFormation template(s) | -| [`cdk diff`](#cdk-diff) | Diff stacks against current state | -| [`cdk deploy`](#cdk-deploy) | Deploy a stack into an AWS account | -| [`cdk rollback`](#cdk-rollback) | Roll back a failed deployment | -| [`cdk import`](#cdk-import) | Import existing AWS resources into a CDK stack | -| [`cdk migrate`](#cdk-migrate) | Migrate AWS resources, CloudFormation stacks, and CloudFormation templates to CDK | -| [`cdk watch`](#cdk-watch) | Watches a CDK app for deployable and hotswappable changes | -| [`cdk destroy`](#cdk-destroy) | Deletes a stack from an AWS account | -| [`cdk bootstrap`](#cdk-bootstrap) | Deploy a toolkit stack to support deploying large stacks & artifacts | -| [`cdk gc`](#cdk-gc) | Garbage collect assets associated with the bootstrapped stack | -| [`cdk doctor`](#cdk-doctor) | Inspect the environment and produce information useful for troubleshooting | -| [`cdk acknowledge`](#cdk-acknowledge) | Acknowledge (and hide) a notice by issue number | -| [`cdk notices`](#cdk-notices) | List all relevant notices for the application | +| Command | Description | +|---------------------------------------|-----------------------------------------------------------------------------------| +| [`cdk docs`](#cdk-docs) | Access the online documentation | +| [`cdk init`](#cdk-init) | Start a new CDK project (app or library) | +| [`cdk list`](#cdk-list) | List stacks and their dependencies in an application | +| [`cdk synth`](#cdk-synthesize) | Synthesize a CDK app to CloudFormation template(s) | +| [`cdk diff`](#cdk-diff) | Diff stacks against current state | +| [`cdk deploy`](#cdk-deploy) | Deploy a stack into an AWS account | +| [`cdk rollback`](#cdk-rollback) | Roll back a failed deployment | +| [`cdk import`](#cdk-import) | Import existing AWS resources into a CDK stack | +| [`cdk migrate`](#cdk-migrate) | Migrate AWS resources, CloudFormation stacks, and CloudFormation templates to CDK | +| [`cdk watch`](#cdk-watch) | Watches a CDK app for deployable and hotswappable changes | +| [`cdk destroy`](#cdk-destroy) | Deletes a stack from an AWS account | +| [`cdk bootstrap`](#cdk-bootstrap) | Deploy a toolkit stack to support deploying large stacks & artifacts | +| [`cdk gc`](#cdk-gc) | Garbage collect assets associated with the bootstrapped stack | +| [`cdk doctor`](#cdk-doctor) | Inspect the environment and produce information useful for troubleshooting | +| [`cdk acknowledge`](#cdk-acknowledge) | Acknowledge (and hide) a notice by issue number | +| [`cdk notices`](#cdk-notices) | List all relevant notices for the application | +| [`cdk refactor`](#cdk-refactor) | Moves resources between stacks or within the same stack | - [Bundling](#bundling) - [MFA Support](#mfa-support) @@ -1066,6 +1067,69 @@ $ cdk doctor - AWS_SDK_LOAD_CONFIG = 1 ``` +### `cdk refactor` + +⚠️**CAUTION**⚠️: CDK Refactor is currently experimental and may have +breaking changes in the future. Make sure to use the `--unstable=refactor` flag +when using this command. + +Compares the infrastructure specified in the current state of the CDK app with +the currently deployed application, to determine if any resource was moved +(to a different stack or to a different logical ID, or both). The CLI will +show the correspondence between the old and new locations in a table: + +``` +$ cdk refactor --unstable=refactor --dry-run + +The following resources were moved or renamed: + +┌───────────────────────────────┬───────────────────────────────┬───────────────────────────────────┐ +│ Resource Type │ Old Construct Path │ New Construct Path │ +├───────────────────────────────┼───────────────────────────────┼───────────────────────────────────┤ +│ AWS::S3::Bucket │ MyStack/Bucket/Resource │ Web/Website/Origin/Resource │ +├───────────────────────────────┼───────────────────────────────┼───────────────────────────────────┤ +│ AWS::CloudFront::Distribution │ MyStack/Distribution/Resource │ Web/Website/Distribution/Resource │ +├───────────────────────────────┼───────────────────────────────┼───────────────────────────────────┤ +│ AWS::Lambda::Function │ MyStack/Function/Resource │ Service/Function/Resource │ +└───────────────────────────────┴───────────────────────────────┴───────────────────────────────────┘ +``` + +Note the use of the `--dry-run` flag. When this flag is used, the CLI will +show this table and exit. Eventually, the CLI will also be able to automatically +apply the refactor on your CloudFormation stacks. But for now, only the dry-run +mode is supported. + +If you want to exclude some resources from the refactor, you can pass an +exclude file, containing a list of destination locations to exclude. A +location can be either the stack name + logical ID, or the construct path. For +example, if you don't want to include the bucket and the distribution from +the table above in the refactor, you can create a file called +`exclude.txt`with the following content: + +``` +Web/Website/Origin/Resource +Web/Website/Distribution/Resource +] +``` + +and pass it to the CLI via the `--exclude-file` flag: + +```shell +$ cdk refactor --exclude-file exclude.txt --unstable=refactor --dry-run +``` + +If your application has more than one stack, and you want the refactor +command to consider only a subset of them, you can pass a list of stack +patterns as a parameter: + +```shell +$ cdk refactor Web* --unstable=refactor --dry-run +``` + +The pattern language is the same as the one used in the `cdk deploy` command. +However, unlike `cdk deploy`, in the absence of this parameter, all stacks are +considered. + ## Notices CDK Notices are important messages regarding security vulnerabilities, regressions, and usage of unsupported diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 992045476..ee2f9ffce 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -1,6 +1,5 @@ import * as path from 'path'; import { format } from 'util'; -import { formatAmbiguousMappings, formatTypedMappings } from '@aws-cdk/cloudformation-diff'; import * as cxapi from '@aws-cdk/cx-api'; import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; @@ -11,14 +10,8 @@ import { CliIoHost } from './io-host'; import type { Configuration } from './user-configuration'; import { PROJECT_CONFIG } from './user-configuration'; import type { ToolkitAction } from '../../../@aws-cdk/toolkit-lib/lib/api'; -import { - ambiguousMovements, - findResourceMovements, - resourceMappings, - ToolkitError, -} from '../../../@aws-cdk/toolkit-lib/lib/api'; +import { StackSelectionStrategy, ToolkitError } from '../../../@aws-cdk/toolkit-lib/lib/api'; import { asIoHelper } from '../../../@aws-cdk/toolkit-lib/lib/api/io/private'; -import { AmbiguityError } from '../../../@aws-cdk/toolkit-lib/lib/api/refactoring'; import { PermissionChangeType } from '../../../@aws-cdk/toolkit-lib/lib/payloads'; import type { ToolkitOptions } from '../../../@aws-cdk/toolkit-lib/lib/toolkit'; import { Toolkit } from '../../../@aws-cdk/toolkit-lib/lib/toolkit'; @@ -1218,30 +1211,28 @@ export class CdkToolkit { } public async refactor(options: RefactorOptions): Promise { - if (!options.dryRun) { - info('Refactor is not available yet. Too see the proposed changes, use the --dry-run flag.'); - return 1; + let exclude: string[] = []; + if (options.excludeFile != null) { + if (!(await fs.pathExists(options.excludeFile))) { + throw new ToolkitError(`The exclude file '${options.excludeFile}' does not exist`); + } + exclude = fs.readFileSync(options.excludeFile).toString('utf-8').split('\n'); } - // Initially, we select all stacks to find all resource movements. - // Otherwise, we might miss some resources that are not in the selected stacks. - // Example: resource X was moved from Stack A to Stack B. If we only select Stack A, - // we will only see a deletion of resource X, but not the creation of resource X in Stack B. - const stacks = await this.selectStacksForList([]); - const movements = await findResourceMovements(stacks.stackArtifacts, this.props.sdkProvider); - const ambiguous = ambiguousMovements(movements); - - if (ambiguous.length === 0) { - // Now we can filter the stacks to only include the ones that are relevant for the user. - const patterns = options.selector.allTopLevel ? [] : options.selector.patterns; - const filteredStacks = await this.selectStacksForList(patterns); - const selectedMappings = resourceMappings(movements, filteredStacks.stackArtifacts); - const typedMappings = selectedMappings.map(m => m.toTypedMapping()); - formatTypedMappings(process.stdout, typedMappings); - } else { - const e = new AmbiguityError(ambiguous); - formatAmbiguousMappings(process.stdout, e.paths()); + try { + await this.toolkit.refactor(this.props.cloudExecutable, { + dryRun: options.dryRun, + exclude, + stacks: { + patterns: options.selector.patterns, + strategy: options.selector.patterns.length > 0 ? StackSelectionStrategy.PATTERN_MATCH : StackSelectionStrategy.ALL_STACKS, + }, + }); + } catch (e) { + error((e as Error).message); + return 1; } + return 0; } @@ -1936,6 +1927,20 @@ export interface RefactorOptions { * Criteria for selecting stacks to deploy */ selector: StackSelector; + + /** + * The absolute path to a file that contains a list of resources that + * should be excluded during the refactor. This file should contain a + * newline separated list of _destination_ locations to exclude, i.e., + * the location to which a resource would be moved if the refactor + * were to happen. + * + * The format of the locations in the file can be either: + * + * - Stack name and logical ID (e.g. `Stack1.MyQueue`) + * - A construct path (e.g. `Stack1/Foo/Bar/Resource`). + */ + excludeFile?: string; } function buildParameterMap( diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 183b511be..0ff41c2d4 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -411,6 +411,11 @@ export async function makeConfig(): Promise { desc: 'Do not perform any changes, just show what would be done', default: false, }, + 'exclude-file': { + type: 'string', + requiresArg: true, + desc: 'If specified, CDK will use the given file to exclude resources from the refactor', + }, }, }, }, diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 08ee2f0bf..5b40b8f5f 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -272,6 +272,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { ) .command('doctor', 'Check your set-up for potential problems') .command('refactor [STACKS..]', 'Moves resources between stacks or within the same stack', (yargs: Argv) => - yargs.option('dry-run', { - default: false, - type: 'boolean', - desc: 'Do not perform any changes, just show what would be done', - }), + yargs + .option('dry-run', { + default: false, + type: 'boolean', + desc: 'Do not perform any changes, just show what would be done', + }) + .option('exclude-file', { + default: undefined, + type: 'string', + requiresArg: true, + desc: 'If specified, CDK will use the given file to exclude resources from the refactor', + }), ) .version(helpers.cliVersion()) .demandCommand(1, '') diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 4bfc9c3c7..5480e9e04 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -1353,6 +1353,13 @@ export interface RefactorOptions { */ readonly dryRun?: boolean; + /** + * If specified, CDK will use the given file to exclude resources from the refactor + * + * @default - undefined + */ + readonly excludeFile?: string; + /** * Positional argument for refactor */