Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}),
);
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
}
Expand Down Expand Up @@ -514,6 +518,40 @@ function fmtdict<A>(xs: Record<string, A>) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading