Skip to content

feat: add ad-hoc task runs #304

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For users with a capacity provider, the launchType param is supposed to be omitted. The implicit default of FARGATE here would prevent that.

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
Loading