From 6c0d018891f2fa5f514fc351774bfb413d5e5afb Mon Sep 17 00:00:00 2001 From: Roman Kydybets <27759761+romankydybets@users.noreply.github.com> Date: Wed, 7 Feb 2024 19:08:52 +0100 Subject: [PATCH 1/2] feat: Add desired tasks (#505) * add desired count * add desired count * add desired count * fix test for desired count * small change * update dist/index.js * address feedback @iamhopaul123 * update dist/index.js --------- Co-authored-by: Adithya Kolla --- action.yml | 15 +++++ dist/index.js | 111 ++++++++++++++++++++++++++++++++ index.js | 111 ++++++++++++++++++++++++++++++++ index.test.js | 157 ++++++++++++++++++++++++++++++++++++++++++---- package-lock.json | 2 +- 5 files changed, 384 insertions(+), 12 deletions(-) diff --git a/action.yml b/action.yml index e1f7db5b6..d719a2718 100644 --- a/action.yml +++ b/action.yml @@ -37,6 +37,21 @@ inputs: force-new-deployment: description: 'Whether to force a new deployment of the service. Valid value is "true". Will default to not force a new deployment.' required: false + + init-task-command: + description: 'A command to be run as a task before deploy.' + required: false + init-task-name: + description: 'Container name for init run task.' + default: 'app' + required: false + init-task-network-configuration: + description: 'Network configuration as json string required for network mode `awsvpc`.' + required: false + started-by: + description: "The value of the task started-by" + required: false + outputs: task-definition-arn: description: 'The ARN of the registered ECS task definition' diff --git a/dist/index.js b/dist/index.js index c5c0f4b9a..32463de08 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26,6 +26,109 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [ 'registeredBy' ]; +async function runInitTask(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, initTaskCommand) { + core.info(`Starting init task "${initTaskCommand}"`) + + const agent = 'amazon-ecs-run-task-for-github-actions' + const startedBy = core.getInput('started-by', { required: false }) || agent; + const networkConfiguration = JSON.parse(core.getInput('init-task-network-configuration', { required : false })); + const containerName = core.getInput('init-task-name', { required : false }) + + const runTaskResponse = await ecs.runTask({ + startedBy: startedBy, + cluster: clusterName, + taskDefinition: taskDefArn, + enableExecuteCommand: true, + overrides: { + containerOverrides: [ + { + name: containerName, + command: initTaskCommand.split(' ') + } + ] + }, + launchType: 'FARGATE', + networkConfiguration: networkConfiguration + }).promise(); + + core.debug(`Run task response ${JSON.stringify(runTaskResponse)}`) + + const taskArns = runTaskResponse.tasks.map(task => task.taskArn); + core.setOutput('init-task-arn', taskArns); + + taskArns.map(taskArn =>{ + let taskId = taskArn.split('/').pop(); + + core.info(`Init task started. Watch the task logs in https://${aws.config.region}.console.aws.amazon.com/cloudwatch/home?region=${aws.config.region}#logsV2:log-groups/log-group/ecs$252F${service}/log-events/ecs$252Fapp$252F${taskId}`) + }); + + if (runTaskResponse.failures && runTaskResponse.failures.length > 0) { + const failure = runTaskResponse.failures[0]; + throw new Error(`${failure.arn} is ${failure.reason}`); + } + + // Wait for task to end + if (waitForService && waitForService.toLowerCase() === 'true') { + core.debug(`Waiting for the service to become stable. Will wait for ${waitForMinutes} minutes`); + await waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) + await tasksExitCode(ecs, clusterName, taskArns) + } else { + core.debug('Not waiting for the service to become stable'); + } +} + +async function waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) { + if (waitForMinutes > MAX_WAIT_MINUTES) { + waitForMinutes = MAX_WAIT_MINUTES; + } + + const maxAttempts = (waitForMinutes * 60) / WAIT_DEFAULT_DELAY_SEC; + + core.info('Waiting for tasks to stop'); + + const waitTaskResponse = await ecs.waitFor('tasksStopped', { + cluster: clusterName, + tasks: taskArns, + $waiter: { + delay: WAIT_DEFAULT_DELAY_SEC, + maxAttempts: maxAttempts + } + }).promise(); + + core.debug(`Run task response ${JSON.stringify(waitTaskResponse)}`) + + core.info(`All tasks have stopped. Watch progress in the Amazon ECS console: https://console.aws.amazon.com/ecs/home?region=${aws.config.region}#/clusters/${clusterName}/tasks`); +} + +async function tasksExitCode(ecs, clusterName, taskArns) { + const describeResponse = await ecs.describeTasks({ + cluster: clusterName, + tasks: taskArns + }).promise(); + + const containers = [].concat(...describeResponse.tasks.map(task => task.containers)) + const exitCodes = containers.map(container => container.exitCode) + const reasons = containers.map(container => container.reason) + + const failuresIdx = []; + + exitCodes.filter((exitCode, index) => { + if (exitCode !== 0) { + failuresIdx.push(index) + } + }) + + const failures = reasons.filter((_, index) => failuresIdx.indexOf(index) !== -1) + + if (failures.length > 0) { + console.log(`failed to with exit code${failures}`) + core.setFailed(failures.join("\n")); + throw new Error(`Run task failed: ${JSON.stringify(failures)}`); + } else { + core.info(`All tasks have exited successfully.`); + } +} + // Deploy to a service that uses the 'ECS' deployment controller async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, forceNewDeployment, desiredCount) { core.debug('Updating the service'); @@ -273,6 +376,7 @@ async function run() { 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; + const initTaskCommand = core.getInput('init-task-command'); if (waitForMinutes > MAX_WAIT_MINUTES) { waitForMinutes = MAX_WAIT_MINUTES; } @@ -322,6 +426,12 @@ async function run() { throw new Error(`Service is ${serviceResponse.status}`); } + if (initTaskCommand) { + await runInitTask(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, initTaskCommand); + } else { + core.debug('InitTaskCommand was not specified, no init run task.'); + } + if (!serviceResponse.deploymentController || !serviceResponse.deploymentController.type || serviceResponse.deploymentController.type === 'ECS') { // Service uses the 'ECS' deployment controller, so we can call UpdateService await updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, forceNewDeployment, desiredCount); @@ -336,6 +446,7 @@ async function run() { } } catch (error) { + console.log(error); core.setFailed(error.message); core.debug(error.stack); } diff --git a/index.js b/index.js index aafd6e4cf..15721d6e0 100644 --- a/index.js +++ b/index.js @@ -20,6 +20,109 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [ 'registeredBy' ]; +async function runInitTask(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, initTaskCommand) { + core.info(`Starting init task "${initTaskCommand}"`) + + const agent = 'amazon-ecs-run-task-for-github-actions' + const startedBy = core.getInput('started-by', { required: false }) || agent; + const networkConfiguration = JSON.parse(core.getInput('init-task-network-configuration', { required : false })); + const containerName = core.getInput('init-task-name', { required : false }) + + const runTaskResponse = await ecs.runTask({ + startedBy: startedBy, + cluster: clusterName, + taskDefinition: taskDefArn, + enableExecuteCommand: true, + overrides: { + containerOverrides: [ + { + name: containerName, + command: initTaskCommand.split(' ') + } + ] + }, + launchType: 'FARGATE', + networkConfiguration: networkConfiguration + }).promise(); + + core.debug(`Run task response ${JSON.stringify(runTaskResponse)}`) + + const taskArns = runTaskResponse.tasks.map(task => task.taskArn); + core.setOutput('init-task-arn', taskArns); + + taskArns.map(taskArn =>{ + let taskId = taskArn.split('/').pop(); + + core.info(`Init task started. Watch the task logs in https://${aws.config.region}.console.aws.amazon.com/cloudwatch/home?region=${aws.config.region}#logsV2:log-groups/log-group/ecs$252F${service}/log-events/ecs$252Fapp$252F${taskId}`) + }); + + if (runTaskResponse.failures && runTaskResponse.failures.length > 0) { + const failure = runTaskResponse.failures[0]; + throw new Error(`${failure.arn} is ${failure.reason}`); + } + + // Wait for task to end + if (waitForService && waitForService.toLowerCase() === 'true') { + core.debug(`Waiting for the service to become stable. Will wait for ${waitForMinutes} minutes`); + await waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) + await tasksExitCode(ecs, clusterName, taskArns) + } else { + core.debug('Not waiting for the service to become stable'); + } +} + +async function waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) { + if (waitForMinutes > MAX_WAIT_MINUTES) { + waitForMinutes = MAX_WAIT_MINUTES; + } + + const maxAttempts = (waitForMinutes * 60) / WAIT_DEFAULT_DELAY_SEC; + + core.info('Waiting for tasks to stop'); + + const waitTaskResponse = await ecs.waitFor('tasksStopped', { + cluster: clusterName, + tasks: taskArns, + $waiter: { + delay: WAIT_DEFAULT_DELAY_SEC, + maxAttempts: maxAttempts + } + }).promise(); + + core.debug(`Run task response ${JSON.stringify(waitTaskResponse)}`) + + core.info(`All tasks have stopped. Watch progress in the Amazon ECS console: https://console.aws.amazon.com/ecs/home?region=${aws.config.region}#/clusters/${clusterName}/tasks`); +} + +async function tasksExitCode(ecs, clusterName, taskArns) { + const describeResponse = await ecs.describeTasks({ + cluster: clusterName, + tasks: taskArns + }).promise(); + + const containers = [].concat(...describeResponse.tasks.map(task => task.containers)) + const exitCodes = containers.map(container => container.exitCode) + const reasons = containers.map(container => container.reason) + + const failuresIdx = []; + + exitCodes.filter((exitCode, index) => { + if (exitCode !== 0) { + failuresIdx.push(index) + } + }) + + const failures = reasons.filter((_, index) => failuresIdx.indexOf(index) !== -1) + + if (failures.length > 0) { + console.log(`failed to with exit code${failures}`) + core.setFailed(failures.join("\n")); + throw new Error(`Run task failed: ${JSON.stringify(failures)}`); + } else { + core.info(`All tasks have exited successfully.`); + } +} + // Deploy to a service that uses the 'ECS' deployment controller async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, forceNewDeployment, desiredCount) { core.debug('Updating the service'); @@ -267,6 +370,7 @@ async function run() { 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; + const initTaskCommand = core.getInput('init-task-command'); if (waitForMinutes > MAX_WAIT_MINUTES) { waitForMinutes = MAX_WAIT_MINUTES; } @@ -316,6 +420,12 @@ async function run() { throw new Error(`Service is ${serviceResponse.status}`); } + if (initTaskCommand) { + await runInitTask(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, initTaskCommand); + } else { + core.debug('InitTaskCommand was not specified, no init run task.'); + } + if (!serviceResponse.deploymentController || !serviceResponse.deploymentController.type || serviceResponse.deploymentController.type === 'ECS') { // Service uses the 'ECS' deployment controller, so we can call UpdateService await updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, forceNewDeployment, desiredCount); @@ -330,6 +440,7 @@ async function run() { } } catch (error) { + console.log(error); core.setFailed(error.message); core.debug(error.stack); } diff --git a/index.test.js b/index.test.js index ba4f6ac97..0bb048b99 100644 --- a/index.test.js +++ b/index.test.js @@ -20,6 +20,8 @@ let config = { region: 'fake-region', }; +const mockRunTasks = jest.fn(); +const mockEcsDescribeTasks = jest.fn(); jest.mock('aws-sdk', () => { return { config, @@ -27,6 +29,8 @@ jest.mock('aws-sdk', () => { registerTaskDefinition: mockEcsRegisterTaskDef, updateService: mockEcsUpdateService, describeServices: mockEcsDescribeServices, + describeTasks: mockEcsDescribeTasks, + runTask: mockRunTasks, waitFor: mockEcsWaiter })), CodeDeploy: jest.fn(() => ({ @@ -149,6 +153,57 @@ describe('Deploy to ECS', () => { } }; }); + + mockRunTasks.mockImplementation(() => { + return { + promise() { + return Promise.resolve({ + failures: [], + tasks: [ + { + containers: [ + { + lastStatus: "RUNNING", + exitCode: 0, + reason: '', + taskArn: "arn:aws:ecs:fake-region:account_id:task/arn" + } + ], + desiredStatus: "RUNNING", + lastStatus: "RUNNING", + taskArn: "arn:aws:ecs:fake-region:account_id:task/arn" + // taskDefinitionArn: "arn:aws:ecs:::task-definition/amazon-ecs-sample:1" + } + ] + }); + } + }; + }); + + mockEcsDescribeTasks.mockImplementation(() => { + return { + promise() { + return Promise.resolve({ + failures: [], + tasks: [ + { + containers: [ + { + lastStatus: "RUNNING", + exitCode: 0, + reason: '', + taskArn: "arn:aws:ecs:fake-region:account_id:task/arn" + } + ], + desiredStatus: "RUNNING", + lastStatus: "RUNNING", + taskArn: "arn:aws:ecs:fake-region:account_id:task/arn" + } + ] + }); + } + }; + }); }); test('registers the task definition contents and updates the service', async () => { @@ -687,15 +742,15 @@ describe('Deploy to ECS', () => { core.getInput = jest .fn() .mockReturnValueOnce('task-definition.json') // task-definition - .mockReturnValueOnce('service-456') // service - .mockReturnValueOnce('cluster-789') // cluster - .mockReturnValueOnce('false') // wait-for-service-stability - .mockReturnValueOnce('') // wait-for-minutes - .mockReturnValueOnce('') // force-new-deployment - .mockReturnValueOnce('') // desired count - .mockReturnValueOnce('/hello/appspec.json') // codedeploy-appspec - .mockReturnValueOnce('MyApplication') // codedeploy-application - .mockReturnValueOnce('MyDeploymentGroup') // codedeploy-deployment-group + .mockReturnValueOnce('service-456') // service + .mockReturnValueOnce('cluster-789') // cluster + .mockReturnValueOnce('false') // wait-for-service-stability + .mockReturnValueOnce('') // wait-for-minutes + .mockReturnValueOnce('') // init-task-command + .mockReturnValueOnce('') // force-new-deployment + .mockReturnValueOnce('/hello/appspec.json') // codedeploy-appspec + .mockReturnValueOnce('MyApplication') // codedeploy-application + .mockReturnValueOnce('MyDeploymentGroup'); // codedeploy-deployment-group fs.readFileSync.mockReturnValue(` { @@ -1021,8 +1076,8 @@ describe('Deploy to ECS', () => { .mockReturnValueOnce('cluster-789') // cluster .mockReturnValueOnce('false') // wait-for-service-stability .mockReturnValueOnce('') // wait-for-minutes - .mockReturnValueOnce('true') // force-new-deployment - .mockReturnValueOnce('4'); // desired count is number + .mockReturnValueOnce('') // init-task-command + .mockReturnValueOnce('true'); // force-new-deployment await run(); expect(core.setFailed).toHaveBeenCalledTimes(0); @@ -1042,6 +1097,86 @@ describe('Deploy to ECS', () => { }); }); + test('run init task before deployment', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('service-456') // service + .mockReturnValueOnce('cluster-789') // cluster + .mockReturnValueOnce('false') // wait-for-service-stability + .mockReturnValueOnce('') // wait-for-minutes + .mockReturnValueOnce('npm test') // init-task-command + .mockReturnValueOnce('true') // force-new-deployment + .mockReturnValueOnce('{}') // init-task-network-configuration + .mockReturnValueOnce('app'); // init-task-name + + 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(mockRunTasks).toHaveBeenCalledTimes(1); + expect(mockEcsUpdateService).toHaveBeenNthCalledWith(1, { + cluster: 'cluster-789', + service: 'service-456', + taskDefinition: 'task:def:arn', + forceNewDeployment: true + }); + }); + + test('waits for the init task before deployment', 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 + .mockReturnValueOnce('npm test') // init-task-command + .mockReturnValueOnce('true') // force-new-deployment + .mockReturnValueOnce('{}') // init-task-network-configuration + .mockReturnValueOnce('app'); // init-task-name + + 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(core.setOutput).toHaveBeenNthCalledWith(2, 'init-task-arn', ["arn:aws:ecs:fake-region:account_id:task/arn"]) + + expect(mockEcsDescribeServices).toHaveBeenNthCalledWith(1, { + cluster: 'cluster-789', + services: ['service-456'] + }); + expect(mockRunTasks).toHaveBeenCalledTimes(1); + expect(mockEcsUpdateService).toHaveBeenNthCalledWith(1, { + cluster: 'cluster-789', + service: 'service-456', + taskDefinition: 'task:def:arn', + forceNewDeployment: true + }); + expect(mockEcsWaiter).toHaveBeenNthCalledWith(1, 'tasksStopped', { + tasks: ['arn:aws:ecs:fake-region:account_id:task/arn'], + cluster: 'cluster-789', + "$waiter": { + "delay": 15, + "maxAttempts": 6 * 60 * 4, + }, + }); + expect(mockEcsWaiter).toHaveBeenNthCalledWith(2, 'servicesStable', { + services: ['service-456'], + cluster: 'cluster-789', + "$waiter": { + "delay": 15, + "maxAttempts": 6 * 60 * 4, + }, + }); + }); + test('defaults to the default cluster', async () => { core.getInput = jest .fn() diff --git a/package-lock.json b/package-lock.json index 7300f71e6..5d69980b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8312,4 +8312,4 @@ "dev": true } } -} +} \ No newline at end of file From 99ebbbedc8b78b9f2119a354dc7f945fa89be5ec Mon Sep 17 00:00:00 2001 From: Chirag Rajkarnikar Date: Wed, 10 Nov 2021 14:35:11 +0100 Subject: [PATCH 2/2] Run init task before updateing service --- .tool-versions | 1 + 1 file changed, 1 insertion(+) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000..4120b7f87 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 20.12.1