Skip to content

Commit 24b23e4

Browse files
authored
feat: refactor (dry-run mode only) (#342)
Add a `refactor` command. For now, it only works in dry run mode (`-dry-run` in the command line, or `dryRun: true` to the toolkit). It computes the mappings based on the difference between the deployed stacks and the cloud assembly stacks, and shows them in a table. Example: ``` $ cdk refactor --dry-run --unstable=refactor The following resources were moved or renamed: ┌───────────────────────┬────────────────────────────────────────┬───────────────────────────────────────┐ │ Resource Type │ Old Construct Path │ New Construct Path │ ├───────────────────────┼────────────────────────────────────────┼───────────────────────────────────────┤ │ AWS::IAM::Role │ Consumer/Function/ServiceRole/Resource │ Consumer/NewName/ServiceRole/Resource │ ├───────────────────────┼────────────────────────────────────────┼───────────────────────────────────────┤ │ AWS::Lambda::Function │ Consumer/Function/Resource │ Consumer/NewName/Resource │ └───────────────────────┴────────────────────────────────────────┴───────────────────────────────────────┘ ``` Note that we are launching this feature under unstable mode, which requires the user to acknowledge that by passing the `--unstable=refactor` flag. Closes #132, #133, #141, #134 and #135. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 5f4bfa7 commit 24b23e4

File tree

22 files changed

+1973
-58
lines changed

22 files changed

+1973
-58
lines changed

Diff for: packages/@aws-cdk/cloudformation-diff/lib/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './diff-template';
22
export * from './format';
33
export * from './format-table';
4+
export * from './mappings';
45
export { deepEqual, mangleLikeCloudFormation } from './diff/util';
+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as chalk from 'chalk';
2+
import { Formatter } from './format';
3+
import { formatTable } from './format-table';
4+
5+
export interface TypedMapping {
6+
readonly type: string;
7+
readonly sourcePath: string;
8+
readonly destinationPath: string;
9+
}
10+
11+
export function formatTypedMappings(stream: NodeJS.WritableStream, mappings: TypedMapping[]) {
12+
const header = [['Resource Type', 'Old Construct Path', 'New Construct Path']];
13+
const rows = mappings.map((m) => [m.type, m.sourcePath, m.destinationPath]);
14+
15+
const formatter = new Formatter(stream, {});
16+
if (mappings.length > 0) {
17+
formatter.printSectionHeader('The following resources were moved or renamed:');
18+
formatter.print(chalk.green(formatTable(header.concat(rows), undefined)));
19+
} else {
20+
formatter.print('Nothing to refactor.');
21+
}
22+
}
23+
24+
export function formatAmbiguousMappings(
25+
stream: NodeJS.WritableStream,
26+
pairs: [string[], string[]][],
27+
) {
28+
const tables = pairs.map(renderTable);
29+
const formatter = new Formatter(stream, {});
30+
31+
formatter.printSectionHeader('Ambiguous Resource Name Changes');
32+
formatter.print(tables.join('\n\n'));
33+
formatter.printSectionFooter();
34+
35+
function renderTable([removed, added]: [string[], string[]]) {
36+
return formatTable([['', 'Resource'], renderRemoval(removed), renderAddition(added)], undefined);
37+
}
38+
39+
function renderRemoval(locations: string[]) {
40+
return [chalk.red('-'), chalk.red(renderLocations(locations))];
41+
}
42+
43+
function renderAddition(locations: string[]) {
44+
return [chalk.green('+'), chalk.green(renderLocations(locations))];
45+
}
46+
47+
function renderLocations(locs: string[]) {
48+
return locs.join('\n');
49+
}
50+
}

Diff for: packages/@aws-cdk/tmp-toolkit-helpers/src/api/aws-auth/sdk.ts

+11
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,10 @@ import type {
8787
UpdateStackCommandOutput,
8888
UpdateTerminationProtectionCommandInput,
8989
UpdateTerminationProtectionCommandOutput,
90+
StackSummary,
9091
} from '@aws-sdk/client-cloudformation';
9192
import {
93+
paginateListStacks,
9294
CloudFormationClient,
9395
ContinueUpdateRollbackCommand,
9496
CreateChangeSetCommand,
@@ -440,6 +442,7 @@ export interface ICloudFormationClient {
440442
// Pagination functions
441443
describeStackEvents(input: DescribeStackEventsCommandInput): Promise<DescribeStackEventsCommandOutput>;
442444
listStackResources(input: ListStackResourcesCommandInput): Promise<StackResourceSummary[]>;
445+
paginatedListStacks(input: ListStacksCommandInput): Promise<StackSummary[]>;
443446
}
444447

445448
export interface ICloudWatchLogsClient {
@@ -730,6 +733,14 @@ export class SDK {
730733
}
731734
return stackResources;
732735
},
736+
paginatedListStacks: async (input: ListStacksCommandInput): Promise<StackSummary[]> => {
737+
const stackResources = Array<StackSummary>();
738+
const paginator = paginateListStacks({ client }, input);
739+
for await (const page of paginator) {
740+
stackResources.push(...(page?.StackSummaries || []));
741+
}
742+
return stackResources;
743+
},
733744
};
734745
}
735746

Diff for: packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff-formatter.ts

+4-26
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,21 @@
11
import { format } from 'node:util';
2-
import { Writable } from 'stream';
32
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
43
import {
5-
type TemplateDiff,
6-
fullDiff,
7-
formatSecurityChanges,
84
formatDifferences,
5+
formatSecurityChanges,
6+
fullDiff,
97
mangleLikeCloudFormation,
8+
type TemplateDiff,
109
} from '@aws-cdk/cloudformation-diff';
1110
import type * as cxapi from '@aws-cdk/cx-api';
1211
import * as chalk from 'chalk';
1312
import type { NestedStackTemplates } from '../cloudformation';
1413
import type { IoHelper } from '../io/private';
1514
import { IoDefaultMessages } from '../io/private';
1615
import { RequireApproval } from '../require-approval';
16+
import { StringWriteStream } from '../streams';
1717
import { ToolkitError } from '../toolkit-error';
1818

19-
/*
20-
* Custom writable stream that collects text into a string buffer.
21-
* Used on classes that take in and directly write to a stream, but
22-
* we intend to capture the output rather than print.
23-
*/
24-
class StringWriteStream extends Writable {
25-
private buffer: string[] = [];
26-
27-
constructor() {
28-
super();
29-
}
30-
31-
_write(chunk: any, _encoding: string, callback: (error?: Error | null) => void): void {
32-
this.buffer.push(chunk.toString());
33-
callback();
34-
}
35-
36-
toString(): string {
37-
return this.buffer.join('');
38-
}
39-
}
40-
4119
/**
4220
* Output of formatSecurityDiff
4321
*/

Diff for: packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from './io';
1212
export * from './logs-monitor';
1313
export * from './notices';
1414
export * from './plugin';
15+
export * from './refactoring';
1516
export * from './require-approval';
1617
export * from './resource-import';
1718
export * from './rwlock';

Diff for: packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/private/messages.ts

+13
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { StackDestroy, StackDestroyProgress } from '../../../payloads/destr
99
import type { HotswapDeploymentDetails, HotswapDeploymentAttempt, HotswappableChange, HotswapResult } from '../../../payloads/hotswap';
1010
import type { StackDetailsPayload } from '../../../payloads/list';
1111
import type { CloudWatchLogEvent, CloudWatchLogMonitorControlEvent } from '../../../payloads/logs-monitor';
12+
import type { RefactorResult } from '../../../payloads/refactor';
1213
import type { StackRollbackProgress } from '../../../payloads/rollback';
1314
import type { SdkTrace } from '../../../payloads/sdk-trace';
1415
import type { StackActivity, StackMonitoringControlEvent } from '../../../payloads/stack-activity';
@@ -349,6 +350,18 @@ export const IO = {
349350
interface: 'ErrorPayload',
350351
}),
351352

353+
// 8. Refactor (8xxx)
354+
CDK_TOOLKIT_I8900: make.result<RefactorResult>({
355+
code: 'CDK_TOOLKIT_I8900',
356+
description: 'Refactor result',
357+
interface: 'RefactorResult',
358+
}),
359+
360+
CDK_TOOLKIT_W8010: make.warn({
361+
code: 'CDK_TOOLKIT_W8010',
362+
description: 'Refactor execution not yet supported',
363+
}),
364+
352365
// 9: Bootstrap (9xxx)
353366
CDK_TOOLKIT_I9000: make.info<Duration>({
354367
code: 'CDK_TOOLKIT_I9000',

Diff for: packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/toolkit-action.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export type ToolkitAction =
1616
| 'import'
1717
| 'metadata'
1818
| 'init'
19-
| 'migrate';
19+
| 'migrate'
20+
| 'refactor';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type * as cxapi from '@aws-cdk/cx-api';
2+
3+
export interface CloudFormationTemplate {
4+
Resources?: {
5+
[logicalId: string]: {
6+
Type: string;
7+
Properties?: any;
8+
Metadata?: Record<string, any>;
9+
};
10+
};
11+
}
12+
13+
export interface CloudFormationStack {
14+
readonly environment: cxapi.Environment;
15+
readonly stackName: string;
16+
readonly template: CloudFormationTemplate;
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import * as crypto from 'node:crypto';
2+
import type { CloudFormationTemplate } from './cloudformation';
3+
4+
/**
5+
* Computes the digest for each resource in the template.
6+
*
7+
* Conceptually, the digest is computed as:
8+
*
9+
* digest(resource) = hash(type + properties + dependencies.map(d))
10+
*
11+
* where `hash` is a cryptographic hash function. In other words, the digest of a
12+
* resource is computed from its type, its own properties (that is, excluding
13+
* properties that refer to other resources), and the digests of each of its
14+
* dependencies.
15+
*
16+
* The digest of a resource, defined recursively this way, remains stable even if
17+
* one or more of its dependencies gets renamed. Since the resources in a
18+
* CloudFormation template form a directed acyclic graph, this function is
19+
* well-defined.
20+
*/
21+
export function computeResourceDigests(template: CloudFormationTemplate): Record<string, string> {
22+
const resources = template.Resources || {};
23+
const graph: Record<string, Set<string>> = {};
24+
const reverseGraph: Record<string, Set<string>> = {};
25+
26+
// 1. Build adjacency lists
27+
for (const id of Object.keys(resources)) {
28+
graph[id] = new Set();
29+
reverseGraph[id] = new Set();
30+
}
31+
32+
// 2. Detect dependencies by searching for Ref/Fn::GetAtt
33+
const findDependencies = (value: any): string[] => {
34+
if (!value || typeof value !== 'object') return [];
35+
if (Array.isArray(value)) {
36+
return value.flatMap(findDependencies);
37+
}
38+
if ('Ref' in value) {
39+
return [value.Ref];
40+
}
41+
if ('Fn::GetAtt' in value) {
42+
const refTarget = Array.isArray(value['Fn::GetAtt']) ? value['Fn::GetAtt'][0] : value['Fn::GetAtt'].split('.')[0];
43+
return [refTarget];
44+
}
45+
if ('DependsOn' in value) {
46+
return [value.DependsOn];
47+
}
48+
return Object.values(value).flatMap(findDependencies);
49+
};
50+
51+
for (const [id, res] of Object.entries(resources)) {
52+
const deps = findDependencies(res || {});
53+
for (const dep of deps) {
54+
if (dep in resources && dep !== id) {
55+
graph[id].add(dep);
56+
reverseGraph[dep].add(id);
57+
}
58+
}
59+
}
60+
61+
// 3. Topological sort
62+
const outDegree = Object.keys(graph).reduce((acc, k) => {
63+
acc[k] = graph[k].size;
64+
return acc;
65+
}, {} as Record<string, number>);
66+
67+
const queue = Object.keys(outDegree).filter((k) => outDegree[k] === 0);
68+
const order: string[] = [];
69+
70+
while (queue.length > 0) {
71+
const node = queue.shift()!;
72+
order.push(node);
73+
for (const nxt of reverseGraph[node]) {
74+
outDegree[nxt]--;
75+
if (outDegree[nxt] === 0) {
76+
queue.push(nxt);
77+
}
78+
}
79+
}
80+
81+
// 4. Compute digests in sorted order
82+
const result: Record<string, string> = {};
83+
for (const id of order) {
84+
const resource = resources[id];
85+
const depDigests = Array.from(graph[id]).map((d) => result[d]);
86+
const propsWithoutRefs = hashObject(stripReferences(stripConstructPath(resource)));
87+
const toHash = resource.Type + propsWithoutRefs + depDigests.join('');
88+
result[id] = crypto.createHash('sha256').update(toHash).digest('hex');
89+
}
90+
91+
return result;
92+
}
93+
94+
export function hashObject(obj: any): string {
95+
const hash = crypto.createHash('sha256');
96+
97+
function addToHash(value: any) {
98+
if (value == null) {
99+
addToHash('null');
100+
} else if (typeof value === 'object') {
101+
if (Array.isArray(value)) {
102+
value.forEach(addToHash);
103+
} else {
104+
Object.keys(value)
105+
.sort()
106+
.forEach((key) => {
107+
hash.update(key);
108+
addToHash(value[key]);
109+
});
110+
}
111+
} else {
112+
hash.update(typeof value + value.toString());
113+
}
114+
}
115+
116+
addToHash(obj);
117+
return hash.digest('hex');
118+
}
119+
120+
/**
121+
* Removes sub-properties containing Ref or Fn::GetAtt to avoid hashing
122+
* references themselves but keeps the property structure.
123+
*/
124+
function stripReferences(value: any): any {
125+
if (!value || typeof value !== 'object') return value;
126+
if (Array.isArray(value)) {
127+
return value.map(stripReferences);
128+
}
129+
if ('Ref' in value) {
130+
return { __cloud_ref__: 'Ref' };
131+
}
132+
if ('Fn::GetAtt' in value) {
133+
return { __cloud_ref__: 'Fn::GetAtt' };
134+
}
135+
if ('DependsOn' in value) {
136+
return { __cloud_ref__: 'DependsOn' };
137+
}
138+
const result: any = {};
139+
for (const [k, v] of Object.entries(value)) {
140+
result[k] = stripReferences(v);
141+
}
142+
return result;
143+
}
144+
145+
function stripConstructPath(resource: any): any {
146+
if (resource?.Metadata?.['aws:cdk:path'] == null) {
147+
return resource;
148+
}
149+
150+
const copy = JSON.parse(JSON.stringify(resource));
151+
delete copy.Metadata['aws:cdk:path'];
152+
return copy;
153+
}

0 commit comments

Comments
 (0)