From e79230ae738e4afcb36b142bc5009765a7870a74 Mon Sep 17 00:00:00 2001 From: HuzaifaChaudary Date: Tue, 22 Sep 2026 23:04:30 +0500 Subject: [PATCH 1/2] fix(toolkit-lib): cdk import fails on a stack that contains non-ASCII characters `cdk import` builds the IMPORT change set from the deployed template as returned by `GetTemplate`, which flattens every codepoint above \u007f to a literal '?'. The change set therefore carries a '?' where the deployed resource really holds the original character, and CloudFormation rejects the import naming a resource that is not part of it: You have modified resources [] in your template that are not being imported. `cdk diff` has compensated for the same mangling since aws/aws-cdk#25912, but that lives in the diff formatter, so the import path never saw it. The import's own pre-flight diff even prints "Omitted 1 changes because they are likely mangled non-ASCII characters" and then submits the mangled template anyway. The deployed template is now walked against the local one, and a deployed string is replaced by the local one only when the local one mangles to exactly it. A real modification does not match and is still submitted as a modification. fixes #1915 Co-Authored-By: Claude Opus 5 --- .../lib/api/resource-import/importer.ts | 40 +++++++++- .../test/api/resource-import/import.test.ts | 80 +++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/resource-import/importer.ts b/packages/@aws-cdk/toolkit-lib/lib/api/resource-import/importer.ts index dedfa273d..357af8c30 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/resource-import/importer.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/resource-import/importer.ts @@ -311,7 +311,11 @@ export class ResourceImporter { // leaking changes - the import additions, or normalizations such as sorted `DependsOn` // arrays - into the submitted change set, which CloudFormation rejects as modifications to // resources that are not being imported. See https://github.com/aws/aws-cdk-cli/issues/1575. - const template = structuredClone(await this.currentTemplate()); + // `GetTemplate` returns every codepoint above \u007f as a literal '?', so the deployed + // template can differ from what is really deployed. Submitting that as an IMPORT change set + // makes CloudFormation reject a resource nobody touched. See + // https://github.com/aws/aws-cdk-cli/issues/1915. + const template = healMangledNonAscii(structuredClone(await this.currentTemplate()), this.stack.template); if (!template.Resources) { template.Resources = {}; } @@ -514,6 +518,40 @@ function fmtdict(xs: Record) { return Object.entries(xs).map(([k, v]) => `${k}=${v}`).join(', '); } +/** + * Undo the mangling `GetTemplate` does to non-ASCII characters + * + * `GetTemplate` flattens every codepoint above \u007f to a literal '?', which is what + * `mangleLikeCloudFormation` reproduces for `cdk diff`. The import template is built from the + * deployed template, so without this the change set carries '?' where the deployed resource + * really holds the original character, and CloudFormation rejects the import naming a resource + * that is not part of it. + * + * A deployed string is only replaced when the local one mangles to exactly that string, so a + * real modification is still submitted as a modification. + */ +function healMangledNonAscii(deployed: any, local: any): any { + if (typeof deployed === 'string') { + return typeof local === 'string' && + deployed !== local && + cfnDiff.mangleLikeCloudFormation(local) === deployed + ? local + : deployed; + } + if (Array.isArray(deployed)) { + return Array.isArray(local) ? deployed.map((item, i) => healMangledNonAscii(item, local[i])) : deployed; + } + if (deployed !== null && typeof deployed === 'object') { + if (local === null || typeof local !== 'object' || Array.isArray(local)) { + return deployed; + } + return Object.fromEntries( + Object.entries(deployed).map(([key, value]) => [key, healMangledNonAscii(value, (local as any)[key])]), + ); + } + return deployed; +} + /** * Add a default `DeletionPolicy` policy. * The default value is set to 'Retain', to lower risk of unintentionally diff --git a/packages/@aws-cdk/toolkit-lib/test/api/resource-import/import.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/resource-import/import.test.ts index a562624d1..ab1d38c2a 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/resource-import/import.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/resource-import/import.test.ts @@ -328,6 +328,86 @@ test('issue 1575: IMPORT change set preserves DependsOn order of non-imported re expect(submittedTemplate.Resources.Existing.DependsOn).toEqual(naturalOrder); }); +test('issue 1915: IMPORT change set keeps non-ASCII characters that GetTemplate mangled', async () => { + // GIVEN a deployed resource whose description holds an em dash. `GetTemplate` hands that back + // with a literal '?' in its place, which is what the fake deployed template has here. + const description = 'Scoped access \u2014 resources only.'; + const stackToImportInto = testStack({ + stackName: 'StackImport1915', + template: { + Resources: { + Existing: { Type: 'AWS::IAM::ManagedPolicy', Properties: { Description: description } }, + MyQueue: { Type: 'AWS::SQS::Queue', Properties: { QueueName: 'TheQueueName' } }, + }, + }, + }); + + givenCurrentStack(stackToImportInto.stackName, { + Resources: { + Existing: { + Type: 'AWS::IAM::ManagedPolicy', + Properties: { Description: 'Scoped access ? resources only.' }, + }, + }, + }); + + const importer = new ResourceImporter(stackToImportInto, props); + const { additions } = await importer.discoverImportableResources(); + const importMap: ImportMap = { + importResources: additions, + resourceMap: { MyQueue: { QueueName: 'TheQueueName' } }, + }; + + // WHEN + await advanceTime(importer.importResourcesFromMap(importMap)); + + // THEN - the submitted template carries the real character, so `Existing` is not a modification + const calls = mockCloudFormationClient.commandCalls(CreateChangeSetCommand); + expect(calls.length).toBeGreaterThan(0); + const submittedTemplate = yaml.parse((calls[calls.length - 1].args[0].input as any).TemplateBody); + expect(submittedTemplate.Resources.Existing.Properties.Description).toEqual(description); +}); + +test('issue 1915: a real change to a non-ASCII string is still submitted as deployed', async () => { + // GIVEN a deployed value that is not just the mangled form of the local one + const stackToImportInto = testStack({ + stackName: 'StackImport1915Changed', + template: { + Resources: { + Existing: { + Type: 'AWS::IAM::ManagedPolicy', + Properties: { Description: 'Something else \u2014 entirely.' }, + }, + MyQueue: { Type: 'AWS::SQS::Queue', Properties: { QueueName: 'TheQueueName' } }, + }, + }, + }); + + givenCurrentStack(stackToImportInto.stackName, { + Resources: { + Existing: { + Type: 'AWS::IAM::ManagedPolicy', + Properties: { Description: 'Scoped access ? resources only.' }, + }, + }, + }); + + const importer = new ResourceImporter(stackToImportInto, props); + const { additions } = await importer.discoverImportableResources(); + const importMap: ImportMap = { + importResources: additions, + resourceMap: { MyQueue: { QueueName: 'TheQueueName' } }, + }; + + // WHEN + await advanceTime(importer.importResourcesFromMap(importMap)); + + // THEN - the deployed value is left alone, the import does not quietly apply the local change + const calls = mockCloudFormationClient.commandCalls(CreateChangeSetCommand); + const submittedTemplate = yaml.parse((calls[calls.length - 1].args[0].input as any).TemplateBody); + expect(submittedTemplate.Resources.Existing.Properties.Description).toEqual('Scoped access ? resources only.'); +}); + test('importing resources from migrate strips cdk metadata and outputs', async () => { // GIVEN const MyQueue = { From ef85bd49c9426f51ba5b0ba1e01fa0d14847e435 Mon Sep 17 00:00:00 2001 From: Huzaifa Iftikhar Date: Sun, 27 Sep 2026 08:46:44 +0500 Subject: [PATCH 2/2] test(cli-integ): import into a stack that holds a non-ascii character the importable stack can now carry a managed policy whose description has an em dash . the new test deploys it with a retained queue , drops the queue , then imports it back and expects IMPORT_COMPLETE . --- .../cli-integ/resources/cdk-apps/app/app.js | 13 +++++ ...ort-with-non-ascii-characters.integtest.ts | 58 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/import/cdk-test-resource-import-with-non-ascii-characters.integtest.ts diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index d84b5bdfb..fb45d8d18 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -303,6 +303,19 @@ class ImportableStack extends cdk.Stack { }); } + if (process.env.INCLUDE_NON_ASCII_POLICY === '1') { + // GetTemplate returns every non-ASCII character as '?', which import must not submit back as a change + new iam.ManagedPolicy(this, 'NonAsciiPolicy', { + description: 'Policy with a non-ASCII character — em dash', + statements: [ + new iam.PolicyStatement({ + actions: ['sqs:GetQueueUrl'], + resources: ['*'], + }), + ], + }); + } + if (process.env.LARGE_TEMPLATE === '1') { for (let i = 1; i <= 70; i++) { new sqs.Queue(this, `cdk-import-queue-test${i}`, { diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/import/cdk-test-resource-import-with-non-ascii-characters.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/import/cdk-test-resource-import-with-non-ascii-characters.integtest.ts new file mode 100644 index 000000000..21e066560 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/import/cdk-test-resource-import-with-non-ascii-characters.integtest.ts @@ -0,0 +1,58 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { DescribeStacksCommand, GetTemplateCommand } from '@aws-sdk/client-cloudformation'; +import { integTest, withDefaultFixture, randomString } from '../../../lib'; + +integTest( + 'test resource import into a stack that contains non-ASCII characters', + withDefaultFixture(async (fixture) => { + // GIVEN + const randomPrefix = randomString(); + const uniqueOutputsFileName = `${randomPrefix}Outputs.json`; // other tests use the outputs file. Make sure we don't collide. + const outputsFile = path.join(fixture.integTestDir, 'outputs', uniqueOutputsFileName); + await fs.mkdir(path.dirname(outputsFile), { recursive: true }); + + // First, create a stack with a policy whose description contains a non-ASCII character, + // and one queue that will be removed from the stack but NOT deleted from AWS. + await fixture.cdkDeploy('importable-stack', { + modEnv: { INCLUDE_NON_ASCII_POLICY: '1', INCLUDE_SINGLE_QUEUE: '1', RETAIN_SINGLE_QUEUE: '1' }, + options: ['--outputs-file', outputsFile], + }); + + // Second, now the queue we will remove is in the stack and has a logicalId. We can now make the resource mapping file. + const fullStackName = fixture.fullStackName('importable-stack'); + const outputs = JSON.parse((await fs.readFile(outputsFile, { encoding: 'utf-8' })).toString()); + const queueLogicalId = outputs[fullStackName].QueueLogicalId; + const queueResourceMap = { + [queueLogicalId]: { QueueUrl: outputs[fullStackName].QueueUrl }, + }; + const mappingFile = path.join(fixture.integTestDir, 'outputs', `${randomPrefix}Mapping.json`); + await fs.writeFile(mappingFile, JSON.stringify(queueResourceMap), { encoding: 'utf-8' }); + + // Third, remove the queue from the stack, but don't delete the queue from AWS. + await fixture.cdkDeploy('importable-stack', { + modEnv: { INCLUDE_NON_ASCII_POLICY: '1', INCLUDE_SINGLE_QUEUE: '0', RETAIN_SINGLE_QUEUE: '0' }, + }); + const cfnTemplateBeforeImport = await fixture.aws.cloudFormation.send( + new GetTemplateCommand({ StackName: fullStackName }), + ); + expect(cfnTemplateBeforeImport.TemplateBody).not.toContain(queueLogicalId); + + // WHEN + // GetTemplate returns the policy description with '?' in place of the em dash. If import + // submits that back, CloudFormation rejects the change set because it modifies the policy. + await fixture.cdk(['import', '--resource-mapping', mappingFile, fixture.fullStackName('importable-stack')], { + modEnv: { INCLUDE_NON_ASCII_POLICY: '1', INCLUDE_SINGLE_QUEUE: '1', RETAIN_SINGLE_QUEUE: '0' }, + }); + + // THEN + const describeStacksResponse = await fixture.aws.cloudFormation.send( + new DescribeStacksCommand({ StackName: fullStackName }), + ); + const cfnTemplateAfterImport = await fixture.aws.cloudFormation.send( + new GetTemplateCommand({ StackName: fullStackName }), + ); + expect(describeStacksResponse.Stacks![0].StackStatus).toEqual('IMPORT_COMPLETE'); + expect(cfnTemplateAfterImport.TemplateBody).toContain(queueLogicalId); + }), +);