Skip to content

Commit b3a528e

Browse files
authored
feat: add ad-hoc task runs (aws-actions#304)
* 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 ---------
1 parent 0ae3111 commit b3a528e

File tree

5 files changed

+402
-11
lines changed

5 files changed

+402
-11
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: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,35 @@ inputs:
3232
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}'."
3333
required: false
3434
codedeploy-deployment-description:
35-
description: "A description of the deployment, if the ECS service uses the CODE_DEPLOY deployment controller."
35+
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."
3636
required: false
3737
codedeploy-deployment-config:
3838
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."
3939
required: false
4040
force-new-deployment:
4141
description: 'Whether to force a new deployment of the service. Valid value is "true". Will default to not force a new deployment.'
4242
required: false
43+
run-task:
44+
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.'
45+
required: false
46+
run-task-container-overrides:
47+
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'
48+
required: false
49+
run-task-security-groups:
50+
description: 'A comma-separated list of security group IDs to assign to a task when run outside of a service. Will default to none.'
51+
required: false
52+
run-task-subnets:
53+
description: 'A comma-separated list of subnet IDs to assign to a task when run outside of a service. Will default to none.'
54+
required: false
55+
run-task-launch-type:
56+
description: "ECS launch type for tasks run outside of a service. Valid values are 'FARGATE' or 'EC2'. Will default to 'FARGATE'."
57+
required: false
58+
run-task-started-by:
59+
description: "A name to use for the startedBy tag when running a task outside of a service. Will default to 'GitHub-Actions'."
60+
required: false
61+
wait-for-task-stopped:
62+
description: 'Whether to wait for the task to stop when running it outside of a service. Will default to not wait.'
63+
required: false
4364
outputs:
4465
task-definition-arn:
4566
description: 'The ARN of the registered ECS task definition'

dist/index.js

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
const path = __nccwpck_require__(1017);
88
const core = __nccwpck_require__(2186);
99
const { CodeDeploy, waitUntilDeploymentSuccessful } = __nccwpck_require__(6692);
10-
const { ECS, waitUntilServicesStable } = __nccwpck_require__(8209);
10+
const { ECS, waitUntilServicesStable, waitUntilTasksStopped } = __nccwpck_require__(8209);
1111
const yaml = __nccwpck_require__(4083);
1212
const fs = __nccwpck_require__(7147);
1313
const crypto = __nccwpck_require__(6113);
@@ -27,6 +27,107 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [
2727
'registeredBy'
2828
];
2929

30+
// Run task outside of a service
31+
async function runTask(ecs, clusterName, taskDefArn, waitForMinutes) {
32+
core.info('Running task')
33+
34+
const waitForTask = core.getInput('wait-for-task-stopped', { required: false }) || 'false';
35+
const startedBy = core.getInput('run-task-started-by', { required: false }) || 'GitHub-Actions';
36+
const launchType = core.getInput('run-task-launch-type', { required: false }) || 'FARGATE';
37+
const subnetIds = core.getInput('run-task-subnets', { required: false }) || '';
38+
const securityGroupIds = core.getInput('run-task-security-groups', { required: false }) || '';
39+
const containerOverrides = JSON.parse(core.getInput('run-task-container-overrides', { required: false }) || '[]');
40+
let awsvpcConfiguration = {}
41+
42+
if (subnetIds != "") {
43+
awsvpcConfiguration["subnets"] = subnetIds.split(',')
44+
}
45+
46+
if (securityGroupIds != "") {
47+
awsvpcConfiguration["securityGroups"] = securityGroupIds.split(',')
48+
}
49+
50+
const runTaskResponse = await ecs.runTask({
51+
startedBy: startedBy,
52+
cluster: clusterName,
53+
taskDefinition: taskDefArn,
54+
overrides: {
55+
containerOverrides: containerOverrides
56+
},
57+
launchType: launchType,
58+
networkConfiguration: Object.keys(awsvpcConfiguration).length === 0 ? {} : { awsvpcConfiguration: awsvpcConfiguration }
59+
});
60+
61+
core.debug(`Run task response ${JSON.stringify(runTaskResponse)}`)
62+
63+
const taskArns = runTaskResponse.tasks.map(task => task.taskArn);
64+
core.setOutput('run-task-arn', taskArns);
65+
66+
const region = await ecs.config.region();
67+
const consoleHostname = region.startsWith('cn') ? 'console.amazonaws.cn' : 'console.aws.amazon.com';
68+
69+
core.info(`Task running: https://${consoleHostname}/ecs/home?region=${region}#/clusters/${clusterName}/tasks`);
70+
71+
if (runTaskResponse.failures && runTaskResponse.failures.length > 0) {
72+
const failure = runTaskResponse.failures[0];
73+
throw new Error(`${failure.arn} is ${failure.reason}`);
74+
}
75+
76+
// Wait for task to end
77+
if (waitForTask && waitForTask.toLowerCase() === "true") {
78+
await waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes)
79+
await tasksExitCode(ecs, clusterName, taskArns)
80+
} else {
81+
core.debug('Not waiting for the task to stop');
82+
}
83+
}
84+
85+
// Poll tasks until they enter a stopped state
86+
async function waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) {
87+
if (waitForMinutes > MAX_WAIT_MINUTES) {
88+
waitForMinutes = MAX_WAIT_MINUTES;
89+
}
90+
91+
core.info(`Waiting for tasks to stop. Will wait for ${waitForMinutes} minutes`);
92+
93+
const waitTaskResponse = await waitUntilTasksStopped({
94+
client: ecs,
95+
minDelay: WAIT_DEFAULT_DELAY_SEC,
96+
maxWaitTime: waitForMinutes * 60,
97+
}, {
98+
cluster: clusterName,
99+
tasks: taskArns,
100+
});
101+
102+
core.debug(`Run task response ${JSON.stringify(waitTaskResponse)}`);
103+
core.info('All tasks have stopped.');
104+
}
105+
106+
// Check a task's exit code and fail the job on error
107+
async function tasksExitCode(ecs, clusterName, taskArns) {
108+
const describeResponse = await ecs.describeTasks({
109+
cluster: clusterName,
110+
tasks: taskArns
111+
});
112+
113+
const containers = [].concat(...describeResponse.tasks.map(task => task.containers))
114+
const exitCodes = containers.map(container => container.exitCode)
115+
const reasons = containers.map(container => container.reason)
116+
117+
const failuresIdx = [];
118+
119+
exitCodes.filter((exitCode, index) => {
120+
if (exitCode !== 0) {
121+
failuresIdx.push(index)
122+
}
123+
})
124+
125+
const failures = reasons.filter((_, index) => failuresIdx.indexOf(index) !== -1)
126+
if (failures.length > 0) {
127+
throw new Error(`Run task failed: ${JSON.stringify(failures)}`);
128+
}
129+
}
130+
30131
// Deploy to a service that uses the 'ECS' deployment controller
31132
async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, forceNewDeployment, desiredCount) {
32133
core.debug('Updating the service');
@@ -229,9 +330,11 @@ async function createCodeDeployDeployment(codedeploy, clusterName, service, task
229330
}
230331
}
231332
};
333+
232334
// If it hasn't been set then we don't even want to pass it to the api call to maintain previous behaviour.
233335
if (codeDeployDescription) {
234-
deploymentParams.description = codeDeployDescription
336+
// CodeDeploy Deployment Descriptions have a max length of 512 characters, so truncate if necessary
337+
deploymentParams.description = (codeDeployDescription.length <= 512) ? codeDeployDescription : `${codeDeployDescription.substring(0,511)}…`;
235338
}
236339
if (codeDeployConfig) {
237340
deploymentParams.deploymentConfigName = codeDeployConfig
@@ -307,10 +410,18 @@ async function run() {
307410
const taskDefArn = registerResponse.taskDefinition.taskDefinitionArn;
308411
core.setOutput('task-definition-arn', taskDefArn);
309412

413+
// Run the task outside of the service
414+
const clusterName = cluster ? cluster : 'default';
415+
const shouldRunTaskInput = core.getInput('run-task', { required: false }) || 'false';
416+
const shouldRunTask = shouldRunTaskInput.toLowerCase() === 'true';
417+
core.debug(`shouldRunTask: ${shouldRunTask}`);
418+
if (shouldRunTask) {
419+
core.debug("Running ad-hoc task...");
420+
await runTask(ecs, clusterName, taskDefArn, waitForMinutes);
421+
}
422+
310423
// Update the service with the new task definition
311424
if (service) {
312-
const clusterName = cluster ? cluster : 'default';
313-
314425
// Determine the deployment controller
315426
const describeResponse = await ecs.describeServices({
316427
services: [service],

index.js

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const path = require('path');
22
const core = require('@actions/core');
33
const { CodeDeploy, waitUntilDeploymentSuccessful } = require('@aws-sdk/client-codedeploy');
4-
const { ECS, waitUntilServicesStable } = require('@aws-sdk/client-ecs');
4+
const { ECS, waitUntilServicesStable, waitUntilTasksStopped } = require('@aws-sdk/client-ecs');
55
const yaml = require('yaml');
66
const fs = require('fs');
77
const crypto = require('crypto');
@@ -21,6 +21,107 @@ const IGNORED_TASK_DEFINITION_ATTRIBUTES = [
2121
'registeredBy'
2222
];
2323

24+
// Run task outside of a service
25+
async function runTask(ecs, clusterName, taskDefArn, waitForMinutes) {
26+
core.info('Running task')
27+
28+
const waitForTask = core.getInput('wait-for-task-stopped', { required: false }) || 'false';
29+
const startedBy = core.getInput('run-task-started-by', { required: false }) || 'GitHub-Actions';
30+
const launchType = core.getInput('run-task-launch-type', { required: false }) || 'FARGATE';
31+
const subnetIds = core.getInput('run-task-subnets', { required: false }) || '';
32+
const securityGroupIds = core.getInput('run-task-security-groups', { required: false }) || '';
33+
const containerOverrides = JSON.parse(core.getInput('run-task-container-overrides', { required: false }) || '[]');
34+
let awsvpcConfiguration = {}
35+
36+
if (subnetIds != "") {
37+
awsvpcConfiguration["subnets"] = subnetIds.split(',')
38+
}
39+
40+
if (securityGroupIds != "") {
41+
awsvpcConfiguration["securityGroups"] = securityGroupIds.split(',')
42+
}
43+
44+
const runTaskResponse = await ecs.runTask({
45+
startedBy: startedBy,
46+
cluster: clusterName,
47+
taskDefinition: taskDefArn,
48+
overrides: {
49+
containerOverrides: containerOverrides
50+
},
51+
launchType: launchType,
52+
networkConfiguration: Object.keys(awsvpcConfiguration).length === 0 ? {} : { awsvpcConfiguration: awsvpcConfiguration }
53+
});
54+
55+
core.debug(`Run task response ${JSON.stringify(runTaskResponse)}`)
56+
57+
const taskArns = runTaskResponse.tasks.map(task => task.taskArn);
58+
core.setOutput('run-task-arn', taskArns);
59+
60+
const region = await ecs.config.region();
61+
const consoleHostname = region.startsWith('cn') ? 'console.amazonaws.cn' : 'console.aws.amazon.com';
62+
63+
core.info(`Task running: https://${consoleHostname}/ecs/home?region=${region}#/clusters/${clusterName}/tasks`);
64+
65+
if (runTaskResponse.failures && runTaskResponse.failures.length > 0) {
66+
const failure = runTaskResponse.failures[0];
67+
throw new Error(`${failure.arn} is ${failure.reason}`);
68+
}
69+
70+
// Wait for task to end
71+
if (waitForTask && waitForTask.toLowerCase() === "true") {
72+
await waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes)
73+
await tasksExitCode(ecs, clusterName, taskArns)
74+
} else {
75+
core.debug('Not waiting for the task to stop');
76+
}
77+
}
78+
79+
// Poll tasks until they enter a stopped state
80+
async function waitForTasksStopped(ecs, clusterName, taskArns, waitForMinutes) {
81+
if (waitForMinutes > MAX_WAIT_MINUTES) {
82+
waitForMinutes = MAX_WAIT_MINUTES;
83+
}
84+
85+
core.info(`Waiting for tasks to stop. Will wait for ${waitForMinutes} minutes`);
86+
87+
const waitTaskResponse = await waitUntilTasksStopped({
88+
client: ecs,
89+
minDelay: WAIT_DEFAULT_DELAY_SEC,
90+
maxWaitTime: waitForMinutes * 60,
91+
}, {
92+
cluster: clusterName,
93+
tasks: taskArns,
94+
});
95+
96+
core.debug(`Run task response ${JSON.stringify(waitTaskResponse)}`);
97+
core.info('All tasks have stopped.');
98+
}
99+
100+
// Check a task's exit code and fail the job on error
101+
async function tasksExitCode(ecs, clusterName, taskArns) {
102+
const describeResponse = await ecs.describeTasks({
103+
cluster: clusterName,
104+
tasks: taskArns
105+
});
106+
107+
const containers = [].concat(...describeResponse.tasks.map(task => task.containers))
108+
const exitCodes = containers.map(container => container.exitCode)
109+
const reasons = containers.map(container => container.reason)
110+
111+
const failuresIdx = [];
112+
113+
exitCodes.filter((exitCode, index) => {
114+
if (exitCode !== 0) {
115+
failuresIdx.push(index)
116+
}
117+
})
118+
119+
const failures = reasons.filter((_, index) => failuresIdx.indexOf(index) !== -1)
120+
if (failures.length > 0) {
121+
throw new Error(`Run task failed: ${JSON.stringify(failures)}`);
122+
}
123+
}
124+
24125
// Deploy to a service that uses the 'ECS' deployment controller
25126
async function updateEcsService(ecs, clusterName, service, taskDefArn, waitForService, waitForMinutes, forceNewDeployment, desiredCount) {
26127
core.debug('Updating the service');
@@ -223,9 +324,11 @@ async function createCodeDeployDeployment(codedeploy, clusterName, service, task
223324
}
224325
}
225326
};
327+
226328
// If it hasn't been set then we don't even want to pass it to the api call to maintain previous behaviour.
227329
if (codeDeployDescription) {
228-
deploymentParams.description = codeDeployDescription
330+
// CodeDeploy Deployment Descriptions have a max length of 512 characters, so truncate if necessary
331+
deploymentParams.description = (codeDeployDescription.length <= 512) ? codeDeployDescription : `${codeDeployDescription.substring(0,511)}…`;
229332
}
230333
if (codeDeployConfig) {
231334
deploymentParams.deploymentConfigName = codeDeployConfig
@@ -301,10 +404,18 @@ async function run() {
301404
const taskDefArn = registerResponse.taskDefinition.taskDefinitionArn;
302405
core.setOutput('task-definition-arn', taskDefArn);
303406

407+
// Run the task outside of the service
408+
const clusterName = cluster ? cluster : 'default';
409+
const shouldRunTaskInput = core.getInput('run-task', { required: false }) || 'false';
410+
const shouldRunTask = shouldRunTaskInput.toLowerCase() === 'true';
411+
core.debug(`shouldRunTask: ${shouldRunTask}`);
412+
if (shouldRunTask) {
413+
core.debug("Running ad-hoc task...");
414+
await runTask(ecs, clusterName, taskDefArn, waitForMinutes);
415+
}
416+
304417
// Update the service with the new task definition
305418
if (service) {
306-
const clusterName = cluster ? cluster : 'default';
307-
308419
// Determine the deployment controller
309420
const describeResponse = await ecs.describeServices({
310421
services: [service],

0 commit comments

Comments
 (0)