Skip to content

Commit a581b23

Browse files
committed
feat: Add ad-hoc task runs
1 parent 1060a34 commit a581b23

File tree

5 files changed

+403
-5
lines changed

5 files changed

+403
-5
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,26 @@ The minimal permissions require access to CodeDeploy:
222222
}
223223
```
224224

225+
## Running Tasks
226+
227+
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.
228+
229+
In the following example, the service would not be updated until the ad-hoc task exits successfully.
230+
231+
```yaml
232+
- name: Deploy to Amazon ECS
233+
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
234+
with:
235+
task-definition: task-definition.json
236+
service: my-service
237+
cluster: my-cluster
238+
wait-for-service-stability: true
239+
run-task: true
240+
wait-for-task-stopped: true
241+
```
242+
243+
Overrides and VPC networking options are available as well. See [actions.yml](actions.yml) for more details.
244+
225245
## Troubleshooting
226246

227247
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.

action.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,27 @@ inputs:
3434
force-new-deployment:
3535
description: 'Whether to force a new deployment of the service. Valid value is "true". Will default to not force a new deployment.'
3636
required: false
37+
run-task:
38+
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.'
39+
required: false
40+
run-task-container-overrides:
41+
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'
42+
required: false
43+
run-task-security-groups:
44+
description: 'A comma-separated list of security group IDs to assign to a task when run outside of a service. Will default to none.'
45+
required: false
46+
run-task-subnets:
47+
description: 'A comma-separated list of subnet IDs to assign to a task when run outside of a service. Will default to none.'
48+
required: false
49+
run-task-launch-type:
50+
description: "ECS launch type for tasks run outside of a service. Valid values are 'FARGATE' or 'EC2'. Will default to 'FARGATE'."
51+
required: false
52+
run-task-started-by:
53+
description: "A name to use for the startedBy tag when running a task outside of a service. Will default to 'GitHub-Actions'."
54+
required: false
55+
wait-for-task-stopped:
56+
description: 'Whether to wait for the task to stop when running it outside of a service. Will default to not wait.'
57+
required: false
3758
outputs:
3859
task-definition-arn:
3960
description: 'The ARN of the registered ECS task definition'

dist/index.js

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,107 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [
2626
'registeredBy'
2727
];
2828

29+
// Run task outside of a service
30+
async function runTask(ecs, clusterName, taskDefArn, waitForMinutes) {
31+
core.info('Running task')
32+
33+
const waitForTask = core.getInput('wait-for-task-stopped', { required: false }) || 'false';
34+
const startedBy = core.getInput('run-task-started-by', { required: false }) || 'GitHub-Actions';
35+
const launchType = core.getInput('run-task-launch-type', { required: false }) || 'FARGATE';
36+
const subnetIds = core.getInput('run-task-subnets', { required: false }) || '';
37+
const securityGroupIds = core.getInput('run-task-security-groups', { required: false }) || '';
38+
const containerOverrides = JSON.parse(core.getInput('run-task-container-overrides', { required: false }) || '[]');
39+
let awsvpcConfiguration = {}
40+
41+
if (subnetIds != "") {
42+
awsvpcConfiguration["subnets"] = subnetIds.split(',')
43+
}
44+
45+
if (securityGroupIds != "") {
46+
awsvpcConfiguration["securityGroups"] = securityGroupIds.split(',')
47+
}
48+
49+
const runTaskResponse = await ecs.runTask({
50+
startedBy: startedBy,
51+
cluster: clusterName,
52+
taskDefinition: taskDefArn,
53+
overrides: {
54+
containerOverrides: containerOverrides
55+
},
56+
launchType: launchType,
57+
networkConfiguration: awsvpcConfiguration === {} ? {} : { awsvpcConfiguration: awsvpcConfiguration }
58+
}).promise();
59+
60+
core.debug(`Run task response ${JSON.stringify(runTaskResponse)}`)
61+
62+
const taskArns = runTaskResponse.tasks.map(task => task.taskArn);
63+
core.setOutput('run-task-arn', taskArns);
64+
65+
taskArns.map(taskArn => {
66+
let taskId = taskArn.split('/').pop();
67+
core.info(`Task running: https://console.aws.amazon.com/ecs/home?region=${aws.config.region}#/clusters/${clusterName}/tasks`)
68+
});
69+
70+
if (runTaskResponse.failures && runTaskResponse.failures.length > 0) {
71+
const failure = runTaskResponse.failures[0];
72+
throw new Error(`${failure.arn} is ${failure.reason}`);
73+
}
74+
75+
// Wait for task to end
76+
if (waitForTask && waitForTask.toLowerCase() === "true") {
77+
await waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes)
78+
await tasksExitCode(ecs, clusterName, taskArns)
79+
} else {
80+
core.debug('Not waiting for the task to stop');
81+
}
82+
}
83+
84+
// Poll tasks until they enter a stopped state
85+
async function waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) {
86+
if (waitForMinutes > MAX_WAIT_MINUTES) {
87+
waitForMinutes = MAX_WAIT_MINUTES;
88+
}
89+
90+
core.info(`Waiting for tasks to stop. Will wait for ${waitForMinutes} minutes`);
91+
92+
const waitTaskResponse = await ecs.waitFor('tasksStopped', {
93+
cluster: clusterName,
94+
tasks: taskArns,
95+
$waiter: {
96+
delay: WAIT_DEFAULT_DELAY_SEC,
97+
maxAttempts: (waitForMinutes * 60) / WAIT_DEFAULT_DELAY_SEC
98+
}
99+
}).promise();
100+
101+
core.debug(`Run task response ${JSON.stringify(waitTaskResponse)}`);
102+
core.info('All tasks have stopped.');
103+
}
104+
105+
// Check a task's exit code and fail the job on error
106+
async function tasksExitCode(ecs, clusterName, taskArns) {
107+
const describeResponse = await ecs.describeTasks({
108+
cluster: clusterName,
109+
tasks: taskArns
110+
}).promise();
111+
112+
const containers = [].concat(...describeResponse.tasks.map(task => task.containers))
113+
const exitCodes = containers.map(container => container.exitCode)
114+
const reasons = containers.map(container => container.reason)
115+
116+
const failuresIdx = [];
117+
118+
exitCodes.filter((exitCode, index) => {
119+
if (exitCode !== 0) {
120+
failuresIdx.push(index)
121+
}
122+
})
123+
124+
const failures = reasons.filter((_, index) => failuresIdx.indexOf(index) !== -1)
125+
if (failures.length > 0) {
126+
throw new Error(`Run task failed: ${JSON.stringify(failures)}`);
127+
}
128+
}
129+
29130
// Deploy to a service that uses the 'ECS' deployment controller
30131
async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, forceNewDeployment) {
31132
core.debug('Updating the service');
@@ -294,10 +395,18 @@ async function run() {
294395
const taskDefArn = registerResponse.taskDefinition.taskDefinitionArn;
295396
core.setOutput('task-definition-arn', taskDefArn);
296397

398+
// Run the task outside of the service
399+
const clusterName = cluster ? cluster : 'default';
400+
const shouldRunTaskInput = core.getInput('run-task', { required: false }) || 'false';
401+
const shouldRunTask = shouldRunTaskInput.toLowerCase() === 'true';
402+
core.debug(`shouldRunTask: ${shouldRunTask}`);
403+
if (shouldRunTask) {
404+
core.debug("Running ad-hoc task...");
405+
await runTask(ecs, clusterName, taskDefArn, waitForMinutes);
406+
}
407+
297408
// Update the service with the new task definition
298409
if (service) {
299-
const clusterName = cluster ? cluster : 'default';
300-
301410
// Determine the deployment controller
302411
const describeResponse = await ecs.describeServices({
303412
services: [service],

index.js

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,107 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [
2020
'registeredBy'
2121
];
2222

23+
// Run task outside of a service
24+
async function runTask(ecs, clusterName, taskDefArn, waitForMinutes) {
25+
core.info('Running task')
26+
27+
const waitForTask = core.getInput('wait-for-task-stopped', { required: false }) || 'false';
28+
const startedBy = core.getInput('run-task-started-by', { required: false }) || 'GitHub-Actions';
29+
const launchType = core.getInput('run-task-launch-type', { required: false }) || 'FARGATE';
30+
const subnetIds = core.getInput('run-task-subnets', { required: false }) || '';
31+
const securityGroupIds = core.getInput('run-task-security-groups', { required: false }) || '';
32+
const containerOverrides = JSON.parse(core.getInput('run-task-container-overrides', { required: false }) || '[]');
33+
let awsvpcConfiguration = {}
34+
35+
if (subnetIds != "") {
36+
awsvpcConfiguration["subnets"] = subnetIds.split(',')
37+
}
38+
39+
if (securityGroupIds != "") {
40+
awsvpcConfiguration["securityGroups"] = securityGroupIds.split(',')
41+
}
42+
43+
const runTaskResponse = await ecs.runTask({
44+
startedBy: startedBy,
45+
cluster: clusterName,
46+
taskDefinition: taskDefArn,
47+
overrides: {
48+
containerOverrides: containerOverrides
49+
},
50+
launchType: launchType,
51+
networkConfiguration: awsvpcConfiguration === {} ? {} : { awsvpcConfiguration: awsvpcConfiguration }
52+
}).promise();
53+
54+
core.debug(`Run task response ${JSON.stringify(runTaskResponse)}`)
55+
56+
const taskArns = runTaskResponse.tasks.map(task => task.taskArn);
57+
core.setOutput('run-task-arn', taskArns);
58+
59+
taskArns.map(taskArn => {
60+
let taskId = taskArn.split('/').pop();
61+
core.info(`Task running: https://console.aws.amazon.com/ecs/home?region=${aws.config.region}#/clusters/${clusterName}/tasks`)
62+
});
63+
64+
if (runTaskResponse.failures && runTaskResponse.failures.length > 0) {
65+
const failure = runTaskResponse.failures[0];
66+
throw new Error(`${failure.arn} is ${failure.reason}`);
67+
}
68+
69+
// Wait for task to end
70+
if (waitForTask && waitForTask.toLowerCase() === "true") {
71+
await waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes)
72+
await tasksExitCode(ecs, clusterName, taskArns)
73+
} else {
74+
core.debug('Not waiting for the task to stop');
75+
}
76+
}
77+
78+
// Poll tasks until they enter a stopped state
79+
async function waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) {
80+
if (waitForMinutes > MAX_WAIT_MINUTES) {
81+
waitForMinutes = MAX_WAIT_MINUTES;
82+
}
83+
84+
core.info(`Waiting for tasks to stop. Will wait for ${waitForMinutes} minutes`);
85+
86+
const waitTaskResponse = await ecs.waitFor('tasksStopped', {
87+
cluster: clusterName,
88+
tasks: taskArns,
89+
$waiter: {
90+
delay: WAIT_DEFAULT_DELAY_SEC,
91+
maxAttempts: (waitForMinutes * 60) / WAIT_DEFAULT_DELAY_SEC
92+
}
93+
}).promise();
94+
95+
core.debug(`Run task response ${JSON.stringify(waitTaskResponse)}`);
96+
core.info('All tasks have stopped.');
97+
}
98+
99+
// Check a task's exit code and fail the job on error
100+
async function tasksExitCode(ecs, clusterName, taskArns) {
101+
const describeResponse = await ecs.describeTasks({
102+
cluster: clusterName,
103+
tasks: taskArns
104+
}).promise();
105+
106+
const containers = [].concat(...describeResponse.tasks.map(task => task.containers))
107+
const exitCodes = containers.map(container => container.exitCode)
108+
const reasons = containers.map(container => container.reason)
109+
110+
const failuresIdx = [];
111+
112+
exitCodes.filter((exitCode, index) => {
113+
if (exitCode !== 0) {
114+
failuresIdx.push(index)
115+
}
116+
})
117+
118+
const failures = reasons.filter((_, index) => failuresIdx.indexOf(index) !== -1)
119+
if (failures.length > 0) {
120+
throw new Error(`Run task failed: ${JSON.stringify(failures)}`);
121+
}
122+
}
123+
23124
// Deploy to a service that uses the 'ECS' deployment controller
24125
async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, forceNewDeployment) {
25126
core.debug('Updating the service');
@@ -288,10 +389,18 @@ async function run() {
288389
const taskDefArn = registerResponse.taskDefinition.taskDefinitionArn;
289390
core.setOutput('task-definition-arn', taskDefArn);
290391

392+
// Run the task outside of the service
393+
const clusterName = cluster ? cluster : 'default';
394+
const shouldRunTaskInput = core.getInput('run-task', { required: false }) || 'false';
395+
const shouldRunTask = shouldRunTaskInput.toLowerCase() === 'true';
396+
core.debug(`shouldRunTask: ${shouldRunTask}`);
397+
if (shouldRunTask) {
398+
core.debug("Running ad-hoc task...");
399+
await runTask(ecs, clusterName, taskDefArn, waitForMinutes);
400+
}
401+
291402
// Update the service with the new task definition
292403
if (service) {
293-
const clusterName = cluster ? cluster : 'default';
294-
295404
// Determine the deployment controller
296405
const describeResponse = await ecs.describeServices({
297406
services: [service],

0 commit comments

Comments
 (0)