Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const cdk = require('aws-cdk-lib/core');
const iam = require('aws-cdk-lib/aws-iam');

const stackPrefix = process.env.STACK_NAME_PREFIX;
if (!stackPrefix) {
throw new Error('the STACK_NAME_PREFIX environment variable is required');
}

class ImportExistingResourcesStack extends cdk.Stack {
constructor(scope, id, props) {
super(scope, id, props);

const retain = process.env.REMOVAL_POLICY === 'retain';

const role = new iam.Role(this, 'MyRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
roleName: `${stackPrefix}-import-role`,
});
role.applyRemovalPolicy(retain ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY);
}
}

const app = new cdk.App();
new ImportExistingResourcesStack(app, `${stackPrefix}-import-existing`);

app.synth();
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"app": "node app"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { DeleteRoleCommand } from '@aws-sdk/client-iam';
import { integTest, withSpecificFixture } from '../../../lib';

integTest(
'import-existing-resources error message includes construct paths',
withSpecificFixture('import-existing-resources-app', async (fixture) => {
const roleName = `${fixture.stackNamePrefix}-import-role`;

try {
// Step 1: Deploy with RETAIN so the role exists and can survive stack deletion
await fixture.cdkDeploy('import-existing', {
modEnv: { REMOVAL_POLICY: 'retain' },
});

// Step 2: Delete the stack — the role survives because of RETAIN
await fixture.cdkDestroy('import-existing', {
modEnv: { REMOVAL_POLICY: 'retain' },
});

// Step 3: Re-deploy with DESTROY (no retain) and --import-existing-resources
// This should fail because CloudFormation requires DeletionPolicy=Retain for import
const stdErr = await fixture.cdkDeploy('import-existing', {
modEnv: { REMOVAL_POLICY: 'destroy' },
options: ['--import-existing-resources'],
allowErrExit: true,
});

expect(stdErr).toContain('Import of existing resources failed');
expect(stdErr).toContain('MyRole');
expect(stdErr).toContain('RemovalPolicy.RETAIN');
expect(stdErr).toContain('https://docs.aws.amazon.com/cdk/v2/guide/resources.html#resources-removal');

// Step 4: Deploy with RETAIN and --import-existing-resources — this should succeed
await fixture.cdkDeploy('import-existing', {
modEnv: { REMOVAL_POLICY: 'retain' },
options: ['--import-existing-resources'],
});
} finally {
// Clean up: delete the role if it was retained
try {
await fixture.aws.iam.send(new DeleteRoleCommand({ RoleName: roleName }));
} catch {
// Role may already be deleted
}
}
}),
);
51 changes: 47 additions & 4 deletions packages/@aws-cdk/toolkit-lib/lib/api/deployments/deploy-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,10 +479,53 @@ class FullCloudFormationDeployment {
const environmentResourcesRegistry = new EnvironmentResourcesRegistry();
const envResources = environmentResourcesRegistry.for(this.options.resolvedEnvironment, this.options.sdk, this.ioHelper);
const validationReporter = new EarlyValidationReporter(this.options.sdk, envResources);
return waitForChangeSet(this.cfn, this.ioHelper, this.stackName, changeSetName, {
fetchAll: willExecute,
validationReporter,
});
try {
return await waitForChangeSet(this.cfn, this.ioHelper, this.stackName, changeSetName, {
fetchAll: willExecute,
validationReporter,
});
} catch (e: any) {
if (importExistingResources && ToolkitError.isDeploymentError(e) && e.deploymentErrorCode === 'ChangeSetCreationFailed') {
throw new DeploymentError(this.enhanceImportErrorMessage(e.message), 'ChangeSetCreationFailed');
}
throw e;
}
}

/**
* Enhance an import-related changeset error message by mapping CFN logical IDs to CDK construct paths.
*/
private enhanceImportErrorMessage(message: string): string {
// Only enhance the specific CFN error about importing existing resources
if (!message.includes('CloudFormation is attempting to import some resources because they already exist in your account')) {
return message;
}

const marker = 'The affected resources are ';
const markerIndex = message.indexOf(marker);
if (markerIndex === -1) {
return message;
}

// Only extract logical IDs from the "The affected resources are ..." suffix
const resourceList = message.substring(markerIndex + marker.length);
const logicalIdPattern = /\b([A-Za-z][A-Za-z0-9]+)\s+\(\{/g;
const resources = this.stackArtifact.template?.Resources ?? {};

const affected: string[] = [];
for (const match of resourceList.matchAll(logicalIdPattern)) {
const logicalId = match[1];
const path = resources[logicalId]?.Metadata?.['aws:cdk:path'];
affected.push(path ? ` - ${path} (${logicalId})` : ` - ${logicalId}`);
}

return [
`Import of existing resources failed for stack '${this.stackName}' because the following resources need a DeletionPolicy of 'Retain' or 'RetainExceptOnCreate':`,
...affected,
'',
"Set the removal policy to 'RemovalPolicy.RETAIN' or 'RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE' on these resources.",
'See https://docs.aws.amazon.com/cdk/v2/guide/resources.html#resources-removal',
].join('\n');
}

private async executeChangeSet(changeSet: DescribeChangeSetCommandOutput): Promise<SuccessfulDeployStackResult> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,130 @@ describe('import-existing-resources', () => {
ImportExistingResources: true,
} as CreateChangeSetCommandInput);
});

test('enhances error message with construct paths when changeset fails', async () => {
// GIVEN
const stack = testStack({
stackName: 'import-error-stack',
template: {
Resources: {
DashboardsMyRoleABC123: {
Type: 'AWS::IAM::Role',
Metadata: { 'aws:cdk:path': 'import-error-stack/Dashboards/MyRole/Resource' },
},
AnotherResourceABC123: {
Type: 'AWS::S3::Bucket',
Metadata: { 'aws:cdk:path': 'import-error-stack/MyService/AnotherResource/Resource' },
},
},
},
});
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves({
Status: ChangeSetStatus.FAILED,
StatusReason:
'CloudFormation is attempting to import some resources because they already exist in your account. ' +
"The resources must have the DeletionPolicy attribute set to 'Retain' or 'RetainExceptOnCreate' in the template for successful import. " +
'The affected resources are DashboardsMyRoleABC123 ({RoleName=CloudWatchDashboards}), AnotherResourceABC123 ({BucketName=my-bucket})',
});

// THEN
await expect(testDeployStack({
...standardDeployStackArguments(),
stack,
deploymentMethod: {
method: 'change-set',
importExistingResources: true,
},
})).rejects.toThrow(
[
"Import of existing resources failed for stack 'import-error-stack' because the following resources need a DeletionPolicy of 'Retain' or 'RetainExceptOnCreate':",
' - import-error-stack/Dashboards/MyRole/Resource (DashboardsMyRoleABC123)',
' - import-error-stack/MyService/AnotherResource/Resource (AnotherResourceABC123)',
'',
"Set the removal policy to 'RemovalPolicy.RETAIN' or 'RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE' on these resources.",
'See https://docs.aws.amazon.com/cdk/v2/guide/resources.html#resources-removal',
].join('\n'),
);
});

test('does not enhance error message when importExistingResources is false', async () => {
// GIVEN
const stack = testStack({
stackName: 'import-error-stack-no-enhance',
template: {
Resources: {
MyRoleF4B2B07F: {
Type: 'AWS::IAM::Role',
Metadata: { 'aws:cdk:path': 'MyStack/MyConstruct/MyRole/Resource' },
},
},
},
});
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves({
Status: ChangeSetStatus.FAILED,
StatusReason:
'CloudFormation is attempting to import some resources because they already exist in your account. ' +
"The resources must have the DeletionPolicy attribute set to 'Retain' or 'RetainExceptOnCreate' in the template for successful import. " +
'The affected resources are MyRoleF4B2B07F ({RoleName=MyRole})',
});

// THEN - original error without enhancement, because importExistingResources is false
let error: Error | undefined;
try {
await testDeployStack({
...standardDeployStackArguments(),
stack,
deploymentMethod: {
method: 'change-set',
},
});
} catch (e: any) {
error = e;
}
expect(error).toBeDefined();
expect(error!.message).toContain('Failed to create ChangeSet cdk-deploy-change-set on import-error-stack-no-enhance: FAILED');
expect(error!.message).not.toContain('Import of existing resources failed');
expect(error!.message).not.toContain('RemovalPolicy');
});

test('falls back to logical ID when no construct path metadata exists', async () => {
// GIVEN
const stack = testStack({
stackName: 'import-error-no-metadata',
template: {
Resources: {
MyRoleF4B2B07F: {
Type: 'AWS::IAM::Role',
},
},
},
});
mockCloudFormationClient.on(DescribeChangeSetCommand).resolves({
Status: ChangeSetStatus.FAILED,
StatusReason:
'CloudFormation is attempting to import some resources because they already exist in your account. ' +
"The resources must have the DeletionPolicy attribute set to 'Retain' or 'RetainExceptOnCreate' in the template for successful import. " +
'The affected resources are MyRoleF4B2B07F ({RoleName=MyRole})',
});

// THEN - enhanced message with logical ID fallback (no construct path)
await expect(testDeployStack({
...standardDeployStackArguments(),
stack,
deploymentMethod: {
method: 'change-set',
importExistingResources: true,
},
})).rejects.toThrow(
[
"Import of existing resources failed for stack 'import-error-no-metadata' because the following resources need a DeletionPolicy of 'Retain' or 'RetainExceptOnCreate':",
' - MyRoleF4B2B07F',
'',
"Set the removal policy to 'RemovalPolicy.RETAIN' or 'RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE' on these resources.",
'See https://docs.aws.amazon.com/cdk/v2/guide/resources.html#resources-removal',
].join('\n'),
);
});
});

describe('revert-drift', () => {
Expand Down
Loading