packages/@aws-cdk/toolkit-lib/lib/api/refactoring/index.ts (164 lines of code) (raw):
import type { TypedMapping } from '@aws-cdk/cloudformation-diff';
import {
formatAmbiguousMappings as fmtAmbiguousMappings,
formatTypedMappings as fmtTypedMappings,
} from '@aws-cdk/cloudformation-diff';
import type * as cxapi from '@aws-cdk/cx-api';
import type { StackSummary } from '@aws-sdk/client-cloudformation';
import { deserializeStructure } from '../../util';
import type { SdkProvider } from '../aws-auth/private';
import { Mode } from '../plugin';
import { StringWriteStream } from '../streams';
import type { CloudFormationStack } from './cloudformation';
import { ResourceMapping, ResourceLocation } from './cloudformation';
import { computeResourceDigests, hashObject } from './digest';
import { NeverExclude, type ExcludeList } from './exclude';
export * from './exclude';
/**
* Represents a set of possible movements of a resource from one location
* to another. In the ideal case, there is only one source and only one
* destination.
*/
export type ResourceMovement = [ResourceLocation[], ResourceLocation[]];
export class AmbiguityError extends Error {
constructor(public readonly movements: ResourceMovement[]) {
super('Ambiguous resource mappings');
}
public paths(): [string[], string[]][] {
return this.movements.map(([a, b]) => [convert(a), convert(b)]);
function convert(locations: ResourceLocation[]): string[] {
return locations.map((l) => l.toPath());
}
}
}
function groupByKey<A>(entries: [string, A][]): Record<string, A[]> {
const result: Record<string, A[]> = {};
for (const [hash, location] of entries) {
if (hash in result) {
result[hash].push(location);
} else {
result[hash] = [location];
}
}
return result;
}
export function resourceMovements(before: CloudFormationStack[], after: CloudFormationStack[]): ResourceMovement[] {
return Object.values(
removeUnmovedResources(
zip(groupByKey(before.flatMap(resourceDigests)), groupByKey(after.flatMap(resourceDigests))),
),
);
}
export function ambiguousMovements(movements: ResourceMovement[]) {
// A movement is considered ambiguous if these two conditions are met:
// 1. Both sides have at least one element (otherwise, it's just addition or deletion)
// 2. At least one side has more than one element
return movements
.filter(([pre, post]) => pre.length > 0 && post.length > 0)
.filter(([pre, post]) => pre.length > 1 || post.length > 1);
}
/**
* Converts a list of unambiguous resource movements into a list of resource mappings.
*
*/
export function resourceMappings(
movements: ResourceMovement[],
stacks?: CloudFormationStack[],
): 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));
};
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]))
.filter(stacksPredicate);
}
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)));
result[hash] = [
before.filter((b) => !common.some((c) => b.equalTo(c))),
after.filter((a) => !common.some((c) => a.equalTo(c))),
];
}
return result;
}
/**
* For each hash, identifying a single resource, zip the two lists of locations,
* producing a resource movement
*/
function zip(
m1: Record<string, ResourceLocation[]>,
m2: Record<string, ResourceLocation[]>,
): Record<string, ResourceMovement> {
const result: Record<string, ResourceMovement> = {};
for (const [hash, locations] of Object.entries(m1)) {
if (hash in m2) {
result[hash] = [locations, m2[hash]];
} else {
result[hash] = [locations, []];
}
}
for (const [hash, locations] of Object.entries(m2)) {
if (!(hash in m1)) {
result[hash] = [[], locations];
}
}
return result;
}
/**
* Computes a list of pairs [digest, location] for each resource in the stack.
*/
function resourceDigests(stack: CloudFormationStack): [string, ResourceLocation][] {
const digests = computeResourceDigests(stack.template);
return Object.entries(digests).map(([logicalId, digest]) => {
const location: ResourceLocation = new ResourceLocation(stack, logicalId);
return [digest, location];
});
}
/**
* Compares the deployed state to the cloud assembly state, and finds all resources
* that were moved from one location (stack + logical ID) to another. The comparison
* is done per environment.
*/
export async function findResourceMovements(
stacks: CloudFormationStack[],
sdkProvider: SdkProvider,
exclude: ExcludeList = new NeverExclude(),
): Promise<ResourceMovement[]> {
const stackGroups: Map<string, [CloudFormationStack[], CloudFormationStack[]]> = new Map();
// Group stacks by environment
for (const stack of stacks) {
const environment = stack.environment;
const key = hashObject(environment);
if (stackGroups.has(key)) {
stackGroups.get(key)![1].push(stack);
} else {
// The first time we see an environment, we need to fetch all stacks deployed to it.
const before = await getDeployedStacks(sdkProvider, environment);
stackGroups.set(key, [before, [stack]]);
}
}
const result: ResourceMovement[] = [];
for (const [_, [before, after]] of stackGroups) {
result.push(...resourceMovements(before, after));
}
return result.filter(mov => {
const after = mov[1];
return after.every(l => !exclude.isExcluded(l));
});
}
async function getDeployedStacks(
sdkProvider: SdkProvider,
environment: cxapi.Environment,
): Promise<CloudFormationStack[]> {
const cfn = (await sdkProvider.forEnvironment(environment, Mode.ForReading)).sdk.cloudFormation();
const summaries = await cfn.paginatedListStacks({
StackStatusFilter: [
'CREATE_COMPLETE',
'UPDATE_COMPLETE',
'UPDATE_ROLLBACK_COMPLETE',
'IMPORT_COMPLETE',
'ROLLBACK_COMPLETE',
],
});
const normalize = async (summary: StackSummary) => {
const templateCommandOutput = await cfn.getTemplate({ StackName: summary.StackName! });
const template = deserializeStructure(templateCommandOutput.TemplateBody ?? '{}');
return {
environment,
stackName: summary.StackName!,
template,
};
};
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
return Promise.all(summaries.map(normalize));
}
export function formatTypedMappings(mappings: TypedMapping[]): string {
const stream = new StringWriteStream();
fmtTypedMappings(stream, mappings);
return stream.toString();
}
export function formatAmbiguousMappings(paths: [string[], string[]][]): string {
const stream = new StringWriteStream();
fmtAmbiguousMappings(stream, paths);
return stream.toString();
}