Skip to content

Commit 84c6e68

Browse files
committed
feat: Add ad-hoc task runs
1 parent f1b98fd commit 84c6e68

File tree

4 files changed

+292
-3
lines changed

4 files changed

+292
-3
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'

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-subnet-ids', { required: false }) || '';
31+
const securityGroupIds = core.getInput('run-task-security-group-ids', { required: false }) || '';
32+
const containerOverrides = JSON.parse(core.getInput('run-task-container-overrides', { required: false }) || '[]');
33+
let awsVpcNetworkConfiguration = {}
34+
35+
if (subnetIds != "") {
36+
awsVpcNetworkConfiguration["subnets"] = subnetIds.split(',')
37+
}
38+
39+
if (securityGroupIds != "") {
40+
awsVpcNetworkConfiguration["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: awsVpcNetworkConfiguration === {} ? {} : { awsVpcNetworkConfiguration: awsVpcNetworkConfiguration }
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+
if (shouldRunTask) {
394+
await runTask(ecs, clusterName, taskDefArn, waitForMinutes);
395+
} else {
396+
core.debug('run-task is not true, skipping.')
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],

index.test.js

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const mockEcsWaiter = jest.fn();
1313
const mockCodeDeployCreateDeployment = jest.fn();
1414
const mockCodeDeployGetDeploymentGroup = jest.fn();
1515
const mockCodeDeployWaiter = jest.fn();
16+
const mockRunTask = jest.fn();
17+
const mockEcsDescribeTasks = jest.fn();
1618
jest.mock('aws-sdk', () => {
1719
return {
1820
config: {
@@ -22,7 +24,9 @@ jest.mock('aws-sdk', () => {
2224
registerTaskDefinition: mockEcsRegisterTaskDef,
2325
updateService: mockEcsUpdateService,
2426
describeServices: mockEcsDescribeServices,
25-
waitFor: mockEcsWaiter
27+
waitFor: mockEcsWaiter,
28+
describeTasks: mockEcsDescribeTasks,
29+
runTask: mockRunTask
2630
})),
2731
CodeDeploy: jest.fn(() => ({
2832
createDeployment: mockCodeDeployCreateDeployment,
@@ -144,6 +148,58 @@ describe('Deploy to ECS', () => {
144148
}
145149
};
146150
});
151+
152+
mockRunTask.mockImplementation(() => {
153+
return {
154+
promise() {
155+
return Promise.resolve({
156+
failures: [],
157+
tasks: [
158+
{
159+
containers: [
160+
{
161+
lastStatus: "RUNNING",
162+
exitCode: 0,
163+
reason: '',
164+
taskArn: "arn:aws:ecs:fake-region:account_id:task/arn"
165+
}
166+
],
167+
desiredStatus: "RUNNING",
168+
lastStatus: "RUNNING",
169+
taskArn: "arn:aws:ecs:fake-region:account_id:task/arn"
170+
// taskDefinitionArn: "arn:aws:ecs:<region>:<aws_account_id>:task-definition/amazon-ecs-sample:1"
171+
}
172+
]
173+
});
174+
}
175+
};
176+
});
177+
178+
mockEcsDescribeTasks.mockImplementation(() => {
179+
return {
180+
promise() {
181+
return Promise.resolve({
182+
failures: [],
183+
tasks: [
184+
{
185+
containers: [
186+
{
187+
lastStatus: "RUNNING",
188+
exitCode: 0,
189+
reason: '',
190+
taskArn: "arn:aws:ecs:fake-region:account_id:task/arn"
191+
}
192+
],
193+
desiredStatus: "RUNNING",
194+
lastStatus: "RUNNING",
195+
taskArn: "arn:aws:ecs:fake-region:account_id:task/arn"
196+
}
197+
]
198+
});
199+
}
200+
};
201+
});
202+
147203
});
148204

149205
test('registers the task definition contents and updates the service', async () => {
@@ -641,6 +697,7 @@ describe('Deploy to ECS', () => {
641697
.mockReturnValueOnce('false') // wait-for-service-stability
642698
.mockReturnValueOnce('') // wait-for-minutes
643699
.mockReturnValueOnce('') // force-new-deployment
700+
.mockReturnValueOnce('') // run-task
644701
.mockReturnValueOnce('/hello/appspec.json') // codedeploy-appspec
645702
.mockReturnValueOnce('MyApplication') // codedeploy-application
646703
.mockReturnValueOnce('MyDeploymentGroup'); // codedeploy-deployment-group
@@ -1022,6 +1079,88 @@ describe('Deploy to ECS', () => {
10221079
expect(mockEcsUpdateService).toHaveBeenCalledTimes(0);
10231080
});
10241081

1082+
test('run task', async () => {
1083+
core.getInput = jest
1084+
.fn()
1085+
.mockReturnValueOnce('task-definition.json') // task-definition
1086+
.mockReturnValueOnce('') // service
1087+
.mockReturnValueOnce('') // cluster
1088+
.mockReturnValueOnce('') // wait-for-service-stability
1089+
.mockReturnValueOnce('') // wait-for-minutes
1090+
.mockReturnValueOnce('') // force-new-deployment
1091+
.mockReturnValueOnce('true'); // run-task
1092+
1093+
await run();
1094+
expect(core.setFailed).toHaveBeenCalledTimes(0);
1095+
1096+
expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family' });
1097+
expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn');
1098+
expect(mockRunTask).toHaveBeenCalledTimes(1);
1099+
expect(core.setOutput).toHaveBeenNthCalledWith(2, 'run-task-arn', ["arn:aws:ecs:fake-region:account_id:task/arn"]);
1100+
});
1101+
1102+
test('run task with options', async () => {
1103+
core.getInput = jest
1104+
.fn()
1105+
.mockReturnValueOnce('task-definition.json') // task-definition
1106+
.mockReturnValueOnce('') // service
1107+
.mockReturnValueOnce('somecluster') // cluster
1108+
.mockReturnValueOnce('') // wait-for-service-stability
1109+
.mockReturnValueOnce('') // wait-for-minutes
1110+
.mockReturnValueOnce('') // force-new-deployment
1111+
.mockReturnValueOnce('true') // run-task
1112+
.mockReturnValueOnce('false') // wait-for-task-stopped
1113+
.mockReturnValueOnce('someJoe') // run-task-started-by
1114+
.mockReturnValueOnce('EC2') // run-task-launch-type
1115+
.mockReturnValueOnce('a,b') // run-task-subnet-ids
1116+
.mockReturnValueOnce('c,d') // run-task-security-group-ids
1117+
.mockReturnValueOnce(JSON.stringify([{ name: 'someapp', command: 'somecmd' }])); // run-task-container-overrides
1118+
1119+
await run();
1120+
expect(core.setFailed).toHaveBeenCalledTimes(0);
1121+
1122+
expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family' });
1123+
expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn');
1124+
expect(mockRunTask).toHaveBeenCalledWith({
1125+
startedBy: 'someJoe',
1126+
cluster: 'somecluster',
1127+
launchType: "EC2",
1128+
taskDefinition: 'task:def:arn',
1129+
overrides: { containerOverrides: [{ name: 'someapp', command: 'somecmd' }] },
1130+
networkConfiguration: { awsVpcNetworkConfiguration: { subnets: ['a', 'b'], securityGroups: ['c', 'd'] } }
1131+
});
1132+
expect(core.setOutput).toHaveBeenNthCalledWith(2, 'run-task-arn', ["arn:aws:ecs:fake-region:account_id:task/arn"]);
1133+
});
1134+
1135+
test('run task and wait for it to stop', async () => {
1136+
core.getInput = jest
1137+
.fn()
1138+
.mockReturnValueOnce('task-definition.json') // task-definition
1139+
.mockReturnValueOnce('') // service
1140+
.mockReturnValueOnce('somecluster') // cluster
1141+
.mockReturnValueOnce('') // wait-for-service-stability
1142+
.mockReturnValueOnce('') // wait-for-minutes
1143+
.mockReturnValueOnce('') // force-new-deployment
1144+
.mockReturnValueOnce('true') // run-task
1145+
.mockReturnValueOnce('true'); // wait-for-task-stopped
1146+
1147+
await run();
1148+
expect(core.setFailed).toHaveBeenCalledTimes(0);
1149+
1150+
expect(mockEcsRegisterTaskDef).toHaveBeenNthCalledWith(1, { family: 'task-def-family' });
1151+
expect(core.setOutput).toHaveBeenNthCalledWith(1, 'task-definition-arn', 'task:def:arn')
1152+
expect(mockRunTask).toHaveBeenCalledTimes(1);
1153+
expect(core.setOutput).toHaveBeenNthCalledWith(2, 'run-task-arn', ["arn:aws:ecs:fake-region:account_id:task/arn"])
1154+
expect(mockEcsWaiter).toHaveBeenNthCalledWith(1, 'tasksStopped', {
1155+
tasks: ['arn:aws:ecs:fake-region:account_id:task/arn'],
1156+
cluster: 'somecluster',
1157+
"$waiter": {
1158+
"delay": 15,
1159+
"maxAttempts": 120,
1160+
},
1161+
});
1162+
});
1163+
10251164
test('error caught if AppSpec file is not formatted correctly', async () => {
10261165
mockEcsDescribeServices.mockImplementation(() => {
10271166
return {

0 commit comments

Comments
 (0)