@squiz/job-runner-lib 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/lib/domains/job/S3Repository/S3RepositoryBase.d.ts +6 -0
- package/lib/domains/job/services/JobExecutionTaskService/JobExecutionTaskService.defs.d.ts +0 -1
- package/lib/index.js +7 -7
- package/lib/index.js.map +3 -3
- package/package.json +2 -2
- package/src/domains/job/S3Repository/S3RepositoryBase.ts +19 -0
- package/src/domains/job/services/JobExecutionTaskService/JobExecutionTaskService.defs.ts +0 -1
- package/src/domains/job/services/JobExecutionTaskService/JobExecutionTaskService.spec.ts +63 -13
- package/src/domains/job/services/JobExecutionTaskService/JobExecutionTaskService.ts +25 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@squiz/job-runner-lib",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"private": false,
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@squiz/dx-json-schema-lib": "1.78.1",
|
|
39
39
|
"@squiz/dx-logger-lib": "^1.64.0",
|
|
40
40
|
"@squiz/optimization-logger": "^1.9.0",
|
|
41
|
-
"axios": "^1.
|
|
41
|
+
"axios": "^1.15.0",
|
|
42
42
|
"cli-color": "^2.0.4",
|
|
43
43
|
"fs-extra": "11.1.0",
|
|
44
44
|
"immer": "^10.1.3",
|
|
@@ -31,6 +31,20 @@ export class S3RepositoryBase {
|
|
|
31
31
|
this.bucketName = bucketName;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Validates S3 key to prevent malicious input
|
|
36
|
+
* @param {string} key The S3 object key to validate
|
|
37
|
+
* @private
|
|
38
|
+
*/
|
|
39
|
+
private validateS3Key(key: string): void {
|
|
40
|
+
if (!key || typeof key !== 'string') {
|
|
41
|
+
throw new Error('Invalid S3 key: must be a non-empty string');
|
|
42
|
+
}
|
|
43
|
+
if (key.includes('..') || key.startsWith('/')) {
|
|
44
|
+
throw new Error('Invalid S3 key: contains suspicious patterns');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
/**
|
|
35
49
|
* Save object into S3.
|
|
36
50
|
* @param {string} key The S3 object key.
|
|
@@ -39,6 +53,7 @@ export class S3RepositoryBase {
|
|
|
39
53
|
* @memberof S3RepositoryBase
|
|
40
54
|
*/
|
|
41
55
|
async put(key: string, body: PutObjectCommandInput['Body']): Promise<void> {
|
|
56
|
+
this.validateS3Key(key);
|
|
42
57
|
await this.s3Client.send(
|
|
43
58
|
new PutObjectCommand({
|
|
44
59
|
Bucket: this.bucketName,
|
|
@@ -55,6 +70,7 @@ export class S3RepositoryBase {
|
|
|
55
70
|
* @memberof S3RepositoryBase
|
|
56
71
|
*/
|
|
57
72
|
public async delete(key: string): Promise<void> {
|
|
73
|
+
this.validateS3Key(key);
|
|
58
74
|
await this.s3Client.send(
|
|
59
75
|
new DeleteObjectCommand({
|
|
60
76
|
Bucket: this.bucketName,
|
|
@@ -70,6 +86,7 @@ export class S3RepositoryBase {
|
|
|
70
86
|
* @memberof S3RepositoryBase
|
|
71
87
|
*/
|
|
72
88
|
async get(key: string): Promise<string | null> {
|
|
89
|
+
this.validateS3Key(key);
|
|
73
90
|
const s3Object = await this.s3Client.send(
|
|
74
91
|
new GetObjectCommand({
|
|
75
92
|
Bucket: this.bucketName,
|
|
@@ -87,6 +104,7 @@ export class S3RepositoryBase {
|
|
|
87
104
|
* @memberof S3RepositoryBase
|
|
88
105
|
*/
|
|
89
106
|
async check(key: string): Promise<HeadObjectCommandOutput> {
|
|
107
|
+
this.validateS3Key(key);
|
|
90
108
|
return this.s3Client.send(
|
|
91
109
|
new HeadObjectCommand({
|
|
92
110
|
Bucket: this.bucketName,
|
|
@@ -96,6 +114,7 @@ export class S3RepositoryBase {
|
|
|
96
114
|
}
|
|
97
115
|
|
|
98
116
|
async getS3SignedUrl(key: string): Promise<string> {
|
|
117
|
+
this.validateS3Key(key);
|
|
99
118
|
const command = new GetObjectCommand({
|
|
100
119
|
Bucket: this.bucketName,
|
|
101
120
|
Key: key,
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import 'aws-sdk
|
|
2
|
-
import { AwsClientStub, mockClient } from 'aws-sdk-client-mock';
|
|
3
|
-
import { ConflictException, ResourceNotFoundException, ECSClient, RunTaskCommand } from '@aws-sdk/client-ecs';
|
|
1
|
+
import { ConflictException, ECSClient, ResourceNotFoundException, RunTaskCommand } from '@aws-sdk/client-ecs';
|
|
4
2
|
import { faker } from '@faker-js/faker';
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import { JobContextService } from '../JobContextService/JobContextService';
|
|
3
|
+
import { AwsClientStub, mockClient } from 'aws-sdk-client-mock';
|
|
4
|
+
import 'aws-sdk-client-mock-jest';
|
|
8
5
|
import { JobDetailsRepository, JobUploadsRepository } from '../../S3Repository';
|
|
9
|
-
import {
|
|
10
|
-
import { createMockJobContext } from '../../model/JobContext';
|
|
6
|
+
import { JOB_EXECUTION_STATUS } from '../../constants/JobExecutionStatus.constants';
|
|
11
7
|
import { createMockJobManifest } from '../../manifest';
|
|
12
|
-
import {
|
|
8
|
+
import { createMockJobContext } from '../../model/JobContext';
|
|
9
|
+
import { createMockJobExecution } from '../../model/JobExecution';
|
|
13
10
|
import { JobExecutionRepository } from '../../repository';
|
|
14
|
-
import {
|
|
11
|
+
import { JobContextService } from '../JobContextService/JobContextService';
|
|
12
|
+
import { JobManifestService } from '../JobManifestService/JobManifestService';
|
|
13
|
+
import { JobExecutionTaskService } from './JobExecutionTaskService';
|
|
14
|
+
import { RunJobTaskParams } from './JobExecutionTaskService.defs';
|
|
15
15
|
|
|
16
16
|
describe('JobExecutionTaskService', () => {
|
|
17
17
|
let mockEcsClient: AwsClientStub<ECSClient>;
|
|
@@ -30,7 +30,6 @@ describe('JobExecutionTaskService', () => {
|
|
|
30
30
|
shortRegion: 'au',
|
|
31
31
|
shortEnvironment: 'dev',
|
|
32
32
|
clusterArn: `clusterArn`,
|
|
33
|
-
subnets: ['subnet1'],
|
|
34
33
|
taskDefinitionArn: `taskDefinitionArn`,
|
|
35
34
|
tenantId: 'some_tenant',
|
|
36
35
|
};
|
|
@@ -124,8 +123,6 @@ describe('JobExecutionTaskService', () => {
|
|
|
124
123
|
expect(response).toBe(mockArn);
|
|
125
124
|
expect(mockEcsClient).toHaveReceivedCommandWith(RunTaskCommand, {
|
|
126
125
|
cluster: 'clusterArn',
|
|
127
|
-
launchType: 'FARGATE',
|
|
128
|
-
networkConfiguration: { awsvpcConfiguration: { subnets: ['subnet1'] } },
|
|
129
126
|
overrides: {
|
|
130
127
|
containerOverrides: [
|
|
131
128
|
{
|
|
@@ -268,6 +265,59 @@ describe('JobExecutionTaskService', () => {
|
|
|
268
265
|
expect(response).toBe(undefined);
|
|
269
266
|
});
|
|
270
267
|
|
|
268
|
+
it('should handle when ECS returns no task ARNs or failures', async () => {
|
|
269
|
+
const jobContext = createMockJobContext();
|
|
270
|
+
const jobManifest = createMockJobManifest();
|
|
271
|
+
const jobExecution = createMockJobExecution({
|
|
272
|
+
id: `20250917T005949395Z-9602417782`,
|
|
273
|
+
jobName: 'jobName',
|
|
274
|
+
version: 'jobVersion',
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
mockJobContextService.findJobContext.mockResolvedValue(jobContext);
|
|
278
|
+
mockJobManifestService.findJobManifest.mockResolvedValue(jobManifest);
|
|
279
|
+
mockEcsClient.on(RunTaskCommand).resolves({ tasks: [], failures: [] });
|
|
280
|
+
|
|
281
|
+
const response = await jobExecutionTaskService.runJobExecutionTask({
|
|
282
|
+
...defaultRunJobTaskParams,
|
|
283
|
+
jobExecution: jobExecution,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Similar to a ConflictException being thrown. ECS appears to sometimes return an empty tasks and failures
|
|
287
|
+
// array when the idempotency key is used.
|
|
288
|
+
expect(response).toBe(undefined);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should throw when ECS returns no task ARNs and has failures', async () => {
|
|
292
|
+
const jobContext = createMockJobContext();
|
|
293
|
+
const jobManifest = createMockJobManifest();
|
|
294
|
+
const jobExecution = createMockJobExecution({
|
|
295
|
+
id: `20250917T005949395Z-9602417782`,
|
|
296
|
+
jobName: 'jobName',
|
|
297
|
+
version: 'jobVersion',
|
|
298
|
+
});
|
|
299
|
+
const failures = [
|
|
300
|
+
{
|
|
301
|
+
arn: faker.string.uuid(),
|
|
302
|
+
detail: 'Insufficient capacity to start job',
|
|
303
|
+
reason: faker.lorem.words(),
|
|
304
|
+
},
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
mockJobContextService.findJobContext.mockResolvedValue(jobContext);
|
|
308
|
+
mockJobManifestService.findJobManifest.mockResolvedValue(jobManifest);
|
|
309
|
+
mockEcsClient.on(RunTaskCommand).resolves({ tasks: [], failures });
|
|
310
|
+
|
|
311
|
+
await expect(
|
|
312
|
+
jobExecutionTaskService.runJobExecutionTask({
|
|
313
|
+
...defaultRunJobTaskParams,
|
|
314
|
+
jobExecution: jobExecution,
|
|
315
|
+
}),
|
|
316
|
+
).rejects.toThrow(
|
|
317
|
+
`Execution start failed: ${JSON.stringify(failures)} (id: "20250917T005949395Z-9602417782", job: jobName, tenant: some_tenant)`,
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
271
321
|
it('should re-throw unhandled errors when starting a task', async () => {
|
|
272
322
|
const expectedError = new ResourceNotFoundException({ message: faker.lorem.words(), $metadata: {} });
|
|
273
323
|
const jobContext = createMockJobContext();
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Copyright Squiz Australia Pty Ltd. All Rights Reserved.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { ConflictException, ECSClient,
|
|
6
|
+
import { ConflictException, ECSClient, RunTaskCommand } from '@aws-sdk/client-ecs';
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
8
|
import { JobDetailsRepository, JobUploadsRepository } from '../../S3Repository';
|
|
9
9
|
import { JOB_EXECUTION_STATUS } from '../../constants/JobExecutionStatus.constants';
|
|
@@ -104,12 +104,6 @@ export class JobExecutionTaskService {
|
|
|
104
104
|
const result = await this.ecsClient.send(
|
|
105
105
|
new RunTaskCommand({
|
|
106
106
|
cluster: params.clusterArn,
|
|
107
|
-
networkConfiguration: {
|
|
108
|
-
awsvpcConfiguration: {
|
|
109
|
-
subnets: params.subnets,
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
launchType: LaunchType.FARGATE,
|
|
113
107
|
taskDefinition: params.taskDefinitionArn,
|
|
114
108
|
overrides: {
|
|
115
109
|
containerOverrides: [
|
|
@@ -127,6 +121,30 @@ export class JobExecutionTaskService {
|
|
|
127
121
|
}),
|
|
128
122
|
);
|
|
129
123
|
|
|
124
|
+
if (!result.tasks?.length) {
|
|
125
|
+
if (result.failures?.length) {
|
|
126
|
+
// Task has probably already started.
|
|
127
|
+
// ECS can sometimes return no tasks/failures if attempting to start
|
|
128
|
+
// the same task multiple times in quick succession:
|
|
129
|
+
// * {"tasks":[],"failures":[],"$metadata":{"httpStatusCode":200,...}}
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Execution start failed: ${JSON.stringify(result.failures)} ` +
|
|
132
|
+
`(id: "${params.jobExecution.id}", job: ${params.jobExecution.jobName}, tenant: ${params.tenantId})`,
|
|
133
|
+
);
|
|
134
|
+
} else {
|
|
135
|
+
// Task has probably already started.
|
|
136
|
+
// ECS can sometimes return no tasks/failures if attempting to start
|
|
137
|
+
// the same task multiple times in quick succession:
|
|
138
|
+
// * {"tasks":[],"failures":[],"$metadata":{"httpStatusCode":200,...}}
|
|
139
|
+
console.warn(
|
|
140
|
+
`Execution start returned no ECS task ID` +
|
|
141
|
+
` (id: "${params.jobExecution.id}", job: ${params.jobExecution.jobName}, tenant: ${params.tenantId})`,
|
|
142
|
+
JSON.stringify(result),
|
|
143
|
+
);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
130
148
|
// This task ARN can be used to terminate the job task.
|
|
131
149
|
// If no error is returned, assume task has started.
|
|
132
150
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|