diff --git a/.projenrc.ts b/.projenrc.ts index 735404bda..40a3d9a55 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -835,6 +835,7 @@ const toolkitLib = configureProject( 'cdk-from-cfn', 'chalk@^4', 'chokidar@^3', + 'fast-deep-equal', 'fs-extra@^9', 'glob', 'minimatch', diff --git a/packages/@aws-cdk/cloudformation-diff/lib/mappings.ts b/packages/@aws-cdk/cloudformation-diff/lib/mappings.ts index 498590fc3..296906a07 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/mappings.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/mappings.ts @@ -35,6 +35,8 @@ export function formatAmbiguousMappings( formatter.print('Detected ambiguities:'); formatter.print(tables.join('\n\n')); formatter.print(' '); + formatter.print(chalk.yellow('Please provide an override file to resolve these ambiguous mappings.')); + formatter.print(' '); function renderTable([removed, added]: [string[], string[]]) { return formatTable([['', 'Resource'], renderRemoval(removed), renderAddition(added)], undefined); diff --git a/packages/@aws-cdk/toolkit-lib/.projen/deps.json b/packages/@aws-cdk/toolkit-lib/.projen/deps.json index 5367aebc6..d0511e3f1 100644 --- a/packages/@aws-cdk/toolkit-lib/.projen/deps.json +++ b/packages/@aws-cdk/toolkit-lib/.projen/deps.json @@ -333,6 +333,10 @@ "version": "^3", "type": "runtime" }, + { + "name": "fast-deep-equal", + "type": "runtime" + }, { "name": "fs-extra", "version": "^9", diff --git a/packages/@aws-cdk/toolkit-lib/.projen/tasks.json b/packages/@aws-cdk/toolkit-lib/.projen/tasks.json index 857edbc09..138ff2362 100644 --- a/packages/@aws-cdk/toolkit-lib/.projen/tasks.json +++ b/packages/@aws-cdk/toolkit-lib/.projen/tasks.json @@ -53,7 +53,7 @@ }, "steps": [ { - "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@aws-cdk/aws-service-spec,@cdklabs/eslint-plugin,@jest/environment,@jest/globals,@jest/types,@microsoft/api-extractor,@smithy/util-stream,@types/fs-extra,@types/jest,@types/jest-when,@types/split2,aws-sdk-client-mock,aws-sdk-client-mock-jest,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,jest-environment-node,jest-when,license-checker,ts-jest,xml-js,archiver,cdk-from-cfn,glob,minimatch,semver,split2,uuid" + "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@aws-cdk/aws-service-spec,@cdklabs/eslint-plugin,@jest/environment,@jest/globals,@jest/types,@microsoft/api-extractor,@smithy/util-stream,@types/fs-extra,@types/jest,@types/jest-when,@types/split2,aws-sdk-client-mock,aws-sdk-client-mock-jest,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,jest-environment-node,jest-when,license-checker,ts-jest,xml-js,archiver,cdk-from-cfn,fast-deep-equal,glob,minimatch,semver,split2,uuid" } ] }, diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index faf35634d..813713991 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -127,6 +127,7 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu | `CDK_TOOLKIT_E7900` | Stack deletion failed | `error` | {@link ErrorPayload} | | `CDK_TOOLKIT_E8900` | Stack refactor failed | `error` | {@link ErrorPayload} | | `CDK_TOOLKIT_I8900` | Refactor result | `result` | {@link RefactorResult} | +| `CDK_TOOLKIT_I8910` | Confirm refactor | `info` | {@link ConfirmationRequest} | | `CDK_TOOLKIT_W8010` | Refactor execution not yet supported | `warn` | n/a | | `CDK_TOOLKIT_I9000` | Provides bootstrap times | `info` | {@link Duration} | | `CDK_TOOLKIT_I9100` | Bootstrap progress | `info` | {@link BootstrapEnvironmentProgress} | 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 64da26a38..2a2bf5bdb 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/refactor/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/refactor/index.ts @@ -89,6 +89,27 @@ export interface RefactorOptions { * A list of names of additional deployed stacks to be included in the comparison. */ additionalStackNames?: string[]; + + /** + * List of patterns for filtering local stacks. If no patterns are passed, + * then all stacks, except the bootstrap stacks are considered. If you want + * to consider all stacks (including bootstrap stacks), pass the wildcard + * '*'. + */ + localStacks?: string[]; + + /** + * List of patterns for filtering deployed stacks. If no patterns are passed, + * then all stacks, except the bootstrap stacks are considered. If you want + * to consider all stacks (including bootstrap stacks), pass the wildcard + * '*'. + */ + deployedStacks?: string[]; + + /** + * Whether to do the refactor without prompting the user for confirmation. + */ + force?: boolean; } export interface MappingGroup { diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts b/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts index 028832e78..caeed6644 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts @@ -50,6 +50,7 @@ import type { CreateGeneratedTemplateCommandOutput, CreateStackCommandInput, CreateStackCommandOutput, + CreateStackRefactorCommandInput, DeleteChangeSetCommandInput, DeleteChangeSetCommandOutput, DeleteGeneratedTemplateCommandInput, @@ -97,6 +98,9 @@ import type { DetectStackResourceDriftCommandInput, DetectStackResourceDriftCommandOutput, DescribeStackResourceDriftsCommandInput, + ExecuteStackRefactorCommandInput, + DescribeStackRefactorCommandInput, + CreateStackRefactorCommandOutput, ExecuteStackRefactorCommandOutput, } from '@aws-sdk/client-cloudformation'; import { paginateListStacks, @@ -105,6 +109,7 @@ import { CreateChangeSetCommand, CreateGeneratedTemplateCommand, CreateStackCommand, + CreateStackRefactorCommand, DeleteChangeSetCommand, DeleteGeneratedTemplateCommand, DeleteStackCommand, @@ -115,6 +120,7 @@ import { DescribeStackResourcesCommand, DescribeStacksCommand, ExecuteChangeSetCommand, + ExecuteStackRefactorCommand, GetGeneratedTemplateCommand, GetTemplateCommand, GetTemplateSummaryCommand, @@ -132,6 +138,8 @@ import { DescribeStackResourceDriftsCommand, DetectStackDriftCommand, DetectStackResourceDriftCommand, + waitUntilStackRefactorCreateComplete, + waitUntilStackRefactorExecuteComplete, } from '@aws-sdk/client-cloudformation'; import type { FilterLogEventsCommandInput, @@ -460,6 +468,10 @@ export interface ICloudFormationClient { describeStackEvents(input: DescribeStackEventsCommandInput): Promise; listStackResources(input: ListStackResourcesCommandInput): Promise; paginatedListStacks(input: ListStacksCommandInput): Promise; + createStackRefactor(input: CreateStackRefactorCommandInput): Promise; + executeStackRefactor(input: ExecuteStackRefactorCommandInput): Promise; + waitUntilStackRefactorCreateComplete(input: DescribeStackRefactorCommandInput): Promise; + waitUntilStackRefactorExecuteComplete(input: DescribeStackRefactorCommandInput): Promise; } export interface ICloudWatchLogsClient { @@ -766,6 +778,34 @@ export class SDK { } return stackResources; }, + createStackRefactor: (input: CreateStackRefactorCommandInput): Promise => { + return client.send(new CreateStackRefactorCommand(input)); + }, + executeStackRefactor: (input: ExecuteStackRefactorCommandInput): Promise => { + return client.send(new ExecuteStackRefactorCommand(input)); + }, + waitUntilStackRefactorCreateComplete: (input: DescribeStackRefactorCommandInput): Promise => { + return waitUntilStackRefactorCreateComplete( + { + client, + maxWaitTime: 600, + minDelay: 6, + maxDelay: 6, + }, + input, + ); + }, + waitUntilStackRefactorExecuteComplete: (input: DescribeStackRefactorCommandInput): Promise => { + return waitUntilStackRefactorExecuteComplete( + { + client, + maxWaitTime: 600, + minDelay: 6, + maxDelay: 6, + }, + input, + ); + }, }; } 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 9314e8530..a48fc44bd 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 @@ -17,7 +17,16 @@ import type { StackRollbackProgress } from '../../../payloads/rollback'; import type { MfaTokenRequest, SdkTrace } from '../../../payloads/sdk'; import type { StackActivity, StackMonitoringControlEvent } from '../../../payloads/stack-activity'; import type { StackSelectionDetails } from '../../../payloads/synth'; -import type { AssemblyData, ConfirmationRequest, ContextProviderMessageSource, Duration, ErrorPayload, SingleStack, StackAndAssemblyData } from '../../../payloads/types'; +import type { + AssemblyData, + ConfirmationRequest, + ContextProviderMessageSource, + DataRequest, + Duration, + ErrorPayload, + SingleStack, + StackAndAssemblyData, +} from '../../../payloads/types'; import type { FileWatchEvent, WatchSettings } from '../../../payloads/watch'; /** @@ -381,6 +390,12 @@ export const IO = { interface: 'RefactorResult', }), + CDK_TOOLKIT_I8910: make.question({ + code: 'CDK_TOOLKIT_I8910', + description: 'Confirm refactor', + interface: 'ConfirmationRequest', + }), + CDK_TOOLKIT_W8010: make.warn({ code: 'CDK_TOOLKIT_W8010', description: 'Refactor execution not yet supported', 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 2c06e4e8d..81cf5bedf 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/cloudformation.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/cloudformation.ts @@ -1,5 +1,6 @@ import type { TypedMapping } from '@aws-cdk/cloudformation-diff'; import type * as cxapi from '@aws-cdk/cx-api'; +import type { ResourceMapping as CfnResourceMapping } from '@aws-sdk/client-cloudformation'; export interface CloudFormationResource { Type: string; @@ -13,6 +14,8 @@ export interface CloudFormationTemplate { [logicalId: string]: CloudFormationResource; }; Outputs?: Record; + Rules?: Record; + Parameters?: Record; } export interface CloudFormationStack { @@ -54,6 +57,10 @@ export class ResourceLocation { public equalTo(other: ResourceLocation): boolean { return this.logicalResourceId === other.logicalResourceId && this.stack.stackName === other.stack.stackName; } + + public get stackName(): string { + return this.stack.stackName; + } } /** @@ -72,4 +79,17 @@ export class ResourceMapping { destinationPath: this.destination.toPath(), }; } + + public toCloudFormation(): CfnResourceMapping { + return { + Source: { + StackName: this.source.stack.stackName, + LogicalResourceId: this.source.logicalResourceId, + }, + Destination: { + StackName: this.destination.stack.stackName, + LogicalResourceId: this.destination.logicalResourceId, + }, + }; + } } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/context.ts b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/context.ts index 5ad66625b..68638b89d 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/context.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/context.ts @@ -1,10 +1,16 @@ import type { Environment } from '@aws-cdk/cx-api'; +import type { StackDefinition } from '@aws-sdk/client-cloudformation'; import type { CloudFormationStack } from './cloudformation'; import { ResourceLocation, ResourceMapping } from './cloudformation'; import type { GraphDirection } from './digest'; import { computeResourceDigests } from './digest'; import { ToolkitError } from '../../toolkit/toolkit-error'; import { equalSets } from '../../util/sets'; +import type { SDK } from '../aws-auth/sdk'; +import type { SdkProvider } from '../aws-auth/sdk-provider'; +import { EnvironmentResourcesRegistry } from '../environment'; +import type { IoHelper } from '../io/private'; +import { Mode } from '../plugin'; /** * Represents a set of possible moves of a resource from one location @@ -51,6 +57,56 @@ export class RefactoringContext { public get mappings(): ResourceMapping[] { return this._mappings; } + + public async execute(stackDefinitions: StackDefinition[], sdkProvider: SdkProvider, ioHelper: IoHelper): Promise { + if (this.mappings.length === 0) { + return; + } + + const sdk = (await sdkProvider.forEnvironment(this.environment, Mode.ForWriting)).sdk; + + await this.checkBootstrapVersion(sdk, ioHelper); + + const cfn = sdk.cloudFormation(); + const mappings = this.mappings; + + const input = { + EnableStackCreation: true, + ResourceMappings: mappings.map((m) => m.toCloudFormation()), + StackDefinitions: stackDefinitions, + }; + const refactor = await cfn.createStackRefactor(input); + + await cfn.waitUntilStackRefactorCreateComplete({ + StackRefactorId: refactor.StackRefactorId, + }); + + await cfn.executeStackRefactor({ + StackRefactorId: refactor.StackRefactorId, + }); + + await cfn.waitUntilStackRefactorExecuteComplete({ + StackRefactorId: refactor.StackRefactorId, + }); + } + + private async checkBootstrapVersion(sdk: SDK, ioHelper: IoHelper) { + const environmentResourcesRegistry = new EnvironmentResourcesRegistry(); + const envResources = environmentResourcesRegistry.for(this.environment, sdk, ioHelper); + let bootstrapVersion: number | undefined = undefined; + try { + // Try to get the bootstrap version + bootstrapVersion = (await envResources.lookupToolkit()).version; + } catch (e) { + // But if we can't, keep going. Maybe we can still succeed. + } + if (bootstrapVersion != null && bootstrapVersion < 28) { + const environment = `aws://${this.environment.account}/${this.environment.region}`; + throw new ToolkitError( + `The CDK toolkit stack in environment ${environment} doesn't support refactoring. Please run 'cdk bootstrap ${environment}' to update it.`, + ); + } + } } /** 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 5ed26b49a..e19033ecf 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/index.ts @@ -25,6 +25,12 @@ interface StackGroup { deployedStacks: CloudFormationStack[]; } +interface StackGroup { + environment: cxapi.Environment; + localStacks: CloudFormationStack[]; + deployedStacks: CloudFormationStack[]; +} + export async function usePrescribedMappings( mappingGroups: MappingGroup[], sdkProvider: SdkProvider, diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/stack-definitions.ts b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/stack-definitions.ts index 4b449a946..fd459feb4 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/stack-definitions.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/stack-definitions.ts @@ -1,593 +1,74 @@ -/* - * The Cloudformation refactoring API needs, in addition to the mappings, the - * resulting templates for each affected stack. The resulting templates are - * basically the synthesis produced, but with some differences: - * - * - Resources that exist in the local stacks, but not in the remote stacks, are - * not included. - * - Resources that exist in the remote stacks, but not in the local stacks, are - * preserved. - * - For resources that exist in both stacks, but have different properties, the - * deployed properties are used, but the references may need to be updated, if - * the resources they reference were moved in the refactoring. - * - * Why does the last difference exist, to begin with? By default, to establish - * whether two given resources are the same, roughly speaking we compute the hash - * of their properties and compare them. But there is a better source of resource - * identity, that we can exploit when it is present: the physical name. In such - * cases, we can track a resource move even if the properties are different, as - * long as the physical name is the same. - * - * The process of computing the resulting templates consists in: - * - * 1. Computing a graph of deployed resources. - * 2. Mapping edges and nodes according to the mappings (that we either - * computed or got directly from the user). - * 3. Computing the resulting templates by traversing the graph and - * collecting the resources that are not mapped out, and updating the - * references to the resources that were moved. - */ - import type { StackDefinition } from '@aws-sdk/client-cloudformation'; -import type { CloudFormationStack, CloudFormationTemplate, ResourceMapping } from './cloudformation'; -import { ResourceLocation } from './cloudformation'; -import { ToolkitError } from '../../toolkit/toolkit-error'; +import type { CloudFormationStack, ResourceMapping } from './cloudformation'; +// namespace object imports won't work in the bundle for function exports +// eslint-disable-next-line @typescript-eslint/no-require-imports +const deepEqual = require('fast-deep-equal'); export function generateStackDefinitions( mappings: ResourceMapping[], deployedStacks: CloudFormationStack[], localStacks: CloudFormationStack[], ): StackDefinition[] { - const localExports: Record = indexExports(localStacks); - const deployedExports: Record = indexExports(deployedStacks); - const edgeMapper = new EdgeMapper(mappings); - - // Build a graph of the deployed stacks - const deployedGraph = graph(deployedStacks, deployedExports); - - // Map all the edges, including their endpoints, to their new locations. - const edges = edgeMapper.mapEdges(deployedGraph.edges); - - // All the edges have been mapped, which means that isolated nodes were left behind. Map them too. - const nodes = mapNodes(deployedGraph.isolatedNodes, mappings); - - // Now we can generate the templates for each stack - const templates = generateTemplates(edges, nodes, edgeMapper.affectedStackNames, localExports, deployedStacks); - - // Finally, generate the stack definitions, to be included in the refactor request. - return Object.entries(templates).map(([stackName, template]) => ({ - StackName: stackName, - TemplateBody: JSON.stringify(template), - })); -} - -function graph(deployedStacks: CloudFormationStack[], deployedExports: Record): -{ edges: ResourceEdge[]; isolatedNodes: ResourceNode[] } { - const deployedNodeMap: Map = buildNodes(deployedStacks); - const deployedNodes = Array.from(deployedNodeMap.values()); - - const edges = buildEdges(deployedNodeMap, deployedExports); - - const isolatedNodes = deployedNodes.filter((node) => { - return !edges.some( - (edge) => - edge.source.location.equalTo(node.location) || - edge.targets.some((target) => target.location.equalTo(node.location)), - ); - }); - - return { edges, isolatedNodes }; -} - -function buildNodes(stacks: CloudFormationStack[]): Map { - const result = new Map(); - - for (const stack of stacks) { - const template = stack.template; - for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) { - const location = new ResourceLocation(stack, logicalId); - result.set(`${stack.stackName}.${logicalId}`, { - location, - rawValue: resource, - }); - } - } - - return result; -} - -function buildEdges( - nodeMap: Map, - exports: Record< - string, - { - stackName: string; - value: any; - } - >, -): ResourceEdge[] { - const nodes = Array.from(nodeMap.values()); - return nodes.flatMap((node) => buildEdgesForResource(node, node.rawValue)); - - function buildEdgesForResource(source: ResourceNode, value: any, path: string[] = []): ResourceEdge[] { - if (!value || typeof value !== 'object') return []; - if (Array.isArray(value)) { - return value.flatMap((x, index) => buildEdgesForResource(source, x, path.concat(String(index)))); - } - - if ('Ref' in value) { - return [makeRef(source.location.stack.stackName, value.Ref)]; - } - - if ('Fn::GetAtt' in value) { - return [makeGetAtt(source.location.stack.stackName, value['Fn::GetAtt'])]; - } - - if ('Fn::ImportValue' in value) { - const exportName = value['Fn::ImportValue']; - const x = exports[exportName]!; - - if ('Ref' in x.value) { - return [ - { - ...makeRef(x.stackName, x.value.Ref), - reference: new ImportValue(Ref.INSTANCE), - }, - ]; - } - - if ('Fn::GetAtt' in x.value) { - const getAtt = makeGetAtt(x.stackName, x.value['Fn::GetAtt']); - return [ - { - ...getAtt, - reference: new ImportValue(getAtt.reference), - }, - ]; - } - - return []; - } - - if ('Fn::Sub' in value) { - let inputString: string; - let variables: Record | undefined; - const sub = value['Fn::Sub']; - if (typeof sub === 'string') { - inputString = sub; - } else { - [inputString, variables] = sub; - } - - let varNames = Array.from(inputString.matchAll(/\${([a-zA-Z0-9_.]+)}/g)) - .map((x) => x[1]) - .filter((varName) => (value['Fn::Sub'][1] ?? {})[varName] == null); - - const edges = varNames.map((varName) => { - return varName.includes('.') - ? makeGetAtt(source.location.stack.stackName, varName) - : makeRef(source.location.stack.stackName, varName); - }); - - const edgesFromInputString = [ - { - source, - targets: edges.flatMap((edge) => edge.targets), - reference: new Sub(inputString, varNames), - path: path.concat('Fn::Sub', '0'), - }, - ]; - - const edgesFromVariables = buildEdgesForResource(source, variables, path.concat('Fn::Sub', '1')); - - return [...edgesFromInputString, ...edgesFromVariables]; - } - - const edges: ResourceEdge[] = []; - - // DependsOn is only handled at the top level of the resource - if ('DependsOn' in value && path.length === 0) { - if (typeof value.DependsOn === 'string') { - edges.push({ - ...makeRef(source.location.stack.stackName, value.DependsOn), - reference: DependsOn.INSTANCE, - }); - } else if (Array.isArray(value.DependsOn)) { - edges.push({ - source, - targets: value.DependsOn.flatMap( - (dependsOn: string) => makeRef(source.location.stack.stackName, dependsOn).targets, - ), - path: path.concat('DependsOn'), - reference: DependsOn.INSTANCE, - }); - } + const deployedStackMap: Map = new Map(deployedStacks.map((s) => [s.stackName, s])); + + // For every local stack that is also deployed, update the local template, + // overwriting its CDKMetadata resource with the one from the deployed stack + for (const localStack of localStacks) { + const deployedStack = deployedStackMap.get(localStack.stackName); + const localTemplate = localStack.template; + const deployedTemplate = deployedStack?.template; + + // The CDKMetadata resource is never part of a refactor. So at this point we need + // to adjust the template we will send to the API to make sure it has the same CDKMetadata + // as the deployed template. And if the deployed template doesn't have any, we cannot + // send any either. + if (deployedTemplate?.Resources?.CDKMetadata != null) { + localTemplate.Resources = localTemplate.Resources ?? {}; + localTemplate.Resources.CDKMetadata = deployedTemplate.Resources.CDKMetadata; + } else { + delete localTemplate.Resources?.CDKMetadata; } - edges.push(...Object.entries(value).flatMap(([k, v]) => buildEdgesForResource(source, v, path.concat(k)))); - - return edges; - - function makeRef(stackName: string, logicalId: string): ResourceEdge { - const key = `${stackName}.${logicalId}`; - const target = nodeMap.get(key)!; - - return { - path, - source, - targets: [target], - reference: Ref.INSTANCE, - }; - } + // For every resource in the local template, take the Metadata['aws:cdk:path'] from the corresponding resource in the deployed template. + // A corresponding resource is one that the local maps to (using the `mappings` parameter). If there is no entry mapping the local + // resource, use the same id + // TODO Remove this logic once CloudFormation starts allowing changes to the construct path. + // But we need it for now, otherwise we won't be able to refactor anything. + for (const [logicalId, localResource] of Object.entries(localTemplate.Resources ?? {})) { + const mapping = mappings.find( + (m) => m.destination.stackName === localStack.stackName && m.destination.logicalResourceId === logicalId, + ); - function makeGetAtt(stackName: string, att: string | string[]): ResourceEdge { - let logicalId: string = ''; - let attributeName: string = ''; - if (typeof att === 'string') { - [logicalId, attributeName] = att.split(/\.(.*)/s); - } else if (Array.isArray(att) && att.length === 2) { - [logicalId, attributeName] = att; + if (mapping != null) { + const deployed = deployedStackMap.get(mapping.source.stackName)!; + const deployedResource = deployed.template?.Resources?.[mapping.source.logicalResourceId]!; + if (deployedResource.Metadata != null || localResource.Metadata != null) { + localResource.Metadata = localResource.Metadata ?? {}; + localResource.Metadata['aws:cdk:path'] = deployedResource?.Metadata?.['aws:cdk:path']; + } } - - const key = `${stackName}.${logicalId}`; - const target = nodeMap.get(key)!; - - return { - path, - source, - targets: [target], - reference: new GetAtt(attributeName), - }; } } -} - -function mapNodes(nodes: ResourceNode[], mappings: ResourceMapping[]): ResourceNode[] { - return nodes.map((node) => { - const newLocation = mapLocation(node.location, mappings); - return { - location: newLocation, - rawValue: node.rawValue, - } as ResourceNode; - }); -} - -function generateTemplates( - edges: ResourceEdge[], - nodes: ResourceNode[], - stackNames: string[], - exports: Record, - deployedStacks: CloudFormationStack[]): Record { - updateReferences(edges, exports); - const templates: Record = {}; - - // Take the CloudFormation raw value of each the node and put it into the appropriate template. - const allNodes = unique(edges.flatMap((e) => [e.source, ...e.targets]).concat(nodes)); - allNodes.forEach((node) => { - const stackName = node.location.stack.stackName; - const logicalId = node.location.logicalResourceId; - - if (templates[stackName] === undefined) { - templates[stackName] = { - Resources: {}, - }; - } - templates[stackName].Resources![logicalId] = node.rawValue; - }); - // Add outputs to the templates - edges.forEach((edge) => { - if (edge.reference instanceof ImportValue) { - const stackName = edge.targets[0].location.stack.stackName; - const template = templates[stackName]; - template.Outputs = { - ...(template.Outputs ?? {}), - ...edge.reference.output, - }; - } + const stacksToProcess = localStacks.filter((localStack) => { + const deployedStack = deployedStackMap.get(localStack.stackName); + return !deployedStack || !deepEqual(localStack.template, deployedStack.template); }); - // The freshly generated templates contain only resources and outputs. - // Combine them with the existing templates to preserve metadata and other properties. - return Object.fromEntries( - stackNames.map((stackName) => { - const oldTemplate = deployedStacks.find((s) => s.stackName === stackName)?.template ?? {}; - const newTemplate = templates[stackName] ?? { Resources: {} }; - const combinedTemplate = { ...oldTemplate, ...newTemplate }; - - sanitizeDependencies(combinedTemplate); - return [stackName, combinedTemplate]; - }), - ); -} - -/** - * Update the CloudFormation resources based on information from the edges. - * Each edge corresponds to a path in some resource object. The value at that - * path is updated to the CloudFormation value represented by the edge's annotation. - */ -function updateReferences(edges: ResourceEdge[], exports: Record) { - edges.forEach((edge) => { - const cfnValue = edge.reference.toCfn(edge.targets, exports); - const obj = edge.path.slice(0, edge.path.length - 1).reduce(getPropValue, edge.source.rawValue); - setPropValue(obj, edge.path[edge.path.length - 1], cfnValue); - }); - - function getPropValue(obj: any, prop: string): any { - const index = parseInt(prop); - return obj[Number.isNaN(index) ? prop : index]; - } - - function setPropValue(obj: any, prop: string, value: any) { - const index = parseInt(prop); - obj[Number.isNaN(index) ? prop : index] = value; - } -} - -class EdgeMapper { - public readonly affectedStacks: Set = new Set(); - private readonly nodeMap: Map = new Map(); - - constructor(private readonly mappings: ResourceMapping[]) { - } - - /** - * For each input edge, produce an output edge such that: - * - The source and targets are mapped to their new locations - * - The annotation is converted between in-stack and cross-stack references, as appropriate - */ - mapEdges(edges: ResourceEdge[]): ResourceEdge[] { - return edges - .map((edge) => { - const oldSource = edge.source; - const oldTargets = edge.targets; - const newSource = this.mapNode(oldSource); - const newTargets = oldTargets.map((t) => this.mapNode(t)); - - const oldSourceStackName = oldSource.location.stack.stackName; - const oldTargetStackName = oldTargets[0].location.stack.stackName; - - const newSourceStackName = newSource.location.stack.stackName; - const newTargetStackName = newTargets[0].location.stack.stackName; - - this.affectedStacks.add(newSourceStackName); - this.affectedStacks.add(newTargetStackName); - this.affectedStacks.add(oldSourceStackName); - this.affectedStacks.add(oldTargetStackName); - - let reference: CloudFormationReference = edge.reference; - if (oldSourceStackName === oldTargetStackName && newSourceStackName !== newTargetStackName) { - if (edge.reference instanceof DependsOn) { - return undefined; - } - - // in-stack reference to cross-stack reference: wrap the old annotation - reference = new ImportValue(edge.reference); - } else if (oldSourceStackName !== oldTargetStackName && newSourceStackName === newTargetStackName) { - // cross-stack reference to in-stack reference: unwrap the old annotation - if (edge.reference instanceof ImportValue) { - reference = edge.reference.reference; - } - } - - return { - path: edge.path, - source: newSource, - targets: newTargets, - reference, - }; - }) - .filter((edge) => edge !== undefined); - } - - get affectedStackNames(): string[] { - const fromMappings = this.mappings.flatMap((m) => [m.source.stack.stackName, m.destination.stack.stackName]); - return unique([...this.affectedStacks, ...fromMappings]); - } - - private mapNode(node: ResourceNode): ResourceNode { - const newLocation = mapLocation(node.location, this.mappings); - const key = `${newLocation.stack.stackName}.${newLocation.logicalResourceId}`; - if (!this.nodeMap.has(key)) { - this.nodeMap.set(key, { - location: newLocation, - rawValue: node.rawValue, - }); - } - return this.nodeMap.get(key)!; - } -} - -function mapLocation(location: ResourceLocation, mappings: ResourceMapping[]): ResourceLocation { - const mapping = mappings.find((m) => m.source.equalTo(location)); - if (mapping) { - return mapping.destination; - } - return location; -} - -function indexExports(stacks: CloudFormationStack[]): Record { - return Object.fromEntries( - stacks.flatMap((s) => - Object.entries(s.template.Outputs ?? {}) - .filter( - ([_, o]) => typeof o.Export?.Name === 'string' && (o.Value.Ref != null || o.Value['Fn::GetAtt'] != null), - ) - .map(([name, o]) => [o.Export.Name, { stackName: s.stackName, outputName: name, value: o.Value }]), - ), - ); -} - -function unique(arr: Array) { - return Array.from(new Set(arr)); -} - -/** - * Updates the DependsOn property of all resources, removing references - * to resources that do not exist in the template. Unlike Refs and GetAtts, - * which get transformed to ImportValues when the referenced resource is - * moved to another stack, DependsOn doesn't cross stack boundaries. - */ -function sanitizeDependencies(template: CloudFormationTemplate) { - const resources = template.Resources ?? {}; - for (const resource of Object.values(resources)) { - if (typeof resource.DependsOn === 'string' && resources[resource.DependsOn] == null) { - delete resource.DependsOn; - } - - if (Array.isArray(resource.DependsOn)) { - resource.DependsOn = resource.DependsOn.filter((dep) => resources[dep] != null); - if (resource.DependsOn.length === 0) { - delete resource.DependsOn; + // For stacks created by the refactor, CloudFormation does not allow Rules or Parameters + for (const stack of stacksToProcess) { + if (!deployedStacks.some(deployed => deployed.stackName === stack.stackName)) { + if ('Rules' in stack.template) { + delete stack.template.Rules; + } + if ('Parameters' in stack.template) { + delete stack.template.Parameters; } } } -} - -interface ScopedExport { - stackName: string; - outputName: string; - value: any; -} - -interface ResourceNode { - location: ResourceLocation; - rawValue: any; -} - -/** - * An edge in the resource graph, representing a reference from one resource - * to one or more target resources. (Technically, a hyperedge.) - */ -interface ResourceEdge { - /** - * The source resource of the edge. - */ - source: ResourceNode; - - /** - * The target resources of the edge. In case of DependsOn, - * this can be multiple resources. - */ - targets: ResourceNode[]; - - /** - * The path in the source resource where the reference is located. - */ - path: string[]; - - /** - * The CloudFormation reference that this edge represents. - */ - reference: CloudFormationReference; -} - -interface CloudFormationReference { - toCfn(targets: ResourceNode[], exports: Record): any; -} - -class Ref implements CloudFormationReference { - public static INSTANCE = new Ref(); - - private constructor() { - } - - toCfn(targets: ResourceNode[]): any { - return { Ref: targets[0].location.logicalResourceId }; - } -} - -class GetAtt implements CloudFormationReference { - constructor(public readonly attributeName: string) { - } - - toCfn(targets: ResourceNode[]): any { - return { - 'Fn::GetAtt': [targets[0].location.logicalResourceId, this.attributeName], - }; - } -} - -class ImportValue implements CloudFormationReference { - private outputName?: string; - private outputContent?: any; - - constructor(public readonly reference: CloudFormationReference) { - } - - toCfn(targets: ResourceNode[], exports: Record): any { - const exp = this.findExport(targets, exports); - if (exp) { - this.outputName = exp[1].outputName; - this.outputContent = { - Value: exp[1].value, - Export: { - Name: exp[0], - }, - }; - return { 'Fn::ImportValue': exp[0] }; - } - // TODO better message - throw new ToolkitError('Unknown export for ImportValue: ' + JSON.stringify(this.reference)); - } - - private findExport(targets: ResourceNode[], exports: Record) { - const target = targets[0]; - if (this.reference instanceof Ref) { - return Object.entries(exports).find(([_, exportValue]) => { - return ( - exportValue.stackName === target.location.stack.stackName && - exportValue.value.Ref === target.location.logicalResourceId - ); - }); - } else { - return Object.entries(exports).find(([_, exportValue]) => { - const getAtt = this.reference as GetAtt; - - return ( - exportValue.stackName === target.location.stack.stackName && - exportValue.value['Fn::GetAtt'] && - ((exportValue.value['Fn::GetAtt'][0] === target.location.logicalResourceId && - exportValue.value['Fn::GetAtt'][1] === getAtt.attributeName) || - exportValue.value['Fn::GetAtt'] === `${target.location.logicalResourceId}.${getAtt.attributeName}`) - ); - }); - } - } - - get output(): Record { - if (this.outputName == null) { - throw new ToolkitError('Cannot access output before calling toCfn'); - } - return { [this.outputName]: this.outputContent }; - } -} - -class Sub implements CloudFormationReference { - constructor(public readonly inputString: string, public readonly varNames: string[]) { - } - toCfn(targets: ResourceNode[]): any { - let inputString = this.inputString; - - this.varNames.forEach((varName, index) => { - const [_, attr] = varName.split(/\.(.*)/s); - const target = targets[index]; - inputString = inputString.replace(`\${${varName}`, `\${${target.location.logicalResourceId}${attr ? `.${attr}` : ''}`, - ); - }); - - return inputString; - } -} - -class DependsOn implements CloudFormationReference { - public static INSTANCE = new DependsOn(); - - private constructor() { - } - - toCfn(targets: ResourceNode[]): any { - return targets.map((t) => t.location.logicalResourceId); - } + return stacksToProcess.map((stack) => ({ + StackName: stack.stackName, + TemplateBody: JSON.stringify(stack.template), + })); } diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 8d0e1df7b..04d11a78b 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -11,7 +11,7 @@ import { NonInteractiveIoHost } from './non-interactive-io-host'; import type { ToolkitServices } from './private'; import { assemblyFromSource } from './private'; import { ToolkitError } from './toolkit-error'; -import type { FeatureFlag, DeployResult, DestroyResult, RollbackResult } from './types'; +import type { DeployResult, DestroyResult, FeatureFlag, RollbackResult } from './types'; import type { BootstrapEnvironments, BootstrapOptions, @@ -68,6 +68,7 @@ import { import type { CloudFormationStack } from '../api/refactoring/cloudformation'; import { ResourceMapping, ResourceLocation } from '../api/refactoring/cloudformation'; import { RefactoringContext } from '../api/refactoring/context'; +import { generateStackDefinitions } from '../api/refactoring/stack-definitions'; import { ResourceMigrator } from '../api/resource-import'; import { tagsForStack } from '../api/tags/private'; import { DEFAULT_TOOLKIT_STACK_NAME } from '../api/toolkit-info'; @@ -1059,10 +1060,6 @@ export class Toolkit extends CloudAssemblySourceBuilder { } private async _refactor(assembly: StackAssembly, ioHelper: IoHelper, options: RefactorOptions = {}): Promise { - if (!options.dryRun) { - throw new ToolkitError('Refactor is not available yet. Too see the proposed changes, use the --dry-run flag.'); - } - const sdkProvider = await this.sdkProvider('refactor'); const selectedStacks = await assembly.selectStacksV2(options.stacks ?? ALL_STACKS); const groups = await groupStacks(sdkProvider, selectedStacks.stackArtifacts, options.additionalStackNames ?? []); @@ -1092,6 +1089,8 @@ export class Toolkit extends CloudAssemblySourceBuilder { let refactorMessage = formatTypedMappings(typedMappings); const refactorResult: RefactorResult = { typedMappings }; + const stackDefinitions = generateStackDefinitions(mappings, deployedStacks, localStacks); + if (context.ambiguousPaths.length > 0) { const paths = context.ambiguousPaths; refactorMessage += '\n' + formatAmbiguousMappings(paths); @@ -1099,8 +1098,29 @@ export class Toolkit extends CloudAssemblySourceBuilder { } await ioHelper.notify(IO.CDK_TOOLKIT_I8900.msg(refactorMessage, refactorResult)); + + if (options.dryRun || context.mappings.length === 0 || context.ambiguousPaths.length > 0) { + // Nothing left to do. + continue; + } + + // In interactive mode (TTY) we need confirmation before proceeding + if (process.stdout.isTTY && !await confirm(options.force ?? false)) { + await ioHelper.defaults.info(chalk.red(`Refactoring canceled for environment aws://${environment.account}/${environment.region}\n`)); + continue; + } + + await ioHelper.defaults.info('Refactoring...'); + await context.execute(stackDefinitions, sdkProvider, ioHelper); + await ioHelper.defaults.info('✅ Stack refactor complete'); + + await ioHelper.notify(IO.CDK_TOOLKIT_I8900.msg(refactorMessage, refactorResult)); } catch (e: any) { - await ioHelper.notify(IO.CDK_TOOLKIT_E8900.msg(e.message, { error: e })); + const message = `❌ Refactor failed: ${formatError(e)}`; + await ioHelper.notify(IO.CDK_TOOLKIT_E8900.msg(message, { error: e })); + + // Also debugging the error, because the API does not always return a user-friendly message + await ioHelper.defaults.debug(e.message); } } @@ -1142,6 +1162,35 @@ export class Toolkit extends CloudAssemblySourceBuilder { return result; } } + + async function confirm(force: boolean): Promise { + // 'force' is set to true is the equivalent of having pre-approval for any refactor + if (force) { + return true; + } + + const question = 'Do you wish to refactor these resources?'; + const response = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I8910.req(question, { + responseDescription: '[Y]es/[n]o', + }, 'y')); + return ['y', 'yes'].includes(response.toLowerCase()); + } + + function formatError(error: any): string { + try { + const payload = JSON.parse(error.message); + const messages: string[] = []; + if (payload.reason?.StatusReason) { + messages.push(`Refactor creation: [${payload.reason?.Status}] ${payload.reason.StatusReason}`); + } + if (payload.reason?.ExecutionStatusReason) { + messages.push(`Refactor execution: [${payload.reason?.Status}] ${payload.reason.ExecutionStatusReason}`); + } + return messages.length > 0 ? messages.join('\n') : 'Unknown error'; + } catch (e) { + return formatErrorMessage(error); + } + } } /** diff --git a/packages/@aws-cdk/toolkit-lib/package.json b/packages/@aws-cdk/toolkit-lib/package.json index 4948be2ca..523579eb1 100644 --- a/packages/@aws-cdk/toolkit-lib/package.json +++ b/packages/@aws-cdk/toolkit-lib/package.json @@ -112,6 +112,7 @@ "cdk-from-cfn": "^0.228.0", "chalk": "^4", "chokidar": "^3", + "fast-deep-equal": "^3.1.3", "fs-extra": "^9", "glob": "^11.0.3", "minimatch": "10.0.3", 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 01b99a1ce..51756a576 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/refactor.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/refactor.test.ts @@ -1,8 +1,16 @@ -import { GetTemplateCommand, ListStacksCommand } from '@aws-sdk/client-cloudformation'; +import { + CreateStackRefactorCommand, + DescribeStackRefactorCommand, + DescribeStacksCommand, + ExecuteStackRefactorCommand, + GetTemplateCommand, + ListStacksCommand, +} from '@aws-sdk/client-cloudformation'; +import { GetCallerIdentityCommand } from '@aws-sdk/client-sts'; import { type RefactorOptions, StackSelectionStrategy, Toolkit } from '../../lib'; import { SdkProvider } from '../../lib/api/aws-auth/private'; import { builderFixture, TestIoHost } from '../_helpers'; -import { mockCloudFormationClient, MockSdk } from '../_helpers/mock-sdk'; +import { mockCloudFormationClient, MockSdk, mockSTSClient } from '../_helpers/mock-sdk'; const ioHost = new TestIoHost(); const toolkit = new Toolkit({ ioHost, unstableFeatures: ['refactor'] }); @@ -348,15 +356,6 @@ test('detects modifications to the infrastructure', async () => { ); }); -test('fails when dry-run is false', async () => { - const cx = await builderFixture(toolkit, 'stack-with-bucket'); - await expect( - toolkit.refactor(cx, { - dryRun: false, - }), - ).rejects.toThrow('Refactor is not available yet. Too see the proposed changes, use the --dry-run flag.'); -}); - test('overrides can be used to resolve ambiguities', async () => { // GIVEN mockCloudFormationClient.on(ListStacksCommand).resolves({ @@ -667,6 +666,391 @@ test('computes one set of mappings per environment', async () => { ); }); +describe('refactor execution', () => { + beforeEach(() => { + process.stdout.isTTY = false; + }); + + test('happy path', async () => { + // GIVEN + givenDeployedStackWithResources('Stack1', { + OldLogicalID: { + Type: 'AWS::S3::Bucket', + UpdateReplacePolicy: 'Retain', + DeletionPolicy: 'Retain', + Metadata: { + 'aws:cdk:path': 'Stack1/OldLogicalID/Resource', + }, + }, + }); + + mockSTSClient.on(GetCallerIdentityCommand).resolves({ + Account: '333333333333', + Arn: 'arn:aws:sts::333333333333:assumed-role/role-name/role-session-name', + }); + + mockCloudFormationClient.on(DescribeStacksCommand).resolves({ + Stacks: [ + { + StackName: 'CDKToolkit', + CreationTime: new Date(), + StackStatus: 'UPDATE_COMPLETE', + Outputs: [ + { + OutputKey: 'BootstrapVersion', + OutputValue: '28', + }, + ], + }, + ], + }); + + mockCloudFormationClient.on(CreateStackRefactorCommand).resolves({ + StackRefactorId: 'refactor-id', + }); + + mockCloudFormationClient.on(DescribeStackRefactorCommand).resolves({ + Status: 'CREATE_COMPLETE', + ExecutionStatus: 'EXECUTE_COMPLETE', + }); + + mockCloudFormationClient.on(ExecuteStackRefactorCommand).resolves({}); + + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await toolkit.refactor(cx, { + dryRun: false, + }); + + // THEN + expect(mockCloudFormationClient).toHaveReceivedCommandWith(CreateStackRefactorCommand, { + EnableStackCreation: true, + ResourceMappings: [ + { + Destination: { LogicalResourceId: 'MyBucketF68F3FF0', StackName: 'Stack1' }, + Source: { LogicalResourceId: 'OldLogicalID', StackName: 'Stack1' }, + }, + ], + StackDefinitions: [ + { + StackName: 'Stack1', + TemplateBody: JSON.stringify({ + Resources: { + MyBucketF68F3FF0: { + Type: 'AWS::S3::Bucket', + UpdateReplacePolicy: 'Retain', + DeletionPolicy: 'Retain', + Metadata: { 'aws:cdk:path': 'Stack1/OldLogicalID/Resource' }, + }, + }, + Parameters: { + BootstrapVersion: { + Type: 'AWS::SSM::Parameter::Value', + Default: '/cdk-bootstrap/hnb659fds/version', + Description: + 'Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]', + }, + }, + Rules: { + CheckBootstrapVersion: { + Assertions: [ + { + Assert: { + 'Fn::Not': [{ 'Fn::Contains': [['1', '2', '3', '4', '5'], { Ref: 'BootstrapVersion' }] }], + }, + AssertDescription: + "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.", + }, + ], + }, + }, + }), + }, + ], + }); + + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringMatching(/Stack refactor complete/), + }), + ); + }); + + test('interactive mode, without force', async () => { + // GIVEN + givenDeployedStackWithResources('Stack1', { + OldLogicalID: { + Type: 'AWS::S3::Bucket', + UpdateReplacePolicy: 'Retain', + DeletionPolicy: 'Retain', + Metadata: { + 'aws:cdk:path': 'Stack1/OldLogicalID/Resource', + }, + }, + }); + + try { + process.stdout.isTTY = true; + + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await toolkit.refactor(cx, { + dryRun: false, + }); + + // THEN + expect(ioHost.requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'refactor', + code: 'CDK_TOOLKIT_I8910', + defaultResponse: 'y', + level: 'info', + message: 'Do you wish to refactor these resources?', + }), + ); + } finally { + process.stdout.isTTY = false; + } + }); + + test('interactive mode, with force', async () => { + // GIVEN + givenDeployedStackWithResources('Stack1', { + OldLogicalID: { + Type: 'AWS::S3::Bucket', + UpdateReplacePolicy: 'Retain', + DeletionPolicy: 'Retain', + Metadata: { + 'aws:cdk:path': 'Stack1/OldLogicalID/Resource', + }, + }, + }); + process.stdout.isTTY = true; + + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await toolkit.refactor(cx, { + dryRun: false, + force: true, + }); + + // THEN + // No confirmation is requested from the user + expect(ioHost.requestSpy).not.toHaveBeenCalled(); + }); + + test('refactor execution fails', async () => { + // GIVEN + givenDeployedStackWithResources('Stack1', { + OldLogicalID: { + Type: 'AWS::S3::Bucket', + UpdateReplacePolicy: 'Retain', + DeletionPolicy: 'Retain', + Metadata: { + 'aws:cdk:path': 'Stack1/OldLogicalID/Resource', + }, + }, + }); + + mockSTSClient.on(GetCallerIdentityCommand).resolves({ + Account: '333333333333', + Arn: 'arn:aws:sts::333333333333:assumed-role/role-name/role-session-name', + }); + + mockCloudFormationClient.on(DescribeStacksCommand).resolves({ + Stacks: [ + { + StackName: 'CDKToolkit', + CreationTime: new Date(), + StackStatus: 'UPDATE_COMPLETE', + Outputs: [ + { + OutputKey: 'BootstrapVersion', + OutputValue: '28', + }, + ], + }, + ], + }); + + mockCloudFormationClient.on(CreateStackRefactorCommand).resolves({ + StackRefactorId: 'refactor-id', + }); + + mockCloudFormationClient.on(DescribeStackRefactorCommand).resolves({ + Status: 'CREATE_COMPLETE', + ExecutionStatus: 'EXECUTE_FAILED', + StatusReason: 'Some error occurred during execution', + }); + + mockCloudFormationClient.on(ExecuteStackRefactorCommand).resolves({}); + + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await toolkit.refactor(cx, { + dryRun: false, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'refactor', + code: 'CDK_TOOLKIT_E8900', + level: 'error', + message: expect.stringMatching('Some error occurred during execution'), + }), + ); + }); + + test('refactor creation fails', async () => { + // GIVEN + givenDeployedStackWithResources('Stack1', { + OldLogicalID: { + Type: 'AWS::S3::Bucket', + UpdateReplacePolicy: 'Retain', + DeletionPolicy: 'Retain', + Metadata: { + 'aws:cdk:path': 'Stack1/OldLogicalID/Resource', + }, + }, + }); + + mockSTSClient.on(GetCallerIdentityCommand).resolves({ + Account: '333333333333', + Arn: 'arn:aws:sts::333333333333:assumed-role/role-name/role-session-name', + }); + + mockCloudFormationClient.on(DescribeStacksCommand).resolves({ + Stacks: [ + { + StackName: 'CDKToolkit', + CreationTime: new Date(), + StackStatus: 'UPDATE_COMPLETE', + Outputs: [ + { + OutputKey: 'BootstrapVersion', + OutputValue: '28', + }, + ], + }, + ], + }); + + mockCloudFormationClient.on(CreateStackRefactorCommand).resolves({ + StackRefactorId: 'refactor-id', + }); + + mockCloudFormationClient.on(DescribeStackRefactorCommand).resolves({ + Status: 'CREATE_FAILED', + StatusReason: 'Some error occurred during creation', + }); + + mockCloudFormationClient.on(ExecuteStackRefactorCommand).resolves({}); + + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await toolkit.refactor(cx, { + dryRun: false, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'refactor', + code: 'CDK_TOOLKIT_E8900', + level: 'error', + message: expect.stringMatching('Some error occurred during creation'), + }), + ); + }); + + test('bootstrap version lower than minimum required', async () => { + // GIVEN + givenDeployedStackWithResources('Stack1', { + OldLogicalID: { + Type: 'AWS::S3::Bucket', + UpdateReplacePolicy: 'Retain', + DeletionPolicy: 'Retain', + Metadata: { + 'aws:cdk:path': 'Stack1/OldLogicalID/Resource', + }, + }, + }); + + mockSTSClient.on(GetCallerIdentityCommand).resolves({ + Account: '333333333333', + Arn: 'arn:aws:sts::333333333333:assumed-role/role-name/role-session-name', + }); + + mockCloudFormationClient.on(DescribeStacksCommand).resolves({ + Stacks: [ + { + StackName: 'CDKToolkit', + CreationTime: new Date(), + StackStatus: 'UPDATE_COMPLETE', + Outputs: [ + { + OutputKey: 'BootstrapVersion', + OutputValue: '27', + }, + ], + }, + ], + }); + + mockCloudFormationClient.on(CreateStackRefactorCommand).resolves({ + StackRefactorId: 'refactor-id', + }); + + mockCloudFormationClient.on(DescribeStackRefactorCommand).resolves({ + Status: 'CREATE_FAILED', + }); + + mockCloudFormationClient.on(ExecuteStackRefactorCommand).resolves({}); + + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await toolkit.refactor(cx, { + dryRun: false, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'refactor', + code: 'CDK_TOOLKIT_E8900', + level: 'error', + message: expect.stringMatching( + "The CDK toolkit stack in environment aws://123456789012/us-east-1 doesn't support refactoring. Please run 'cdk bootstrap aws://123456789012/us-east-1' to update it.", + ), + }), + ); + }); +}); + +function givenDeployedStackWithResources(stackName: string, resources: Record) { + mockCloudFormationClient.on(ListStacksCommand).resolves({ + StackSummaries: [ + { + StackName: stackName, + StackId: `arn:aws:cloudformation:us-east-1:333333333333:stack/${stackName}`, + StackStatus: 'CREATE_COMPLETE', + CreationTime: new Date(), + }, + ], + }); + + mockCloudFormationClient + .on(GetTemplateCommand, { + StackName: stackName, + }) + .resolves({ + TemplateBody: JSON.stringify({ + Resources: resources, + }), + }); +} + async function expectRefactorBehavior(fixtureName: string, input: RefactorOptions, output: E) { const host = new TestIoHost(); const tk = new Toolkit({ ioHost: host, unstableFeatures: ['refactor'] }); 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 367f2f593..fdec05aa0 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 @@ -846,80 +846,6 @@ describe(generateStackDefinitions, () => { region: 'us-east-1', }; - test('renames a resource within the same stack', () => { - const stack1: CloudFormationStack = { - environment: environment, - stackName: 'Foo', - template: { - Resources: { - Bucket1: { - Type: 'AWS::S3::Bucket', - }, - NotInvolved: { - Type: 'AWS::X::Y', - }, - Consumer: { - Type: 'AWS::X::Y', - Properties: { - Bucket: { Ref: 'Bucket1' }, - }, - }, - }, - }, - }; - - const stack2: CloudFormationStack = { - environment: environment, - stackName: 'Foo', - template: { - Resources: { - Bucket2: { - Type: 'AWS::S3::Bucket', - }, - NotInvolved: { - Type: 'AWS::X::Y', - }, - Consumer: { - Type: 'AWS::X::Y', - Properties: { - Bucket: { Ref: 'Bucket2' }, - }, - }, - }, - }, - }; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack1, 'Bucket2')), - ]; - - const result = generateStackDefinitions(mappings, [stack1], [stack2]); - expect(result).toEqual([ - { - StackName: 'Foo', - TemplateBody: JSON.stringify({ - Resources: { - Consumer: { - Type: 'AWS::X::Y', - Properties: { - // The reference has also been updated - Bucket: { Ref: 'Bucket2' }, - }, - }, - Bucket2: { - Type: 'AWS::S3::Bucket', - }, - // Not involved in the refactor, but still part of the - // original template. Should be included. - NotInvolved: { - Type: 'AWS::X::Y', - }, - }, - }), - }, - ]); - }); - test('moves a resource to another stack that has already been deployed', () => { const deployedStack1: CloudFormationStack = { environment, @@ -981,11 +907,7 @@ describe(generateStackDefinitions, () => { ), ]; - const result = generateStackDefinitions( - mappings, - [deployedStack1, deployedStack2], - [localStack1, localStack2], - ); + const result = generateStackDefinitions(mappings, [deployedStack1, deployedStack2], [localStack1, localStack2]); expect(result).toEqual([ { StackName: 'Stack1', @@ -1004,16 +926,106 @@ describe(generateStackDefinitions, () => { StackName: 'Stack2', TemplateBody: JSON.stringify({ Resources: { + // Wasn't touched by the refactor + B: { + Type: 'AWS::B::B', + }, + // Old Bucket1 is now Bucket2 here Bucket2: { Type: 'AWS::S3::Bucket', }, + }, + }), + }, + ]); + }); - // Wasn't touched by the refactor - B: { - Type: 'AWS::B::B', + test('with cross-stack references', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, }, + B: { Type: 'AWS::B::B' }, }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Resources: {}, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[1], 'B'), new ResourceLocation(localStacks[0], 'B')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + // The reference has been updated to the moved resource + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }), + }, + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Resources: {}, }), }, ]); @@ -1066,26 +1078,26 @@ describe(generateStackDefinitions, () => { const result = generateStackDefinitions(mappings, [deployedStack], [localStack1, localStack2]); expect(result).toEqual([ { - StackName: 'Stack1', + StackName: 'Stack2', TemplateBody: JSON.stringify({ Resources: { - // Wasn't touched by the refactor - A: { - Type: 'AWS::A::A', + // Old Bucket1 is now Bucket2 here + Bucket2: { + Type: 'AWS::S3::Bucket', }, - - // Bucket1 doesn't exist anymore }, }), }, { - StackName: 'Stack2', + StackName: 'Stack1', TemplateBody: JSON.stringify({ Resources: { - // Old Bucket1 is now Bucket2 here - Bucket2: { - Type: 'AWS::S3::Bucket', + // Wasn't touched by the refactor + A: { + Type: 'AWS::A::A', }, + + // Bucket1 doesn't exist anymore }, }), }, @@ -1162,11 +1174,7 @@ describe(generateStackDefinitions, () => { ), ]; - const result = generateStackDefinitions( - mappings, - [deployedStack1, deployedStack2], - [localStack1, localStack2], - ); + const result = generateStackDefinitions(mappings, [deployedStack1, deployedStack2], [localStack1, localStack2]); expect(result).toEqual([ { StackName: 'Stack1', @@ -1250,13 +1258,10 @@ describe(generateStackDefinitions, () => { ), ]; - const result = generateStackDefinitions( - mappings, - [deployedStack1, deployedStack2], - [localStack1, localStack2], - ); + const result = generateStackDefinitions(mappings, [deployedStack1, deployedStack2], [localStack1, localStack2]); expect(result).toEqual([ { + // Stack2 and Stack3 are not involved in the refactoring. Only Stack1 is. StackName: 'Stack1', TemplateBody: JSON.stringify({ Resources: { @@ -1269,7 +1274,7 @@ describe(generateStackDefinitions, () => { ]); }); - test('local stacks have more resources than deployed stacks', async () => { + test('CDK path in Metadata is preserved', () => { const deployedStack: CloudFormationStack = { environment, stackName: 'Stack1', @@ -1277,6 +1282,9 @@ describe(generateStackDefinitions, () => { Resources: { Bucket1: { Type: 'AWS::S3::Bucket', + Metadata: { + 'aws:cdk:path': 'Stack1/Bucket1/Resource', + }, }, }, }, @@ -1289,9 +1297,10 @@ describe(generateStackDefinitions, () => { Resources: { Bucket2: { Type: 'AWS::S3::Bucket', - }, - ExtraStuff: { - Type: 'AWS::X::Y', + Metadata: { + // Here the CDK path is consistent with the new logical ID... + 'aws:cdk:path': 'Stack1/Bucket2/Resource', + }, }, }, }, @@ -1312,15 +1321,18 @@ describe(generateStackDefinitions, () => { Resources: { Bucket2: { Type: 'AWS::S3::Bucket', + Metadata: { + // ...but we keep the original CDK path from the deployed stack, to make CloudFormation happy. + 'aws:cdk:path': 'Stack1/Bucket1/Resource', + }, }, - // ExtraStuff is not involved in the refactor and was not part of the deployed stack, so we keep it out. }, }), }, ]); }); - test('local stacks have fewer resources than deployed stacks', () => { + test('stack definitions come from the local templates', () => { const deployedStack: CloudFormationStack = { environment, stackName: 'Stack1', @@ -1328,9 +1340,18 @@ describe(generateStackDefinitions, () => { Resources: { Bucket1: { Type: 'AWS::S3::Bucket', + Properties: { + Foo: 'Bar', + }, }, - ExtraStuff: { - Type: 'AWS::X::Y', + CDKMetadata: { + Type: 'AWS::CDK::Metadata', + Properties: { + Analytics: 'v2:deflate64:deployed', + }, + Metadata: { + 'aws:cdk:path': 'Stack1/CDKMetadata/Default', + }, }, }, }, @@ -1343,6 +1364,18 @@ describe(generateStackDefinitions, () => { Resources: { Bucket2: { Type: 'AWS::S3::Bucket', + Properties: { + Foo: 'Bar', + }, + }, + CDKMetadata: { + Type: 'AWS::CDK::Metadata', + Properties: { + Analytics: 'v2:deflate64:local', + }, + Metadata: { + 'aws:cdk:path': 'Stack1/CDKMetadata/Default', + }, }, }, }, @@ -1361,12 +1394,22 @@ describe(generateStackDefinitions, () => { StackName: 'Stack1', TemplateBody: JSON.stringify({ Resources: { + // For regular resources, we pick the local one Bucket2: { Type: 'AWS::S3::Bucket', + Properties: { + Foo: 'Bar', + }, }, - // ExtraStuff is not involved in the refactor, but it was part of the deployed stack, so we keep it in. - ExtraStuff: { - Type: 'AWS::X::Y', + CDKMetadata: { + Type: 'AWS::CDK::Metadata', + Properties: { + // But for CDKMetadata, we pick the deployed one + Analytics: 'v2:deflate64:deployed', + }, + Metadata: { + 'aws:cdk:path': 'Stack1/CDKMetadata/Default', + }, }, }, }), @@ -1374,7 +1417,7 @@ describe(generateStackDefinitions, () => { ]); }); - test('CDK path in Metadata is preserved', () => { + test('Rules and Parameters are removed for new stacks', () => { const deployedStack: CloudFormationStack = { environment, stackName: 'Stack1', @@ -1382,1367 +1425,94 @@ describe(generateStackDefinitions, () => { Resources: { Bucket1: { Type: 'AWS::S3::Bucket', - Metadata: { - 'aws:cdk:path': 'Stack1/Bucket1/Resource', + Properties: { + Foo: 'Bar', + }, + }, + Bucket2: { + Type: 'AWS::S3::Bucket', + Properties: { + Foo: 'Zee', }, }, }, }, }; - const localStack: CloudFormationStack = { + const localStack1: CloudFormationStack = { environment, stackName: 'Stack1', template: { Resources: { + Bucket1: { + Type: 'AWS::S3::Bucket', + Properties: { + Foo: 'Bar', + }, + }, + }, + }, + }; + + const localStack2: CloudFormationStack = { + environment, + stackName: 'Stack2', + template: { + Resources: { + // Moved out of the original stack to a new one. Bucket2: { Type: 'AWS::S3::Bucket', - Metadata: { - // Here the CDK path is consistent with the new logical ID... - 'aws:cdk:path': 'Stack1/Bucket2/Resource', + Properties: { + Foo: 'Zee', }, }, }, + Rules: { + CheckBootstrapVersion: { + Assertions: [], + }, + }, + Parameters: { + BootstrapVersion: { + Type: 'AWS::SSM::Parameter::Value', + Default: '/cdk-bootstrap/hnb659fds/version', + Description: 'Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]', + }, + }, }, }; const mappings: ResourceMapping[] = [ - new ResourceMapping( - new ResourceLocation(deployedStack, 'Bucket1'), - new ResourceLocation(deployedStack, 'Bucket2'), - ), + new ResourceMapping(new ResourceLocation(deployedStack, 'Bucket2'), new ResourceLocation(localStack2, 'Bucket2')), ]; - const result = generateStackDefinitions(mappings, [deployedStack], [localStack]); + const result = generateStackDefinitions(mappings, [deployedStack], [localStack1, localStack2]); expect(result).toEqual([ { StackName: 'Stack1', TemplateBody: JSON.stringify({ Resources: { - Bucket2: { + Bucket1: { Type: 'AWS::S3::Bucket', - Metadata: { - // ...but we keep the original CDK path from the deployed stack, to make CloudFormation happy. - 'aws:cdk:path': 'Stack1/Bucket1/Resource', + Properties: { + Foo: 'Bar', }, }, }, }), }, - ]); - }); - - describe('With references between resources', () => { - describe('Divergence - reference starts within the same stack and, in some cases, crosses stacks', () => { - test('No stack move', () => { - const deployedStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'B' }, - }, - }, - B: { - Type: 'AWS::B::B', - }, - }, - }, - }, - ]; - - const localStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'Bn' }, - }, - }, - Bn: { - Type: 'AWS::B::B', - }, - }, + { + StackName: 'Stack2', + // No Rules or Parameters, even though they are present in the local stack + TemplateBody: JSON.stringify({ + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + Properties: { Foo: 'Zee' }, }, }, - ]; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[0], 'Bn')), - ]; - - const result = generateStackDefinitions(mappings, deployedStacks, localStacks); - expect(result).toEqual([ - { - StackName: 'StackX', - TemplateBody: JSON.stringify({ - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'Bn' }, // Updated reference - }, - }, - Bn: { - Type: 'AWS::B::B', - }, - }, - }), - }, - ]); - }); - - test('tail of the reference moved', () => { - const deployedStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'B' }, - }, - }, - B: { - Type: 'AWS::B::B', - }, - }, - }, - }, - ]; - - const localStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackY', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { 'Fn::ImportValue': 'BFromOtherStack' }, - }, - }, - }, - }, - }, - { - environment, - stackName: 'StackX', - template: { - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - Resources: { - B: { Type: 'AWS::B::B' }, - }, - }, - }, - ]; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[0], 'A')), - ]; - - const result = generateStackDefinitions(mappings, deployedStacks, localStacks); - expect(result).toEqual([ - { - StackName: 'StackY', - TemplateBody: JSON.stringify({ - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { 'Fn::ImportValue': 'BFromOtherStack' }, // Reference to the moved resource - }, - }, - }, - }), - }, - { - StackName: 'StackX', - TemplateBody: JSON.stringify({ - Resources: { - B: { Type: 'AWS::B::B' }, // The moved resource - }, - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - }), - }, - ]); - }); - - test('head of the reference moved', () => { - const deployedStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'B' }, - }, - }, - B: { - Type: 'AWS::B::B', - }, - }, - }, - }, - ]; - - const localStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { 'Fn::ImportValue': 'BFromOtherStack' }, - }, - }, - }, - }, - }, - { - environment, - stackName: 'StackY', - template: { - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - Resources: { - B: { Type: 'AWS::B::B' }, - }, - }, - }, - ]; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[1], 'B')), - ]; - - const result = generateStackDefinitions(mappings, deployedStacks, localStacks); - expect(result).toEqual([ - { - StackName: 'StackX', - TemplateBody: JSON.stringify({ - Resources: { - // A was moved - A: { - Type: 'AWS::A::A', - Properties: { - Props: { 'Fn::ImportValue': 'BFromOtherStack' }, // Reference to the resource that stayed behind - }, - }, - }, - }), - }, - { - StackName: 'StackY', - TemplateBody: JSON.stringify({ - Resources: { - B: { Type: 'AWS::B::B' }, - }, - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - }), - }, - ]); - }); - - test('both moved to the same stack', () => { - const deployedStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'B' }, - }, - }, - B: { - Type: 'AWS::B::B', - }, - C: { - Type: 'AWS::C::C', - }, - }, - }, - }, - ]; - - const localStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - C: { - Type: 'AWS::C::C', - }, - }, - }, - }, - { - environment, - stackName: 'StackY', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'B' }, - }, - }, - B: { - Type: 'AWS::B::B', - }, - }, - }, - }, - ]; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[1], 'B')), - new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[1], 'A')), - ]; - - const result = generateStackDefinitions(mappings, deployedStacks, localStacks); - expect(result).toEqual([ - { - StackName: 'StackY', - TemplateBody: JSON.stringify({ - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'B' }, - }, - }, - B: { Type: 'AWS::B::B' }, - }, - }), - }, - { - StackName: 'StackX', - TemplateBody: JSON.stringify({ - Resources: { - C: { - Type: 'AWS::C::C', - }, - }, - }), - }, - ]); - }); - - test('both moved to different stacks', () => { - const deployedStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'B' }, - }, - }, - B: { - Type: 'AWS::B::B', - }, - C: { - Type: 'AWS::C::C', - }, - }, - }, - }, - ]; - - const localStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - C: { - Type: 'AWS::C::C', - }, - }, - }, - }, - { - environment, - stackName: 'StackY', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { 'Fn::ImportValue': 'BFromOtherStack' }, - }, - }, - }, - }, - }, - { - environment, - stackName: 'StackZ', - template: { - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - Resources: { - B: { - Type: 'AWS::B::B', - }, - }, - }, - }, - ]; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[1], 'A')), - new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[2], 'B')), - ]; - - const result = generateStackDefinitions(mappings, deployedStacks, localStacks); - expect(result).toEqual([ - { - StackName: 'StackY', - TemplateBody: JSON.stringify({ - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { 'Fn::ImportValue': 'BFromOtherStack' }, - }, - }, - }, - }), - }, - { - StackName: 'StackZ', - TemplateBody: JSON.stringify({ - Resources: { - B: { - Type: 'AWS::B::B', - }, - }, - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - }), - }, - { - StackName: 'StackX', - TemplateBody: JSON.stringify({ - Resources: { - C: { - Type: 'AWS::C::C', - }, - }, - }), - }, - ]); - }); - }); - - describe('Convergence - reference starts cross-stack and, in some cases, moves to within the same stack', () => { - test('No stack move', () => { - const deployedStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { 'Fn::ImportValue': 'BFromOtherStack' }, - }, - }, - }, - }, - }, - { - environment, - stackName: 'StackY', - template: { - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - Resources: { - B: { Type: 'AWS::B::B' }, - }, - }, - }, - ]; - - const localStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { 'Fn::ImportValue': 'BnFromOtherStack' }, - }, - }, - }, - }, - }, - { - environment, - stackName: 'StackY', - template: { - Outputs: { - Bout: { - Value: { Ref: 'Bn' }, - Export: { - Name: 'BnFromOtherStack', - }, - }, - }, - Resources: { - Bn: { Type: 'AWS::B::B' }, - }, - }, - }, - ]; - - const mappings: ResourceMapping[] = [ - new ResourceMapping( - new ResourceLocation(deployedStacks[1], 'B'), - new ResourceLocation(deployedStacks[1], 'Bn'), - ), - ]; - - const result = generateStackDefinitions(mappings, deployedStacks, localStacks); - expect(result).toEqual([ - { - StackName: 'StackX', - TemplateBody: JSON.stringify({ - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { Props: { 'Fn::ImportValue': 'BnFromOtherStack' } }, - }, - }, - }), - }, - { - StackName: 'StackY', - TemplateBody: JSON.stringify({ - Outputs: { - Bout: { - Value: { Ref: 'Bn' }, - Export: { - Name: 'BnFromOtherStack', - }, - }, - }, - Resources: { - Bn: { Type: 'AWS::B::B' }, - }, - }), - }, - ]); - }); - - test('tail of the reference moved', () => { - const deployedStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { 'Fn::ImportValue': 'BFromOtherStack' }, - }, - }, - }, - }, - }, - { - environment, - stackName: 'StackY', - template: { - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - Resources: { - B: { Type: 'AWS::B::B' }, - }, - }, - }, - ]; - - const localStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackY', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'B' }, - }, - }, - B: { Type: 'AWS::B::B' }, - }, - }, - }, - ]; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[0], 'A')), - ]; - - const result = generateStackDefinitions(mappings, deployedStacks, localStacks); - expect(result).toEqual([ - { - StackName: 'StackY', - TemplateBody: JSON.stringify({ - Outputs: { - Bout: { Value: { Ref: 'B' }, Export: { Name: 'BFromOtherStack' } }, - }, - Resources: { - A: { Type: 'AWS::A::A', Properties: { Props: { Ref: 'B' } } }, - B: { Type: 'AWS::B::B' }, - }, - }), - }, - { - StackName: 'StackX', - TemplateBody: JSON.stringify({ Resources: {} }), - }, - ]); - }); - - test('head of the reference moved', () => { - const deployedStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { 'Fn::ImportValue': 'BFromOtherStack' }, - }, - }, - }, - }, - }, - { - environment, - stackName: 'StackY', - template: { - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - Resources: { - B: { Type: 'AWS::B::B' }, - }, - }, - }, - ]; - - const localStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'B' }, - }, - }, - B: { Type: 'AWS::B::B' }, - }, - }, - }, - ]; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStacks[1], 'B'), new ResourceLocation(localStacks[0], 'B')), - ]; - - const result = generateStackDefinitions(mappings, deployedStacks, localStacks); - expect(result).toEqual([ - { - StackName: 'StackX', - TemplateBody: JSON.stringify({ - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - // The reference has been updated to the moved resource - Props: { Ref: 'B' }, - }, - }, - B: { Type: 'AWS::B::B' }, - }, - }), - }, - { - StackName: 'StackY', - TemplateBody: JSON.stringify({ - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { Name: 'BFromOtherStack' }, - }, - }, - Resources: {}, - }), - }, - ]); - }); - - test('both moved', () => { - const deployedStacks: CloudFormationStack[] = [ - { - environment, - stackName: 'StackX', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { 'Fn::ImportValue': 'BFromOtherStack' }, - }, - }, - }, - }, - }, - { - environment, - stackName: 'StackY', - template: { - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - Resources: { - B: { Type: 'AWS::B::B' }, - }, - }, - }, - ]; - - const localStacks: CloudFormationStack[] = [ - { - environment, - // This is a third stack that will receive both resources - stackName: 'StackZ', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'B' }, - }, - }, - B: { Type: 'AWS::B::B' }, - }, - }, - }, - ]; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[0], 'A')), - new ResourceMapping(new ResourceLocation(deployedStacks[1], 'B'), new ResourceLocation(localStacks[0], 'B')), - ]; - - const result = generateStackDefinitions(mappings, deployedStacks, localStacks); - expect(result).toEqual([ - { - StackName: 'StackZ', - TemplateBody: JSON.stringify({ - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Props: { Ref: 'B' }, - }, - }, - B: { Type: 'AWS::B::B' }, - }, - }), - }, - { - StackName: 'StackX', - TemplateBody: JSON.stringify({ Resources: {} }), - }, - { - StackName: 'StackY', - TemplateBody: JSON.stringify({ - Outputs: { - Bout: { - Value: { Ref: 'B' }, - Export: { Name: 'BFromOtherStack' }, - }, - }, - Resources: {}, - }), - }, - ]); - }); - }); - }); - - describe('Local and deployed resources are not identical', () => { - // This may happen if the resource has a physical ID defined, which gives - // the user freedom to change the resource properties, while the matching - // algorithm still recognizes them as being the same. - - test('deployed resource configuration prevails', () => { - const deployedStack: CloudFormationStack = { - environment: environment, - stackName: 'Foo', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Prop: { Ref: 'B' }, - Prop2: { 'Fn::GetAtt': ['C', 'Banana'] }, - Prop3: { 'Fn::Sub': ['${C} ${SomeVar} ${B.foo}', { SomeVar: { Ref: 'B' } }] }, - Foo: 123, - }, - DependsOn: ['D'], - }, - B: { - Type: 'AWS::B::B', - Properties: { - Foo: 123, - }, - }, - C: { - Type: 'AWS::C::C', - Properties: { - Banana: 'BananaValue', - }, - }, - D: { - Type: 'AWS::D::D', - Properties: { - Foo: 123, - }, - }, - }, - }, - }; - - const localStack: CloudFormationStack = { - environment: environment, - stackName: 'Foo', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Prop: { Ref: 'Bn' }, - Prop2: { 'Fn::GetAtt': ['Cn', 'Banana'] }, - Prop3: { 'Fn::Sub': ['${Cn} ${SomeVar} ${Bn.foo}', { SomeVar: { Ref: 'Bn' } }] }, - Bar: 456, // Different property - }, - DependsOn: ['Dn'], - }, - Bn: { - Type: 'AWS::B::B', - Properties: { - Bar: 456, - }, - }, - Cn: { - Type: 'AWS::C::C', - Properties: { - Banana: 'BananaValue', - }, - }, - Dn: { - Type: 'AWS::D::D', - Properties: { - Bar: 456, - }, - }, - }, - }, - }; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStack, 'B'), new ResourceLocation(deployedStack, 'Bn')), - new ResourceMapping(new ResourceLocation(deployedStack, 'C'), new ResourceLocation(deployedStack, 'Cn')), - new ResourceMapping(new ResourceLocation(deployedStack, 'D'), new ResourceLocation(deployedStack, 'Dn')), - ]; - - const result = generateStackDefinitions(mappings, [deployedStack], [localStack]); - expect(result).toEqual([ - { - StackName: 'Foo', - TemplateBody: JSON.stringify({ - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Prop: { Ref: 'Bn' }, - Prop2: { 'Fn::GetAtt': ['Cn', 'Banana'] }, - Prop3: { 'Fn::Sub': ['${Cn} ${SomeVar} ${Bn.foo}', { SomeVar: { Ref: 'Bn' } }] }, - Foo: 123, - }, - DependsOn: ['Dn'], - }, - Dn: { - Type: 'AWS::D::D', - Properties: { - Foo: 123, - }, - }, - Bn: { - Type: 'AWS::B::B', - Properties: { - Foo: 123, - }, - }, - Cn: { - Type: 'AWS::C::C', - Properties: { - Banana: 'BananaValue', - }, - }, - }, - }), - }, - ]); - }); - - test('within -> cross', () => { - const deployedStack: CloudFormationStack = { - environment: environment, - stackName: 'Foo', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Prop: { Ref: 'B' }, // Reference to a resource in the same stack - Foo: 123, - }, - DependsOn: 'B', - }, - B: { - Type: 'AWS::B::B', - Properties: { - Foo: 123, - }, - }, - }, - }, - }; - - const localStack1: CloudFormationStack = { - environment: environment, - stackName: 'Foo', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Prop: { 'Fn::ImportValue': 'BFromOtherStack' }, // Reference to a resource in the same stack - Bar: 456, // Different property - }, - }, - }, - }, - }; - - const localStack2: CloudFormationStack = { - environment: environment, - stackName: 'Bar', - template: { - Outputs: { - Bout: { - Value: { Ref: 'Bn' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - Resources: { - Bn: { - Type: 'AWS::B::B', - Properties: { - Bar: 456, - }, - }, - }, - }, - }; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStack, 'B'), new ResourceLocation(localStack2, 'Bn')), - ]; - - const result = generateStackDefinitions(mappings, [deployedStack], [localStack1, localStack2]); - expect(result).toEqual([ - { - StackName: 'Foo', - TemplateBody: JSON.stringify({ - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Prop: { 'Fn::ImportValue': 'BFromOtherStack' }, // Reference to the moved resource - Foo: 123, // But we keep the original property from the deployed stack - }, - // Note the absence of DependsOn - }, - }, - }), - }, - { - StackName: 'Bar', - TemplateBody: JSON.stringify({ - Resources: { - Bn: { - Type: 'AWS::B::B', - Properties: { - Foo: 123, - }, - }, - }, - Outputs: { - Bout: { - Value: { Ref: 'Bn' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - }), - }, - ]); - }); - - test('cross -> within', () => { - const deployedStack1: CloudFormationStack = { - environment: environment, - stackName: 'Foo', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Prop: { 'Fn::ImportValue': 'BFromOtherStack' }, // Reference to a resource in the same stack - Foo: 123, - }, - }, - }, - }, - }; - - const deployedStack2: CloudFormationStack = { - environment: environment, - stackName: 'Bar', - template: { - Outputs: { - Bout: { - Value: { Ref: 'Bn' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - Resources: { - Bn: { - Type: 'AWS::B::B', - Properties: { - Foo: 123, - }, - }, - }, - }, - }; - - const localStack: CloudFormationStack = { - environment: environment, - stackName: 'Foo', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Prop: { Ref: 'B' }, - Bar: 456, - }, - }, - B: { - Type: 'AWS::B::B', - Properties: { - Bar: 456, - }, - }, - }, - }, - }; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStack2, 'Bn'), new ResourceLocation(localStack, 'B')), - ]; - - const result = generateStackDefinitions(mappings, [deployedStack1, deployedStack2], [localStack]); - expect(result).toEqual([ - { - StackName: 'Foo', - TemplateBody: JSON.stringify({ - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Prop: { Ref: 'B' }, - Foo: 123, // We keep the original property from the deployed stack - }, - }, - B: { - Type: 'AWS::B::B', - Properties: { - Foo: 123, // We keep the original property from the deployed stack - }, - }, - }, - }), - }, - { - StackName: 'Bar', - TemplateBody: JSON.stringify({ - Outputs: { - Bout: { - Value: { Ref: 'Bn' }, - Export: { Name: 'BFromOtherStack' }, - }, - }, - Resources: {}, - }), - }, - ]); - }); - - test('cross -> cross', () => { - const deployedStack1: CloudFormationStack = { - environment: environment, - stackName: 'Foo', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Prop: { 'Fn::ImportValue': 'BFromOtherStack' }, // Reference to a resource in the same stack - Foo: 123, - }, - }, - }, - }, - }; - - const deployedStack2: CloudFormationStack = { - environment: environment, - stackName: 'Bar', - template: { - Outputs: { - Bout: { - Value: { Ref: 'Bn' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - Resources: { - Bn: { - Type: 'AWS::B::B', - Properties: { - Foo: 123, - }, - }, - }, - }, - }; - - const localStack1: CloudFormationStack = { - environment: environment, - stackName: 'Foo', - template: { - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { - Prop: { 'Fn::ImportValue': 'BFromOtherStack' }, - Bar: 456, - }, - }, - }, - }, - }; - - const localStack2: CloudFormationStack = { - environment: environment, - stackName: 'Zee', - template: { - Outputs: { - Bout: { - Value: { Ref: 'Bn2' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - Resources: { - Bn2: { - Type: 'AWS::B::B', - Properties: { - Bar2: 456, - }, - }, - }, - }, - }; - - const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(deployedStack2, 'Bn'), new ResourceLocation(localStack2, 'Bn2')), - ]; - - const result = generateStackDefinitions( - mappings, - [deployedStack1, deployedStack2], - [localStack1, localStack2], - ); - expect(result).toEqual([ - { - StackName: 'Foo', - TemplateBody: JSON.stringify({ - Resources: { - A: { - Type: 'AWS::A::A', - Properties: { Prop: { 'Fn::ImportValue': 'BFromOtherStack' }, Foo: 123 }, - }, - }, - }), - }, - { - StackName: 'Zee', - TemplateBody: JSON.stringify({ - Resources: { - Bn2: { - Type: 'AWS::B::B', - Properties: { - Foo: 123, - }, - }, - }, - Outputs: { - Bout: { - Value: { Ref: 'Bn2' }, - Export: { - Name: 'BFromOtherStack', - }, - }, - }, - }), - }, - { - StackName: 'Bar', - TemplateBody: JSON.stringify({ - Outputs: { - Bout: { - Value: { Ref: 'Bn' }, - Export: { Name: 'BFromOtherStack' }, - }, - }, - Resources: {}, - }), - }, - ]); - }); + }), + }, + ]); }); }); - diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index ba02d61ef..4cd896259 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -1130,13 +1130,17 @@ apply the refactor on your CloudFormation stacks. But for now, only the dry-run mode is supported. 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: +command to consider only a subset of them, you can specify the stacks you +want, both local and deployed: ```shell -$ cdk refactor Web* --unstable=refactor --dry-run +$ cdk refactor --local-stack Foo --local-stack Bar --deployed-stack Foo --unstable=refactor --dry-run ``` +This is useful if, for example, you have more than one CDK application deployed +to a given environment, and you want to only include the deployed stacks that +belong to the application that you are refactoring. + 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. diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index e5a28801e..5ce072813 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -1255,6 +1255,7 @@ export class CdkToolkit { patterns: patterns, strategy: patterns.length > 0 ? StackSelectionStrategy.PATTERN_MATCH : StackSelectionStrategy.ALL_STACKS, }, + force: options.force, additionalStackNames: options.additionalStackNames, overrides: readOverrides(options.overrideFile, options.revert), }); @@ -2001,12 +2002,17 @@ export interface RefactorOptions { overrideFile?: string; /** - * Modifies the behavior of the `mappingFile` option by swapping source and + * Modifies the behavior of the `overrideFile` option by swapping source and * destination locations. This is useful when you want to undo a refactor * that was previously applied. */ revert?: boolean; + /** + * Whether to do the refactor without prompting the user for confirmation. + */ + force?: boolean; + /** * Criteria for selecting stacks to compare with the deployed stacks in the * target environment. diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index ef49b1588..91f9bb2d1 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -461,6 +461,11 @@ export async function makeConfig(): Promise { default: false, desc: 'If specified, the command will revert the refactor operation. This is only valid if a mapping file was provided.', }, + 'force': { + type: 'boolean', + default: false, + desc: 'Whether to do the refactor without asking for confirmation', + }, }, }, 'cli-telemetry': { diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index 0065a9f41..fb642023c 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -924,6 +924,11 @@ "type": "boolean", "default": false, "desc": "If specified, the command will revert the refactor operation. This is only valid if a mapping file was provided." + }, + "force": { + "type": "boolean", + "default": false, + "desc": "Whether to do the refactor without asking for confirmation" } } }, diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 9612fc59b..a289ba7ac 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -288,6 +288,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { default: false, type: 'boolean', desc: 'If specified, the command will revert the refactor operation. This is only valid if a mapping file was provided.', + }) + .option('force', { + default: false, + type: 'boolean', + desc: 'Whether to do the refactor without asking for confirmation', }), ) .command('cli-telemetry', 'Enable or disable anonymous telemetry', (yargs: Argv) => diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 646f7082f..0a973ef5f 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -1463,6 +1463,13 @@ export interface RefactorOptions { * @default - false */ readonly revert?: boolean; + + /** + * Whether to do the refactor without asking for confirmation + * + * @default - false + */ + readonly force?: boolean; } /**