Skip to content

Commit 5c30255

Browse files
authored
fix(cli): ecs hotswap deployment waits correctly for success or failure (#28448)
Reraising #27943 as it was incorrectly closed as stale. See original PR for details. Closes #27882. See linked issue for further details. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent e77ce26 commit 5c30255

File tree

4 files changed

+153
-7
lines changed

4 files changed

+153
-7
lines changed

packages/@aws-cdk-testing/cli-integ/lib/aws.ts

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export class AwsClients {
1818
public readonly cloudFormation: AwsCaller<AWS.CloudFormation>;
1919
public readonly s3: AwsCaller<AWS.S3>;
2020
public readonly ecr: AwsCaller<AWS.ECR>;
21+
public readonly ecs: AwsCaller<AWS.ECS>;
2122
public readonly sns: AwsCaller<AWS.SNS>;
2223
public readonly iam: AwsCaller<AWS.IAM>;
2324
public readonly lambda: AwsCaller<AWS.Lambda>;
@@ -34,6 +35,7 @@ export class AwsClients {
3435
this.cloudFormation = makeAwsCaller(AWS.CloudFormation, this.config);
3536
this.s3 = makeAwsCaller(AWS.S3, this.config);
3637
this.ecr = makeAwsCaller(AWS.ECR, this.config);
38+
this.ecs = makeAwsCaller(AWS.ECS, this.config);
3739
this.sns = makeAwsCaller(AWS.SNS, this.config);
3840
this.iam = makeAwsCaller(AWS.IAM, this.config);
3941
this.lambda = makeAwsCaller(AWS.Lambda, this.config);

packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js

+57
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ var constructs = require('constructs');
44
if (process.env.PACKAGE_LAYOUT_VERSION === '1') {
55
var cdk = require('@aws-cdk/core');
66
var ec2 = require('@aws-cdk/aws-ec2');
7+
var ecs = require('@aws-cdk/aws-ecs');
78
var s3 = require('@aws-cdk/aws-s3');
89
var ssm = require('@aws-cdk/aws-ssm');
910
var iam = require('@aws-cdk/aws-iam');
@@ -17,6 +18,7 @@ if (process.env.PACKAGE_LAYOUT_VERSION === '1') {
1718
DefaultStackSynthesizer,
1819
LegacyStackSynthesizer,
1920
aws_ec2: ec2,
21+
aws_ecs: ecs,
2022
aws_s3: s3,
2123
aws_ssm: ssm,
2224
aws_iam: iam,
@@ -357,6 +359,60 @@ class LambdaHotswapStack extends cdk.Stack {
357359
}
358360
}
359361

362+
class EcsHotswapStack extends cdk.Stack {
363+
constructor(parent, id, props) {
364+
super(parent, id, props);
365+
366+
// define a simple vpc and cluster
367+
const vpc = new ec2.Vpc(this, 'vpc', {
368+
natGateways: 0,
369+
subnetConfiguration: [
370+
{
371+
cidrMask: 24,
372+
name: 'Public',
373+
subnetType: ec2.SubnetType.PUBLIC,
374+
},
375+
],
376+
maxAzs: 1,
377+
});
378+
const cluster = new ecs.Cluster(this, 'cluster', {
379+
vpc,
380+
});
381+
382+
// allow stack to be used to test failed deployments
383+
const image =
384+
process.env.USE_INVALID_ECS_HOTSWAP_IMAGE == 'true'
385+
? 'nginx:invalidtag'
386+
: 'nginx:alpine';
387+
388+
// deploy basic service
389+
const taskDefinition = new ecs.FargateTaskDefinition(
390+
this,
391+
'task-definition'
392+
);
393+
taskDefinition.addContainer('nginx', {
394+
image: ecs.ContainerImage.fromRegistry(image),
395+
environment: {
396+
SOME_VARIABLE: process.env.DYNAMIC_ECS_PROPERTY_VALUE ?? 'environment',
397+
},
398+
healthCheck: {
399+
command: ['CMD-SHELL', 'exit 0'], // fake health check to speed up deployment
400+
interval: cdk.Duration.seconds(5),
401+
},
402+
});
403+
const service = new ecs.FargateService(this, 'service', {
404+
cluster,
405+
taskDefinition,
406+
assignPublicIp: true, // required without NAT to pull image
407+
circuitBreaker: { rollback: false },
408+
desiredCount: 1,
409+
});
410+
411+
new cdk.CfnOutput(this, 'ClusterName', { value: cluster.clusterName });
412+
new cdk.CfnOutput(this, 'ServiceName', { value: service.serviceName });
413+
}
414+
}
415+
360416
class DockerStack extends cdk.Stack {
361417
constructor(parent, id, props) {
362418
super(parent, id, props);
@@ -532,6 +588,7 @@ switch (stackSet) {
532588

533589
new LambdaStack(app, `${stackPrefix}-lambda`);
534590
new LambdaHotswapStack(app, `${stackPrefix}-lambda-hotswap`);
591+
new EcsHotswapStack(app, `${stackPrefix}-ecs-hotswap`);
535592
new DockerStack(app, `${stackPrefix}-docker`);
536593
new DockerStackWithCustomFile(app, `${stackPrefix}-docker-with-custom-file`);
537594
const failed = new FailedStack(app, `${stackPrefix}-failed`)

packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts

+77
Original file line numberDiff line numberDiff line change
@@ -1571,6 +1571,83 @@ integTest('hotswap deployment supports Fn::ImportValue intrinsic', withDefaultFi
15711571
}
15721572
}));
15731573

1574+
integTest('hotswap deployment supports ecs service', withDefaultFixture(async (fixture) => {
1575+
// GIVEN
1576+
const stackArn = await fixture.cdkDeploy('ecs-hotswap', {
1577+
captureStderr: false,
1578+
});
1579+
1580+
// WHEN
1581+
const deployOutput = await fixture.cdkDeploy('ecs-hotswap', {
1582+
options: ['--hotswap'],
1583+
captureStderr: true,
1584+
onlyStderr: true,
1585+
modEnv: {
1586+
DYNAMIC_ECS_PROPERTY_VALUE: 'new value',
1587+
},
1588+
});
1589+
1590+
const response = await fixture.aws.cloudFormation('describeStacks', {
1591+
StackName: stackArn,
1592+
});
1593+
const serviceName = response.Stacks?.[0].Outputs?.find(output => output.OutputKey == 'ServiceName')?.OutputValue;
1594+
1595+
// THEN
1596+
1597+
// The deployment should not trigger a full deployment, thus the stack's status must remains
1598+
// "CREATE_COMPLETE"
1599+
expect(response.Stacks?.[0].StackStatus).toEqual('CREATE_COMPLETE');
1600+
expect(deployOutput).toContain(`ECS Service '${serviceName}' hotswapped!`);
1601+
}));
1602+
1603+
integTest('hotswap deployment for ecs service waits for deployment to complete', withDefaultFixture(async (fixture) => {
1604+
// GIVEN
1605+
const stackArn = await fixture.cdkDeploy('ecs-hotswap', {
1606+
captureStderr: false,
1607+
});
1608+
1609+
// WHEN
1610+
await fixture.cdkDeploy('ecs-hotswap', {
1611+
options: ['--hotswap'],
1612+
modEnv: {
1613+
DYNAMIC_ECS_PROPERTY_VALUE: 'new value',
1614+
},
1615+
});
1616+
1617+
const describeStacksResponse = await fixture.aws.cloudFormation('describeStacks', {
1618+
StackName: stackArn,
1619+
});
1620+
const clusterName = describeStacksResponse.Stacks?.[0].Outputs?.find(output => output.OutputKey == 'ClusterName')?.OutputValue!;
1621+
const serviceName = describeStacksResponse.Stacks?.[0].Outputs?.find(output => output.OutputKey == 'ServiceName')?.OutputValue!;
1622+
1623+
// THEN
1624+
1625+
const describeServicesResponse = await fixture.aws.ecs('describeServices', {
1626+
cluster: clusterName,
1627+
services: [serviceName],
1628+
});
1629+
expect(describeServicesResponse.services?.[0].deployments).toHaveLength(1); // only one deployment present
1630+
1631+
}));
1632+
1633+
integTest('hotswap deployment for ecs service detects failed deployment and errors', withDefaultFixture(async (fixture) => {
1634+
// GIVEN
1635+
await fixture.cdkDeploy('ecs-hotswap');
1636+
1637+
// WHEN
1638+
const deployOutput = await fixture.cdkDeploy('ecs-hotswap', {
1639+
options: ['--hotswap'],
1640+
modEnv: {
1641+
USE_INVALID_ECS_HOTSWAP_IMAGE: 'true',
1642+
},
1643+
allowErrExit: true,
1644+
});
1645+
1646+
// THEN
1647+
expect(deployOutput).toContain(`❌ ${fixture.stackNamePrefix}-ecs-hotswap failed: ResourceNotReady: Resource is not in the state deploymentCompleted`);
1648+
expect(deployOutput).not.toContain('hotswapped!');
1649+
}));
1650+
15741651
async function listChildren(parent: string, pred: (x: string) => Promise<boolean>) {
15751652
const ret = new Array<string>();
15761653
for (const child of await fs.readdir(parent, { encoding: 'utf-8' })) {

packages/aws-cdk/lib/api/hotswap/ecs-services.ts

+17-7
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,11 @@ export async function isHotswappableEcsServiceChange(
119119

120120
// Step 3 - wait for the service deployments triggered in Step 2 to finish
121121
// configure a custom Waiter
122-
(sdk.ecs() as any).api.waiters.deploymentToFinish = {
123-
name: 'DeploymentToFinish',
122+
(sdk.ecs() as any).api.waiters.deploymentCompleted = {
123+
name: 'DeploymentCompleted',
124124
operation: 'describeServices',
125-
delay: 10,
126-
maxAttempts: 60,
125+
delay: 6,
126+
maxAttempts: 100,
127127
acceptors: [
128128
{
129129
matcher: 'pathAny',
@@ -143,16 +143,26 @@ export async function isHotswappableEcsServiceChange(
143143
expected: 'INACTIVE',
144144
state: 'failure',
145145
},
146+
147+
// failure if any services report a deployment with status FAILED
148+
{
149+
matcher: 'path',
150+
argument: "length(services[].deployments[? rolloutState == 'FAILED'][]) > `0`",
151+
expected: true,
152+
state: 'failure',
153+
},
154+
155+
// wait for all services to report only a single deployment
146156
{
147157
matcher: 'path',
148-
argument: "length(services[].deployments[? status == 'PRIMARY' && runningCount < desiredCount][]) == `0`",
158+
argument: 'length(services[? length(deployments) > `1`]) == `0`',
149159
expected: true,
150160
state: 'success',
151161
},
152162
],
153163
};
154-
// create a custom Waiter that uses the deploymentToFinish configuration added above
155-
const deploymentWaiter = new (AWS as any).ResourceWaiter(sdk.ecs(), 'deploymentToFinish');
164+
// create a custom Waiter that uses the deploymentCompleted configuration added above
165+
const deploymentWaiter = new (AWS as any).ResourceWaiter(sdk.ecs(), 'deploymentCompleted');
156166
// wait for all of the waiters to finish
157167
await Promise.all(Object.entries(servicePerClusterUpdates).map(([clusterName, serviceUpdates]) => {
158168
return deploymentWaiter.wait({

0 commit comments

Comments
 (0)