diff --git a/action.yml b/action.yml index 806553d25..72d715b81 100644 --- a/action.yml +++ b/action.yml @@ -16,6 +16,9 @@ inputs: wait-for-service-stability: description: 'Whether to wait for the ECS service to reach stable state after deploying the new task definition. Valid value is "true". Will default to not waiting.' required: false + wait-for-minutes: + description: 'How long to wait for the ECS service to reach stable state, in minutes (default: 30 minutes, max: 6 hours). For CodeDeploy deployments, any wait time configured in the CodeDeploy deployment group will be added to this value.' + required: false codedeploy-appspec: description: "The path to the AWS CodeDeploy AppSpec file, if the ECS service uses the CODE_DEPLOY deployment controller. Will default to 'appspec.yaml'." required: false diff --git a/index.js b/index.js index 73ea090c1..eecf6d853 100644 --- a/index.js +++ b/index.js @@ -5,10 +5,8 @@ const yaml = require('yaml'); const fs = require('fs'); const crypto = require('crypto'); -const CODE_DEPLOY_WAIT_BUFFER_MINUTES = 10; -const CODE_DEPLOY_MAX_WAIT_MINUTES = 360; // 6 hours -const CODE_DEPLOY_MIN_WAIT_MINUTES = 30; -const CODE_DEPLOY_WAIT_DEFAULT_DELAY_SEC = 15; +const MAX_WAIT_MINUTES = 360; // 6 hours +const WAIT_DEFAULT_DELAY_SEC = 15; // Attributes that are returned by DescribeTaskDefinition, but are not valid RegisterTaskDefinition inputs const IGNORED_TASK_DEFINITION_ATTRIBUTES = [ @@ -20,7 +18,7 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [ ]; // Deploy to a service that uses the 'ECS' deployment controller -async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForService) { +async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes) { core.debug('Updating the service'); await ecs.updateService({ cluster: clusterName, @@ -30,10 +28,15 @@ async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForSe // Wait for service stability if (waitForService && waitForService.toLowerCase() === 'true') { - core.debug('Waiting for the service to become stable'); + core.debug(`Waiting for the service to become stable. Will wait for ${waitForMinutes} minutes`); + const maxAttempts = (waitForMinutes * 60) / WAIT_DEFAULT_DELAY_SEC; await ecs.waitFor('servicesStable', { services: [service], - cluster: clusterName + cluster: clusterName, + $waiter: { + delay: WAIT_DEFAULT_DELAY_SEC, + maxAttempts: maxAttempts + } }).promise(); } else { core.debug('Not waiting for the service to become stable'); @@ -88,7 +91,7 @@ function removeIgnoredAttributes(taskDef) { } // Deploy to a service that uses the 'CODE_DEPLOY' deployment controller -async function createCodeDeployDeployment(codedeploy, clusterName, service, taskDefArn, waitForService) { +async function createCodeDeployDeployment(codedeploy, clusterName, service, taskDefArn, waitForService, waitForMinutes) { core.debug('Updating AppSpec file with new task definition ARN'); let codeDeployAppSpecFile = core.getInput('codedeploy-appspec', { required : false }); @@ -145,20 +148,17 @@ async function createCodeDeployDeployment(codedeploy, clusterName, service, task // Determine wait time const deployReadyWaitMin = deploymentGroupDetails.blueGreenDeploymentConfiguration.deploymentReadyOption.waitTimeInMinutes; const terminationWaitMin = deploymentGroupDetails.blueGreenDeploymentConfiguration.terminateBlueInstancesOnDeploymentSuccess.terminationWaitTimeInMinutes; - let totalWaitMin = deployReadyWaitMin + terminationWaitMin + CODE_DEPLOY_WAIT_BUFFER_MINUTES; - if (totalWaitMin > CODE_DEPLOY_MAX_WAIT_MINUTES) { - totalWaitMin = CODE_DEPLOY_MAX_WAIT_MINUTES; + let totalWaitMin = deployReadyWaitMin + terminationWaitMin + waitForMinutes; + if (totalWaitMin > MAX_WAIT_MINUTES) { + totalWaitMin = MAX_WAIT_MINUTES; } - if (totalWaitMin < CODE_DEPLOY_MIN_WAIT_MINUTES) { - totalWaitMin = CODE_DEPLOY_MIN_WAIT_MINUTES; - } - const maxAttempts = (totalWaitMin * 60) / CODE_DEPLOY_WAIT_DEFAULT_DELAY_SEC; + const maxAttempts = (totalWaitMin * 60) / WAIT_DEFAULT_DELAY_SEC; core.debug(`Waiting for the deployment to complete. Will wait for ${totalWaitMin} minutes`); await codedeploy.waitFor('deploymentSuccessful', { deploymentId: createDeployResponse.deploymentId, $waiter: { - delay: CODE_DEPLOY_WAIT_DEFAULT_DELAY_SEC, + delay: WAIT_DEFAULT_DELAY_SEC, maxAttempts: maxAttempts } }).promise(); @@ -181,6 +181,10 @@ async function run() { const service = core.getInput('service', { required: false }); const cluster = core.getInput('cluster', { required: false }); const waitForService = core.getInput('wait-for-service-stability', { required: false }); + let waitForMinutes = parseInt(core.getInput('wait-for-minutes', { required: false })) || 30; + if (waitForMinutes > MAX_WAIT_MINUTES) { + waitForMinutes = MAX_WAIT_MINUTES; + } // Register the task definition core.debug('Registering the task definition'); @@ -215,10 +219,10 @@ async function run() { if (!serviceResponse.deploymentController) { // Service uses the 'ECS' deployment controller, so we can call UpdateService - await updateEcsService(ecs, clusterName, service, taskDefArn, waitForService); + await updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes); } else if (serviceResponse.deploymentController.type == 'CODE_DEPLOY') { // Service uses CodeDeploy, so we should start a CodeDeploy deployment - await createCodeDeployDeployment(codedeploy, clusterName, service, taskDefArn, waitForService); + await createCodeDeployDeployment(codedeploy, clusterName, service, taskDefArn, waitForService, waitForMinutes); } else { throw new Error(`Unsupported deployment controller: ${serviceResponse.deploymentController.type}`); } diff --git a/index.test.js b/index.test.js index 642474246..d0942c71c 100644 --- a/index.test.js +++ b/index.test.js @@ -184,7 +184,7 @@ describe('Deploy to ECS', () => { expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); }); - test('registers the task definition contents and creates a CodeDeploy deployment', async () => { + test('registers the task definition contents and creates a CodeDeploy deployment, waits for 30 minutes + deployment group wait time', async () => { core.getInput = jest .fn() .mockReturnValueOnce('task-definition.json') // task-definition @@ -247,7 +247,151 @@ describe('Deploy to ECS', () => { deploymentId: 'deployment-1', $waiter: { delay: 15, - maxAttempts: 400 + maxAttempts: (30 + 90) * 4 + } + }); + + expect(mockEcsUpdateService).toHaveBeenCalledTimes(0); + expect(mockEcsWaiter).toHaveBeenCalledTimes(0); + }); + + test('registers the task definition contents and creates a CodeDeploy deployment, waits for 1 hour + deployment group\'s wait time', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('service-456') // service + .mockReturnValueOnce('cluster-789') // cluster + .mockReturnValueOnce('TRUE') // wait-for-service-stability + .mockReturnValueOnce('60'); // wait-for-minutes + + mockEcsDescribeServices.mockImplementation(() => { + return { + promise() { + return Promise.resolve({ + failures: [], + services: [{ + status: 'ACTIVE', + deploymentController: { + type: 'CODE_DEPLOY' + } + }] + }); + } + }; + }); + + await run(); + expect(core.setFailed).toHaveBeenCalledTimes(0); + + expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); + expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); + expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { + cluster: 'cluster-789', + services: ['service-456'] + }); + + expect(mockCodeDeployCreateDeployment).toHaveBeenNthCalledWith(1, { + applicationName: 'AppECS-cluster-789-service-456', + deploymentGroupName: 'DgpECS-cluster-789-service-456', + revision: { + revisionType: 'AppSpecContent', + appSpecContent: { + content: JSON.stringify({ + Resources: [{ + TargetService: { + Type: 'AWS::ECS::Service', + Properties: { + TaskDefinition: 'task:def:arn', + LoadBalancerInfo: { + ContainerName: "web", + ContainerPort: 80 + } + } + } + }] + }), + sha256: '0911d1e99f48b492e238d1284d8ddb805382d33e1d1fc74ffadf37d8b7e6d096' + } + } + }); + + expect(mockCodeDeployWaiter).toHaveBeenNthCalledWith(1, 'deploymentSuccessful', { + deploymentId: 'deployment-1', + $waiter: { + delay: 15, + maxAttempts: (60 + 90) * 4 + } + }); + + expect(mockEcsUpdateService).toHaveBeenCalledTimes(0); + expect(mockEcsWaiter).toHaveBeenCalledTimes(0); + }); + + test('registers the task definition contents and creates a CodeDeploy deployment, waits for max 6 hours', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('service-456') // service + .mockReturnValueOnce('cluster-789') // cluster + .mockReturnValueOnce('TRUE') // wait-for-service-stability + .mockReturnValueOnce('1000'); // wait-for-minutes + + mockEcsDescribeServices.mockImplementation(() => { + return { + promise() { + return Promise.resolve({ + failures: [], + services: [{ + status: 'ACTIVE', + deploymentController: { + type: 'CODE_DEPLOY' + } + }] + }); + } + }; + }); + + await run(); + expect(core.setFailed).toHaveBeenCalledTimes(0); + + expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); + expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); + expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { + cluster: 'cluster-789', + services: ['service-456'] + }); + + expect(mockCodeDeployCreateDeployment).toHaveBeenNthCalledWith(1, { + applicationName: 'AppECS-cluster-789-service-456', + deploymentGroupName: 'DgpECS-cluster-789-service-456', + revision: { + revisionType: 'AppSpecContent', + appSpecContent: { + content: JSON.stringify({ + Resources: [{ + TargetService: { + Type: 'AWS::ECS::Service', + Properties: { + TaskDefinition: 'task:def:arn', + LoadBalancerInfo: { + ContainerName: "web", + ContainerPort: 80 + } + } + } + }] + }), + sha256: '0911d1e99f48b492e238d1284d8ddb805382d33e1d1fc74ffadf37d8b7e6d096' + } + } + }); + + expect(mockCodeDeployWaiter).toHaveBeenNthCalledWith(1, 'deploymentSuccessful', { + deploymentId: 'deployment-1', + $waiter: { + delay: 15, + maxAttempts: 6 * 60 * 4 } }); @@ -262,6 +406,7 @@ describe('Deploy to ECS', () => { .mockReturnValueOnce('service-456') // service .mockReturnValueOnce('cluster-789') // cluster .mockReturnValueOnce('false') // wait-for-service-stability + .mockReturnValueOnce('') // wait-for-minutes .mockReturnValueOnce('/hello/appspec.json') // codedeploy-appspec .mockReturnValueOnce('MyApplication') // codedeploy-application .mockReturnValueOnce('MyDeploymentGroup'); // codedeploy-deployment-group @@ -418,7 +563,77 @@ describe('Deploy to ECS', () => { }); expect(mockEcsWaiter).toHaveBeenNthCalledWith(1, 'servicesStable', { services: ['service-456'], - cluster: 'cluster-789' + cluster: 'cluster-789', + "$waiter": { + "delay": 15, + "maxAttempts": 120, + }, + }); + }); + + test('waits for the service to be stable for specified minutes', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('service-456') // service + .mockReturnValueOnce('cluster-789') // cluster + .mockReturnValueOnce('TRUE') // wait-for-service-stability + .mockReturnValue('60'); // wait-for-minutes + + await run(); + expect(core.setFailed).toHaveBeenCalledTimes(0); + + expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); + expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); + expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { + cluster: 'cluster-789', + services: ['service-456'] + }); + expect(mockEcsUpdateService).toHaveBeenNthCalledWith(1, { + cluster: 'cluster-789', + service: 'service-456', + taskDefinition: 'task:def:arn' + }); + expect(mockEcsWaiter).toHaveBeenNthCalledWith(1, 'servicesStable', { + services: ['service-456'], + cluster: 'cluster-789', + "$waiter": { + "delay": 15, + "maxAttempts": 240, + }, + }); + }); + + test('waits for the service to be stable for max 6 hours', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('service-456') // service + .mockReturnValueOnce('cluster-789') // cluster + .mockReturnValueOnce('TRUE') // wait-for-service-stability + .mockReturnValue('1000'); // wait-for-minutes + + await run(); + expect(core.setFailed).toHaveBeenCalledTimes(0); + + expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family'}); + expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn'); + expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { + cluster: 'cluster-789', + services: ['service-456'] + }); + expect(mockEcsUpdateService).toHaveBeenNthCalledWith(1, { + cluster: 'cluster-789', + service: 'service-456', + taskDefinition: 'task:def:arn' + }); + expect(mockEcsWaiter).toHaveBeenNthCalledWith(1, 'servicesStable', { + services: ['service-456'], + cluster: 'cluster-789', + "$waiter": { + "delay": 15, + "maxAttempts": 6 * 60 * 4, + }, }); });