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
14 changes: 14 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 @@ -502,6 +502,17 @@ class DriftableStack extends cdk.Stack {
}
}

class EarlyValidationStack extends cdk.Stack {
constructor(parent, id, props) {
super(parent, id, props);

new s3.Bucket(this, 'MyBucket', {
bucketName: process.env.BUCKET_NAME,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
}
}

class IamRolesStack extends cdk.Stack {
constructor(parent, id, props) {
super(parent, id, props);
Expand Down Expand Up @@ -971,6 +982,9 @@ switch (stackSet) {
new MetadataStack(app, `${stackPrefix}-metadata`);

new DriftableStack(app, `${stackPrefix}-driftable`);

new EarlyValidationStack(app, `${stackPrefix}-early-validation-stack1`);
new EarlyValidationStack(app, `${stackPrefix}-early-validation-stack2`);
break;

case 'stage-using-context':
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { randomUUID } from 'node:crypto';
import { integTest, withDefaultFixture } from '../../../lib';

jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime

integTest(
'deploy - early validation error',
withDefaultFixture(async (fixture) => {
const bucketName = randomUUID();

// First, deploy a stack that creates a bucket with a custom name,
// which we expect to succeed
await fixture.cdkDeploy('early-validation-stack1', {
modEnv: {
BUCKET_NAME: bucketName,
},
});

// Then deploy a different instance of the stack, that creates another
// bucket with the same name, to induce an early validation error
const stdErr = await fixture.cdkDeploy('early-validation-stack2', {
modEnv: {
BUCKET_NAME: bucketName,
},
allowErrExit: true,
});

expect(stdErr).toContain(`Resource of type 'AWS::S3::Bucket' with identifier '${bucketName}' already exists`,
);
}),
);

21 changes: 20 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,14 @@ import type {
DescribeStackResourceDriftsCommandInput,
ExecuteStackRefactorCommandInput,
DescribeStackRefactorCommandInput,
CreateStackRefactorCommandOutput, ExecuteStackRefactorCommandOutput,
CreateStackRefactorCommandOutput,
ExecuteStackRefactorCommandOutput,
DescribeEventsCommandOutput,
DescribeEventsCommandInput,
} from '@aws-sdk/client-cloudformation';
import {
paginateDescribeEvents,

paginateListStacks,
CloudFormationClient,
ContinueUpdateRollbackCommand,
Expand All @@ -113,6 +118,7 @@ import {
DeleteChangeSetCommand,
DeleteGeneratedTemplateCommand,
DeleteStackCommand,
DescribeEventsCommand,
DescribeChangeSetCommand,
DescribeGeneratedTemplateCommand,
DescribeResourceScanCommand,
Expand Down Expand Up @@ -141,6 +147,7 @@ import {
waitUntilStackRefactorCreateComplete,
waitUntilStackRefactorExecuteComplete,
} from '@aws-sdk/client-cloudformation';
import type { OperationEvent } from '@aws-sdk/client-cloudformation/dist-types/models/models_0';
import type {
FilterLogEventsCommandInput,
FilterLogEventsCommandOutput,
Expand Down Expand Up @@ -434,6 +441,7 @@ export interface ICloudFormationClient {
deleteChangeSet(input: DeleteChangeSetCommandInput): Promise<DeleteChangeSetCommandOutput>;
deleteGeneratedTemplate(input: DeleteGeneratedTemplateCommandInput): Promise<DeleteGeneratedTemplateCommandOutput>;
deleteStack(input: DeleteStackCommandInput): Promise<DeleteStackCommandOutput>;
describeEvents(input: DescribeEventsCommandInput): Promise<DescribeEventsCommandOutput>;
describeChangeSet(input: DescribeChangeSetCommandInput): Promise<DescribeChangeSetCommandOutput>;
describeGeneratedTemplate(
input: DescribeGeneratedTemplateCommandInput,
Expand Down Expand Up @@ -468,6 +476,7 @@ export interface ICloudFormationClient {
describeStackEvents(input: DescribeStackEventsCommandInput): Promise<DescribeStackEventsCommandOutput>;
listStackResources(input: ListStackResourcesCommandInput): Promise<StackResourceSummary[]>;
paginatedListStacks(input: ListStacksCommandInput): Promise<StackSummary[]>;
paginatedDescribeEvents(input: DescribeEventsCommandInput): Promise<OperationEvent[]>;
createStackRefactor(input: CreateStackRefactorCommandInput): Promise<CreateStackRefactorCommandOutput>;
executeStackRefactor(input: ExecuteStackRefactorCommandInput): Promise<ExecuteStackRefactorCommandOutput>;
waitUntilStackRefactorCreateComplete(input: DescribeStackRefactorCommandInput): Promise<WaiterResult>;
Expand Down Expand Up @@ -710,6 +719,8 @@ export class SDK {
client.send(new DetectStackDriftCommand(input)),
detectStackResourceDrift: (input: DetectStackResourceDriftCommandInput): Promise<DetectStackResourceDriftCommandOutput> =>
client.send(new DetectStackResourceDriftCommand(input)),
describeEvents: (input: DescribeEventsCommandInput): Promise<DescribeEventsCommandOutput> =>
client.send(new DescribeEventsCommand(input)),
describeChangeSet: (input: DescribeChangeSetCommandInput): Promise<DescribeChangeSetCommandOutput> =>
client.send(new DescribeChangeSetCommand(input)),
describeGeneratedTemplate: (
Expand Down Expand Up @@ -775,6 +786,14 @@ export class SDK {
}
return stackResources;
},
paginatedDescribeEvents: async (input: DescribeEventsCommandInput): Promise<OperationEvent[]> => {
const stackResources = Array<OperationEvent>();
const paginator = paginateDescribeEvents({ client }, input);
for await (const page of paginator) {
stackResources.push(...(page.OperationEvents || []));
}
return stackResources;
},
createStackRefactor: (input: CreateStackRefactorCommandInput): Promise<CreateStackRefactorCommandOutput> => {
return client.send(new CreateStackRefactorCommand(input));
},
Expand Down
21 changes: 16 additions & 5 deletions packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import { CloudFormationStack, makeBodyParameter } from '../cloudformation';
import type { IoHelper } from '../io/private';
import type { ResourcesToImport } from '../resource-import';

export interface ValidationReporter {
report(changeSetName: string, stackName: string): Promise<void>;
}

/**
* Describe a changeset in CloudFormation, regardless of its current state.
*
Expand Down Expand Up @@ -103,7 +107,7 @@ export async function waitForChangeSet(
ioHelper: IoHelper,
stackName: string,
changeSetName: string,
{ fetchAll }: { fetchAll: boolean },
{ fetchAll, validationReporter }: { fetchAll: boolean; validationReporter?: ValidationReporter },
): Promise<DescribeChangeSetCommandOutput> {
await ioHelper.defaults.debug(format('Waiting for changeset %s on stack %s to finish creating...', changeSetName, stackName));
const ret = await waitFor(async () => {
Expand All @@ -121,10 +125,17 @@ export async function waitForChangeSet(
return description;
}

// eslint-disable-next-line @stylistic/max-len
throw new ToolkitError(
`Failed to create ChangeSet ${changeSetName} on ${stackName}: ${description.Status || 'NO_STATUS'}, ${description.StatusReason || 'no reason provided'}`,
);
if (description.Status === ChangeSetStatus.FAILED && description.StatusReason?.includes('AWS::EarlyValidation')) {
await validationReporter?.report(changeSetName, stackName);
return description;
} else {
// eslint-disable-next-line @stylistic/max-len
throw new ToolkitError(
`Failed to create ChangeSet ${changeSetName} on ${stackName}: ${description.Status || 'NO_STATUS'}, ${
description.StatusReason || 'no reason provided'
}`,
);
}
});

if (!ret) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { tryHotswapDeployment } from '../hotswap/hotswap-deployments';
import type { IoHelper } from '../io/private';
import type { ResourcesToImport } from '../resource-import';
import { StackActivityMonitor } from '../stack-events';
import { EarlyValidationReporter } from './early-validation';

export interface DeployStackOptions {
/**
Expand Down Expand Up @@ -511,8 +512,10 @@ class FullCloudFormationDeployment {

await this.ioHelper.defaults.debug(format('Initiated creation of changeset: %s; waiting for it to finish creating...', changeSet.Id));
// Fetching all pages if we'll execute, so we can have the correct change count when monitoring.
const validationReporter = new EarlyValidationReporter(this.options.sdk, this.ioHelper);
return waitForChangeSet(this.cfn, this.ioHelper, this.stackName, changeSetName, {
fetchAll: willExecute,
validationReporter,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { OperationEvent } from '@aws-sdk/client-cloudformation';
import type { ValidationReporter } from './cfn-api';
import { ToolkitError } from '../../toolkit/toolkit-error';
import type { SDK } from '../aws-auth/sdk';
import type { IoHelper } from '../io/private';

/**
* A ValidationReporter that checks for early validation errors right after
* creating the change set. If any are found, it throws an error listing all validation failures.
* If the DescribeEvents API call fails (for example, due to insufficient permissions),
* it logs a warning instead.
*/
export class EarlyValidationReporter implements ValidationReporter {
constructor(private readonly sdk: SDK, private readonly ioHelper: IoHelper) {
}

public async report(changeSetName: string, stackName: string) {
let operationEvents: OperationEvent[] = [];
try {
operationEvents = await this.getFailedEvents(stackName, changeSetName);
} catch (error) {
const message =
'While creating the change set, CloudFormation detected errors in the generated templates,' +
' but the deployment role does not have permissions to call the DescribeEvents API to retrieve details about these errors.\n' +
'To see more details, re-bootstrap your environment, or otherwise ensure that the deployment role has permissions to call the DescribeEvents API.';

await this.ioHelper.defaults.warn(message);
}

if (operationEvents.length > 0) {
const failures = operationEvents
.map((event) => ` - ${event.ValidationStatusReason} (at ${event.ValidationPath})`)
.join('\n');

const message = `ChangeSet '${changeSetName}' on stack '${stackName}' failed early validation:\n${failures}`;
throw new ToolkitError(message);
}
}

private async getFailedEvents(stackName: string, changeSetName: string) {
return this.sdk.cloudFormation().paginatedDescribeEvents({
StackName: stackName,
ChangeSetName: changeSetName,
Filters: {
FailedEvents: true,
},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DeleteChangeSetCommand,
DeleteStackCommand,
DescribeChangeSetCommand,
DescribeEventsCommand,
DescribeStacksCommand,
ExecuteChangeSetCommand,
type ExecuteChangeSetCommandInput,
Expand Down Expand Up @@ -34,6 +35,7 @@ import { TestIoHost } from '../../_helpers/test-io-host';

let ioHost = new TestIoHost();
let ioHelper = ioHost.asHelper('deploy');
let ioHelperWarn: jest.SpyInstance<Promise<void>, [input: string, ...args: unknown[]], any>;

function testDeployStack(options: DeployStackApiOptions) {
return deployStack(options, ioHelper);
Expand Down Expand Up @@ -111,6 +113,7 @@ beforeEach(() => {
mockCloudFormationClient.on(UpdateTerminationProtectionCommand).resolves({
StackId: 'stack-id',
});
ioHelperWarn = jest.spyOn(ioHelper.defaults, 'warn');
});

function standardDeployStackArguments(): DeployStackApiOptions {
Expand Down Expand Up @@ -763,6 +766,49 @@ test('deployStack reports no change if describeChangeSet returns specific error'
expect(deployResult.type === 'did-deploy-stack' && deployResult.noOp).toEqual(true);
});

test('deployStack throws error in case of early validation failures', async () => {
mockCloudFormationClient.on(DescribeChangeSetCommand).resolvesOnce({
Status: ChangeSetStatus.FAILED,
StatusReason: '(AWS::EarlyValidation::SomeError). Blah blah blah.',
});

mockCloudFormationClient.on(DescribeEventsCommand).resolves({
OperationEvents: [
{
ValidationStatus: 'FAILED',
ValidationStatusReason: 'Resource already exists',
ValidationPath: 'Resources/MyResource',
},
],
});

await expect(
testDeployStack({
...standardDeployStackArguments(),
}),
).rejects.toThrow(`ChangeSet 'cdk-deploy-change-set' on stack 'withouterrors' failed early validation:
- Resource already exists (at Resources/MyResource)`);
});

test('deployStack warns when it cannot get the events in case of early validation errors', async () => {
mockCloudFormationClient.on(DescribeChangeSetCommand).resolvesOnce({
Status: ChangeSetStatus.FAILED,
StatusReason: '(AWS::EarlyValidation::SomeError). Blah blah blah.',
});

mockCloudFormationClient.on(DescribeEventsCommand).rejectsOnce({
message: 'AccessDenied',
});

await testDeployStack({
...standardDeployStackArguments(),
});

expect(ioHelperWarn).toHaveBeenCalledWith(
expect.stringContaining('does not have permissions to call the DescribeEvents API'),
);
});

test('deploy not skipped if template did not change but one tag removed', async () => {
// GIVEN
mockCloudFormationClient.on(DescribeStacksCommand).resolves({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { EarlyValidationReporter } from '../../../lib/api/deployments/early-validation';

it('throws an error when there are failed validation events', async () => {
const sdkMock = {
cloudFormation: jest.fn().mockReturnValue({
paginatedDescribeEvents: jest.fn().mockResolvedValue([
{ ValidationStatusReason: 'Resource already exists', ValidationPath: 'Resources/MyResource' },
]),
}),
};
const ioHelperMock = { defaults: { warn: jest.fn() } };
const reporter = new EarlyValidationReporter(sdkMock as any, ioHelperMock as any);

await expect(reporter.report('test-change-set', 'test-stack')).rejects.toThrow(
"ChangeSet 'test-change-set' on stack 'test-stack' failed early validation:\n - Resource already exists (at Resources/MyResource)",
);
});

it('does not throw when there are no failed validation events', async () => {
const sdkMock = {
cloudFormation: jest.fn().mockReturnValue({
paginatedDescribeEvents: jest.fn().mockResolvedValue([]),
}),
};
const ioHelperMock = { defaults: { warn: jest.fn() } };
const reporter = new EarlyValidationReporter(sdkMock as any, ioHelperMock as any);

await expect(reporter.report('test-change-set', 'test-stack')).resolves.not.toThrow();
expect(ioHelperMock.defaults.warn).not.toHaveBeenCalled();
});

it('logs a warning when DescribeEvents API call fails', async () => {
const sdkMock = {
cloudFormation: jest.fn().mockReturnValue({
paginatedDescribeEvents: jest.fn().mockRejectedValue(new Error('AccessDenied')),
}),
};
const ioHelperMock = { defaults: { warn: jest.fn() } };
const reporter = new EarlyValidationReporter(sdkMock as any, ioHelperMock as any);

await reporter.report('test-change-set', 'test-stack');

expect(ioHelperMock.defaults.warn).toHaveBeenCalledWith(
expect.stringContaining('While creating the change set, CloudFormation detected errors in the generated templates'),
);
});
5 changes: 3 additions & 2 deletions packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@ Resources:
- cloudformation:DeleteChangeSet
- cloudformation:DescribeChangeSet
- cloudformation:DescribeStacks
- cloudformation:DescribeEvents
- cloudformation:ExecuteChangeSet
- cloudformation:CreateStack
- cloudformation:UpdateStack
Expand Down Expand Up @@ -814,7 +815,7 @@ Resources:
Name:
Fn::Sub: '/cdk-bootstrap/${Qualifier}/version'
# Also update this value below (see comment there)
Value: '29'
Value: '30'
Outputs:
BucketName:
Description: The name of the S3 bucket owned by the CDK toolkit stack
Expand Down Expand Up @@ -849,4 +850,4 @@ Outputs:
# {Fn::GetAtt} on an SSM Parameter is eventually consistent, and can fail with "parameter
# doesn't exist" even after just having been created. To reduce our deploy failure rate, we
# duplicate the value here and use a build-time test to ensure the two values are the same.
Value: '29'
Value: '30'
Loading