Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,11 @@ export enum ArtifactMetadataEntryType {
* Represents tags of a stack.
*/
STACK_TAGS = 'aws:cdk:stack-tags',

/**
* Whether the resource should be skipped during refactoring.
*/
SKIP_REFACTOR = 'aws:cdk:skip-refactor',
Copy link
Contributor

@mrgrain mrgrain May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be called exclude now? Or

Suggested change
SKIP_REFACTOR = 'aws:cdk:skip-refactor',
NO_REFACTOR = 'aws:cdk:no-refactor',

or

Suggested change
SKIP_REFACTOR = 'aws:cdk:skip-refactor',
DO_NOT_REFACTOR = 'aws:cdk:do-not-refactor',

}

/**
Expand Down
38 changes: 32 additions & 6 deletions packages/@aws-cdk/tmp-toolkit-helpers/src/api/refactoring/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Mode } from '../plugin';
import { StringWriteStream } from '../streams';
import type { CloudFormationStack } from './cloudformation';
import { computeResourceDigests, hashObject } from './digest';
import type { SkipList } from './skip';

/**
* Represents a set of possible movements of a resource from one location
Expand Down Expand Up @@ -39,7 +40,7 @@ export class AmbiguityError extends Error {
* merely the stack name.
*/
export class ResourceLocation {
constructor(readonly stack: CloudFormationStack, readonly logicalResourceId: string) {
constructor(public readonly stack: CloudFormationStack, public readonly logicalResourceId: string) {
}

public toPath(): string {
Expand Down Expand Up @@ -118,15 +119,40 @@ export function ambiguousMovements(movements: ResourceMovement[]) {
* Converts a list of unambiguous resource movements into a list of resource mappings.
*
*/
export function resourceMappings(movements: ResourceMovement[]): ResourceMapping[] {
export function resourceMappings(
movements: ResourceMovement[],
stacks?: CloudFormationStack[],
skipList?: SkipList,
): ResourceMapping[] {
const stacksPredicate =
stacks == null
? () => true
: (m: ResourceMapping) => {
// Any movement that involves one of the selected stacks (either moving from or to)
// is considered a candidate for refactoring.
const stackNames = [m.source.stack.stackName, m.destination.stack.stackName];
return stacks.some((stack) => stackNames.includes(stack.stackName));
};

const logicalIdsPredicate =
skipList == null
? () => true
: (m: ResourceMapping) => {
return !skipList!.resourceLocations.some(
(loc) =>
loc.StackName === m.destination.stack.stackName &&
loc.LogicalResourceId === m.destination.logicalResourceId,
);
};

return movements
.filter(([pre, post]) => pre.length === 1 && post.length === 1 && !pre[0].equalTo(post[0]))
.map(([pre, post]) => new ResourceMapping(pre[0], post[0]));
.map(([pre, post]) => new ResourceMapping(pre[0], post[0]))
.filter(stacksPredicate)
.filter(logicalIdsPredicate);
}

function removeUnmovedResources(
m: Record<string, ResourceMovement>,
): Record<string, ResourceMovement> {
function removeUnmovedResources(m: Record<string, ResourceMovement>): Record<string, ResourceMovement> {
const result: Record<string, ResourceMovement> = {};
for (const [hash, [before, after]] of Object.entries(m)) {
const common = before.filter((b) => after.some((a) => a.equalTo(b)));
Expand Down
83 changes: 83 additions & 0 deletions packages/@aws-cdk/tmp-toolkit-helpers/src/api/refactoring/skip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import * as fs from 'node:fs';
import type { AssemblyManifest } from '@aws-cdk/cloud-assembly-schema';
import { ArtifactMetadataEntryType, ArtifactType } from '@aws-cdk/cloud-assembly-schema';
import type { ResourceLocation as CfnResourceLocation } from '@aws-sdk/client-cloudformation';
import { ToolkitError } from '../toolkit-error';

export interface SkipList {
resourceLocations: CfnResourceLocation[];
}

export class ManifestSkipList implements SkipList {
constructor(private readonly manifest: AssemblyManifest) {
}

get resourceLocations(): CfnResourceLocation[] {
// First, we need to filter the artifacts to only include CloudFormation stacks
const stackManifests = Object.entries(this.manifest.artifacts ?? {}).filter(
([_, manifest]) => manifest.type === ArtifactType.AWS_CLOUDFORMATION_STACK,
);

const result: CfnResourceLocation[] = [];
for (let [stackName, manifest] of stackManifests) {
const locations = Object.values(manifest.metadata ?? {})
// Then pick only the resources in each stack marked with SKIP_REFACTOR
.filter((entries) =>
entries.some((entry) => entry.type === ArtifactMetadataEntryType.SKIP_REFACTOR && entry.data === true),
)
// Finally, get the logical ID of each resource
.map((entries) => {
const logicalIdEntry = entries.find((entry) => entry.type === ArtifactMetadataEntryType.LOGICAL_ID);
const location: CfnResourceLocation = {
StackName: stackName,
LogicalResourceId: logicalIdEntry!.data! as string,
};
return location;
});
result.push(...locations);
}
return result;
}
}

export class SkipFile implements SkipList {
constructor(private readonly filePath?: string) {
}

get resourceLocations(): CfnResourceLocation[] {
if (!this.filePath) {
return [];
}
const parsedData = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
if (!isValidSkipFileContent(parsedData)) {
throw new ToolkitError('The content of a skip file must be a JSON array of strings');
}

const result: CfnResourceLocation[] = [];
parsedData.forEach((item: string) => {
const parts = item.split('.');
if (parts.length !== 2) {
throw new ToolkitError(`Invalid resource location format: ${item}. Expected format: stackName.logicalId`);
}
const [stackName, logicalId] = parts;
result.push({
StackName: stackName,
LogicalResourceId: logicalId,
});
});
return result;
}
}

function isValidSkipFileContent(data: any): data is string[] {
return Array.isArray(data) && data.every((item: any) => typeof item === 'string');
}

export class UnionSkipList implements SkipList {
constructor(private readonly skipLists: SkipList[]) {
}

get resourceLocations(): CfnResourceLocation[] {
return this.skipLists.flatMap((skipList) => skipList.resourceLocations);
}
}
9 changes: 9 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/actions/refactor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,13 @@ export interface RefactorOptions {
* @default - all stacks
*/
stacks?: StackSelector;

/**
* The absolute path to a file that contains a list of
* resources to skip during the refactor. The file should
* be in JSON format and contain an array of _destination_
* logical IDs, that is, the logical IDs of the resources
* as they would be after the refactor.
*/
skipFile?: string;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking for a better name. If anyone has any suggestions, let me know.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For toolkit-lib, this should be an array. We can make the read from file a CLI feature.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking for a better name. If anyone has any suggestions, let me know.

exclude?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'm going with exclude.

}
38 changes: 23 additions & 15 deletions packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { NonInteractiveIoHost } from './non-interactive-io-host';
import type { ToolkitServices } from './private';
import { assemblyFromSource } from './private';
import type { DeployResult, DestroyResult, RollbackResult } from './types';
import { SkipFile, ManifestSkipList, UnionSkipList } from '../../../tmp-toolkit-helpers/src/api/refactoring/skip';
import type {
BootstrapEnvironments,
BootstrapOptions,
Expand Down Expand Up @@ -240,7 +241,7 @@ export class Toolkit extends CloudAssemblySourceBuilder {
const bootstrapSpan = await ioHelper.span(SPAN.BOOTSTRAP_SINGLE)
.begin(`${chalk.bold(environment.name)}: bootstrapping...`, {
total: bootstrapEnvironments.length,
current: currentIdx+1,
current: currentIdx + 1,
environment,
});

Expand Down Expand Up @@ -334,7 +335,7 @@ export class Toolkit extends CloudAssemblySourceBuilder {
/**
* Diff Action
*/
public async diff(cx: ICloudAssemblySource, options: DiffOptions): Promise<{ [name: string]: TemplateDiff}> {
public async diff(cx: ICloudAssemblySource, options: DiffOptions): Promise<{ [name: string]: TemplateDiff }> {
const ioHelper = asIoHelper(this.ioHost, 'diff');
const selectStacks = options.stacks ?? ALL_STACKS;
const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: selectStacks });
Expand Down Expand Up @@ -620,7 +621,10 @@ export class Toolkit extends CloudAssemblySourceBuilder {

// Perform a rollback
await this._rollback(assembly, action, {
stacks: { patterns: [stack.hierarchicalId], strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE },
stacks: {
patterns: [stack.hierarchicalId],
strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE,
},
orphanFailedResources: options.orphanFailedResourcesDuringRollback,
});

Expand Down Expand Up @@ -760,7 +764,7 @@ export class Toolkit extends CloudAssemblySourceBuilder {
if (options.include === undefined && options.exclude === undefined) {
throw new ToolkitError(
"Cannot use the 'watch' command without specifying at least one directory to monitor. " +
'Make sure to add a "watch" key to your cdk.json',
'Make sure to add a "watch" key to your cdk.json',
);
}

Expand Down Expand Up @@ -979,19 +983,20 @@ export class Toolkit extends CloudAssemblySourceBuilder {
throw new ToolkitError('Refactor is not available yet. Too see the proposed changes, use the --dry-run flag.');
}

const strategy = options.stacks?.strategy ?? StackSelectionStrategy.ALL_STACKS;
if (strategy !== StackSelectionStrategy.ALL_STACKS) {
await ioHelper.notify(IO.CDK_TOOLKIT_W8010.msg(
'Refactor does not yet support stack selection. Proceeding with the default behavior (considering all stacks).',
));
}
const stacks = await assembly.selectStacksV2(ALL_STACKS);

const sdkProvider = await this.sdkProvider('refactor');
const movements = await findResourceMovements(stacks.stackArtifacts, sdkProvider);
const ambiguous = ambiguousMovements(movements);
if (ambiguous.length === 0) {
const typedMappings = resourceMappings(movements).map(m => m.toTypedMapping());
const filteredStacks = await assembly.selectStacksV2(options.stacks ?? ALL_STACKS);

const skipList = new UnionSkipList([
new ManifestSkipList(assembly.cloudAssembly.manifest),
new SkipFile(options.skipFile),
]);

const mappings = resourceMappings(movements, filteredStacks.stackArtifacts, skipList);
const typedMappings = mappings.map(m => m.toTypedMapping());
await ioHelper.notify(IO.CDK_TOOLKIT_I8900.msg(formatTypedMappings(typedMappings), {
typedMappings,
}));
Expand Down Expand Up @@ -1086,9 +1091,12 @@ export class Toolkit extends CloudAssemblySourceBuilder {
private async validateStacksMetadata(stacks: StackCollection, ioHost: IoHelper) {
const builder = (level: IoMessageLevel) => {
switch (level) {
case 'error': return IO.CDK_ASSEMBLY_E9999;
case 'warn': return IO.CDK_ASSEMBLY_W9999;
default: return IO.CDK_ASSEMBLY_I9999;
case 'error':
return IO.CDK_ASSEMBLY_E9999;
case 'warn':
return IO.CDK_ASSEMBLY_W9999;
default:
return IO.CDK_ASSEMBLY_I9999;
}
};
await stacks.validateMetadata(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as core from 'aws-cdk-lib/core';

export default async () => {
const app = new core.App({ autoSynth: false });
const stack = new core.Stack(app, 'Stack1');
const bucket = new s3.Bucket(stack, 'MyBucket');
bucket.node.defaultChild?.node.addMetadata('aws:cdk:skip-refactor', true);
return app.synth();
};
Loading
Loading