Skip to content

Commit 319f1ad

Browse files
GavinZZmrgrainrix0rrr
authored
feat(cli): allow change set imports resources that already exist (#447)
Allow `cdk diff` command to create a change set that imports existing resources. The current `cdk diff` command implicitly calls CloudFormation change set creation, providing high-level details such as "add", "delete", "modify", "import", and etc. like the following: ```s $ cdk diff [-] AWS::DynamoDB::Table MyTable orphan [+] AWS::DynamoDB::GlobalTable MyGlobalTable add ``` However, when the resource is meant to be imported, the `cdk diff` command still shows this as add. Adding `cdk diff --import-existing-resources` flag to show the new resource being imported instead of `add`. ```s $ cdk diff --import-existing-resources [-] AWS::DynamoDB::Table MyTable orphan [←] AWS::DynamoDB::GlobalTable MyGlobalTable import ``` Here is the underlying CFN change set JSON output ```json [ { "type": "Resource", "resourceChange": { "action": "Import", # NOTE THAT THIS SHOWS "Import" "logicalResourceId": "MyTable794EDED1", "physicalResourceId": "DemoStack-MyTable794EDED1-11W4MR8VZ0UPE", "resourceType": "AWS::DynamoDB::GlobalTable", "replacement": "True", "scope": [], "details": [], "afterContext": "..." } }, { "type": "Resource", "resourceChange": { "policyAction": "Retain", # Note that this is "Retain" "action": "Remove", "logicalResourceId": "MyTable794EDED1", "physicalResourceId": "DemoStack-MyTable794EDED1-11W4MR8VZ0UPE", "resourceType": "AWS::DynamoDB::Table", "scope": [], "details": [], "beforeContext": "..." } } ] ``` --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Signed-off-by: github-actions <github-actions@github.com> Co-authored-by: Momo Kornher <kornherm@amazon.co.uk> Co-authored-by: Rico Huijbers <rix0rrr@gmail.com>
1 parent 647d686 commit 319f1ad

File tree

15 files changed

+377
-2
lines changed

15 files changed

+377
-2
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
const cdk = require('aws-cdk-lib/core');
2+
const dynamodb = require('aws-cdk-lib/aws-dynamodb');
3+
4+
const stackPrefix = process.env.STACK_NAME_PREFIX;
5+
if (!stackPrefix) {
6+
throw new Error(`the STACK_NAME_PREFIX environment variable is required`);
7+
}
8+
9+
class BaseStack extends cdk.Stack {
10+
constructor(scope, id, props) {
11+
super(scope, id, props);
12+
13+
// Create a random table name with prefix
14+
if (process.env.VERSION == 'v2') {
15+
new dynamodb.TableV2(this, 'MyGlobalTable', {
16+
partitionKey: {
17+
name: 'PK',
18+
type: dynamodb.AttributeType.STRING,
19+
},
20+
tableName: 'integ-test-import-app-base-table-1',
21+
});
22+
} else {
23+
new dynamodb.Table(this, 'MyTable', {
24+
partitionKey: {
25+
name: 'PK',
26+
type: dynamodb.AttributeType.STRING,
27+
},
28+
tableName: 'integ-test-import-app-base-table-1',
29+
removalPolicy: process.env.VERSION == 'v1' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
30+
});
31+
}
32+
}
33+
}
34+
35+
const app = new cdk.App();
36+
new BaseStack(app, `${stackPrefix}-base-1`);
37+
38+
app.synth();
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"app": "node app.js",
3+
"versionReporting": false,
4+
"context": {
5+
"aws-cdk:enableDiffNoFail": "true"
6+
}
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { integTest, withSpecificFixture } from '../../lib';
2+
3+
jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime
4+
5+
integTest(
6+
'cdk diff --import-existing-resources show resource being imported',
7+
withSpecificFixture('import-app', async (fixture) => {
8+
// GIVEN
9+
await fixture.cdkDeploy('base-1', {
10+
modEnv: {
11+
VERSION: 'v1',
12+
},
13+
});
14+
15+
// THEN
16+
let diff = await fixture.cdk(['diff', '--import-existing-resources', fixture.fullStackName('base-1')], {
17+
modEnv: {
18+
VERSION: 'v2',
19+
},
20+
});
21+
22+
// Assert there are no changes and diff shows import
23+
expect(diff).not.toContain('There were no differences');
24+
expect(diff).toContain('[←]');
25+
expect(diff).toContain('import');
26+
27+
// THEN
28+
diff = await fixture.cdk(['diff', fixture.fullStackName('base-1')], {
29+
modEnv: {
30+
VERSION: 'v2',
31+
},
32+
});
33+
34+
// Assert there are no changes and diff shows add
35+
expect(diff).not.toContain('There were no differences');
36+
expect(diff).toContain('[+]');
37+
38+
// Deploy the stack with v3 to set table removal policy as destroy
39+
await fixture.cdkDeploy('base-1', {
40+
modEnv: {
41+
VERSION: 'v3',
42+
},
43+
});
44+
}),
45+
);

packages/@aws-cdk/toolkit-lib/lib/actions/diff/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ export interface ChangeSetDiffOptions extends CloudFormationDiffOptions {
2626
* @default - no parameters
2727
*/
2828
readonly parameters?: { [name: string]: string | undefined };
29+
30+
/**
31+
* Whether or not the change set imports resources that already exist
32+
*
33+
* @default false
34+
*/
35+
readonly importExistingResources?: boolean;
2936
}
3037

3138
export interface LocalFileDiffOptions {

packages/@aws-cdk/toolkit-lib/lib/actions/diff/private/helpers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ async function cfnDiff(
8989
resourcesToImport,
9090
methodOptions.parameters,
9191
methodOptions.fallbackToTemplate,
92+
methodOptions.importExistingResources,
9293
) : undefined;
9394

9495
templateInfos.push({
@@ -111,6 +112,7 @@ async function changeSetDiff(
111112
resourcesToImport?: ResourcesToImport,
112113
parameters: { [name: string]: string | undefined } = {},
113114
fallBackToTemplate: boolean = true,
115+
importExistingResources: boolean = false,
114116
): Promise<any | undefined> {
115117
let stackExists = false;
116118
try {
@@ -139,6 +141,7 @@ async function changeSetDiff(
139141
parameters: parameters,
140142
resourcesToImport,
141143
failOnError: !fallBackToTemplate,
144+
importExistingResources,
142145
});
143146
} else {
144147
if (!fallBackToTemplate) {

packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export type PrepareChangeSetOptions = {
142142
sdkProvider: SdkProvider;
143143
parameters: { [name: string]: string | undefined };
144144
resourcesToImport?: ResourcesToImport;
145+
importExistingResources?: boolean;
145146
/**
146147
* Default behavior is to log AWS CloudFormation errors and move on. Set this property to true to instead
147148
* fail on errors received by AWS CloudFormation.
@@ -161,6 +162,7 @@ export type CreateChangeSetOptions = {
161162
bodyParameter: TemplateBodyParameter;
162163
parameters: { [name: string]: string | undefined };
163164
resourcesToImport?: ResourceToImport[];
165+
importExistingResources?: boolean;
164166
role?: string;
165167
};
166168

@@ -244,6 +246,7 @@ async function uploadBodyParameterAndCreateChangeSet(
244246
bodyParameter,
245247
parameters: options.parameters,
246248
resourcesToImport: options.resourcesToImport,
249+
importExistingResources: options.importExistingResources,
247250
role: executionRoleArn,
248251
});
249252
} catch (e: any) {
@@ -309,6 +312,7 @@ export async function createChangeSet(
309312
TemplateBody: options.bodyParameter.TemplateBody,
310313
Parameters: stackParams.apiParameters,
311314
ResourcesToImport: options.resourcesToImport,
315+
ImportExistingResources: options.importExistingResources,
312316
RoleARN: options.role,
313317
Tags: toCfnTags(options.stack.tags),
314318
Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'],

packages/@aws-cdk/toolkit-lib/test/actions/diff.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as awsauth from '../../lib/api/aws-auth/private';
55
import { StackSelectionStrategy } from '../../lib/api/cloud-assembly';
66
import * as deployments from '../../lib/api/deployments';
77
import { RequireApproval } from '../../lib/api/require-approval';
8+
import { cfnApi } from '../../lib/api/shared-private';
89
import { Toolkit } from '../../lib/toolkit';
910
import { builderFixture, disposableCloudAssemblySource, TestIoHost } from '../_helpers';
1011
import { MockSdk, restoreSdkMocksToDefault, setDefaultSTSMocks } from '../_helpers/mock-sdk';
@@ -242,6 +243,35 @@ describe('diff', () => {
242243
}));
243244
});
244245

246+
test('ChangeSet diff method with import existing resources option enabled', async () => {
247+
// Setup mock BEFORE calling the function
248+
const createDiffChangeSetMock = jest.spyOn(cfnApi, 'createDiffChangeSet').mockImplementationOnce(async () => {
249+
return {
250+
$metadata: {},
251+
Changes: [
252+
{
253+
ResourceChange: {
254+
Action: 'Import',
255+
LogicalResourceId: 'MyBucketF68F3FF0',
256+
},
257+
},
258+
],
259+
};
260+
});
261+
262+
// WHEN
263+
ioHost.level = 'debug';
264+
const cx = await builderFixture(toolkit, 'stack-with-bucket');
265+
const result = await toolkit.diff(cx, {
266+
stacks: { strategy: StackSelectionStrategy.ALL_STACKS },
267+
method: DiffMethod.ChangeSet({ importExistingResources: true }),
268+
});
269+
270+
// THEN
271+
expect(createDiffChangeSetMock).toHaveBeenCalled();
272+
expect(result.Stack1.resources.get('MyBucketF68F3FF0').isImport).toBe(true);
273+
});
274+
245275
test('ChangeSet diff method throws if changeSet fails and fallBackToTemplate = false', async () => {
246276
// WHEN
247277
const cx = await builderFixture(toolkit, 'stack-with-bucket');

packages/aws-cdk/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,23 @@ The `change-set` flag will make `diff` create a change set and extract resource
189189
The `--no-change-set` mode will consider any change to a property that requires replacement to be a resource replacement,
190190
even if the change is purely cosmetic (like replacing a resource reference with a hardcoded arn).
191191

192+
The `--import-existing-resources` option will make `diff` create a change set and compare it using
193+
the CloudFormation resource import mechanism. This allows CDK to detect changes and show report of resources that
194+
will be imported rather added. Use this flag when preparing to import existing resources into a CDK stack to
195+
ensure and validate the changes are correctly reflected by showing 'import'.
196+
197+
```console
198+
$ cdk diff
199+
[+] AWS::DynamoDB::GlobalTable MyGlobalTable MyGlobalTable5DC12DB4
200+
201+
$ cdk diff --import-existing-resources
202+
[←] AWS::DynamoDB::GlobalTable MyGlobalTable MyGlobalTable5DC12DB4 import
203+
```
204+
205+
In the output above:
206+
[+] indicates a new resource that would be created.
207+
[] indicates a resource that would be imported into the stack instead.
208+
192209
### `cdk deploy`
193210

194211
Deploys a stack of your CDK app to its environment. During the deployment, the toolkit will output progress

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,12 @@ export class CdkToolkit {
213213
throw new ToolkitError(`There is no file at ${options.templatePath}`);
214214
}
215215

216+
if (options.importExistingResources) {
217+
throw new ToolkitError(
218+
'Can only use --import-existing-resources flag when comparing against deployed stacks.',
219+
);
220+
}
221+
216222
const template = deserializeStructure(await fs.readFile(options.templatePath, { encoding: 'UTF-8' }));
217223
const formatter = new DiffFormatter({
218224
ioHelper: asIoHelper(this.ioHost, 'diff'),
@@ -287,6 +293,7 @@ export class CdkToolkit {
287293
sdkProvider: this.props.sdkProvider,
288294
parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]),
289295
resourcesToImport,
296+
importExistingResources: options.importExistingResources,
290297
});
291298
} else {
292299
debug(
@@ -1514,6 +1521,13 @@ export interface DiffOptions {
15141521
* @default true
15151522
*/
15161523
readonly changeSet?: boolean;
1524+
1525+
/**
1526+
* Whether or not the change set imports resources that already exist.
1527+
*
1528+
* @default false
1529+
*/
1530+
readonly importExistingResources?: boolean;
15171531
}
15181532

15191533
interface CfnDeployOptions {

packages/aws-cdk/lib/cli/cli-config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ export async function makeConfig(): Promise<CliConfig> {
336336
'processed': { type: 'boolean', desc: 'Whether to compare against the template with Transforms already processed', default: false },
337337
'quiet': { type: 'boolean', alias: 'q', desc: 'Do not print stack name and default message when there is no diff to stdout', default: false },
338338
'change-set': { type: 'boolean', alias: 'changeset', desc: 'Whether to create a changeset to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role.', default: true },
339+
'import-existing-resources': { type: 'boolean', desc: 'Whether or not the change set imports resources that already exist', default: false },
339340
},
340341
},
341342
metadata: {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
258258
quiet: args.quiet,
259259
changeSet: args['change-set'],
260260
toolkitStackName: toolkitStackName,
261+
importExistingResources: args.importExistingResources,
261262
});
262263

263264
case 'refactor':

packages/aws-cdk/lib/cli/convert-to-user-input.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export function convertYargsToUserInput(args: any): UserInput {
191191
processed: args.processed,
192192
quiet: args.quiet,
193193
changeSet: args.changeSet,
194+
importExistingResources: args.importExistingResources,
194195
STACKS: args.STACKS,
195196
};
196197
break;
@@ -421,6 +422,7 @@ export function convertConfigToUserInput(config: any): UserInput {
421422
processed: config.diff?.processed,
422423
quiet: config.diff?.quiet,
423424
changeSet: config.diff?.changeSet,
425+
importExistingResources: config.diff?.importExistingResources,
424426
};
425427
const metadataOptions = {};
426428
const acknowledgeOptions = {};

packages/aws-cdk/lib/cli/parse-command-line-arguments.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,11 @@ export function parseCommandLineArguments(args: Array<string>): any {
744744
type: 'boolean',
745745
alias: 'changeset',
746746
desc: 'Whether to create a changeset to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role.',
747+
})
748+
.option('import-existing-resources', {
749+
default: false,
750+
type: 'boolean',
751+
desc: 'Whether or not the change set imports resources that already exist',
747752
}),
748753
)
749754
.command('metadata [STACK]', 'Returns all metadata associated with this stack')

packages/aws-cdk/lib/cli/user-input.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,6 +1152,13 @@ export interface DiffOptions {
11521152
*/
11531153
readonly changeSet?: boolean;
11541154

1155+
/**
1156+
* Whether or not the change set imports resources that already exist
1157+
*
1158+
* @default - false
1159+
*/
1160+
readonly importExistingResources?: boolean;
1161+
11551162
/**
11561163
* Positional argument for diff
11571164
*/

0 commit comments

Comments
 (0)