diff --git a/packages/aws-cdk/lib/commands/migrate.ts b/packages/aws-cdk/lib/commands/migrate.ts index e9d9d914d..b5cfea6f6 100644 --- a/packages/aws-cdk/lib/commands/migrate.ts +++ b/packages/aws-cdk/lib/commands/migrate.ts @@ -658,11 +658,13 @@ function deduplicateResources(resources: ResourceDetail[]) { let uniqueResources: { [key: string]: ResourceDetail } = {}; for (const resource of resources) { - const key = Object.keys(resource.ResourceIdentifier!)[0]; - - // Creating our unique identifier using the resource type, the key, and the value of the resource identifier - // The resource identifier is a combination of a key value pair defined by a resource's schema, and the resource type of the resource. - const uniqueIdentifer = `${resource.ResourceType}:${key}:${resource.ResourceIdentifier![key]}`; + // Creating our unique identifier using the resource type and the full resource identifier. + // The resource identifier can be made up of more than one key/value pair (e.g. a resource + // like AWS::Route53::KeySigningKey is identified by the pair HostedZoneId + Name), so every + // key must be included -- using only the first key would incorrectly collapse distinct + // resources that merely share the value of their first identifier component. + const sortedIdentifierEntries = Object.entries(resource.ResourceIdentifier!).sort(([a], [b]) => a.localeCompare(b)); + const uniqueIdentifer = `${resource.ResourceType}:${sortedIdentifierEntries.map(([k, v]) => `${k}=${v}`).join(',')}`; uniqueResources[uniqueIdentifer] = resource; } diff --git a/packages/aws-cdk/test/commands/migrate.test.ts b/packages/aws-cdk/test/commands/migrate.test.ts index dfe9707d7..2010e4ae4 100644 --- a/packages/aws-cdk/test/commands/migrate.test.ts +++ b/packages/aws-cdk/test/commands/migrate.test.ts @@ -596,4 +596,52 @@ describe('generateTemplate', () => { const template = await generateTemplate(opts); expect(template).toEqual(defaultExpectedResult); }); + + test('generateTemplate does not drop distinct resources that share the first key of a compound ResourceIdentifier', async () => { + // AWS::Route53::KeySigningKey (among other resource types) is uniquely identified by a *pair* + // of keys: HostedZoneId + Name. Two distinct resources sharing the same HostedZoneId but a + // different Name must both be retained by deduplication. + const compoundResourceA = { + ResourceType: 'AWS::Route53::KeySigningKey', + ManagedByStack: false, + ResourceIdentifier: { HostedZoneId: 'Z1', Name: 'ksk-one' }, + LogicalResourceId: 'ksk-one', + }; + const compoundResourceB = { + ResourceType: 'AWS::Route53::KeySigningKey', + ManagedByStack: false, + ResourceIdentifier: { HostedZoneId: 'Z1', Name: 'ksk-two' }, + LogicalResourceId: 'ksk-two', + }; + + mockCloudFormationClient + .on(ListResourceScanResourcesCommand) + .resolves({ + Resources: [compoundResourceA, compoundResourceB], + }); + + const opts: GenerateTemplateOptions = { + ioHelper, + stackName: stackName, + filters: [], + fromScan: FromScan.NEW, + sdkProvider: sdkProvider, + environment: environment, + }; + + await generateTemplate(opts); + + // Both resources must survive deduplication -- they only share the leading identifier key + // (HostedZoneId), not the full compound identifier (HostedZoneId + Name) -- and so both + // must be sent along to CreateGeneratedTemplate. + const createCalls = mockCloudFormationClient.commandCalls(CreateGeneratedTemplateCommand); + expect(createCalls).toHaveLength(1); + const sentResources = createCalls[0].args[0].input.Resources; + expect(sentResources).toContainEqual( + expect.objectContaining({ ResourceIdentifier: { HostedZoneId: 'Z1', Name: 'ksk-one' } }), + ); + expect(sentResources).toContainEqual( + expect.objectContaining({ ResourceIdentifier: { HostedZoneId: 'Z1', Name: 'ksk-two' } }), + ); + }); });