Skip to content

Commit d973615

Browse files
authored
feat(CLI): Diff Supports Import Change Sets (#28787)
#28336 made diff create and parse change sets to determine accurate resource replacement information. This PR expands the change set parsing to support import type change sets. This shows the output of diff with a single resource import: <img width="1609" alt="Screenshot 2024-01-19 at 2 44 09 PM" src="https://github.com/aws/aws-cdk/assets/66279577/a67761fa-f0aa-4cb1-b8ec-049e116400b6"> ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 492ef12 commit d973615

File tree

8 files changed

+252
-12
lines changed

8 files changed

+252
-12
lines changed

packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts

+21
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export function fullDiff(
5353
const theDiff = diffTemplate(currentTemplate, newTemplate);
5454
if (changeSet) {
5555
filterFalsePositivies(theDiff, changeSet);
56+
addImportInformation(theDiff, changeSet);
5657
}
5758

5859
return theDiff;
@@ -208,6 +209,15 @@ function deepCopy(x: any): any {
208209
return x;
209210
}
210211

212+
function addImportInformation(diff: types.TemplateDiff, changeSet: CloudFormation.DescribeChangeSetOutput) {
213+
const imports = findResourceImports(changeSet);
214+
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
215+
if (imports.includes(logicalId)) {
216+
change.isImport = true;
217+
}
218+
});
219+
}
220+
211221
function filterFalsePositivies(diff: types.TemplateDiff, changeSet: CloudFormation.DescribeChangeSetOutput) {
212222
const replacements = findResourceReplacements(changeSet);
213223
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
@@ -245,6 +255,17 @@ function filterFalsePositivies(diff: types.TemplateDiff, changeSet: CloudFormati
245255
});
246256
}
247257

258+
function findResourceImports(changeSet: CloudFormation.DescribeChangeSetOutput): string[] {
259+
const importedResourceLogicalIds = [];
260+
for (const resourceChange of changeSet.Changes ?? []) {
261+
if (resourceChange.ResourceChange?.Action === 'Import') {
262+
importedResourceLogicalIds.push(resourceChange.ResourceChange.LogicalResourceId!);
263+
}
264+
}
265+
266+
return importedResourceLogicalIds;
267+
}
268+
248269
function findResourceReplacements(changeSet: CloudFormation.DescribeChangeSetOutput): types.ResourceReplacements {
249270
const replacements: types.ResourceReplacements = {};
250271
for (const resourceChange of changeSet.Changes ?? []) {

packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts

+12
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,8 @@ export enum ResourceImpact {
480480
WILL_DESTROY = 'WILL_DESTROY',
481481
/** The existing physical resource will be removed from CloudFormation supervision */
482482
WILL_ORPHAN = 'WILL_ORPHAN',
483+
/** The existing physical resource will be added to CloudFormation supervision */
484+
WILL_IMPORT = 'WILL_IMPORT',
483485
/** There is no change in this resource */
484486
NO_CHANGE = 'NO_CHANGE',
485487
}
@@ -495,6 +497,7 @@ function worstImpact(one: ResourceImpact, two?: ResourceImpact): ResourceImpact
495497
if (!two) { return one; }
496498
const badness = {
497499
[ResourceImpact.NO_CHANGE]: 0,
500+
[ResourceImpact.WILL_IMPORT]: 0,
498501
[ResourceImpact.WILL_UPDATE]: 1,
499502
[ResourceImpact.WILL_CREATE]: 2,
500503
[ResourceImpact.WILL_ORPHAN]: 3,
@@ -528,6 +531,11 @@ export class ResourceDifference implements IDifference<Resource> {
528531
*/
529532
public readonly isRemoval: boolean;
530533

534+
/**
535+
* Whether this resource was imported
536+
*/
537+
public isImport?: boolean;
538+
531539
/** Property-level changes on the resource */
532540
private readonly propertyDiffs: { [key: string]: PropertyDifference<any> };
533541

@@ -552,6 +560,7 @@ export class ResourceDifference implements IDifference<Resource> {
552560

553561
this.isAddition = oldValue === undefined;
554562
this.isRemoval = newValue === undefined;
563+
this.isImport = undefined;
555564
}
556565

557566
public get oldProperties(): PropertyMap | undefined {
@@ -647,6 +656,9 @@ export class ResourceDifference implements IDifference<Resource> {
647656
}
648657

649658
public get changeImpact(): ResourceImpact {
659+
if (this.isImport) {
660+
return ResourceImpact.WILL_IMPORT;
661+
}
650662
// Check the Type first
651663
if (this.resourceTypes.oldType !== this.resourceTypes.newType) {
652664
if (this.resourceTypes.oldType === undefined) { return ResourceImpact.WILL_CREATE; }

packages/@aws-cdk/cloudformation-diff/lib/format.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const ADDITION = chalk.green('[+]');
7979
const CONTEXT = chalk.grey('[ ]');
8080
const UPDATE = chalk.yellow('[~]');
8181
const REMOVAL = chalk.red('[-]');
82+
const IMPORT = chalk.blue('[←]');
8283

8384
class Formatter {
8485
constructor(
@@ -159,7 +160,7 @@ class Formatter {
159160
const resourceType = diff.isRemoval ? diff.oldResourceType : diff.newResourceType;
160161

161162
// eslint-disable-next-line max-len
162-
this.print(`${this.formatPrefix(diff)} ${this.formatValue(resourceType, chalk.cyan)} ${this.formatLogicalId(logicalId)} ${this.formatImpact(diff.changeImpact)}`);
163+
this.print(`${this.formatResourcePrefix(diff)} ${this.formatValue(resourceType, chalk.cyan)} ${this.formatLogicalId(logicalId)} ${this.formatImpact(diff.changeImpact)}`);
163164

164165
if (diff.isUpdate) {
165166
const differenceCount = diff.differenceCount;
@@ -171,6 +172,12 @@ class Formatter {
171172
}
172173
}
173174

175+
public formatResourcePrefix(diff: ResourceDifference) {
176+
if (diff.isImport) { return IMPORT; }
177+
178+
return this.formatPrefix(diff);
179+
}
180+
174181
public formatPrefix<T>(diff: Difference<T>) {
175182
if (diff.isAddition) { return ADDITION; }
176183
if (diff.isUpdate) { return UPDATE; }
@@ -204,6 +211,8 @@ class Formatter {
204211
return chalk.italic(chalk.bold(chalk.red('destroy')));
205212
case ResourceImpact.WILL_ORPHAN:
206213
return chalk.italic(chalk.yellow('orphan'));
214+
case ResourceImpact.WILL_IMPORT:
215+
return chalk.italic(chalk.blue('import'));
207216
case ResourceImpact.WILL_UPDATE:
208217
case ResourceImpact.WILL_CREATE:
209218
case ResourceImpact.NO_CHANGE:

packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts

+65
Original file line numberDiff line numberDiff line change
@@ -1117,4 +1117,69 @@ describe('changeset', () => {
11171117
});
11181118
expect(differences.resources.differenceCount).toBe(1);
11191119
});
1120+
1121+
test('imports are respected for new stacks', async () => {
1122+
// GIVEN
1123+
const currentTemplate = {};
1124+
1125+
// WHEN
1126+
const newTemplate = {
1127+
Resources: {
1128+
BucketResource: {
1129+
Type: 'AWS::S3::Bucket',
1130+
},
1131+
},
1132+
};
1133+
1134+
let differences = fullDiff(currentTemplate, newTemplate, {
1135+
Changes: [
1136+
{
1137+
Type: 'Resource',
1138+
ResourceChange: {
1139+
Action: 'Import',
1140+
LogicalResourceId: 'BucketResource',
1141+
},
1142+
},
1143+
],
1144+
});
1145+
expect(differences.resources.differenceCount).toBe(1);
1146+
expect(differences.resources.get('BucketResource').changeImpact === ResourceImpact.WILL_IMPORT);
1147+
});
1148+
1149+
test('imports are respected for existing stacks', async () => {
1150+
// GIVEN
1151+
const currentTemplate = {
1152+
Resources: {
1153+
OldResource: {
1154+
Type: 'AWS::Something::Resource',
1155+
},
1156+
},
1157+
};
1158+
1159+
// WHEN
1160+
const newTemplate = {
1161+
Resources: {
1162+
OldResource: {
1163+
Type: 'AWS::Something::Resource',
1164+
},
1165+
BucketResource: {
1166+
Type: 'AWS::S3::Bucket',
1167+
},
1168+
},
1169+
};
1170+
1171+
let differences = fullDiff(currentTemplate, newTemplate, {
1172+
Changes: [
1173+
{
1174+
Type: 'Resource',
1175+
ResourceChange: {
1176+
Action: 'Import',
1177+
LogicalResourceId: 'BucketResource',
1178+
},
1179+
},
1180+
],
1181+
});
1182+
expect(differences.resources.differenceCount).toBe(1);
1183+
expect(differences.resources.get('BucketResource').changeImpact === ResourceImpact.WILL_IMPORT);
1184+
});
11201185
});

packages/aws-cdk/lib/api/util/cloudformation.ts

+17-3
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ export type PrepareChangeSetOptions = {
292292
sdkProvider: SdkProvider;
293293
stream: NodeJS.WritableStream;
294294
parameters: { [name: string]: string | undefined };
295+
resourcesToImport?: ResourcesToImport;
295296
}
296297

297298
export type CreateChangeSetOptions = {
@@ -303,6 +304,8 @@ export type CreateChangeSetOptions = {
303304
stack: cxapi.CloudFormationStackArtifact;
304305
bodyParameter: TemplateBodyParameter;
305306
parameters: { [name: string]: string | undefined };
307+
resourcesToImport?: ResourcesToImport;
308+
role?: string;
306309
}
307310

308311
/**
@@ -337,7 +340,9 @@ async function uploadBodyParameterAndCreateChangeSet(options: PrepareChangeSetOp
337340
const cfn = preparedSdk.stackSdk.cloudFormation();
338341
const exists = (await CloudFormationStack.lookup(cfn, options.stack.stackName, false)).exists;
339342

343+
const executionRoleArn = preparedSdk.cloudFormationRoleArn;
340344
options.stream.write('Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)\n');
345+
341346
return await createChangeSet({
342347
cfn,
343348
changeSetName: 'cdk-diff-change-set',
@@ -347,6 +352,8 @@ async function uploadBodyParameterAndCreateChangeSet(options: PrepareChangeSetOp
347352
willExecute: options.willExecute,
348353
bodyParameter,
349354
parameters: options.parameters,
355+
resourcesToImport: options.resourcesToImport,
356+
role: executionRoleArn,
350357
});
351358
} catch (e: any) {
352359
debug(e.message);
@@ -367,12 +374,14 @@ async function createChangeSet(options: CreateChangeSetOptions): Promise<CloudFo
367374
const changeSet = await options.cfn.createChangeSet({
368375
StackName: options.stack.stackName,
369376
ChangeSetName: options.changeSetName,
370-
ChangeSetType: options.exists ? 'UPDATE' : 'CREATE',
377+
ChangeSetType: options.resourcesToImport ? 'IMPORT' : options.exists ? 'UPDATE' : 'CREATE',
371378
Description: `CDK Changeset for diff ${options.uuid}`,
372379
ClientToken: `diff${options.uuid}`,
373380
TemplateURL: options.bodyParameter.TemplateURL,
374381
TemplateBody: options.bodyParameter.TemplateBody,
375382
Parameters: stackParams.apiParameters,
383+
ResourcesToImport: options.resourcesToImport,
384+
RoleARN: options.role,
376385
Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'],
377386
}).promise();
378387

@@ -384,12 +393,17 @@ async function createChangeSet(options: CreateChangeSetOptions): Promise<CloudFo
384393
return createdChangeSet;
385394
}
386395

387-
export async function cleanupOldChangeset(exists: boolean, changeSetName: string, stackName: string, cfn: CloudFormation) {
388-
if (exists) {
396+
export async function cleanupOldChangeset(stackExists: boolean, changeSetName: string, stackName: string, cfn: CloudFormation) {
397+
if (stackExists) {
389398
// Delete any existing change sets generated by CDK since change set names must be unique.
390399
// The delete request is successful as long as the stack exists (even if the change set does not exist).
391400
debug(`Removing existing change set with name ${changeSetName} if it exists`);
392401
await cfn.deleteChangeSet({ StackName: stackName, ChangeSetName: changeSetName }).promise();
402+
} else {
403+
// delete the stack since creating a changeset for a stack that doesn't exist leaves that stack in a REVIEW_IN_PROGRESS state
404+
// that prevents other changesets from being created, even after the changeset has been deleted.
405+
debug(`Removing stack with name ${stackName}`);
406+
await cfn.deleteStack({ StackName: stackName }).promise();
393407
}
394408
}
395409

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

+21-4
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { createDiffChangeSet, ResourcesToImport } from './api/util/cloudformatio
1919
import { StackActivityProgress } from './api/util/cloudformation/stack-activity-monitor';
2020
import { generateCdkApp, generateStack, readFromPath, readFromStack, setEnvironment, validateSourceOptions } from './commands/migrate';
2121
import { printSecurityDiff, printStackDiff, RequireApproval } from './diff';
22-
import { ResourceImporter } from './import';
22+
import { ResourceImporter, removeNonImportResources } from './import';
2323
import { data, debug, error, highlight, print, success, warning, withCorkedLogging } from './logging';
2424
import { deserializeStructure, serializeStructure } from './serialize';
2525
import { Configuration, PROJECT_CONFIG } from './settings';
@@ -162,16 +162,26 @@ export class CdkToolkit {
162162
const currentTemplate = templateWithNames.deployedTemplate;
163163
const nestedStackCount = templateWithNames.nestedStackCount;
164164

165+
const resourcesToImport = await this.tryGetResources(await this.props.deployments.resolveEnvironment(stack));
166+
if (resourcesToImport) {
167+
removeNonImportResources(stack);
168+
}
169+
165170
const changeSet = options.changeSet ? await createDiffChangeSet({
166171
stack,
167172
uuid: uuid.v4(),
168173
deployments: this.props.deployments,
169174
willExecute: false,
170175
sdkProvider: this.props.sdkProvider,
171176
parameters: Object.assign({}, parameterMap['*'], parameterMap[stacks.firstStack.stackName]),
177+
resourcesToImport,
172178
stream,
173179
}) : undefined;
174180

181+
if (resourcesToImport) {
182+
stream.write('Parameters and rules created during migration do not affect resource configuration.\n');
183+
}
184+
175185
const stackCount =
176186
options.securityOnly
177187
? (numberFromBool(printSecurityDiff(currentTemplate, stack, RequireApproval.Broadening, changeSet)) > 0 ? 1 : 0)
@@ -205,6 +215,12 @@ export class CdkToolkit {
205215
const elapsedSynthTime = new Date().getTime() - startSynthTime;
206216
print('\n✨ Synthesis time: %ss\n', formatTime(elapsedSynthTime));
207217

218+
if (stackCollection.stackCount === 0) {
219+
// eslint-disable-next-line no-console
220+
console.error('This app contains no stacks');
221+
return;
222+
}
223+
208224
await this.tryMigrateResources(stackCollection, options);
209225

210226
const requireApproval = options.requireApproval ?? RequireApproval.Broadening;
@@ -884,7 +900,7 @@ export class CdkToolkit {
884900
private async tryMigrateResources(stacks: StackCollection, options: DeployOptions): Promise<void> {
885901
const stack = stacks.stackArtifacts[0];
886902
const migrateDeployment = new ResourceImporter(stack, this.props.deployments);
887-
const resourcesToImport = await this.tryGetResources(migrateDeployment);
903+
const resourcesToImport = await this.tryGetResources(await migrateDeployment.resolveEnvironment());
888904

889905
if (resourcesToImport) {
890906
print('%s: creating stack for resource migration...', chalk.bold(stack.displayName));
@@ -918,18 +934,19 @@ export class CdkToolkit {
918934
print('\n✨ Resource migration time: %ss\n', formatTime(elapsedDeployTime));
919935
}
920936

921-
private async tryGetResources(migrateDeployment: ResourceImporter) {
937+
private async tryGetResources(environment: cxapi.Environment): Promise<ResourcesToImport | undefined> {
922938
try {
923939
const migrateFile = fs.readJsonSync('migrate.json', { encoding: 'utf-8' });
924940
const sourceEnv = (migrateFile.Source as string).split(':');
925-
const environment = await migrateDeployment.resolveEnvironment();
926941
if (sourceEnv[0] === 'localfile' ||
927942
(sourceEnv[4] === environment.account && sourceEnv[3] === environment.region)) {
928943
return migrateFile.Resources;
929944
}
930945
} catch (e) {
931946
// Nothing to do
932947
}
948+
949+
return undefined;
933950
}
934951
}
935952

packages/aws-cdk/lib/import.ts

+12-4
Original file line numberDiff line numberDiff line change
@@ -385,13 +385,21 @@ export class ResourceImporter {
385385
* @returns template with import resources only
386386
*/
387387
private removeNonImportResources() {
388-
const template = this.stack.template;
389-
delete template.Resources.CDKMetadata;
390-
delete template.Outputs;
391-
return template;
388+
return removeNonImportResources(this.stack);
392389
}
393390
}
394391

392+
/**
393+
* Removes CDKMetadata and Outputs in the template so that only resources for importing are left.
394+
* @returns template with import resources only
395+
*/
396+
export function removeNonImportResources(stack: cxapi.CloudFormationStackArtifact) {
397+
const template = stack.template;
398+
delete template.Resources.CDKMetadata;
399+
delete template.Outputs;
400+
return template;
401+
}
402+
395403
/**
396404
* Information about a resource in the template that is importable
397405
*/

0 commit comments

Comments
 (0)