From 555733f2fffaa44266cbdcfc4f045ff05ad6f494 Mon Sep 17 00:00:00 2001 From: Aigars Reiters Date: Mon, 27 Jan 2025 11:00:52 +0200 Subject: [PATCH] feat(cli): hotswap deployments for CloudWatch Dashboards --- packages/aws-cdk/README.md | 1 + packages/aws-cdk/lib/api/aws-auth/sdk.ts | 5 + .../aws-cdk/lib/api/hotswap-deployments.ts | 2 + .../lib/api/hotswap/cloudwatch-dashboards.ts | 60 +++++ .../dashboard-hotswap-deployments.test.ts | 229 ++++++++++++++++++ .../test/api/hotswap/hotswap-test-setup.ts | 9 + packages/aws-cdk/test/util/mock-sdk.ts | 4 + 7 files changed, 310 insertions(+) create mode 100644 packages/aws-cdk/lib/api/hotswap/cloudwatch-dashboards.ts create mode 100644 packages/aws-cdk/test/api/hotswap/dashboard-hotswap-deployments.test.ts diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index e4566b7bbb690..7facb6f931f92 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -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. diff --git a/packages/aws-cdk/lib/api/aws-auth/sdk.ts b/packages/aws-cdk/lib/api/aws-auth/sdk.ts index 91fcdc2fede7d..d043b7eb6d44f 100644 --- a/packages/aws-cdk/lib/api/aws-auth/sdk.ts +++ b/packages/aws-cdk/lib/api/aws-auth/sdk.ts @@ -34,6 +34,7 @@ export interface ISDK { secretsManager(): AWS.SecretsManager; kms(): AWS.KMS; stepFunctions(): AWS.StepFunctions; + cloudWatch(): AWS.CloudWatch; } /** @@ -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 { // Get/refresh if necessary before we can access `accessKeyId` await this.forceCredentialRetrieval(); diff --git a/packages/aws-cdk/lib/api/hotswap-deployments.ts b/packages/aws-cdk/lib/api/hotswap-deployments.ts index 138adf904c992..64c362e0fb0b4 100644 --- a/packages/aws-cdk/lib/api/hotswap-deployments.ts +++ b/packages/aws-cdk/lib/api/hotswap-deployments.ts @@ -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, @@ -75,6 +76,7 @@ async function findAllHotswappableChanges( isHotswappableLambdaFunctionChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), isHotswappableStateMachineChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), isHotswappableEcsServiceChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), + isHotswappableDashboardChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), ]); } }); diff --git a/packages/aws-cdk/lib/api/hotswap/cloudwatch-dashboards.ts b/packages/aws-cdk/lib/api/hotswap/cloudwatch-dashboards.ts new file mode 100644 index 0000000000000..7ba753c3b433c --- /dev/null +++ b/packages/aws-cdk/lib/api/hotswap/cloudwatch-dashboards.ts @@ -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 { + 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 { + 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 { + return sdk.cloudWatch().putDashboard({ + DashboardName: this.dashboardResource.dashboardName, + DashboardBody: this.dashboardResource.body, + }).promise(); + } +} diff --git a/packages/aws-cdk/test/api/hotswap/dashboard-hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap/dashboard-hotswap-deployments.test.ts new file mode 100644 index 0000000000000..1503e68429331 --- /dev/null +++ b/packages/aws-cdk/test/api/hotswap/dashboard-hotswap-deployments.test.ts @@ -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/); +}); diff --git a/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts b/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts index 5939b749b9a38..45b6431af1303 100644 --- a/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts +++ b/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts @@ -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'; @@ -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, diff --git a/packages/aws-cdk/test/util/mock-sdk.ts b/packages/aws-cdk/test/util/mock-sdk.ts index 7b9b4f6fb8b1a..e552cff021028 100644 --- a/packages/aws-cdk/test/util/mock-sdk.ts +++ b/packages/aws-cdk/test/util/mock-sdk.ts @@ -109,6 +109,10 @@ export class MockSdkProvider extends SdkProvider { public stubStepFunctions(stubs: SyncHandlerSubsetOf) { (this.sdk as any).stepFunctions = jest.fn().mockReturnValue(partialAwsService(stubs)); } + + public stubCloudWatch(stubs: SyncHandlerSubsetOf) { + (this.sdk as any).cloudWatch = jest.fn().mockReturnValue(partialAwsService(stubs)); + } } export class MockSdk implements ISDK {