Skip to content

Commit

Permalink
feat(cli): hotswap deployments for CloudWatch Dashboards
Browse files Browse the repository at this point in the history
  • Loading branch information
straygar authored Jan 27, 2025
1 parent ad7288f commit 555733f
Show file tree
Hide file tree
Showing 7 changed files with 310 additions and 0 deletions.
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

0 comments on commit 555733f

Please sign in to comment.