Skip to content

Commit eafb53b

Browse files
authored
feat(refactor): ability to exclude selected resources from refactoring (#421)
Allow the user to select resources to be skipped for refactoring. There are two ways to do this: - By adding the `aws:cdk:skip-refactor = true` metadata to each resource to be skipped, in the cloud assembly manifest. - By passing a skip fie via command line or the toolkit library API. The file should be in JSON format and contain a list of destination locations. Entries in this list can be either in the form `"<stack name>.<logical ID>"` or a construct path. The main abstraction is the `SkipList` interface, used by the mapping detection algorithm to query for mappings that should be excluded. Closes #412, #379 --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 27aad24 commit eafb53b

File tree

17 files changed

+579
-141
lines changed

17 files changed

+579
-141
lines changed

packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/metadata-schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,11 @@ export enum ArtifactMetadataEntryType {
312312
* Represents tags of a stack.
313313
*/
314314
STACK_TAGS = 'aws:cdk:stack-tags',
315+
316+
/**
317+
* Whether the resource should be excluded during refactoring.
318+
*/
319+
DO_NOT_REFACTOR = 'aws:cdk:do-not-refactor',
315320
}
316321

317322
/**

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,17 @@ export interface RefactorOptions {
1414
* @default - all stacks
1515
*/
1616
stacks?: StackSelector;
17+
18+
/**
19+
* A list of resources that will not be part of the refactor.
20+
* Elements of this list must be the _destination_ locations
21+
* that should be excluded, i.e., the location to which a
22+
* resource would be moved if the refactor were to happen.
23+
*
24+
* The format of the locations in the file can be either:
25+
*
26+
* - Stack name and logical ID (e.g. `Stack1.MyQueue`)
27+
* - A construct path (e.g. `Stack1/Foo/Bar/Resource`).
28+
*/
29+
exclude?: string[];
1730
}

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { TypedMapping } from '@aws-cdk/cloudformation-diff';
12
import type * as cxapi from '@aws-cdk/cx-api';
23

34
export interface CloudFormationTemplate {
@@ -15,3 +16,54 @@ export interface CloudFormationStack {
1516
readonly stackName: string;
1617
readonly template: CloudFormationTemplate;
1718
}
19+
20+
/**
21+
* This class mirrors the `ResourceLocation` interface from CloudFormation,
22+
* but is richer, since it has a reference to the stack object, rather than
23+
* merely the stack name.
24+
*/
25+
export class ResourceLocation {
26+
constructor(public readonly stack: CloudFormationStack, public readonly logicalResourceId: string) {
27+
}
28+
29+
public toPath(): string {
30+
const stack = this.stack;
31+
const resource = stack.template.Resources?.[this.logicalResourceId];
32+
const result = resource?.Metadata?.['aws:cdk:path'];
33+
34+
if (result != null) {
35+
return result;
36+
}
37+
38+
// If the path is not available, we can use stack name and logical ID
39+
return `${stack.stackName}.${this.logicalResourceId}`;
40+
}
41+
42+
public getType(): string {
43+
const resource = this.stack.template.Resources?.[this.logicalResourceId ?? ''];
44+
return resource?.Type ?? 'Unknown';
45+
}
46+
47+
public equalTo(other: ResourceLocation): boolean {
48+
return this.logicalResourceId === other.logicalResourceId && this.stack.stackName === other.stack.stackName;
49+
}
50+
}
51+
52+
/**
53+
* A mapping between a source and a destination location.
54+
*/
55+
export class ResourceMapping {
56+
constructor(public readonly source: ResourceLocation, public readonly destination: ResourceLocation) {
57+
}
58+
59+
public toTypedMapping(): TypedMapping {
60+
return {
61+
// the type is the same in both source and destination,
62+
// so we can use either one
63+
type: this.source.getType(),
64+
sourcePath: this.source.toPath(),
65+
destinationPath: this.destination.toPath(),
66+
};
67+
}
68+
}
69+
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { AssemblyManifest } from '@aws-cdk/cloud-assembly-schema';
2+
import { ArtifactMetadataEntryType, ArtifactType } from '@aws-cdk/cloud-assembly-schema';
3+
import type { ResourceLocation as CfnResourceLocation } from '@aws-sdk/client-cloudformation';
4+
import type { ResourceLocation } from './cloudformation';
5+
6+
export interface ExcludeList {
7+
isExcluded(location: ResourceLocation): boolean;
8+
}
9+
10+
export class ManifestExcludeList implements ExcludeList {
11+
private readonly excludedLocations: CfnResourceLocation[];
12+
13+
constructor(manifest: AssemblyManifest) {
14+
this.excludedLocations = this.getExcludedLocations(manifest);
15+
}
16+
17+
private getExcludedLocations(asmManifest: AssemblyManifest): CfnResourceLocation[] {
18+
// First, we need to filter the artifacts to only include CloudFormation stacks
19+
const stackManifests = Object.entries(asmManifest.artifacts ?? {}).filter(
20+
([_, manifest]) => manifest.type === ArtifactType.AWS_CLOUDFORMATION_STACK,
21+
);
22+
23+
const result: CfnResourceLocation[] = [];
24+
for (let [stackName, manifest] of stackManifests) {
25+
const locations = Object.values(manifest.metadata ?? {})
26+
// Then pick only the resources in each stack marked with DO_NOT_REFACTOR
27+
.filter((entries) =>
28+
entries.some((entry) => entry.type === ArtifactMetadataEntryType.DO_NOT_REFACTOR && entry.data === true),
29+
)
30+
// Finally, get the logical ID of each resource
31+
.map((entries) => {
32+
const logicalIdEntry = entries.find((entry) => entry.type === ArtifactMetadataEntryType.LOGICAL_ID);
33+
const location: CfnResourceLocation = {
34+
StackName: stackName,
35+
LogicalResourceId: logicalIdEntry!.data! as string,
36+
};
37+
return location;
38+
});
39+
result.push(...locations);
40+
}
41+
return result;
42+
}
43+
44+
isExcluded(location: ResourceLocation): boolean {
45+
return this.excludedLocations.some(
46+
(loc) => loc.StackName === location.stack.stackName && loc.LogicalResourceId === location.logicalResourceId,
47+
);
48+
}
49+
}
50+
51+
export class InMemoryExcludeList implements ExcludeList {
52+
private readonly excludedLocations: CfnResourceLocation[];
53+
private readonly excludedPaths: string[];
54+
55+
constructor(items: string[]) {
56+
this.excludedLocations = [];
57+
this.excludedPaths = [];
58+
59+
if (items.length === 0) {
60+
return;
61+
}
62+
63+
const locationRegex = /^[A-Za-z0-9]+\.[A-Za-z0-9]+$/;
64+
65+
items.forEach((item: string) => {
66+
if (locationRegex.test(item)) {
67+
const [stackName, logicalId] = item.split('.');
68+
this.excludedLocations.push({
69+
StackName: stackName,
70+
LogicalResourceId: logicalId,
71+
});
72+
} else {
73+
this.excludedPaths.push(item);
74+
}
75+
});
76+
}
77+
78+
isExcluded(location: ResourceLocation): boolean {
79+
const containsLocation = this.excludedLocations.some((loc) => {
80+
return loc.StackName === location.stack.stackName && loc.LogicalResourceId === location.logicalResourceId;
81+
});
82+
83+
const containsPath = this.excludedPaths.some((path) => location.toPath() === path);
84+
return containsLocation || containsPath;
85+
}
86+
}
87+
88+
export class UnionExcludeList implements ExcludeList {
89+
constructor(private readonly excludeLists: ExcludeList[]) {
90+
}
91+
92+
isExcluded(location: ResourceLocation): boolean {
93+
return this.excludeLists.some((excludeList) => excludeList.isExcluded(location));
94+
}
95+
}
96+
97+
export class NeverExclude implements ExcludeList {
98+
isExcluded(_location: ResourceLocation): boolean {
99+
return false;
100+
}
101+
}
102+
103+
export class AlwaysExclude implements ExcludeList {
104+
isExcluded(_location: ResourceLocation): boolean {
105+
return true;
106+
}
107+
}
108+
109+
export function fromManifestAndExclusionList(manifest: AssemblyManifest, exclude?: string[]): ExcludeList {
110+
return new UnionExcludeList([new ManifestExcludeList(manifest), new InMemoryExcludeList(exclude ?? [])]);
111+
}
112+

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

Lines changed: 25 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +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';
1314
import { computeResourceDigests, hashObject } from './digest';
15+
import { NeverExclude, type ExcludeList } from './exclude';
16+
17+
export * from './exclude';
1418

1519
/**
1620
* Represents a set of possible movements of a resource from one location
@@ -33,56 +37,6 @@ export class AmbiguityError extends Error {
3337
}
3438
}
3539

36-
/**
37-
* This class mirrors the `ResourceLocation` interface from CloudFormation,
38-
* but is richer, since it has a reference to the stack object, rather than
39-
* merely the stack name.
40-
*/
41-
export class ResourceLocation {
42-
constructor(public readonly stack: CloudFormationStack, public readonly logicalResourceId: string) {
43-
}
44-
45-
public toPath(): string {
46-
const stack = this.stack;
47-
const resource = stack.template.Resources?.[this.logicalResourceId];
48-
const result = resource?.Metadata?.['aws:cdk:path'];
49-
50-
if (result != null) {
51-
return result;
52-
}
53-
54-
// If the path is not available, we can use stack name and logical ID
55-
return `${stack.stackName}.${this.logicalResourceId}`;
56-
}
57-
58-
public getType(): string {
59-
const resource = this.stack.template.Resources?.[this.logicalResourceId ?? ''];
60-
return resource?.Type ?? 'Unknown';
61-
}
62-
63-
public equalTo(other: ResourceLocation): boolean {
64-
return this.logicalResourceId === other.logicalResourceId && this.stack.stackName === other.stack.stackName;
65-
}
66-
}
67-
68-
/**
69-
* A mapping between a source and a destination location.
70-
*/
71-
export class ResourceMapping {
72-
constructor(public readonly source: ResourceLocation, public readonly destination: ResourceLocation) {
73-
}
74-
75-
public toTypedMapping(): TypedMapping {
76-
return {
77-
// the type is the same in both source and destination,
78-
// so we can use either one
79-
type: this.source.getType(),
80-
sourcePath: this.source.toPath(),
81-
destinationPath: this.destination.toPath(),
82-
};
83-
}
84-
}
85-
8640
function groupByKey<A>(entries: [string, A][]): Record<string, A[]> {
8741
const result: Record<string, A[]> = {};
8842

@@ -118,25 +72,27 @@ export function ambiguousMovements(movements: ResourceMovement[]) {
11872
* Converts a list of unambiguous resource movements into a list of resource mappings.
11973
*
12074
*/
121-
export function resourceMappings(movements: ResourceMovement[], stacks?: CloudFormationStack[]): ResourceMapping[] {
122-
const predicate = stacks == null
123-
? () => true
124-
: (m: ResourceMapping) => {
125-
// Any movement that involves one of the selected stacks (either moving from or to)
126-
// is considered a candidate for refactoring.
127-
const stackNames = [m.source.stack.stackName, m.destination.stack.stackName];
128-
return stacks.some((stack) => stackNames.includes(stack.stackName));
129-
};
75+
export function resourceMappings(
76+
movements: ResourceMovement[],
77+
stacks?: CloudFormationStack[],
78+
): ResourceMapping[] {
79+
const stacksPredicate =
80+
stacks == null
81+
? () => true
82+
: (m: ResourceMapping) => {
83+
// Any movement that involves one of the selected stacks (either moving from or to)
84+
// is considered a candidate for refactoring.
85+
const stackNames = [m.source.stack.stackName, m.destination.stack.stackName];
86+
return stacks.some((stack) => stackNames.includes(stack.stackName));
87+
};
13088

13189
return movements
13290
.filter(([pre, post]) => pre.length === 1 && post.length === 1 && !pre[0].equalTo(post[0]))
13391
.map(([pre, post]) => new ResourceMapping(pre[0], post[0]))
134-
.filter(predicate);
92+
.filter(stacksPredicate);
13593
}
13694

137-
function removeUnmovedResources(
138-
m: Record<string, ResourceMovement>,
139-
): Record<string, ResourceMovement> {
95+
function removeUnmovedResources(m: Record<string, ResourceMovement>): Record<string, ResourceMovement> {
14096
const result: Record<string, ResourceMovement> = {};
14197
for (const [hash, [before, after]] of Object.entries(m)) {
14298
const common = before.filter((b) => after.some((a) => a.equalTo(b)));
@@ -196,6 +152,7 @@ function resourceDigests(stack: CloudFormationStack): [string, ResourceLocation]
196152
export async function findResourceMovements(
197153
stacks: CloudFormationStack[],
198154
sdkProvider: SdkProvider,
155+
exclude: ExcludeList = new NeverExclude(),
199156
): Promise<ResourceMovement[]> {
200157
const stackGroups: Map<string, [CloudFormationStack[], CloudFormationStack[]]> = new Map();
201158

@@ -216,7 +173,11 @@ export async function findResourceMovements(
216173
for (const [_, [before, after]] of stackGroups) {
217174
result.push(...resourceMovements(before, after));
218175
}
219-
return result;
176+
177+
return result.filter(mov => {
178+
const after = mov[1];
179+
return after.every(l => !exclude.isExcluded(l));
180+
});
220181
}
221182

222183
async function getDeployedStacks(

0 commit comments

Comments
 (0)