Skip to content

Commit 9a8a47a

Browse files
committed
feat: Add ad-hoc task runs
1 parent f1b98fd commit 9a8a47a

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
@@ -192,6 +192,107 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [
192192
'registeredBy'
193193
];
194194

195+
// Run task outside of a service
196+
async function runTask(ecs, clusterName, taskDefArn, waitForMinutes) {
197+
core.info('Running task')
198+
199+
const waitForTask = core.getInput('wait-for-task-stopped', { required: false }) || 'false';
200+
const startedBy = core.getInput('run-task-started-by', { required: false }) || 'GitHub-Actions';
201+
const launchType = core.getInput('run-task-launch-type', { required: false }) || 'FARGATE';
202+
const subnetIds = core.getInput('run-task-subnets', { required: false }) || '';
203+
const securityGroupIds = core.getInput('run-task-security-groups', { required: false }) || '';
204+
const containerOverrides = JSON.parse(core.getInput('run-task-container-overrides', { required: false }) || '[]');
205+
let awsvpcConfiguration = {}
206+
207+
if (subnetIds != "") {
208+
awsvpcConfiguration["subnets"] = subnetIds.split(',')
209+
}
210+
211+
if (securityGroupIds != "") {
212+
awsvpcConfiguration["securityGroups"] = securityGroupIds.split(',')
213+
}
214+
215+
const runTaskResponse = await ecs.runTask({
216+
startedBy: startedBy,
217+
cluster: clusterName,
218+
taskDefinition: taskDefArn,
219+
overrides: {
220+
containerOverrides: containerOverrides
221+
},
222+
launchType: launchType,
223+
networkConfiguration: awsvpcConfiguration === {} ? {} : { awsvpcConfiguration: awsvpcConfiguration }
224+
}).promise();
225+
226+
core.debug(`Run task response ${JSON.stringify(runTaskResponse)}`)
227+
228+
const taskArns = runTaskResponse.tasks.map(task => task.taskArn);
229+
core.setOutput('run-task-arn', taskArns);
230+
231+
taskArns.map(taskArn => {
232+
let taskId = taskArn.split('/').pop();
233+
core.info(`Task running: https://console.aws.amazon.com/ecs/home?region=${aws.config.region}#/clusters/${clusterName}/tasks`)
234+
});
235+
236+
if (runTaskResponse.failures && runTaskResponse.failures.length > 0) {
237+
const failure = runTaskResponse.failures[0];
238+
throw new Error(`${failure.arn} is ${failure.reason}`);
239+
}
240+
241+
// Wait for task to end
242+
if (waitForTask && waitForTask.toLowerCase() === "true") {
243+
await waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes)
244+
await tasksExitCode(ecs, clusterName, taskArns)
245+
} else {
246+
core.debug('Not waiting for the task to stop');
247+
}
248+
}
249+
250+
// Poll tasks until they enter a stopped state
251+
async function waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) {
252+
if (waitForMinutes > MAX_WAIT_MINUTES) {
253+
waitForMinutes = MAX_WAIT_MINUTES;
254+
}
255+
256+
core.info(`Waiting for tasks to stop. Will wait for ${waitForMinutes} minutes`);
257+
258+
const waitTaskResponse = await ecs.waitFor('tasksStopped', {
259+
cluster: clusterName,
260+
tasks: taskArns,
261+
$waiter: {
262+
delay: WAIT_DEFAULT_DELAY_SEC,
263+
maxAttempts: (waitForMinutes * 60) / WAIT_DEFAULT_DELAY_SEC
264+
}
265+
}).promise();
266+
267+
core.debug(`Run task response ${JSON.stringify(waitTaskResponse)}`);
268+
core.info('All tasks have stopped.');
269+
}
270+
271+
// Check a task's exit code and fail the job on error
272+
async function tasksExitCode(ecs, clusterName, taskArns) {
273+
const describeResponse = await ecs.describeTasks({
274+
cluster: clusterName,
275+
tasks: taskArns
276+
}).promise();
277+
278+
const containers = [].concat(...describeResponse.tasks.map(task => task.containers))
279+
const exitCodes = containers.map(container => container.exitCode)
280+
const reasons = containers.map(container => container.reason)
281+
282+
const failuresIdx = [];
283+
284+
exitCodes.filter((exitCode, index) => {
285+
if (exitCode !== 0) {
286+
failuresIdx.push(index)
287+
}
288+
})
289+
290+
const failures = reasons.filter((_, index) => failuresIdx.indexOf(index) !== -1)
291+
if (failures.length > 0) {
292+
throw new Error(`Run task failed: ${JSON.stringify(failures)}`);
293+
}
294+
}
295+
195296
// Deploy to a service that uses the 'ECS' deployment controller
196297
async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, forceNewDeployment) {
197298
core.debug('Updating the service');
@@ -457,10 +558,18 @@ async function run() {
457558
const taskDefArn = registerResponse.taskDefinition.taskDefinitionArn;
458559
core.setOutput('task-definition-arn', taskDefArn);
459560

561+
// Run the task outside of the service
562+
const clusterName = cluster ? cluster : 'default';
563+
const shouldRunTaskInput = core.getInput('run-task', { required: false }) || 'false';
564+
const shouldRunTask = shouldRunTaskInput.toLowerCase() === 'true';
565+
core.debug(`shouldRunTask: ${shouldRunTask}`);
566+
if (shouldRunTask) {
567+
core.debug("Running ad-hoc task...");
568+
await runTask(ecs, clusterName, taskDefArn, waitForMinutes);
569+
}
570+
460571
// Update the service with the new task definition
461572
if (service) {
462-
const clusterName = cluster ? cluster : 'default';
463-
464573
// Determine the deployment controller
465574
const describeResponse = await ecs.describeServices({
466575
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');
@@ -285,10 +386,18 @@ async function run() {
285386
const taskDefArn = registerResponse.taskDefinition.taskDefinitionArn;
286387
core.setOutput('task-definition-arn', taskDefArn);
287388

389+
// Run the task outside of the service
390+
const clusterName = cluster ? cluster : 'default';
391+
const shouldRunTaskInput = core.getInput('run-task', { required: false }) || 'false';
392+
const shouldRunTask = shouldRunTaskInput.toLowerCase() === 'true';
393+
core.debug(`shouldRunTask: ${shouldRunTask}`);
394+
if (shouldRunTask) {
395+
core.debug("Running ad-hoc task...");
396+
await runTask(ecs, clusterName, taskDefArn, waitForMinutes);
397+
}
398+
288399
// Update the service with the new task definition
289400
if (service) {
290-
const clusterName = cluster ? cluster : 'default';
291-
292401
// Determine the deployment controller
293402
const describeResponse = await ecs.describeServices({
294403
services: [service],

0 commit comments

Comments
 (0)