From aeccd94ff42e5105758e78033c584aa414f64ce3 Mon Sep 17 00:00:00 2001 From: Aditya Jain Date: Fri, 21 Aug 2026 23:58:37 -0700 Subject: [PATCH] fix(aws-cdk): cdk migrate deduplication drops resources with compound identifiers deduplicateResources() in lib/commands/migrate.ts built its uniqueness key from only the FIRST key of a resource's ResourceIdentifier map. Several CloudFormation resource types (e.g. AWS::Route53::KeySigningKey, identified by the pair HostedZoneId + Name) have compound identifiers with more than one key. Two genuinely distinct resources sharing just the first key's value collided on the same synthetic identifier string and silently overwrote each other in the dedup map, so `cdk migrate --from-scan` could drop real resources from the generated template with no warning. Fix hashes on the full sorted set of ResourceIdentifier entries instead of just the first one. Co-Authored-By: Claude Sonnet 5 --- packages/aws-cdk/lib/commands/migrate.ts | 12 +++-- .../aws-cdk/test/commands/migrate.test.ts | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) 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' } }), + ); + }); });