From b3a528eb690c86037acd19fd6a2a86337f4e3657 Mon Sep 17 00:00:00 2001 From: Shaun Martin Date: Tue, 30 Jul 2024 11:52:57 -0500 Subject: [PATCH] feat: add ad-hoc task runs (#304) * feat: Add ad-hoc task runs * fix: truncate overly long code deploy descriptions * Apply suggestions from code review Co-authored-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> * fix tests * update dist --------- --- README.md | 20 ++++++++ action.yml | 23 ++++++++- dist/index.js | 119 +++++++++++++++++++++++++++++++++++++++++++-- index.js | 119 +++++++++++++++++++++++++++++++++++++++++++-- index.test.js | 132 +++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 402 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0233fe126..ee3db0ebd 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,26 @@ The minimal permissions require access to CodeDeploy: } ``` +## Running Tasks + +For services which need an initialization task, such as database migrations, or ECS tasks that are run without a service, additional configuration can be added to trigger an ad-hoc task run. When combined with GitHub Action's `on: schedule` triggers, runs can also be scheduled without EventBridge. + +In the following example, the service would not be updated until the ad-hoc task exits successfully. + +```yaml + - name: Deploy to Amazon ECS + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: task-definition.json + service: my-service + cluster: my-cluster + wait-for-service-stability: true + run-task: true + wait-for-task-stopped: true +``` + +Overrides and VPC networking options are available as well. See [actions.yml](actions.yml) for more details. + ## Troubleshooting This action emits debug logs to help troubleshoot deployment failures. To see the debug logs, create a secret named `ACTIONS_STEP_DEBUG` with value `true` in your repository. diff --git a/action.yml b/action.yml index fae4fb473..4615689fb 100644 --- a/action.yml +++ b/action.yml @@ -32,7 +32,7 @@ inputs: description: "The name of the AWS CodeDeploy deployment group, if the ECS service uses the CODE_DEPLOY deployment controller. Will default to 'DgpECS-{cluster}-{service}'." required: false codedeploy-deployment-description: - description: "A description of the deployment, if the ECS service uses the CODE_DEPLOY deployment controller." + description: "A description of the deployment, if the ECS service uses the CODE_DEPLOY deployment controller. NOTE: This will be truncated to 512 characters if necessary." required: false codedeploy-deployment-config: description: "The name of the AWS CodeDeploy deployment configuration, if the ECS service uses the CODE_DEPLOY deployment controller. If not specified, the value configured in the deployment group or `CodeDeployDefault.OneAtATime` is used as the default." @@ -40,6 +40,27 @@ 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 + run-task: + description: 'Whether to run the task outside of an ECS service. Task will run before the service is updated if both are provided. Will default to not run.' + required: false + run-task-container-overrides: + description: 'A JSON array of container override objects which should applied when running a task outside of a service. Warning: Do not expose this field to untrusted inputs. More details: https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerOverride.html' + required: false + run-task-security-groups: + description: 'A comma-separated list of security group IDs to assign to a task when run outside of a service. Will default to none.' + required: false + run-task-subnets: + description: 'A comma-separated list of subnet IDs to assign to a task when run outside of a service. Will default to none.' + required: false + run-task-launch-type: + description: "ECS launch type for tasks run outside of a service. Valid values are 'FARGATE' or 'EC2'. Will default to 'FARGATE'." + required: false + run-task-started-by: + description: "A name to use for the startedBy tag when running a task outside of a service. Will default to 'GitHub-Actions'." + required: false + wait-for-task-stopped: + description: 'Whether to wait for the task to stop when running it outside of a service. Will default to not wait.' + 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 42a1efcd5..3596c7d90 100644 --- a/dist/index.js +++ b/dist/index.js @@ -7,7 +7,7 @@ const path = __nccwpck_require__(1017); const core = __nccwpck_require__(2186); const { CodeDeploy, waitUntilDeploymentSuccessful } = __nccwpck_require__(6692); -const { ECS, waitUntilServicesStable } = __nccwpck_require__(8209); +const { ECS, waitUntilServicesStable, waitUntilTasksStopped } = __nccwpck_require__(8209); const yaml = __nccwpck_require__(4083); const fs = __nccwpck_require__(7147); const crypto = __nccwpck_require__(6113); @@ -27,6 +27,107 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [ 'registeredBy' ]; +// Run task outside of a service +async function runTask(ecs, clusterName, taskDefArn, waitForMinutes) { + core.info('Running task') + + const waitForTask = core.getInput('wait-for-task-stopped', { required: false }) || 'false'; + const startedBy = core.getInput('run-task-started-by', { required: false }) || 'GitHub-Actions'; + const launchType = core.getInput('run-task-launch-type', { required: false }) || 'FARGATE'; + const subnetIds = core.getInput('run-task-subnets', { required: false }) || ''; + const securityGroupIds = core.getInput('run-task-security-groups', { required: false }) || ''; + const containerOverrides = JSON.parse(core.getInput('run-task-container-overrides', { required: false }) || '[]'); + let awsvpcConfiguration = {} + + if (subnetIds != "") { + awsvpcConfiguration["subnets"] = subnetIds.split(',') + } + + if (securityGroupIds != "") { + awsvpcConfiguration["securityGroups"] = securityGroupIds.split(',') + } + + const runTaskResponse = await ecs.runTask({ + startedBy: startedBy, + cluster: clusterName, + taskDefinition: taskDefArn, + overrides: { + containerOverrides: containerOverrides + }, + launchType: launchType, + networkConfiguration: Object.keys(awsvpcConfiguration).length === 0 ? {} : { awsvpcConfiguration: awsvpcConfiguration } + }); + + core.debug(`Run task response ${JSON.stringify(runTaskResponse)}`) + + const taskArns = runTaskResponse.tasks.map(task => task.taskArn); + core.setOutput('run-task-arn', taskArns); + + const region = await ecs.config.region(); + const consoleHostname = region.startsWith('cn') ? 'console.amazonaws.cn' : 'console.aws.amazon.com'; + + core.info(`Task running: https://${consoleHostname}/ecs/home?region=${region}#/clusters/${clusterName}/tasks`); + + 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 (waitForTask && waitForTask.toLowerCase() === "true") { + await waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) + await tasksExitCode(ecs, clusterName, taskArns) + } else { + core.debug('Not waiting for the task to stop'); + } +} + +// Poll tasks until they enter a stopped state +async function waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) { + if (waitForMinutes > MAX_WAIT_MINUTES) { + waitForMinutes = MAX_WAIT_MINUTES; + } + + core.info(`Waiting for tasks to stop. Will wait for ${waitForMinutes} minutes`); + + const waitTaskResponse = await waitUntilTasksStopped({ + client: ecs, + minDelay: WAIT_DEFAULT_DELAY_SEC, + maxWaitTime: waitForMinutes * 60, + }, { + cluster: clusterName, + tasks: taskArns, + }); + + core.debug(`Run task response ${JSON.stringify(waitTaskResponse)}`); + core.info('All tasks have stopped.'); +} + +// Check a task's exit code and fail the job on error +async function tasksExitCode(ecs, clusterName, taskArns) { + const describeResponse = await ecs.describeTasks({ + cluster: clusterName, + tasks: taskArns + }); + + 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) { + throw new Error(`Run task failed: ${JSON.stringify(failures)}`); + } +} + // 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'); @@ -229,9 +330,11 @@ async function createCodeDeployDeployment(codedeploy, clusterName, service, task } } }; + // If it hasn't been set then we don't even want to pass it to the api call to maintain previous behaviour. if (codeDeployDescription) { - deploymentParams.description = codeDeployDescription + // CodeDeploy Deployment Descriptions have a max length of 512 characters, so truncate if necessary + deploymentParams.description = (codeDeployDescription.length <= 512) ? codeDeployDescription : `${codeDeployDescription.substring(0,511)}…`; } if (codeDeployConfig) { deploymentParams.deploymentConfigName = codeDeployConfig @@ -307,10 +410,18 @@ async function run() { const taskDefArn = registerResponse.taskDefinition.taskDefinitionArn; core.setOutput('task-definition-arn', taskDefArn); + // Run the task outside of the service + const clusterName = cluster ? cluster : 'default'; + const shouldRunTaskInput = core.getInput('run-task', { required: false }) || 'false'; + const shouldRunTask = shouldRunTaskInput.toLowerCase() === 'true'; + core.debug(`shouldRunTask: ${shouldRunTask}`); + if (shouldRunTask) { + core.debug("Running ad-hoc task..."); + await runTask(ecs, clusterName, taskDefArn, waitForMinutes); + } + // Update the service with the new task definition if (service) { - const clusterName = cluster ? cluster : 'default'; - // Determine the deployment controller const describeResponse = await ecs.describeServices({ services: [service], diff --git a/index.js b/index.js index a807613ae..3bc791f7e 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,7 @@ const path = require('path'); const core = require('@actions/core'); const { CodeDeploy, waitUntilDeploymentSuccessful } = require('@aws-sdk/client-codedeploy'); -const { ECS, waitUntilServicesStable } = require('@aws-sdk/client-ecs'); +const { ECS, waitUntilServicesStable, waitUntilTasksStopped } = require('@aws-sdk/client-ecs'); const yaml = require('yaml'); const fs = require('fs'); const crypto = require('crypto'); @@ -21,6 +21,107 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [ 'registeredBy' ]; +// Run task outside of a service +async function runTask(ecs, clusterName, taskDefArn, waitForMinutes) { + core.info('Running task') + + const waitForTask = core.getInput('wait-for-task-stopped', { required: false }) || 'false'; + const startedBy = core.getInput('run-task-started-by', { required: false }) || 'GitHub-Actions'; + const launchType = core.getInput('run-task-launch-type', { required: false }) || 'FARGATE'; + const subnetIds = core.getInput('run-task-subnets', { required: false }) || ''; + const securityGroupIds = core.getInput('run-task-security-groups', { required: false }) || ''; + const containerOverrides = JSON.parse(core.getInput('run-task-container-overrides', { required: false }) || '[]'); + let awsvpcConfiguration = {} + + if (subnetIds != "") { + awsvpcConfiguration["subnets"] = subnetIds.split(',') + } + + if (securityGroupIds != "") { + awsvpcConfiguration["securityGroups"] = securityGroupIds.split(',') + } + + const runTaskResponse = await ecs.runTask({ + startedBy: startedBy, + cluster: clusterName, + taskDefinition: taskDefArn, + overrides: { + containerOverrides: containerOverrides + }, + launchType: launchType, + networkConfiguration: Object.keys(awsvpcConfiguration).length === 0 ? {} : { awsvpcConfiguration: awsvpcConfiguration } + }); + + core.debug(`Run task response ${JSON.stringify(runTaskResponse)}`) + + const taskArns = runTaskResponse.tasks.map(task => task.taskArn); + core.setOutput('run-task-arn', taskArns); + + const region = await ecs.config.region(); + const consoleHostname = region.startsWith('cn') ? 'console.amazonaws.cn' : 'console.aws.amazon.com'; + + core.info(`Task running: https://${consoleHostname}/ecs/home?region=${region}#/clusters/${clusterName}/tasks`); + + 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 (waitForTask && waitForTask.toLowerCase() === "true") { + await waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) + await tasksExitCode(ecs, clusterName, taskArns) + } else { + core.debug('Not waiting for the task to stop'); + } +} + +// Poll tasks until they enter a stopped state +async function waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) { + if (waitForMinutes > MAX_WAIT_MINUTES) { + waitForMinutes = MAX_WAIT_MINUTES; + } + + core.info(`Waiting for tasks to stop. Will wait for ${waitForMinutes} minutes`); + + const waitTaskResponse = await waitUntilTasksStopped({ + client: ecs, + minDelay: WAIT_DEFAULT_DELAY_SEC, + maxWaitTime: waitForMinutes * 60, + }, { + cluster: clusterName, + tasks: taskArns, + }); + + core.debug(`Run task response ${JSON.stringify(waitTaskResponse)}`); + core.info('All tasks have stopped.'); +} + +// Check a task's exit code and fail the job on error +async function tasksExitCode(ecs, clusterName, taskArns) { + const describeResponse = await ecs.describeTasks({ + cluster: clusterName, + tasks: taskArns + }); + + 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) { + throw new Error(`Run task failed: ${JSON.stringify(failures)}`); + } +} + // 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'); @@ -223,9 +324,11 @@ async function createCodeDeployDeployment(codedeploy, clusterName, service, task } } }; + // If it hasn't been set then we don't even want to pass it to the api call to maintain previous behaviour. if (codeDeployDescription) { - deploymentParams.description = codeDeployDescription + // CodeDeploy Deployment Descriptions have a max length of 512 characters, so truncate if necessary + deploymentParams.description = (codeDeployDescription.length <= 512) ? codeDeployDescription : `${codeDeployDescription.substring(0,511)}…`; } if (codeDeployConfig) { deploymentParams.deploymentConfigName = codeDeployConfig @@ -301,10 +404,18 @@ async function run() { const taskDefArn = registerResponse.taskDefinition.taskDefinitionArn; core.setOutput('task-definition-arn', taskDefArn); + // Run the task outside of the service + const clusterName = cluster ? cluster : 'default'; + const shouldRunTaskInput = core.getInput('run-task', { required: false }) || 'false'; + const shouldRunTask = shouldRunTaskInput.toLowerCase() === 'true'; + core.debug(`shouldRunTask: ${shouldRunTask}`); + if (shouldRunTask) { + core.debug("Running ad-hoc task..."); + await runTask(ecs, clusterName, taskDefArn, waitForMinutes); + } + // Update the service with the new task definition if (service) { - const clusterName = cluster ? cluster : 'default'; - // Determine the deployment controller const describeResponse = await ecs.describeServices({ services: [service], diff --git a/index.test.js b/index.test.js index c9854831a..dec933787 100644 --- a/index.test.js +++ b/index.test.js @@ -1,7 +1,7 @@ const run = require('.'); const core = require('@actions/core'); const { CodeDeploy, waitUntilDeploymentSuccessful } = require('@aws-sdk/client-codedeploy'); -const { ECS, waitUntilServicesStable } = require('@aws-sdk/client-ecs'); +const { ECS, waitUntilServicesStable, waitUntilTasksStopped } = require('@aws-sdk/client-ecs'); const fs = require('fs'); const path = require('path'); @@ -16,6 +16,8 @@ const mockEcsUpdateService = jest.fn(); const mockEcsDescribeServices = jest.fn(); const mockCodeDeployCreateDeployment = jest.fn(); const mockCodeDeployGetDeploymentGroup = jest.fn(); +const mockRunTask = jest.fn(); +const mockEcsDescribeTasks = jest.fn(); const config = { region: () => Promise.resolve('fake-region'), }; @@ -33,7 +35,9 @@ describe('Deploy to ECS', () => { config, registerTaskDefinition: mockEcsRegisterTaskDef, updateService: mockEcsUpdateService, - describeServices: mockEcsDescribeServices + describeServices: mockEcsDescribeServices, + describeTasks: mockEcsDescribeTasks, + runTask: mockRunTask, }; const mockCodeDeployClient = { @@ -109,13 +113,58 @@ describe('Deploy to ECS', () => { }) ); + mockRunTask.mockImplementation( + () => 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( + () => 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" + } + ] + })); + ECS.mockImplementation(() => mockEcsClient); + waitUntilTasksStopped.mockImplementation(() => Promise.resolve({})); + waitUntilServicesStable.mockImplementation(() => Promise.resolve({})); CodeDeploy.mockImplementation(() => mockCodeDeployClient); waitUntilDeploymentSuccessful.mockImplementation(() => Promise.resolve({})); + + }); test('registers the task definition contents and updates the service', async () => { @@ -655,6 +704,7 @@ describe('Deploy to ECS', () => { .mockReturnValueOnce('false') // wait-for-service-stability .mockReturnValueOnce('') // wait-for-minutes .mockReturnValueOnce('') // force-new-deployment + .mockReturnValueOnce('') // run-task .mockReturnValueOnce('') // desired count .mockReturnValueOnce('/hello/appspec.json') // codedeploy-appspec .mockReturnValueOnce('MyApplication') // codedeploy-application @@ -1053,6 +1103,84 @@ describe('Deploy to ECS', () => { expect(mockEcsUpdateService).toHaveBeenCalledTimes(0); }); + test('run task', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('') // service + .mockReturnValueOnce('') // cluster + .mockReturnValueOnce('') // wait-for-service-stability + .mockReturnValueOnce('') // wait-for-minutes + .mockReturnValueOnce('') // force-new-deployment + .mockReturnValueOnce('') // desired-count + .mockReturnValueOnce('true'); // run-task + + 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(mockRunTask).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenNthCalledWith(2, 'run-task-arn', ["arn:aws:ecs:fake-region:account_id:task/arn"]); + }); + + test('run task with options', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('') // service + .mockReturnValueOnce('somecluster') // cluster + .mockReturnValueOnce('') // wait-for-service-stability + .mockReturnValueOnce('') // wait-for-minutes + .mockReturnValueOnce('') // force-new-deployment + .mockReturnValueOnce('') // desired-count + .mockReturnValueOnce('true') // run-task + .mockReturnValueOnce('false') // wait-for-task-stopped + .mockReturnValueOnce('someJoe') // run-task-started-by + .mockReturnValueOnce('EC2') // run-task-launch-type + .mockReturnValueOnce('a,b') // run-task-subnet-ids + .mockReturnValueOnce('c,d') // run-task-security-group-ids + .mockReturnValueOnce(JSON.stringify([{ name: 'someapp', command: 'somecmd' }])); // run-task-container-overrides + + 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(mockRunTask).toHaveBeenCalledWith({ + startedBy: 'someJoe', + cluster: 'somecluster', + launchType: "EC2", + taskDefinition: 'task:def:arn', + overrides: { containerOverrides: [{ name: 'someapp', command: 'somecmd' }] }, + networkConfiguration: { awsvpcConfiguration: { subnets: ['a', 'b'], securityGroups: ['c', 'd'] } } + }); + expect(core.setOutput).toHaveBeenNthCalledWith(2, 'run-task-arn', ["arn:aws:ecs:fake-region:account_id:task/arn"]); + }); + + test('run task and wait for it to stop', async () => { + core.getInput = jest + .fn() + .mockReturnValueOnce('task-definition.json') // task-definition + .mockReturnValueOnce('') // service + .mockReturnValueOnce('somecluster') // cluster + .mockReturnValueOnce('') // wait-for-service-stability + .mockReturnValueOnce('') // wait-for-minutes + .mockReturnValueOnce('') // force-new-deployment + .mockReturnValueOnce('') // desired-count + .mockReturnValueOnce('true') // run-task + .mockReturnValueOnce('true'); // wait-for-task-stopped + + 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(mockRunTask).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenNthCalledWith(2, 'run-task-arn', ["arn:aws:ecs:fake-region:account_id:task/arn"]) + expect(waitUntilTasksStopped).toHaveBeenCalledTimes(1); + }); + test('error caught if AppSpec file is not formatted correctly', async () => { mockEcsDescribeServices.mockImplementation( () => Promise.resolve({