Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(CLI): Diff Supports Import Change Sets #28787

Merged
merged 15 commits into from
Jan 31, 2024
21 changes: 21 additions & 0 deletions packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function fullDiff(
const theDiff = diffTemplate(currentTemplate, newTemplate);
if (changeSet) {
filterFalsePositivies(theDiff, changeSet);
addImportInformation(theDiff, changeSet);
}

return theDiff;
Expand Down Expand Up @@ -208,6 +209,15 @@ function deepCopy(x: any): any {
return x;
}

function addImportInformation(diff: types.TemplateDiff, changeSet: CloudFormation.DescribeChangeSetOutput) {
const imports = findResourceImports(changeSet);
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
if (imports.includes(logicalId)) {
change.isImport = true;
}
});
}

function filterFalsePositivies(diff: types.TemplateDiff, changeSet: CloudFormation.DescribeChangeSetOutput) {
const replacements = findResourceReplacements(changeSet);
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
Expand Down Expand Up @@ -245,6 +255,17 @@ function filterFalsePositivies(diff: types.TemplateDiff, changeSet: CloudFormati
});
}

function findResourceImports(changeSet: CloudFormation.DescribeChangeSetOutput): string[] {
const importedResourceLogicalIds = [];
for (const resourceChange of changeSet.Changes ?? []) {
if (resourceChange.ResourceChange?.Action === 'Import') {
importedResourceLogicalIds.push(resourceChange.ResourceChange.LogicalResourceId!);
}
}

return importedResourceLogicalIds;
}

function findResourceReplacements(changeSet: CloudFormation.DescribeChangeSetOutput): types.ResourceReplacements {
const replacements: types.ResourceReplacements = {};
for (const resourceChange of changeSet.Changes ?? []) {
Expand Down
12 changes: 12 additions & 0 deletions packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,8 @@ export enum ResourceImpact {
WILL_DESTROY = 'WILL_DESTROY',
/** The existing physical resource will be removed from CloudFormation supervision */
WILL_ORPHAN = 'WILL_ORPHAN',
/** The existing physical resource will be added to CloudFormation supervision */
WILL_IMPORT = 'WILL_IMPORT',
/** There is no change in this resource */
NO_CHANGE = 'NO_CHANGE',
}
Expand All @@ -495,6 +497,7 @@ function worstImpact(one: ResourceImpact, two?: ResourceImpact): ResourceImpact
if (!two) { return one; }
const badness = {
[ResourceImpact.NO_CHANGE]: 0,
[ResourceImpact.WILL_IMPORT]: 0,
[ResourceImpact.WILL_UPDATE]: 1,
[ResourceImpact.WILL_CREATE]: 2,
[ResourceImpact.WILL_ORPHAN]: 3,
Expand Down Expand Up @@ -528,6 +531,11 @@ export class ResourceDifference implements IDifference<Resource> {
*/
public readonly isRemoval: boolean;

/**
* Whether this resource was imported
*/
public isImport?: boolean;

/** Property-level changes on the resource */
private readonly propertyDiffs: { [key: string]: PropertyDifference<any> };

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

this.isAddition = oldValue === undefined;
this.isRemoval = newValue === undefined;
this.isImport = undefined;
}

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

public get changeImpact(): ResourceImpact {
if (this.isImport) {
return ResourceImpact.WILL_IMPORT;
}
// Check the Type first
if (this.resourceTypes.oldType !== this.resourceTypes.newType) {
if (this.resourceTypes.oldType === undefined) { return ResourceImpact.WILL_CREATE; }
Expand Down
11 changes: 10 additions & 1 deletion packages/@aws-cdk/cloudformation-diff/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const ADDITION = chalk.green('[+]');
const CONTEXT = chalk.grey('[ ]');
const UPDATE = chalk.yellow('[~]');
const REMOVAL = chalk.red('[-]');
const IMPORT = chalk.blue('[←]');

class Formatter {
constructor(
Expand Down Expand Up @@ -159,7 +160,7 @@ class Formatter {
const resourceType = diff.isRemoval ? diff.oldResourceType : diff.newResourceType;

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

if (diff.isUpdate) {
const differenceCount = diff.differenceCount;
Expand All @@ -171,6 +172,12 @@ class Formatter {
}
}

public formatResourcePrefix(diff: ResourceDifference) {
if (diff.isImport) { return IMPORT; }

return this.formatPrefix(diff);
}

public formatPrefix<T>(diff: Difference<T>) {
if (diff.isAddition) { return ADDITION; }
if (diff.isUpdate) { return UPDATE; }
Expand Down Expand Up @@ -204,6 +211,8 @@ class Formatter {
return chalk.italic(chalk.bold(chalk.red('destroy')));
case ResourceImpact.WILL_ORPHAN:
return chalk.italic(chalk.yellow('orphan'));
case ResourceImpact.WILL_IMPORT:
return chalk.italic(chalk.blue('import'));
case ResourceImpact.WILL_UPDATE:
case ResourceImpact.WILL_CREATE:
case ResourceImpact.NO_CHANGE:
Expand Down
65 changes: 65 additions & 0 deletions packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,4 +1117,69 @@ describe('changeset', () => {
});
expect(differences.resources.differenceCount).toBe(1);
});

test('imports are respected for new stacks', async () => {
// GIVEN
const currentTemplate = {};

// WHEN
const newTemplate = {
Resources: {
BucketResource: {
Type: 'AWS::S3::Bucket',
},
},
};

let differences = fullDiff(currentTemplate, newTemplate, {
Changes: [
{
Type: 'Resource',
ResourceChange: {
Action: 'Import',
LogicalResourceId: 'BucketResource',
},
},
],
});
expect(differences.resources.differenceCount).toBe(1);
expect(differences.resources.get('BucketResource').changeImpact === ResourceImpact.WILL_IMPORT);
});

test('imports are respected for existing stacks', async () => {
// GIVEN
const currentTemplate = {
Resources: {
OldResource: {
Type: 'AWS::Something::Resource',
},
},
};

// WHEN
const newTemplate = {
Resources: {
OldResource: {
Type: 'AWS::Something::Resource',
},
BucketResource: {
Type: 'AWS::S3::Bucket',
},
},
};

let differences = fullDiff(currentTemplate, newTemplate, {
Changes: [
{
Type: 'Resource',
ResourceChange: {
Action: 'Import',
LogicalResourceId: 'BucketResource',
},
},
],
});
expect(differences.resources.differenceCount).toBe(1);
expect(differences.resources.get('BucketResource').changeImpact === ResourceImpact.WILL_IMPORT);
});
});
20 changes: 17 additions & 3 deletions packages/aws-cdk/lib/api/util/cloudformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export type PrepareChangeSetOptions = {
sdkProvider: SdkProvider;
stream: NodeJS.WritableStream;
parameters: { [name: string]: string | undefined };
resourcesToImport?: ResourcesToImport;
}

export type CreateChangeSetOptions = {
Expand All @@ -303,6 +304,8 @@ export type CreateChangeSetOptions = {
stack: cxapi.CloudFormationStackArtifact;
bodyParameter: TemplateBodyParameter;
parameters: { [name: string]: string | undefined };
resourcesToImport?: ResourcesToImport;
role?: string;
}

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

const executionRoleArn = preparedSdk.cloudFormationRoleArn;
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');

return await createChangeSet({
cfn,
changeSetName: 'cdk-diff-change-set',
Expand All @@ -347,6 +352,8 @@ async function uploadBodyParameterAndCreateChangeSet(options: PrepareChangeSetOp
willExecute: options.willExecute,
bodyParameter,
parameters: options.parameters,
resourcesToImport: options.resourcesToImport,
role: executionRoleArn,
});
} catch (e: any) {
debug(e.message);
Expand All @@ -367,12 +374,14 @@ async function createChangeSet(options: CreateChangeSetOptions): Promise<CloudFo
const changeSet = await options.cfn.createChangeSet({
StackName: options.stack.stackName,
ChangeSetName: options.changeSetName,
ChangeSetType: options.exists ? 'UPDATE' : 'CREATE',
ChangeSetType: options.resourcesToImport ? 'IMPORT' : options.exists ? 'UPDATE' : 'CREATE',
Description: `CDK Changeset for diff ${options.uuid}`,
ClientToken: `diff${options.uuid}`,
TemplateURL: options.bodyParameter.TemplateURL,
TemplateBody: options.bodyParameter.TemplateBody,
Parameters: stackParams.apiParameters,
ResourcesToImport: options.resourcesToImport,
RoleARN: options.role,
Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'],
}).promise();

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

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

Expand Down
25 changes: 21 additions & 4 deletions packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { createDiffChangeSet, ResourcesToImport } from './api/util/cloudformatio
import { StackActivityProgress } from './api/util/cloudformation/stack-activity-monitor';
import { generateCdkApp, generateStack, readFromPath, readFromStack, setEnvironment, validateSourceOptions } from './commands/migrate';
import { printSecurityDiff, printStackDiff, RequireApproval } from './diff';
import { ResourceImporter } from './import';
import { ResourceImporter, removeNonImportResources } from './import';
import { data, debug, error, highlight, print, success, warning, withCorkedLogging } from './logging';
import { deserializeStructure, serializeStructure } from './serialize';
import { Configuration, PROJECT_CONFIG } from './settings';
Expand Down Expand Up @@ -162,16 +162,26 @@ export class CdkToolkit {
const currentTemplate = templateWithNames.deployedTemplate;
const nestedStackCount = templateWithNames.nestedStackCount;

const resourcesToImport = await this.tryGetResources(await this.props.deployments.resolveEnvironment(stack));
if (resourcesToImport) {
removeNonImportResources(stack);
}

const changeSet = options.changeSet ? await createDiffChangeSet({
stack,
uuid: uuid.v4(),
deployments: this.props.deployments,
willExecute: false,
sdkProvider: this.props.sdkProvider,
parameters: Object.assign({}, parameterMap['*'], parameterMap[stacks.firstStack.stackName]),
resourcesToImport,
stream,
}) : undefined;

if (resourcesToImport) {
stream.write('Parameters and rules created during migration do not affect resource configuration.\n');
}

const stackCount =
options.securityOnly
? (numberFromBool(printSecurityDiff(currentTemplate, stack, RequireApproval.Broadening, changeSet)) > 0 ? 1 : 0)
Expand Down Expand Up @@ -205,6 +215,12 @@ export class CdkToolkit {
const elapsedSynthTime = new Date().getTime() - startSynthTime;
print('\n✨ Synthesis time: %ss\n', formatTime(elapsedSynthTime));

if (stackCollection.stackCount === 0) {
// eslint-disable-next-line no-console
console.error('This app contains no stacks');
return;
}

await this.tryMigrateResources(stackCollection, options);

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

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

private async tryGetResources(migrateDeployment: ResourceImporter) {
private async tryGetResources(environment: cxapi.Environment): Promise<ResourcesToImport | undefined> {
try {
const migrateFile = fs.readJsonSync('migrate.json', { encoding: 'utf-8' });
const sourceEnv = (migrateFile.Source as string).split(':');
const environment = await migrateDeployment.resolveEnvironment();
if (sourceEnv[0] === 'localfile' ||
(sourceEnv[4] === environment.account && sourceEnv[3] === environment.region)) {
return migrateFile.Resources;
}
} catch (e) {
// Nothing to do
}

return undefined;
}
}

Expand Down
16 changes: 12 additions & 4 deletions packages/aws-cdk/lib/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,13 +385,21 @@ export class ResourceImporter {
* @returns template with import resources only
*/
private removeNonImportResources() {
const template = this.stack.template;
delete template.Resources.CDKMetadata;
delete template.Outputs;
return template;
return removeNonImportResources(this.stack);
}
}

/**
* Removes CDKMetadata and Outputs in the template so that only resources for importing are left.
* @returns template with import resources only
*/
export function removeNonImportResources(stack: cxapi.CloudFormationStackArtifact) {
const template = stack.template;
delete template.Resources.CDKMetadata;
delete template.Outputs;
return template;
}

/**
* Information about a resource in the template that is importable
*/
Expand Down
Loading
Loading