Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): hotswap deployments for CloudWatch Dashboards #33173

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
1 change: 1 addition & 0 deletions packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ Hotswapping is currently supported for the following changes
- Code asset changes of AWS Lambda functions.
- Definition changes of AWS Step Functions State Machines.
- Container asset changes of AWS ECS Services.
- Body changes of AWS CloudWatch Dashboards.

**⚠ Note #1**: This command deliberately introduces drift in CloudFormation stacks in order to speed up deployments.
For this reason, only use it for development purposes.
Expand Down
5 changes: 5 additions & 0 deletions packages/aws-cdk/lib/api/aws-auth/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface ISDK {
secretsManager(): AWS.SecretsManager;
kms(): AWS.KMS;
stepFunctions(): AWS.StepFunctions;
cloudWatch(): AWS.CloudWatch;
}

/**
Expand Down Expand Up @@ -138,6 +139,10 @@ export class SDK implements ISDK {
return this.wrapServiceErrorHandling(new AWS.StepFunctions(this.config));
}

public cloudWatch(): AWS.CloudWatch {
return this.wrapServiceErrorHandling(new AWS.CloudWatch(this.config));
}

public async currentAccount(): Promise<Account> {
// Get/refresh if necessary before we can access `accessKeyId`
await this.forceCredentialRetrieval();
Expand Down
2 changes: 2 additions & 0 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { EvaluateCloudFormationTemplate } from './hotswap/evaluate-cloudformatio
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
import { isHotswappableStateMachineChange } from './hotswap/stepfunctions-state-machines';
import { CloudFormationStack } from './util/cloudformation';
import { isHotswappableDashboardChange } from './hotswap/cloudwatch-dashboards';

/**
* Perform a hotswap deployment,
Expand Down Expand Up @@ -75,6 +76,7 @@ async function findAllHotswappableChanges(
isHotswappableLambdaFunctionChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableStateMachineChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableEcsServiceChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableDashboardChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
]);
}
});
Expand Down
60 changes: 60 additions & 0 deletions packages/aws-cdk/lib/api/hotswap/cloudwatch-dashboards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ISDK } from '../aws-auth';
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate, establishResourcePhysicalName } from './common';
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';

export async function isHotswappableDashboardChange(
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<ChangeHotswapResult> {
const dashboardBodyChange = await isDashboardBodyOnlyChange(change, evaluateCfnTemplate);
if (dashboardBodyChange === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT ||
dashboardBodyChange === ChangeHotswapImpact.IRRELEVANT) {
return dashboardBodyChange;
}

const dashboardNameInCfnTemplate = change.newValue?.Properties?.DashboardName;
const dashboardName = await establishResourcePhysicalName(logicalId, dashboardNameInCfnTemplate, evaluateCfnTemplate);
if (!dashboardName) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

return new DashboardHotswapOperation({
body: dashboardBodyChange,
dashboardName: dashboardName,
});
}

async function isDashboardBodyOnlyChange(
change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<string | ChangeHotswapImpact> {
const newResourceType = change.newValue.Type;
if (newResourceType !== 'AWS::CloudWatch::Dashboard') {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

const propertyUpdates = change.propertyUpdates;
for (const updatedPropName in propertyUpdates) {
// ensure that only changes to the DashboardBody result in a hotswap
if (updatedPropName !== 'DashboardBody') {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
}

return evaluateCfnTemplate.evaluateCfnExpression(propertyUpdates.DashboardBody.newValue);
}

interface DashboardResource {
readonly dashboardName: string;
readonly body: string;
}

class DashboardHotswapOperation implements HotswapOperation {
constructor(private readonly dashboardResource: DashboardResource) {
}

public async apply(sdk: ISDK): Promise<any> {
return sdk.cloudWatch().putDashboard({
DashboardName: this.dashboardResource.dashboardName,
DashboardBody: this.dashboardResource.body,
}).promise();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { CloudWatch } from 'aws-sdk';
import * as setup from './hotswap-test-setup';

let mockPutDashboard: (params: CloudWatch.Types.PutDashboardInput) => CloudWatch.Types.PutDashboardOutput;
let cfnMockProvider: setup.CfnMockProvider;

beforeEach(() => {
cfnMockProvider = setup.setupHotswapTests();
mockPutDashboard = jest.fn();
cfnMockProvider.setPutDashboardMock(mockPutDashboard);
});

test('returns undefined when a new Dashboard is added to the Stack', async () => {
// GIVEN
const cdkStackArtifact = setup.cdkStackArtifactOf({
template: {
Resources: {
Dashboard: {
Type: 'AWS::CloudWatch::Dashboard',
},
},
},
});

// WHEN
const deployStackResult = await cfnMockProvider.tryHotswapDeployment(cdkStackArtifact);

// THEN
expect(deployStackResult).toBeUndefined();
});

test('calls the putDashboard() API when it receives only a DashboardBody change', async () => {
// GIVEN
setup.setCurrentCfnStackTemplate({
Resources: {
Dashboard: {
Type: 'AWS::CloudWatch::Dashboard',
Properties: {
DashboardBody: '{ "widgets": [] }',
DashboardName: 'my-dashboard',
},
},
},
});
const cdkStackArtifact = setup.cdkStackArtifactOf({
template: {
Resources: {
Dashboard: {
Type: 'AWS::CloudWatch::Dashboard',
Properties: {
DashboardBody: '{ "widgets": [{ "type": "text" }] }',
DashboardName: 'my-dashboard',
},
},
},
},
});

// WHEN
const deployStackResult = await cfnMockProvider.tryHotswapDeployment(cdkStackArtifact);

// THEN
expect(deployStackResult).not.toBeUndefined();
expect(mockPutDashboard).toHaveBeenCalledWith({
DashboardName: 'my-dashboard',
DashboardBody: '{ "widgets": [{ "type": "text" }] }',
});
});

test('does not call the putDashboard() API when a non-DashboardBody property is changed', async () => {
// GIVEN
setup.setCurrentCfnStackTemplate({
Resources: {
Dashboard: {
Type: 'AWS::CloudWatch::Dashboard',
Properties: {
DashboardBody: '{ "widgets": [] }',
Tags: [
{ Key: 'Environment', Value: 'Dev' },
],
},
},
},
});
const cdkStackArtifact = setup.cdkStackArtifactOf({
template: {
Resources: {
Dashboard: {
Type: 'AWS::CloudWatch::Dashboard',
Properties: {
DashboardBody: '{ "widgets": [] }',
Tags: [
{ Key: 'Environment', Value: 'Prod' },
],
},
},
},
},
});

// WHEN
const deployStackResult = await cfnMockProvider.tryHotswapDeployment(cdkStackArtifact);

// THEN
expect(deployStackResult).toBeUndefined();
expect(mockPutDashboard).not.toHaveBeenCalled();
});

test('does not call the putDashboard() API when the resource is not an AWS::CloudWatch::Dashboard', async () => {
// GIVEN
setup.setCurrentCfnStackTemplate({
Resources: {
Dashboard: {
Type: 'AWS::NotCloudWatch::NotDashboard',
Properties: {
DashboardBody: '{ "widgets": [] }',
},
},
},
});
const cdkStackArtifact = setup.cdkStackArtifactOf({
template: {
Resources: {
Dashboard: {
Type: 'AWS::NotCloudWatch::NotDashboard',
Properties: {
DashboardBody: '{ "widgets": [{ "type": "text" }] }',
},
},
},
},
});

// WHEN
const deployStackResult = await cfnMockProvider.tryHotswapDeployment(cdkStackArtifact);

// THEN
expect(deployStackResult).toBeUndefined();
expect(mockPutDashboard).not.toHaveBeenCalled();
});

test('can hotswap a dashboard with nested Fn::Join in DashboardBody', async () => {
// GIVEN
setup.setCurrentCfnStackTemplate({
Resources: {
Dashboard: {
Type: 'AWS::CloudWatch::Dashboard',
Properties: {
DashboardBody: {
'Fn::Join': [
'',
[
'{ "widgets": [',
'{ "type": "text", "text": "Old" }',
'] }',
],
],
},
DashboardName: 'my-dashboard',
},
},
},
});
const cdkStackArtifact = setup.cdkStackArtifactOf({
template: {
Resources: {
Dashboard: {
Type: 'AWS::CloudWatch::Dashboard',
Properties: {
DashboardBody: {
'Fn::Join': [
'',
[
'{ "widgets": [',
'{ "type": "text", "text": "New" }',
'] }',
],
],
},
DashboardName: 'my-dashboard',
},
},
},
},
});

// WHEN
const deployStackResult = await cfnMockProvider.tryHotswapDeployment(cdkStackArtifact);

// THEN
expect(deployStackResult).not.toBeUndefined();
expect(mockPutDashboard).toHaveBeenCalledWith({
DashboardName: 'my-dashboard',
DashboardBody: '{ "widgets": [{ "type": "text", "text": "New" }] }',
});
});

test('throws an error for invalid DashboardBody', async () => {
// GIVEN
setup.setCurrentCfnStackTemplate({
Resources: {
Dashboard: {
Type: 'AWS::CloudWatch::Dashboard',
Properties: {
DashboardBody: '{ "invalid": "data" }',
DashboardName: 'invalid-dashboard',
},
},
},
});
const cdkStackArtifact = setup.cdkStackArtifactOf({
template: {
Resources: {
Dashboard: {
Type: 'AWS::CloudWatch::Dashboard',
Properties: {
DashboardBody: '{ "widgets": "invalid-format" }',
DashboardName: 'invalid-dashboard',
},
},
},
},
});

// THEN
await expect(() =>
cfnMockProvider.tryHotswapDeployment(cdkStackArtifact),
).rejects.toThrow(/Invalid DashboardBody/);
});
9 changes: 9 additions & 0 deletions packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as cxapi from '@aws-cdk/cx-api';
import { CloudFormation } from 'aws-sdk';
import * as AWS from 'aws-sdk';
import * as cloudwatch from 'aws-sdk/clients/cloudwatch';
import * as lambda from 'aws-sdk/clients/lambda';
import * as stepfunctions from 'aws-sdk/clients/stepfunctions';
import { DeployStackResult } from '../../../lib';
Expand Down Expand Up @@ -81,6 +82,14 @@ export class CfnMockProvider {
});
}

public setPutDashboardMock(
mockPutDashboardDefinition: (input: cloudwatch.PutDashboardInput) => cloudwatch.PutDashboardOutput,
) {
this.mockSdkProvider.stubCloudWatch({
putDashboard: mockPutDashboardDefinition,
});
}

public setUpdateFunctionCodeMock(mockUpdateLambdaCode: (input: lambda.UpdateFunctionCodeRequest) => lambda.FunctionConfiguration) {
this.mockSdkProvider.stubLambda({
updateFunctionCode: mockUpdateLambdaCode,
Expand Down
4 changes: 4 additions & 0 deletions packages/aws-cdk/test/util/mock-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ export class MockSdkProvider extends SdkProvider {
public stubStepFunctions(stubs: SyncHandlerSubsetOf<AWS.StepFunctions>) {
(this.sdk as any).stepFunctions = jest.fn().mockReturnValue(partialAwsService<AWS.StepFunctions>(stubs));
}

public stubCloudWatch(stubs: SyncHandlerSubsetOf<AWS.CloudWatch>) {
(this.sdk as any).cloudWatch = jest.fn().mockReturnValue(partialAwsService<AWS.CloudWatch>(stubs));
}
}

export class MockSdk implements ISDK {
Expand Down
Loading