Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9ac2f0c
mid work
iliapolo Apr 8, 2025
86f0edf
mid work
iliapolo Apr 15, 2025
12b5385
Merge branch 'main' into epolon/hotswap-operation-timeout
iliapolo Apr 27, 2025
0e54dba
mid work
iliapolo Apr 27, 2025
5e41ea3
mid work
iliapolo Apr 27, 2025
91c5661
mid work
iliapolo Apr 27, 2025
759f511
mid work
iliapolo Apr 27, 2025
4ed46f6
Merge branch 'main' into epolon/hotswap-operation-timeout
iliapolo Apr 27, 2025
cc14cb5
Merge branch 'main' into epolon/hotswap-operation-timeout
iliapolo Apr 29, 2025
b6a7500
Merge branch 'main' into epolon/hotswap-operation-timeout
iliapolo May 5, 2025
19e3c37
mid work
iliapolo May 6, 2025
b6c7741
mid work
iliapolo May 6, 2025
218d9c7
Merge branch 'main' into epolon/hotswap-operation-timeout
iliapolo May 6, 2025
35adbd4
mid work
iliapolo May 6, 2025
67b80e1
mid work
iliapolo May 6, 2025
5210b6c
mid work
iliapolo May 6, 2025
6ac4929
mid work
iliapolo May 7, 2025
c1ecb4b
mid work
iliapolo May 7, 2025
c425575
mid work
iliapolo May 7, 2025
22113eb
mid work
iliapolo May 7, 2025
79f8cb8
mid work
iliapolo May 7, 2025
eb94795
mid work
iliapolo May 7, 2025
376b3c6
mid work
iliapolo May 7, 2025
ea80af1
mid work
iliapolo May 7, 2025
4fc3348
mid work
iliapolo May 7, 2025
bc6e336
mid work
iliapolo May 7, 2025
69e53ef
mid work
iliapolo May 7, 2025
a01e45c
Merge branch 'main' into epolon/hotswap-operation-timeout
iliapolo May 7, 2025
bd4c439
Merge branch 'main' into epolon/hotswap-operation-timeout
iliapolo May 7, 2025
488ef80
Merge branch 'main' into epolon/hotswap-operation-timeout
iliapolo May 7, 2025
5778e4a
Merge branch 'main' into epolon/hotswap-operation-timeout
iliapolo May 8, 2025
9516a49
Merge branch 'main' into epolon/hotswap-operation-timeout
rix0rrr May 9, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ integTest(

// WHEN
const deployOutput = await fixture.cdkDeploy('ecs-hotswap', {
options: ['--hotswap'],
options: ['--hotswap', '--hotswap-ecs-stabilization-timeout-seconds', '10'],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decided not to do the hotswap-ecs-stabilization-timeout=10s option now because:

  • The argument is also quite long even without the -seconds suffix.
  • This can also be defined in cdk.json - so no excessive typing needed.

modEnv: {
USE_INVALID_ECS_HOTSWAP_IMAGE: 'true',
},
Expand Down
6 changes: 3 additions & 3 deletions packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ export interface IECSClient {
registerTaskDefinition(input: RegisterTaskDefinitionCommandInput): Promise<RegisterTaskDefinitionCommandOutput>;
updateService(input: UpdateServiceCommandInput): Promise<UpdateServiceCommandOutput>;
// Waiters
waitUntilServicesStable(input: DescribeServicesCommandInput): Promise<WaiterResult>;
waitUntilServicesStable(input: DescribeServicesCommandInput, timeoutSeconds?: number): Promise<WaiterResult>;
}

export interface IElasticLoadBalancingV2Client {
Expand Down Expand Up @@ -827,11 +827,11 @@ export class SDK {
updateService: (input: UpdateServiceCommandInput): Promise<UpdateServiceCommandOutput> =>
client.send(new UpdateServiceCommand(input)),
// Waiters
waitUntilServicesStable: (input: DescribeServicesCommandInput): Promise<WaiterResult> => {
waitUntilServicesStable: (input: DescribeServicesCommandInput, timeoutSeconds?: number): Promise<WaiterResult> => {
return waitUntilServicesStable(
{
client,
maxWaitTime: 600,
maxWaitTime: timeoutSeconds ?? 600,
minDelay: 6,
maxDelay: 6,
},
Expand Down
8 changes: 7 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/api/hotswap/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,27 @@ export class EcsHotswapProperties {
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;
// The number of seconds to wait for a single service to reach stable state.
readonly stabilizationTimeoutSeconds?: number;

public constructor (minimumHealthyPercent?: number, maximumHealthyPercent?: number) {
public constructor (minimumHealthyPercent?: number, maximumHealthyPercent?: number, stabilizationTimeoutSeconds?: number) {
if (minimumHealthyPercent !== undefined && minimumHealthyPercent < 0 ) {
throw new ToolkitError('hotswap-ecs-minimum-healthy-percent can\'t be a negative number');
}
if (maximumHealthyPercent !== undefined && maximumHealthyPercent < 0 ) {
throw new ToolkitError('hotswap-ecs-maximum-healthy-percent can\'t be a negative number');
}
if (stabilizationTimeoutSeconds !== undefined && stabilizationTimeoutSeconds < 0 ) {
throw new ToolkitError('hotswap-ecs-stabilization-timeout-seconds 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;
this.stabilizationTimeoutSeconds = stabilizationTimeoutSeconds;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export async function isHotswappableEcsServiceChange(
let ecsHotswapProperties = hotswapPropertyOverrides.ecsHotswapProperties;
let minimumHealthyPercent = ecsHotswapProperties?.minimumHealthyPercent;
let maximumHealthyPercent = ecsHotswapProperties?.maximumHealthyPercent;
let stabilizationTimeoutSeconds = ecsHotswapProperties?.stabilizationTimeoutSeconds;

// Step 2 - update the services using that TaskDefinition to point to the new TaskDefinition Revision
// Forcing New Deployment and setting Minimum Healthy Percent to 0.
Expand All @@ -153,7 +154,7 @@ export async function isHotswappableEcsServiceChange(
await sdk.ecs().waitUntilServicesStable({
cluster: update.service?.clusterArn,
services: [service.serviceArn],
});
}, stabilizationTimeoutSeconds);
}),
);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -724,5 +724,97 @@ describe.each([
},
forceNewDeployment: true,
});
expect(mockECSClient).toHaveReceivedCommandWith(DescribeServicesCommand, {
cluster: 'arn:aws:ecs:region:account:service/my-cluster',
services: ['arn:aws:ecs:region:account:service/my-cluster/my-service'],
});
Comment on lines +727 to +730
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed this assertion was missing, to make sure we actually call the waiter.

});
});

test.each([
// default case
[101, undefined],
[2, 10],
[11, 60],
])('DesribeService is called %p times when timeout is %p', async (describeAttempts: number, timeoutSeconds?: number) => {
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'),
);
mockECSClient.on(RegisterTaskDefinitionCommand).resolves({
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(undefined, undefined, timeoutSeconds);
// mock the client such that the service never becomes stable using desiredCount > runningCount
mockECSClient.on(DescribeServicesCommand).resolves({
services: [
{
serviceArn: 'arn:aws:ecs:region:account:service/my-cluster/my-service',
taskDefinition: 'arn:aws:ecs:region:account:task-definition/my-task-def:3',
desiredCount: 1,
runningCount: 0,
},
],
});

jest.useFakeTimers();
jest.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => {
callback();
jest.advanceTimersByTime(ms ?? 0);
return {} as NodeJS.Timeout;
});

await expect(hotswapMockSdkProvider.tryHotswapDeployment(
HotswapMode.HOTSWAP_ONLY,
cdkStackArtifact,
{},
new HotswapPropertyOverrides(ecsHotswapProperties),
)).rejects.toThrow('Resource is not in the expected state due to waiter status');

// THEN
expect(mockECSClient).toHaveReceivedCommandTimes(DescribeServicesCommand, describeAttempts);
});
19 changes: 17 additions & 2 deletions packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,19 +505,34 @@ 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:
You can optionally configure the behavior of your hotswap deployments. Currently you can only configure ECS hotswap behavior:

| Property | Description | Default |
|--------------------------------|--------------------------------------|-------------|
| minimumHealthyPercent | 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 | **REPLICA:** 100, **DAEMON:** 0 |
| maximumHealthyPercent | 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 | **REPLICA:** 200, **DAEMON:**: N/A |
| stabilizationTimeoutSeconds | Number of seconds to wait for a single service to reach stable state, where the desiredCount is equal to the runningCount | 600 |

##### cdk.json

```json
{
"hotswap": {
"ecs": {
"minimumHealthyPercent": 100,
"maximumHealthyPercent": 250
"maximumHealthyPercent": 250,
"stabilizationTimeoutSeconds": 300,
}
}
}
```

##### cli arguments

```console
cdk deploy --hotswap --hotswap-ecs-minimum-healthy-percent 100 --hotswap-ecs-maximum-healthy-percent 250 --hotswap-ecs-stabilization-timeout-seconds 300
```

**⚠ 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**!
Expand Down
3 changes: 2 additions & 1 deletion packages/aws-cdk/lib/cli/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import * as uuid from 'uuid';
import { CliIoHost } from './io-host';
import type { Configuration } from './user-configuration';
import { PROJECT_CONFIG } from './user-configuration';
import type { ToolkitAction } from '../../../@aws-cdk/toolkit-lib';
import { StackSelectionStrategy, ToolkitError } from '../../../@aws-cdk/toolkit-lib';
import type { ToolkitAction } from '../../../@aws-cdk/toolkit-lib/lib/api';
import { asIoHelper } from '../../../@aws-cdk/toolkit-lib/lib/api/io/private';
import { PermissionChangeType } from '../../../@aws-cdk/toolkit-lib/lib/payloads';
import type { ToolkitOptions } from '../../../@aws-cdk/toolkit-lib/lib/toolkit';
Expand Down Expand Up @@ -390,6 +390,7 @@ export class CdkToolkit {
hotswapPropertyOverrides.ecsHotswapProperties = new EcsHotswapProperties(
hotswapPropertiesFromSettings.ecs?.minimumHealthyPercent,
hotswapPropertiesFromSettings.ecs?.maximumHealthyPercent,
hotswapPropertiesFromSettings.ecs?.stabilizationTimeoutSeconds,
);

const stacks = stackCollection.stackArtifacts;
Expand Down
24 changes: 24 additions & 0 deletions packages/aws-cdk/lib/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,18 @@ export async function makeConfig(): Promise<CliConfig> {
'and falls back to a full deployment if that is not possible. ' +
'Do not use this in production environments',
},
'hotswap-ecs-minimum-healthy-percent': {
type: 'string',
desc: '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',
},
'hotswap-ecs-maximum-healthy-percent': {
type: 'string',
desc: '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',
},
Comment on lines +161 to +168
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These were just flat out missing from the yargs definition, even though we did (try to) handle them in:

hotswap: {
ecs: {
minimumEcsHealthyPercent: argv.minimumEcsHealthyPercent,
maximumEcsHealthyPercent: argv.maximumEcsHealthyPercent,
},

'hotswap-ecs-stabilization-timeout-seconds': {
type: 'string',
desc: 'Number of seconds to wait for a single service to reach stable state, where the desiredCount is equal to the runningCount',
},
'watch': {
type: 'boolean',
desc: 'Continuously observe the project files, ' +
Expand Down Expand Up @@ -275,6 +287,18 @@ export async function makeConfig(): Promise<CliConfig> {
'which skips CloudFormation and updates the resources directly, ' +
'and falls back to a full deployment if that is not possible.',
},
'hotswap-ecs-minimum-healthy-percent': {
type: 'string',
desc: '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',
},
'hotswap-ecs-maximum-healthy-percent': {
type: 'string',
desc: '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',
},
'hotswap-ecs-stabilization-timeout-seconds': {
type: 'string',
desc: 'Number of seconds to wait for a single service to reach stable state, where the desiredCount is equal to the runningCount',
},
'logs': {
type: 'boolean',
default: true,
Expand Down
12 changes: 12 additions & 0 deletions packages/aws-cdk/lib/cli/convert-to-user-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ export function convertYargsToUserInput(args: any): UserInput {
rollback: args.rollback,
hotswap: args.hotswap,
hotswapFallback: args.hotswapFallback,
hotswapEcsMinimumHealthyPercent: args.hotswapEcsMinimumHealthyPercent,
hotswapEcsMaximumHealthyPercent: args.hotswapEcsMaximumHealthyPercent,
hotswapEcsStabilizationTimeoutSeconds: args.hotswapEcsStabilizationTimeoutSeconds,
watch: args.watch,
logs: args.logs,
concurrency: args.concurrency,
Expand Down Expand Up @@ -159,6 +162,9 @@ export function convertYargsToUserInput(args: any): UserInput {
rollback: args.rollback,
hotswap: args.hotswap,
hotswapFallback: args.hotswapFallback,
hotswapEcsMinimumHealthyPercent: args.hotswapEcsMinimumHealthyPercent,
hotswapEcsMaximumHealthyPercent: args.hotswapEcsMaximumHealthyPercent,
hotswapEcsStabilizationTimeoutSeconds: args.hotswapEcsStabilizationTimeoutSeconds,
logs: args.logs,
concurrency: args.concurrency,
STACKS: args.STACKS,
Expand Down Expand Up @@ -356,6 +362,9 @@ export function convertConfigToUserInput(config: any): UserInput {
rollback: config.deploy?.rollback,
hotswap: config.deploy?.hotswap,
hotswapFallback: config.deploy?.hotswapFallback,
hotswapEcsMinimumHealthyPercent: config.deploy?.hotswapEcsMinimumHealthyPercent,
hotswapEcsMaximumHealthyPercent: config.deploy?.hotswapEcsMaximumHealthyPercent,
hotswapEcsStabilizationTimeoutSeconds: config.deploy?.hotswapEcsStabilizationTimeoutSeconds,
watch: config.deploy?.watch,
logs: config.deploy?.logs,
concurrency: config.deploy?.concurrency,
Expand Down Expand Up @@ -389,6 +398,9 @@ export function convertConfigToUserInput(config: any): UserInput {
rollback: config.watch?.rollback,
hotswap: config.watch?.hotswap,
hotswapFallback: config.watch?.hotswapFallback,
hotswapEcsMinimumHealthyPercent: config.watch?.hotswapEcsMinimumHealthyPercent,
hotswapEcsMaximumHealthyPercent: config.watch?.hotswapEcsMaximumHealthyPercent,
hotswapEcsStabilizationTimeoutSeconds: config.watch?.hotswapEcsStabilizationTimeoutSeconds,
logs: config.watch?.logs,
concurrency: config.watch?.concurrency,
};
Expand Down
30 changes: 30 additions & 0 deletions packages/aws-cdk/lib/cli/parse-command-line-arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,21 @@ export function parseCommandLineArguments(args: Array<string>): any {
type: 'boolean',
desc: "Attempts to perform a 'hotswap' deployment, which skips CloudFormation and updates the resources directly, and falls back to a full deployment if that is not possible. Do not use this in production environments",
})
.option('hotswap-ecs-minimum-healthy-percent', {
default: undefined,
type: 'string',
desc: "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",
})
.option('hotswap-ecs-maximum-healthy-percent', {
default: undefined,
type: 'string',
desc: "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",
})
.option('hotswap-ecs-stabilization-timeout-seconds', {
default: undefined,
type: 'string',
desc: 'Number of seconds to wait for a single service to reach stable state, where the desiredCount is equal to the runningCount',
})
.option('watch', {
default: undefined,
type: 'boolean',
Expand Down Expand Up @@ -628,6 +643,21 @@ export function parseCommandLineArguments(args: Array<string>): any {
type: 'boolean',
desc: "Attempts to perform a 'hotswap' deployment, which skips CloudFormation and updates the resources directly, and falls back to a full deployment if that is not possible.",
})
.option('hotswap-ecs-minimum-healthy-percent', {
default: undefined,
type: 'string',
desc: "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",
})
.option('hotswap-ecs-maximum-healthy-percent', {
default: undefined,
type: 'string',
desc: "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",
})
.option('hotswap-ecs-stabilization-timeout-seconds', {
default: undefined,
type: 'string',
desc: 'Number of seconds to wait for a single service to reach stable state, where the desiredCount is equal to the runningCount',
})
.option('logs', {
default: true,
type: 'boolean',
Expand Down
5 changes: 3 additions & 2 deletions packages/aws-cdk/lib/cli/user-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,9 @@ export function commandLineArgumentsToSettings(argv: Arguments): Settings {
ignoreNoStacks: argv['ignore-no-stacks'],
hotswap: {
ecs: {
minimumEcsHealthyPercent: argv.minimumEcsHealthyPercent,
maximumEcsHealthyPercent: argv.maximumEcsHealthyPercent,
Comment on lines -308 to -309
Copy link
Contributor Author

@iliapolo iliapolo May 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows for passing the hotswap override properties via the command line. However, it never actually worked because the keys it sets are not the keys we read:

let hotswapPropertiesFromSettings = this.props.configuration.settings.get(['hotswap']) || {};
let hotswapPropertyOverrides = new HotswapPropertyOverrides();
hotswapPropertyOverrides.ecsHotswapProperties = new EcsHotswapProperties(
hotswapPropertiesFromSettings.ecs?.minimumHealthyPercent,
hotswapPropertiesFromSettings.ecs?.maximumHealthyPercent,
);

  • minimumEcsHealthyPercent !== minimumHealthyPercent
  • maximumEcsHealthyPercent !== maximumHealthyPercent

minimumHealthyPercent: argv.hotswapEcsMinimumHealthyPercent,
maximumHealthyPercent: argv.hotswapEcsMaximumHealthyPercent,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So actually a user could never override this behavior from the CLI. Which means, we can change the argument names to be more descriptive:

  • hotswap-ecs-minimum-healthy-percent
  • hotswap-ecs-maximum-healthy-percent

These names are derived and match the argument names that I think we initially wanted, based on:

if (minimumHealthyPercent !== undefined && minimumHealthyPercent < 0 ) {
throw new ToolkitError('hotswap-ecs-minimum-healthy-percent can\'t be a negative number');
}
if (maximumHealthyPercent !== undefined && maximumHealthyPercent < 0 ) {
throw new ToolkitError('hotswap-ecs-maximum-healthy-percent can\'t be a negative number');
}

stabilizationTimeoutSeconds: argv.hotswapEcsStabilizationTimeoutSeconds,
},
},
unstable: argv.unstable,
Expand Down
Loading
Loading