Skip to content

Commit 583bae0

Browse files
otaviomacedorix0rrrgithub-actions
authored
feat(cli): use explicit mapping when provided by the user (#460)
This PR introduces two new options to the `refactor` command: `--mapping-file` and `--revert`. When a mapping file is provided, the CLI won't do try to compute the refactor mappings, and just use what the user instructed it to. The `--revert` can only be used in conjunction with `--mapping-file`, and modifies its behavior by swapping source and destination. Closes #378. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Signed-off-by: github-actions <[email protected]> Co-authored-by: Rico Hermans <[email protected]> Co-authored-by: github-actions <[email protected]>
1 parent 6db0d9b commit 583bae0

File tree

12 files changed

+808
-41
lines changed

12 files changed

+808
-41
lines changed

packages/@aws-cdk/toolkit-lib/lib/actions/refactor/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,41 @@ export interface RefactorOptions {
2727
* - A construct path (e.g. `Stack1/Foo/Bar/Resource`).
2828
*/
2929
exclude?: string[];
30+
31+
/**
32+
* An explicit mapping to be used by the toolkit (as opposed to letting the
33+
* toolkit itself compute the mapping).
34+
*/
35+
mappings?: MappingGroup[];
36+
37+
/**
38+
* Modifies the behavior of the 'mappings' option by swapping source and
39+
* destination locations. This is useful when you want to undo a refactor
40+
* that was previously applied.
41+
*/
42+
revert?: boolean;
43+
}
44+
45+
export interface MappingGroup {
46+
/**
47+
* The account ID of the environment in which the mapping is valid.
48+
*/
49+
account: string;
50+
51+
/**
52+
* The region of the environment in which the mapping is valid.
53+
*/
54+
region: string;
55+
56+
/**
57+
* A collection of resource mappings, where each key is the source location
58+
* and the value is the destination location. Locations must be in the format
59+
* `StackName.LogicalId`. The source must refer to a location where there is
60+
* a resource currently deployed, while the destination must refer to a
61+
* location that is not already occupied by any resource.
62+
*
63+
*/
64+
resources: {
65+
[key: string]: string;
66+
};
3067
}

packages/@aws-cdk/toolkit-lib/lib/api/refactoring/index.ts

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import type { SdkProvider } from '../aws-auth/private';
1010
import { Mode } from '../plugin';
1111
import { StringWriteStream } from '../streams';
1212
import type { CloudFormationStack } from './cloudformation';
13-
import { ResourceMapping, ResourceLocation } from './cloudformation';
13+
import { ResourceLocation, ResourceMapping } from './cloudformation';
1414
import { computeResourceDigests, hashObject } from './digest';
15-
import { NeverExclude, type ExcludeList } from './exclude';
15+
import { type ExcludeList, NeverExclude } from './exclude';
16+
import type { MappingGroup } from '../../actions';
17+
import { ToolkitError } from '../../toolkit/toolkit-error';
1618

1719
export * from './exclude';
1820

@@ -51,6 +53,93 @@ function groupByKey<A>(entries: [string, A][]): Record<string, A[]> {
5153
return result;
5254
}
5355

56+
export async function usePrescribedMappings(
57+
mappingGroups: MappingGroup[],
58+
sdkProvider: SdkProvider,
59+
): Promise<ResourceMapping[]> {
60+
interface StackGroup extends MappingGroup {
61+
stacks: CloudFormationStack[];
62+
}
63+
64+
const stackGroups: StackGroup[] = [];
65+
for (const group of mappingGroups) {
66+
stackGroups.push({
67+
...group,
68+
stacks: await getDeployedStacks(sdkProvider, environmentOf(group)),
69+
});
70+
}
71+
72+
// Validate that there are no duplicate destinations
73+
for (let group of stackGroups) {
74+
const destinations = new Set<string>();
75+
76+
for (const destination of Object.values(group.resources)) {
77+
if (destinations.has(destination)) {
78+
throw new ToolkitError(
79+
`Duplicate destination resource '${destination}' in environment ${group.account}/${group.region}`,
80+
);
81+
}
82+
destinations.add(destination);
83+
}
84+
}
85+
86+
const result: ResourceMapping[] = [];
87+
for (const group of stackGroups) {
88+
for (const [source, destination] of Object.entries(group.resources)) {
89+
if (!inUse(source, group.stacks)) {
90+
throw new ToolkitError(`Source resource '${source}' does not exist in environment ${group.account}/${group.region}`);
91+
}
92+
93+
if (inUse(destination, group.stacks)) {
94+
throw new ToolkitError(
95+
`Destination resource '${destination}' already in use in environment ${group.account}/${group.region}`,
96+
);
97+
}
98+
99+
const environment = environmentOf(group);
100+
const src = makeLocation(source, environment, group.stacks);
101+
const dst = makeLocation(destination, environment);
102+
result.push(new ResourceMapping(src, dst));
103+
}
104+
}
105+
return result;
106+
107+
function inUse(location: string, stacks: CloudFormationStack[]): boolean {
108+
const [stackName, logicalId] = location.split('.');
109+
if (stackName == null || logicalId == null) {
110+
throw new ToolkitError(`Invalid location '${location}'`);
111+
}
112+
const stack = stacks.find((s) => s.stackName === stackName);
113+
return stack != null && stack.template.Resources?.[logicalId] != null;
114+
}
115+
116+
function environmentOf(group: MappingGroup) {
117+
return {
118+
account: group.account,
119+
region: group.region,
120+
name: '',
121+
};
122+
}
123+
124+
function makeLocation(
125+
loc: string,
126+
environment: cxapi.Environment,
127+
stacks: CloudFormationStack[] = [],
128+
): ResourceLocation {
129+
const [stackName, logicalId] = loc.split('.');
130+
const stack = stacks.find((s) => s.stackName === stackName);
131+
132+
return new ResourceLocation(
133+
{
134+
stackName,
135+
environment,
136+
template: stack?.template ?? {},
137+
},
138+
logicalId,
139+
);
140+
}
141+
}
142+
54143
export function resourceMovements(before: CloudFormationStack[], after: CloudFormationStack[]): ResourceMovement[] {
55144
return Object.values(
56145
removeUnmovedResources(
@@ -72,10 +161,7 @@ export function ambiguousMovements(movements: ResourceMovement[]) {
72161
* Converts a list of unambiguous resource movements into a list of resource mappings.
73162
*
74163
*/
75-
export function resourceMappings(
76-
movements: ResourceMovement[],
77-
stacks?: CloudFormationStack[],
78-
): ResourceMapping[] {
164+
export function resourceMappings(movements: ResourceMovement[], stacks?: CloudFormationStack[]): ResourceMapping[] {
79165
const stacksPredicate =
80166
stacks == null
81167
? () => true
@@ -174,9 +260,9 @@ export async function findResourceMovements(
174260
result.push(...resourceMovements(before, after));
175261
}
176262

177-
return result.filter(mov => {
263+
return result.filter((mov) => {
178264
const after = mov[1];
179-
return after.every(l => !exclude.isExcluded(l));
265+
return after.every((l) => !exclude.isExcluded(l));
180266
});
181267
}
182268

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

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type {
1717
EnvironmentBootstrapResult,
1818
} from '../actions/bootstrap';
1919
import { BootstrapSource } from '../actions/bootstrap';
20-
import { AssetBuildTime, HotswapMode, type DeployOptions } from '../actions/deploy';
20+
import { AssetBuildTime, type DeployOptions, HotswapMode } from '../actions/deploy';
2121
import {
2222
buildParameterMap,
2323
createHotswapPropertyOverrides,
@@ -28,7 +28,7 @@ import { type DestroyOptions } from '../actions/destroy';
2828
import type { DiffOptions } from '../actions/diff';
2929
import { appendObject, prepareDiff } from '../actions/diff/private';
3030
import { type ListOptions } from '../actions/list';
31-
import type { RefactorOptions } from '../actions/refactor';
31+
import type { MappingGroup, RefactorOptions } from '../actions/refactor';
3232
import { type RollbackOptions } from '../actions/rollback';
3333
import { type SynthOptions } from '../actions/synth';
3434
import type { WatchOptions } from '../actions/watch';
@@ -50,21 +50,25 @@ import type { IoHelper } from '../api/io/private';
5050
import { asIoHelper, IO, SPAN, withoutColor, withoutEmojis, withTrimmedWhitespace } from '../api/io/private';
5151
import { CloudWatchLogEventMonitor, findCloudWatchLogGroups } from '../api/logs-monitor';
5252
import { PluginHost } from '../api/plugin';
53-
import { AmbiguityError, ambiguousMovements, findResourceMovements, formatAmbiguousMappings, formatTypedMappings, fromManifestAndExclusionList, resourceMappings } from '../api/refactoring';
53+
import {
54+
AmbiguityError,
55+
ambiguousMovements,
56+
findResourceMovements,
57+
formatAmbiguousMappings,
58+
formatTypedMappings,
59+
fromManifestAndExclusionList,
60+
resourceMappings,
61+
usePrescribedMappings,
62+
} from '../api/refactoring';
63+
import type { ResourceMapping } from '../api/refactoring/cloudformation';
5464
import { ResourceMigrator } from '../api/resource-import';
5565
import { tagsForStack } from '../api/tags';
5666
import { DEFAULT_TOOLKIT_STACK_NAME } from '../api/toolkit-info';
57-
import type { Concurrency, AssetBuildNode, AssetPublishNode, StackNode } from '../api/work-graph';
67+
import type { AssetBuildNode, AssetPublishNode, Concurrency, StackNode } from '../api/work-graph';
5868
import { WorkGraphBuilder } from '../api/work-graph';
5969
import type { AssemblyData, StackDetails, SuccessfulDeployStackResult } from '../payloads';
6070
import { PermissionChangeType } from '../payloads';
61-
import {
62-
formatErrorMessage,
63-
formatTime,
64-
obscureTemplate,
65-
serializeStructure,
66-
validateSnsTopicArn,
67-
} from '../util';
71+
import { formatErrorMessage, formatTime, obscureTemplate, serializeStructure, validateSnsTopicArn } from '../util';
6872
import { pLimit } from '../util/concurrency';
6973
import { promiseWithResolvers } from '../util/promises';
7074

@@ -967,27 +971,60 @@ export class Toolkit extends CloudAssemblySourceBuilder {
967971
}
968972

969973
private async _refactor(assembly: StackAssembly, ioHelper: IoHelper, options: RefactorOptions = {}): Promise<void> {
974+
if (options.mappings && options.exclude) {
975+
throw new ToolkitError("Cannot use both 'exclude' and 'mappings'.");
976+
}
977+
978+
if (options.revert && !options.mappings) {
979+
throw new ToolkitError("The 'revert' options can only be used with the 'mappings' option.");
980+
}
981+
970982
if (!options.dryRun) {
971983
throw new ToolkitError('Refactor is not available yet. Too see the proposed changes, use the --dry-run flag.');
972984
}
973985

974-
const stacks = await assembly.selectStacksV2(ALL_STACKS);
975986
const sdkProvider = await this.sdkProvider('refactor');
976-
const exclude = fromManifestAndExclusionList(assembly.cloudAssembly.manifest, options.exclude);
977-
const movements = await findResourceMovements(stacks.stackArtifacts, sdkProvider, exclude);
978-
const ambiguous = ambiguousMovements(movements);
979-
if (ambiguous.length === 0) {
980-
const filteredStacks = await assembly.selectStacksV2(options.stacks ?? ALL_STACKS);
981-
const mappings = resourceMappings(movements, filteredStacks.stackArtifacts);
987+
try {
988+
const mappings = await getMappings();
982989
const typedMappings = mappings.map(m => m.toTypedMapping());
983990
await ioHelper.notify(IO.CDK_TOOLKIT_I8900.msg(formatTypedMappings(typedMappings), {
984991
typedMappings,
985992
}));
986-
} else {
987-
const error = new AmbiguityError(ambiguous);
988-
const paths = error.paths();
989-
await ioHelper.notify(IO.CDK_TOOLKIT_I8900.msg(formatAmbiguousMappings(paths), {
990-
ambiguousPaths: paths,
993+
} catch (e) {
994+
if (e instanceof AmbiguityError) {
995+
const paths = e.paths();
996+
await ioHelper.notify(IO.CDK_TOOLKIT_I8900.msg(formatAmbiguousMappings(paths), {
997+
ambiguousPaths: paths,
998+
}));
999+
} else {
1000+
throw e;
1001+
}
1002+
}
1003+
1004+
async function getMappings(): Promise<ResourceMapping[]> {
1005+
if (options.revert) {
1006+
return usePrescribedMappings(revert(options.mappings ?? []), sdkProvider);
1007+
}
1008+
if (options.mappings != null) {
1009+
return usePrescribedMappings(options.mappings ?? [], sdkProvider);
1010+
} else {
1011+
const stacks = await assembly.selectStacksV2(ALL_STACKS);
1012+
const exclude = fromManifestAndExclusionList(assembly.cloudAssembly.manifest, options.exclude);
1013+
const movements = await findResourceMovements(stacks.stackArtifacts, sdkProvider, exclude);
1014+
const ambiguous = ambiguousMovements(movements);
1015+
if (ambiguous.length === 0) {
1016+
const filteredStacks = await assembly.selectStacksV2(options.stacks ?? ALL_STACKS);
1017+
return resourceMappings(movements, filteredStacks.stackArtifacts);
1018+
} else {
1019+
throw new AmbiguityError(ambiguous);
1020+
}
1021+
}
1022+
}
1023+
1024+
function revert(mappings: MappingGroup[]): MappingGroup[] {
1025+
return mappings.map(group => ({
1026+
...group,
1027+
resources: Object.fromEntries(Object.entries(group.resources).map(([src, dst]) => ([dst, src]))),
9911028
}));
9921029
}
9931030
}

0 commit comments

Comments
 (0)