diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts index a7365c8a1b993..b1c0d4f62bbad 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts @@ -2301,6 +2301,58 @@ integTest('hotswap deployment supports AppSync APIs with many functions', }), ); +integTest('hotswap ECS deployment respects properties override', withDefaultFixture(async (fixture) => { + // Update the CDK context with the new ECS properties + let ecsMinimumHealthyPercent = 100; + let ecsMaximumHealthyPercent = 200; + let cdkJson = JSON.parse(await fs.readFile(path.join(fixture.integTestDir, 'cdk.json'), 'utf8')); + cdkJson = { + ...cdkJson, + hotswap: { + ecs: { + minimumHealthyPercent: ecsMinimumHealthyPercent, + maximumHealthyPercent: ecsMaximumHealthyPercent, + }, + }, + }; + + await fs.writeFile(path.join(fixture.integTestDir, 'cdk.json'), JSON.stringify(cdkJson)); + + // GIVEN + const stackArn = await fixture.cdkDeploy('ecs-hotswap', { + captureStderr: false, + }); + + // WHEN + await fixture.cdkDeploy('ecs-hotswap', { + options: [ + '--hotswap', + ], + modEnv: { + DYNAMIC_ECS_PROPERTY_VALUE: 'new value', + }, + }); + + const describeStacksResponse = await fixture.aws.cloudFormation.send( + new DescribeStacksCommand({ + StackName: stackArn, + }), + ); + + const clusterName = describeStacksResponse.Stacks?.[0].Outputs?.find(output => output.OutputKey == 'ClusterName')?.OutputValue!; + const serviceName = describeStacksResponse.Stacks?.[0].Outputs?.find(output => output.OutputKey == 'ServiceName')?.OutputValue!; + + // THEN + const describeServicesResponse = await fixture.aws.ecs.send( + new DescribeServicesCommand({ + cluster: clusterName, + services: [serviceName], + }), + ); + expect(describeServicesResponse.services?.[0].deploymentConfiguration?.minimumHealthyPercent).toEqual(ecsMinimumHealthyPercent); + expect(describeServicesResponse.services?.[0].deploymentConfiguration?.maximumPercent).toEqual(ecsMaximumHealthyPercent); +})); + async function listChildren(parent: string, pred: (x: string) => Promise) { const ret = new Array(); for (const child of await fs.readdir(parent, { encoding: 'utf-8' })) { diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index deec121451f77..30818c8cb1402 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -451,6 +451,19 @@ Hotswapping is currently supported for the following changes - VTL mapping template changes for AppSync Resolvers and Functions. - Schema changes for AppSync GraphQL Apis. +You can optionally configure the behavior of your hotswap deployments in `cdk.json`. Currently you can only configure ECS hotswap behavior: + +```json +{ +"hotswap": { + "ecs": { + "minimumHealthyPercent": 100, + "maximumHealthyPercent": 250 + } + } +} +``` + **⚠ 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. **Never use this flag for your production deployments**! diff --git a/packages/aws-cdk/lib/api/deploy-stack.ts b/packages/aws-cdk/lib/api/deploy-stack.ts index 4a142d82e4309..16da0447b81f5 100644 --- a/packages/aws-cdk/lib/api/deploy-stack.ts +++ b/packages/aws-cdk/lib/api/deploy-stack.ts @@ -5,7 +5,7 @@ import * as uuid from 'uuid'; import { ISDK, SdkProvider } from './aws-auth'; import { EnvironmentResources } from './environment-resources'; import { CfnEvaluationException } from './evaluate-cloudformation-template'; -import { HotswapMode, ICON } from './hotswap/common'; +import { HotswapMode, HotswapPropertyOverrides, ICON } from './hotswap/common'; import { tryHotswapDeployment } from './hotswap-deployments'; import { addMetadataAssetsToManifest } from '../assets'; import { Tag } from '../cdk-toolkit'; @@ -173,6 +173,11 @@ export interface DeployStackOptions { */ readonly hotswap?: HotswapMode; + /** + * Extra properties that configure hotswap behavior + */ + readonly hotswapPropertyOverrides?: HotswapPropertyOverrides; + /** * The extra string to append to the User-Agent header when performing AWS SDK calls. * @@ -264,6 +269,7 @@ export async function deployStack(options: DeployStackOptions): Promise Promise; const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = { @@ -62,7 +65,7 @@ const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = { export async function tryHotswapDeployment( sdkProvider: SdkProvider, assetParams: { [key: string]: string }, cloudFormationStack: CloudFormationStack, stackArtifact: cxapi.CloudFormationStackArtifact, - hotswapMode: HotswapMode, + hotswapMode: HotswapMode, hotswapPropertyOverrides: HotswapPropertyOverrides, ): Promise { // resolve the environment, so we can substitute things like AWS::Region in CFN expressions const resolvedEnv = await sdkProvider.resolveEnvironment(stackArtifact.environment); @@ -86,7 +89,7 @@ export async function tryHotswapDeployment( const stackChanges = cfn_diff.fullDiff(currentTemplate.deployedRootTemplate, stackArtifact.template); const { hotswappableChanges, nonHotswappableChanges } = await classifyResourceChanges( - stackChanges, evaluateCfnTemplate, sdk, currentTemplate.nestedStacks, + stackChanges, evaluateCfnTemplate, sdk, currentTemplate.nestedStacks, hotswapPropertyOverrides, ); logNonHotswappableChanges(nonHotswappableChanges, hotswapMode); @@ -113,6 +116,7 @@ async function classifyResourceChanges( evaluateCfnTemplate: EvaluateCloudFormationTemplate, sdk: ISDK, nestedStackNames: { [nestedStackName: string]: NestedStackTemplates }, + hotswapPropertyOverrides: HotswapPropertyOverrides, ): Promise { const resourceDifferences = getStackResourceDifferences(stackChanges); @@ -131,7 +135,14 @@ async function classifyResourceChanges( // gather the results of the detector functions for (const [logicalId, change] of Object.entries(resourceDifferences)) { if (change.newValue?.Type === 'AWS::CloudFormation::Stack' && change.oldValue?.Type === 'AWS::CloudFormation::Stack') { - const nestedHotswappableResources = await findNestedHotswappableChanges(logicalId, change, nestedStackNames, evaluateCfnTemplate, sdk); + const nestedHotswappableResources = await findNestedHotswappableChanges( + logicalId, + change, + nestedStackNames, + evaluateCfnTemplate, + sdk, + hotswapPropertyOverrides, + ); hotswappableResources.push(...nestedHotswappableResources.hotswappableChanges); nonHotswappableResources.push(...nestedHotswappableResources.nonHotswappableChanges); @@ -151,7 +162,7 @@ async function classifyResourceChanges( const resourceType: string = hotswappableChangeCandidate.newValue.Type; if (resourceType in RESOURCE_DETECTORS) { // run detector functions lazily to prevent unhandled promise rejections - promises.push(() => RESOURCE_DETECTORS[resourceType](logicalId, hotswappableChangeCandidate, evaluateCfnTemplate)); + promises.push(() => RESOURCE_DETECTORS[resourceType](logicalId, hotswappableChangeCandidate, evaluateCfnTemplate, hotswapPropertyOverrides)); } else { reportNonHotswappableChange(nonHotswappableResources, hotswappableChangeCandidate, undefined, 'This resource type is not supported for hotswap deployments'); } @@ -233,6 +244,7 @@ async function findNestedHotswappableChanges( nestedStackTemplates: { [nestedStackName: string]: NestedStackTemplates }, evaluateCfnTemplate: EvaluateCloudFormationTemplate, sdk: ISDK, + hotswapPropertyOverrides: HotswapPropertyOverrides, ): Promise { const nestedStack = nestedStackTemplates[logicalId]; if (!nestedStack.physicalName) { @@ -256,7 +268,12 @@ async function findNestedHotswappableChanges( nestedStackTemplates[logicalId].deployedTemplate, nestedStackTemplates[logicalId].generatedTemplate, ); - return classifyResourceChanges(nestedDiff, evaluateNestedCfnTemplate, sdk, nestedStackTemplates[logicalId].nestedStackTemplates); + return classifyResourceChanges( + nestedDiff, + evaluateNestedCfnTemplate, + sdk, + nestedStackTemplates[logicalId].nestedStackTemplates, + hotswapPropertyOverrides); } /** Returns 'true' if a pair of changes is for the same resource. */ diff --git a/packages/aws-cdk/lib/api/hotswap/common.ts b/packages/aws-cdk/lib/api/hotswap/common.ts index eb4b787d04e92..a066e7b0e4d5d 100644 --- a/packages/aws-cdk/lib/api/hotswap/common.ts +++ b/packages/aws-cdk/lib/api/hotswap/common.ts @@ -98,6 +98,52 @@ export class HotswappableChangeCandidate { type Exclude = { [key: string]: Exclude | true } +/** + * Represents configuration property overrides for hotswap deployments + */ +export class HotswapPropertyOverrides { + // Each supported resource type will have its own properties. Currently this is ECS + ecsHotswapProperties?: EcsHotswapProperties; + + public constructor (ecsHotswapProperties?: EcsHotswapProperties) { + this.ecsHotswapProperties = ecsHotswapProperties; + } +} + +/** + * Represents configuration properties for ECS hotswap deployments + */ +export class EcsHotswapProperties { + // The lower limit on the number of your service's tasks that must remain in the RUNNING state during a deployment, as a percentage of the desiredCount + readonly minimumHealthyPercent?: number; + // The upper limit on the number of your service's tasks that are allowed in the RUNNING or PENDING state during a deployment, as a percentage of the desiredCount + readonly maximumHealthyPercent?: number; + + public constructor (minimumHealthyPercent?: number, maximumHealthyPercent?: number) { + if (minimumHealthyPercent !== undefined && minimumHealthyPercent < 0 ) { + throw new Error('hotswap-ecs-minimum-healthy-percent can\'t be a negative number'); + } + if (maximumHealthyPercent !== undefined && maximumHealthyPercent < 0 ) { + throw new Error('hotswap-ecs-maximum-healthy-percent can\'t be a negative number'); + } + // In order to preserve the current behaviour, when minimumHealthyPercent is not defined, it will be set to the currently default value of 0 + if (minimumHealthyPercent == undefined) { + this.minimumHealthyPercent = 0; + } else { + this.minimumHealthyPercent = minimumHealthyPercent; + } + this.maximumHealthyPercent = maximumHealthyPercent; + } + + /** + * Check if any hotswap properties are defined + * @returns true if all properties are undefined, false otherwise + */ + public isEmpty(): boolean { + return this.minimumHealthyPercent === 0 && this.maximumHealthyPercent === undefined; + } +} + /** * This function transforms all keys (recursively) in the provided `val` object. * diff --git a/packages/aws-cdk/lib/api/hotswap/ecs-services.ts b/packages/aws-cdk/lib/api/hotswap/ecs-services.ts index c79032eec6e88..f6f626d32f2c9 100644 --- a/packages/aws-cdk/lib/api/hotswap/ecs-services.ts +++ b/packages/aws-cdk/lib/api/hotswap/ecs-services.ts @@ -1,10 +1,13 @@ import * as AWS from 'aws-sdk'; -import { ChangeHotswapResult, classifyChanges, HotswappableChangeCandidate, lowerCaseFirstCharacter, reportNonHotswappableChange, transformObjectKeys } from './common'; +import { ChangeHotswapResult, classifyChanges, HotswappableChangeCandidate, HotswapPropertyOverrides, lowerCaseFirstCharacter, reportNonHotswappableChange, transformObjectKeys } from './common'; import { ISDK } from '../aws-auth'; import { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template'; export async function isHotswappableEcsServiceChange( - logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate, + logicalId: string, + change: HotswappableChangeCandidate, + evaluateCfnTemplate: EvaluateCloudFormationTemplate, + hotswapPropertyOverrides: HotswapPropertyOverrides, ): Promise { // the only resource change we can evaluate here is an ECS TaskDefinition if (change.newValue.Type !== 'AWS::ECS::TaskDefinition') { @@ -83,6 +86,10 @@ export async function isHotswappableEcsServiceChange( const registerTaskDefResponse = await sdk.ecs().registerTaskDefinition(lowercasedTaskDef).promise(); const taskDefRevArn = registerTaskDefResponse.taskDefinition?.taskDefinitionArn; + let ecsHotswapProperties = hotswapPropertyOverrides.ecsHotswapProperties; + let minimumHealthyPercent = ecsHotswapProperties?.minimumHealthyPercent; + let maximumHealthyPercent = ecsHotswapProperties?.maximumHealthyPercent; + // Step 2 - update the services using that TaskDefinition to point to the new TaskDefinition Revision const servicePerClusterUpdates: { [cluster: string]: Array<{ promise: Promise; ecsService: EcsService }> } = {}; for (const ecsService of ecsServicesReferencingTaskDef) { @@ -105,7 +112,8 @@ export async function isHotswappableEcsServiceChange( cluster: clusterName, forceNewDeployment: true, deploymentConfiguration: { - minimumHealthyPercent: 0, + minimumHealthyPercent: minimumHealthyPercent !== undefined ? minimumHealthyPercent : 0, + maximumPercent: maximumHealthyPercent !== undefined ? maximumHealthyPercent : undefined, }, }).promise(), ecsService: ecsService, diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 64a9a0b4dd20c..57dabce74a5dd 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -13,7 +13,7 @@ import { CloudAssembly, DefaultSelection, ExtendedStackSelection, StackCollectio import { CloudExecutable } from './api/cxapp/cloud-executable'; import { Deployments } from './api/deployments'; import { GarbageCollector } from './api/garbage-collection/garbage-collector'; -import { HotswapMode } from './api/hotswap/common'; +import { HotswapMode, HotswapPropertyOverrides, EcsHotswapProperties } from './api/hotswap/common'; import { findCloudWatchLogGroups } from './api/logs/find-cloudwatch-logs'; import { CloudWatchLogEventMonitor } from './api/logs/logs-monitor'; import { createDiffChangeSet, ResourcesToImport } from './api/util/cloudformation'; @@ -237,6 +237,14 @@ export class CdkToolkit { warning('⚠️ They should only be used for development - never use them for your production Stacks!\n'); } + let hotswapPropertiesFromSettings = this.props.configuration.settings.get(['hotswap']) || {}; + + let hotswapPropertyOverrides = new HotswapPropertyOverrides(); + hotswapPropertyOverrides.ecsHotswapProperties = new EcsHotswapProperties( + hotswapPropertiesFromSettings.ecs?.minimumHealthyPercent, + hotswapPropertiesFromSettings.ecs?.maximumHealthyPercent, + ); + const stacks = stackCollection.stackArtifacts; const stackOutputs: { [key: string]: any } = { }; @@ -347,6 +355,7 @@ export class CdkToolkit { ci: options.ci, rollback: options.rollback, hotswap: options.hotswap, + hotswapPropertyOverrides: hotswapPropertyOverrides, extraUserAgent: options.extraUserAgent, assetParallelism: options.assetParallelism, ignoreNoStacks: options.ignoreNoStacks, diff --git a/packages/aws-cdk/lib/settings.ts b/packages/aws-cdk/lib/settings.ts index 74684dc3501d3..37e607f34c4dd 100644 --- a/packages/aws-cdk/lib/settings.ts +++ b/packages/aws-cdk/lib/settings.ts @@ -292,6 +292,12 @@ export class Settings { assetParallelism: argv['asset-parallelism'], assetPrebuild: argv['asset-prebuild'], ignoreNoStacks: argv['ignore-no-stacks'], + hotswap: { + ecs: { + minimumEcsHealthyPercent: argv.minimumEcsHealthyPercent, + maximumEcsHealthyPercent: argv.maximumEcsHealthyPercent, + }, + }, unstable: argv.unstable, }); } diff --git a/packages/aws-cdk/test/api/deploy-stack.test.ts b/packages/aws-cdk/test/api/deploy-stack.test.ts index bdb58f8aad99d..7afcea68f4c60 100644 --- a/packages/aws-cdk/test/api/deploy-stack.test.ts +++ b/packages/aws-cdk/test/api/deploy-stack.test.ts @@ -151,7 +151,7 @@ test('correctly passes CFN parameters when hotswapping', async () => { }); // THEN - expect(tryHotswapDeployment).toHaveBeenCalledWith(expect.anything(), { A: 'A-value', B: 'B=value' }, expect.anything(), expect.anything(), HotswapMode.FALL_BACK); + expect(tryHotswapDeployment).toHaveBeenCalledWith(expect.anything(), { A: 'A-value', B: 'B=value' }, expect.anything(), expect.anything(), HotswapMode.FALL_BACK, expect.anything()); }); test('correctly passes SSM parameters when hotswapping', async () => { @@ -181,7 +181,7 @@ test('correctly passes SSM parameters when hotswapping', async () => { }); // THEN - expect(tryHotswapDeployment).toHaveBeenCalledWith(expect.anything(), { SomeParameter: 'SomeValue' }, expect.anything(), expect.anything(), HotswapMode.FALL_BACK); + expect(tryHotswapDeployment).toHaveBeenCalledWith(expect.anything(), { SomeParameter: 'SomeValue' }, expect.anything(), expect.anything(), HotswapMode.FALL_BACK, expect.anything()); }); test('call CreateStack when method=direct and the stack doesnt exist yet', async () => { diff --git a/packages/aws-cdk/test/api/hotswap/ecs-services-hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap/ecs-services-hotswap-deployments.test.ts index d1ef496819328..182749aaa89d0 100644 --- a/packages/aws-cdk/test/api/hotswap/ecs-services-hotswap-deployments.test.ts +++ b/packages/aws-cdk/test/api/hotswap/ecs-services-hotswap-deployments.test.ts @@ -1,6 +1,7 @@ import * as AWS from 'aws-sdk'; import * as setup from './hotswap-test-setup'; -import { HotswapMode } from '../../../lib/api/hotswap/common'; +import { EcsHotswapProperties, HotswapMode, HotswapPropertyOverrides } from '../../../lib/api/hotswap/common'; +import { Configuration } from '../../../lib/settings'; import { silentTest } from '../../util/silent'; let hotswapMockSdkProvider: setup.HotswapMockSdkProvider; @@ -9,7 +10,6 @@ let mockUpdateService: (params: AWS.ECS.UpdateServiceRequest) => AWS.ECS.UpdateS beforeEach(() => { hotswapMockSdkProvider = setup.setupHotswapTests(); - mockRegisterTaskDef = jest.fn(); mockUpdateService = jest.fn(); hotswapMockSdkProvider.stubEcs({ @@ -637,3 +637,90 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }); }); }); + +describe.each([ + new Configuration().settings.set(['hotswap'], { ecs: { minimumHealthyPercent: 10 } }), + new Configuration().settings.set(['hotswap'], { ecs: { minimumHealthyPercent: 10, maximumHealthyPercent: 100 } }), +])('hotswap properties', (settings) => { + test('should handle all possible hotswap properties', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + Family: 'my-task-def', + ContainerDefinitions: [ + { Image: 'image1' }, + ], + }, + }, + Service: { + Type: 'AWS::ECS::Service', + Properties: { + TaskDefinition: { Ref: 'TaskDef' }, + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('Service', 'AWS::ECS::Service', + 'arn:aws:ecs:region:account:service/my-cluster/my-service'), + ); + mockRegisterTaskDef.mockReturnValue({ + taskDefinition: { + taskDefinitionArn: 'arn:aws:ecs:region:account:task-definition/my-task-def:3', + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + TaskDef: { + Type: 'AWS::ECS::TaskDefinition', + Properties: { + Family: 'my-task-def', + ContainerDefinitions: [ + { Image: 'image2' }, + ], + }, + }, + Service: { + Type: 'AWS::ECS::Service', + Properties: { + TaskDefinition: { Ref: 'TaskDef' }, + }, + }, + }, + }, + }); + + // WHEN + let ecsHotswapProperties = new EcsHotswapProperties(settings.get(['hotswap']).ecs.minimumHealthyPercent, settings.get(['hotswap']).ecs.maximumHealthyPercent); + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment( + HotswapMode.HOTSWAP_ONLY, + cdkStackArtifact, + {}, + new HotswapPropertyOverrides(ecsHotswapProperties), + ); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockRegisterTaskDef).toBeCalledWith({ + family: 'my-task-def', + containerDefinitions: [ + { image: 'image2' }, + ], + }); + expect(mockUpdateService).toBeCalledWith({ + service: 'arn:aws:ecs:region:account:service/my-cluster/my-service', + cluster: 'my-cluster', + taskDefinition: 'arn:aws:ecs:region:account:task-definition/my-task-def:3', + deploymentConfiguration: { + minimumHealthyPercent: settings.get(['hotswap']).ecs?.minimumHealthyPercent == undefined ? + 0 : settings.get(['hotswap']).ecs?.minimumHealthyPercent, + maximumPercent: settings.get(['hotswap']).ecs?.maximumHealthyPercent, + }, + forceNewDeployment: true, + }); + }); +}); 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 505f128a0dee2..1288c827f2300 100644 --- a/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts +++ b/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts @@ -4,7 +4,7 @@ import * as codebuild from 'aws-sdk/clients/codebuild'; import * as lambda from 'aws-sdk/clients/lambda'; import * as stepfunctions from 'aws-sdk/clients/stepfunctions'; import { DeployStackResult } from '../../../lib/api'; -import { HotswapMode } from '../../../lib/api/hotswap/common'; +import { HotswapMode, HotswapPropertyOverrides } from '../../../lib/api/hotswap/common'; import * as deployments from '../../../lib/api/hotswap-deployments'; import { CloudFormationStack, Template } from '../../../lib/api/util/cloudformation'; import { testStack, TestStackArtifact } from '../../util'; @@ -179,7 +179,9 @@ export class HotswapMockSdkProvider { hotswapMode: HotswapMode, stackArtifact: cxapi.CloudFormationStackArtifact, assetParams: { [key: string]: string } = {}, + hotswapPropertyOverrides?: HotswapPropertyOverrides, ): Promise { - return deployments.tryHotswapDeployment(this.mockSdkProvider, assetParams, currentCfnStack, stackArtifact, hotswapMode); + let hotswapProps = hotswapPropertyOverrides || new HotswapPropertyOverrides(); + return deployments.tryHotswapDeployment(this.mockSdkProvider, assetParams, currentCfnStack, stackArtifact, hotswapMode, hotswapProps); } }