Skip to content

Commit

Permalink
feat: add ad-hoc task runs (aws-actions#304)
Browse files Browse the repository at this point in the history
* feat: Add ad-hoc task runs

* fix: truncate overly long code deploy descriptions

* Apply suggestions from code review

Co-authored-by: Trivikram Kamat <[email protected]>

* fix tests

* update dist

---------
  • Loading branch information
inhumantsar authored Jul 30, 2024
1 parent 0ae3111 commit b3a528e
Show file tree
Hide file tree
Showing 5 changed files with 402 additions and 11 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 22 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,35 @@ 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."
required: false
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'
Expand Down
119 changes: 115 additions & 4 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down
119 changes: 115 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down
Loading

0 comments on commit b3a528e

Please sign in to comment.