diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 433eda0b5a10d..d84cc83a082d7 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -53,6 +53,7 @@ export function fullDiff( const theDiff = diffTemplate(currentTemplate, newTemplate); if (changeSet) { filterFalsePositivies(theDiff, changeSet); + addImportInformation(theDiff, changeSet); } return theDiff; @@ -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) => { @@ -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 ?? []) { diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index e85265bf99c1f..af536400f942b 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -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', } @@ -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, @@ -528,6 +531,11 @@ export class ResourceDifference implements IDifference { */ public readonly isRemoval: boolean; + /** + * Whether this resource was imported + */ + public isImport?: boolean; + /** Property-level changes on the resource */ private readonly propertyDiffs: { [key: string]: PropertyDifference }; @@ -552,6 +560,7 @@ export class ResourceDifference implements IDifference { this.isAddition = oldValue === undefined; this.isRemoval = newValue === undefined; + this.isImport = undefined; } public get oldProperties(): PropertyMap | undefined { @@ -647,6 +656,9 @@ export class ResourceDifference implements IDifference { } 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; } diff --git a/packages/@aws-cdk/cloudformation-diff/lib/format.ts b/packages/@aws-cdk/cloudformation-diff/lib/format.ts index ce84c2e40f31c..7935f774fd468 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/format.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/format.ts @@ -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( @@ -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; @@ -171,6 +172,12 @@ class Formatter { } } + public formatResourcePrefix(diff: ResourceDifference) { + if (diff.isImport) { return IMPORT; } + + return this.formatPrefix(diff); + } + public formatPrefix(diff: Difference) { if (diff.isAddition) { return ADDITION; } if (diff.isUpdate) { return UPDATE; } @@ -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: diff --git a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts index b211c1d17c085..805e8a9a7767e 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts @@ -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); + }); }); diff --git a/packages/aws-cdk/lib/api/util/cloudformation.ts b/packages/aws-cdk/lib/api/util/cloudformation.ts index e965c27db1940..b61991bf51699 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation.ts @@ -292,6 +292,7 @@ export type PrepareChangeSetOptions = { sdkProvider: SdkProvider; stream: NodeJS.WritableStream; parameters: { [name: string]: string | undefined }; + resourcesToImport?: ResourcesToImport; } export type CreateChangeSetOptions = { @@ -303,6 +304,8 @@ export type CreateChangeSetOptions = { stack: cxapi.CloudFormationStackArtifact; bodyParameter: TemplateBodyParameter; parameters: { [name: string]: string | undefined }; + resourcesToImport?: ResourcesToImport; + role?: string; } /** @@ -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', @@ -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); @@ -367,12 +374,14 @@ async function createChangeSet(options: CreateChangeSetOptions): Promise 0 ? 1 : 0) @@ -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; @@ -884,7 +900,7 @@ export class CdkToolkit { private async tryMigrateResources(stacks: StackCollection, options: DeployOptions): Promise { 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)); @@ -918,11 +934,10 @@ export class CdkToolkit { print('\n✨ Resource migration time: %ss\n', formatTime(elapsedDeployTime)); } - private async tryGetResources(migrateDeployment: ResourceImporter) { + private async tryGetResources(environment: cxapi.Environment): Promise { 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; @@ -930,6 +945,8 @@ export class CdkToolkit { } catch (e) { // Nothing to do } + + return undefined; } } diff --git a/packages/aws-cdk/lib/import.ts b/packages/aws-cdk/lib/import.ts index 99a54654ecfda..cd6f70cebb03f 100644 --- a/packages/aws-cdk/lib/import.ts +++ b/packages/aws-cdk/lib/import.ts @@ -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 */ diff --git a/packages/aws-cdk/test/diff.test.ts b/packages/aws-cdk/test/diff.test.ts index a7b5905e12f87..a92c6d53551c3 100644 --- a/packages/aws-cdk/test/diff.test.ts +++ b/packages/aws-cdk/test/diff.test.ts @@ -12,6 +12,100 @@ let cloudExecutable: MockCloudExecutable; let cloudFormation: jest.Mocked; let toolkit: CdkToolkit; +describe('imports', () => { + beforeEach(() => { + jest.spyOn(cfn, 'createDiffChangeSet').mockImplementation(async () => { + return { + Changes: [ + { + ResourceChange: { + Action: 'Import', + LogicalResourceId: 'Queue', + }, + }, + { + ResourceChange: { + Action: 'Import', + LogicalResourceId: 'Bucket', + }, + }, + { + ResourceChange: { + Action: 'Import', + LogicalResourceId: 'Queue2', + }, + }, + ], + }; + }); + cloudExecutable = new MockCloudExecutable({ + stacks: [{ + stackName: 'A', + template: { + Resources: { + Queue: { + Type: 'AWS::SQS::Queue', + }, + Queue2: { + Type: 'AWS::SQS::Queue', + }, + Bucket: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }], + }); + + cloudFormation = instanceMockFrom(Deployments); + + toolkit = new CdkToolkit({ + cloudExecutable, + deployments: cloudFormation, + configuration: cloudExecutable.configuration, + sdkProvider: cloudExecutable.sdkProvider, + }); + + // Default implementations + cloudFormation.readCurrentTemplateWithNestedStacks.mockImplementation((_stackArtifact: CloudFormationStackArtifact) => { + return Promise.resolve({ + deployedTemplate: {}, + nestedStackCount: 0, + }); + }); + cloudFormation.deployStack.mockImplementation((options) => Promise.resolve({ + noOp: true, + outputs: {}, + stackArn: '', + stackArtifact: options.stack, + })); + }); + + test('imports', async () => { + // GIVEN + const buffer = new StringWritable(); + + // WHEN + const exitCode = await toolkit.diff({ + stackNames: ['A'], + stream: buffer, + changeSet: true, + }); + + // THEN + const plainTextOutput = buffer.data.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + expect(plainTextOutput).toContain(`Stack A +Resources +[←] AWS::SQS::Queue Queue import +[←] AWS::SQS::Queue Queue2 import +[←] AWS::S3::Bucket Bucket import +`); + + expect(buffer.data.trim()).toContain('✨ Number of stacks with differences: 1'); + expect(exitCode).toBe(0); + }); +}); + describe('non-nested stacks', () => { beforeEach(() => { cloudExecutable = new MockCloudExecutable({