@squiz/job-runner-lib 2.5.0 → 2.6.0
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/constants/RetryConfig.constants.d.ts +4 -0
- package/lib/domains/job/constants/RuntimeImage.constants.d.ts +11 -0
- package/lib/domains/job/index.d.ts +1 -0
- package/lib/domains/job/manifest/JobManifestTypes.d.ts +12 -0
- package/lib/domains/manifest/index.d.ts +1 -0
- package/lib/domains/manifest/services/ManifestServiceForDev.d.ts +2 -2
- package/lib/domains/manifest/utils/validateRuntimeImage.d.ts +5 -0
- package/lib/domains/manifest/utils/validateRuntimeImage.spec.d.ts +5 -0
- package/lib/index.js +9 -9
- package/lib/index.js.map +4 -4
- package/package.json +3 -3
- package/src/domains/job/S3Repository/S3RepositoryBase.ts +19 -0
- package/src/domains/job/constants/RetryConfig.constants.ts +5 -0
- package/src/domains/job/constants/RuntimeImage.constants.ts +22 -0
- package/src/domains/job/index.ts +1 -0
- package/src/domains/job/manifest/JobManifestTypes.ts +14 -0
- package/src/domains/job/manifest/mocks/JobManifest.mock.ts +2 -0
- package/src/domains/job/manifest/mocks/JobManifestDto.mock.ts +4 -0
- package/src/domains/job/manifest/mocks/JobManifestRecord.mock.ts +2 -0
- package/src/domains/job/manifest/mocks/JobManifestRequest.mock.ts +2 -0
- package/src/domains/job/repository/JobManifestRepository/JobManifestRepository.spec.ts +33 -1
- package/src/domains/job/repository/JobManifestRepository/JobManifestRepository.ts +9 -0
- package/src/domains/job/services/JobExecutionTaskService/JobExecutionTaskService.spec.ts +53 -0
- package/src/domains/job/services/JobExecutionTaskService/JobExecutionTaskService.ts +24 -0
- package/src/domains/job/services/JobManifestService/JobManifestService.spec.ts +1 -0
- package/src/domains/manifest/index.ts +1 -0
- package/src/domains/manifest/services/ManifestServiceForDev.spec.ts +1 -1
- package/src/domains/manifest/services/ManifestServiceForDev.ts +3 -3
- package/src/domains/manifest/utils/validateRuntimeImage.spec.ts +40 -0
- package/src/domains/manifest/utils/validateRuntimeImage.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@squiz/job-runner-lib",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"private": false,
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"@aws-sdk/client-sqs": "3.1003.0",
|
|
36
36
|
"@aws-sdk/s3-request-presigner": "3.1003.0",
|
|
37
37
|
"@squiz/dx-common-lib": "^1.66.1",
|
|
38
|
-
"@squiz/dx-json-schema-lib": "1.
|
|
38
|
+
"@squiz/dx-json-schema-lib": "1.85.0",
|
|
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,
|
|
@@ -16,6 +16,11 @@ export const RETRY_DELAYS_SECONDS: ReadonlyArray<number> = [30, 60, 120, 300, 60
|
|
|
16
16
|
*/
|
|
17
17
|
export const MAX_RETRY_ATTEMPTS = RETRY_DELAYS_SECONDS.length;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* After a job has attempted to be started this number of times alerts will start to be sent.
|
|
21
|
+
*/
|
|
22
|
+
export const ALERT_AFTER_ATTEMPTS = 2;
|
|
23
|
+
|
|
19
24
|
/**
|
|
20
25
|
* Get the delay in seconds for a given retry attempt.
|
|
21
26
|
* @param retryAttempt - The current retry attempt (0-indexed)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright Squiz Australia Pty Ltd. All Rights Reserved.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const SUPPORTED_RUNTIME_IMAGES = ['node:20', 'node:24'] as const;
|
|
7
|
+
|
|
8
|
+
// Default for jobs that pre-date runtime image support.
|
|
9
|
+
export const LEGACY_RUNTIME_IMAGE = 'node:20';
|
|
10
|
+
|
|
11
|
+
export const RECOMMENDED_RUNTIME_IMAGE = 'node:24';
|
|
12
|
+
|
|
13
|
+
// Still runnable but rejected on new uploads.
|
|
14
|
+
export const DEPRECATED_RUNTIME_IMAGES: readonly string[] = [];
|
|
15
|
+
|
|
16
|
+
export const getRuntimeImageOrDefault = (image: string | null | undefined): string =>
|
|
17
|
+
image && isSupportedRuntimeImage(image) ? image : LEGACY_RUNTIME_IMAGE;
|
|
18
|
+
|
|
19
|
+
export const isSupportedRuntimeImage = (image: string): boolean =>
|
|
20
|
+
(SUPPORTED_RUNTIME_IMAGES as readonly string[]).includes(image);
|
|
21
|
+
|
|
22
|
+
export const isDeprecatedRuntimeImage = (image: string): boolean => DEPRECATED_RUNTIME_IMAGES.includes(image);
|
package/src/domains/job/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from './constants/InjectTokens';
|
|
|
5
5
|
export * from './constants/JobExecutionStatus.constants';
|
|
6
6
|
export * from './constants/Repository.constants';
|
|
7
7
|
export * from './constants/RetryConfig.constants';
|
|
8
|
+
export * from './constants/RuntimeImage.constants';
|
|
8
9
|
|
|
9
10
|
export * from './services';
|
|
10
11
|
export * from './utils';
|
|
@@ -57,6 +57,13 @@ export interface JobManifest {
|
|
|
57
57
|
*/
|
|
58
58
|
concurrency: number;
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The runtime image the job should execute against (e.g. `node:20`, `node:24`).
|
|
62
|
+
* @type {string}
|
|
63
|
+
* @memberof JobManifest
|
|
64
|
+
*/
|
|
65
|
+
image: string;
|
|
66
|
+
|
|
60
67
|
/**
|
|
61
68
|
* The DynamoDB record type ('job').
|
|
62
69
|
* @type {EntityType}
|
|
@@ -128,6 +135,13 @@ export interface JobManifestRecord {
|
|
|
128
135
|
* @memberof JobManifestRecord
|
|
129
136
|
*/
|
|
130
137
|
concurrency: number;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The runtime image the job should execute against (e.g. `node:20`, `node:24`).
|
|
141
|
+
* @type {string}
|
|
142
|
+
* @memberof JobManifestRecord
|
|
143
|
+
*/
|
|
144
|
+
image: string;
|
|
131
145
|
}
|
|
132
146
|
|
|
133
147
|
export type JobManifestDto = Record<keyof JobManifestRecord, AttributeValue>;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { faker } from '@faker-js/faker/locale/en';
|
|
7
7
|
import { ENTITY_TYPES } from '../../constants/Repository.constants';
|
|
8
|
+
import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
|
|
8
9
|
import { JobManifest } from '../JobManifestTypes';
|
|
9
10
|
|
|
10
11
|
export const createMockJobManifest = (partialRecord: Partial<JobManifest> = {}): JobManifest => {
|
|
@@ -24,6 +25,7 @@ export const createMockJobManifest = (partialRecord: Partial<JobManifest> = {}):
|
|
|
24
25
|
name: faker.lorem.word(),
|
|
25
26
|
version,
|
|
26
27
|
timeoutSeconds: faker.number.int({ max: 28800, min: 5 }),
|
|
28
|
+
image: LEGACY_RUNTIME_IMAGE,
|
|
27
29
|
type: ENTITY_TYPES.manifest,
|
|
28
30
|
...partialRecord,
|
|
29
31
|
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { faker } from '@faker-js/faker/locale/en';
|
|
7
7
|
import { ENTITY_TYPES } from '../../constants/Repository.constants';
|
|
8
|
+
import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
|
|
8
9
|
import { JobManifestDto, JobManifestRecord } from '../JobManifestTypes';
|
|
9
10
|
|
|
10
11
|
export const createMockJobManifestDto = (partialRecord: Partial<JobManifestRecord> = {}): JobManifestDto => {
|
|
@@ -38,6 +39,9 @@ export const createMockJobManifestDto = (partialRecord: Partial<JobManifestRecor
|
|
|
38
39
|
timeoutSeconds: {
|
|
39
40
|
N: (partialRecord.timeoutSeconds ?? faker.number.int({ max: 28800, min: 5 })).toString(),
|
|
40
41
|
},
|
|
42
|
+
image: {
|
|
43
|
+
S: partialRecord.image ?? LEGACY_RUNTIME_IMAGE,
|
|
44
|
+
},
|
|
41
45
|
};
|
|
42
46
|
};
|
|
43
47
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { faker } from '@faker-js/faker/locale/en';
|
|
7
7
|
import { ENTITY_TYPES } from '../../constants/Repository.constants';
|
|
8
|
+
import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
|
|
8
9
|
import { JobManifestRecord } from '../JobManifestTypes';
|
|
9
10
|
|
|
10
11
|
export const createMockJobManifestRecord = (partialRecord: Partial<JobManifestRecord> = {}): JobManifestRecord => {
|
|
@@ -28,6 +29,7 @@ export const createMockJobManifestRecord = (partialRecord: Partial<JobManifestRe
|
|
|
28
29
|
name,
|
|
29
30
|
version,
|
|
30
31
|
timeoutSeconds: faker.number.int({ max: 28800, min: 5 }),
|
|
32
|
+
image: LEGACY_RUNTIME_IMAGE,
|
|
31
33
|
...partialRecord,
|
|
32
34
|
};
|
|
33
35
|
};
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { faker } from '@faker-js/faker/locale/en';
|
|
7
|
+
import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
|
|
7
8
|
import { CreateJobManifestRequest } from '../JobManifestTypes';
|
|
8
9
|
|
|
9
10
|
export const createMockCreateJobManifestRequest = (
|
|
@@ -25,6 +26,7 @@ export const createMockCreateJobManifestRequest = (
|
|
|
25
26
|
name: faker.lorem.word(),
|
|
26
27
|
version,
|
|
27
28
|
timeoutSeconds: faker.number.int({ max: 28800, min: 5 }),
|
|
29
|
+
image: LEGACY_RUNTIME_IMAGE,
|
|
28
30
|
...partialRecord,
|
|
29
31
|
};
|
|
30
32
|
};
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
createMockJobManifestRecord,
|
|
27
27
|
createMockCreateJobManifestRequest,
|
|
28
28
|
} from '../../manifest';
|
|
29
|
+
import { LEGACY_RUNTIME_IMAGE } from '../../constants/RuntimeImage.constants';
|
|
29
30
|
import { JobManifestRepository } from './JobManifestRepository';
|
|
30
31
|
|
|
31
32
|
initIocContainer();
|
|
@@ -99,7 +100,7 @@ describe('JobManifestRepository', (): void => {
|
|
|
99
100
|
|
|
100
101
|
describe('findOne', () => {
|
|
101
102
|
it(`should return job manifest`, async (): Promise<void> => {
|
|
102
|
-
const mockJobManifestItem = createMockJobManifestDto();
|
|
103
|
+
const mockJobManifestItem = createMockJobManifestDto({ image: 'node:24' });
|
|
103
104
|
mockSend.mockImplementationOnce(
|
|
104
105
|
(): GetItemCommandOutput =>
|
|
105
106
|
createMockClientGetResponse({
|
|
@@ -118,11 +119,34 @@ describe('JobManifestRepository', (): void => {
|
|
|
118
119
|
version: mockJobManifestItem.version.S as string,
|
|
119
120
|
entryFunction: JSON.parse(mockJobManifestItem.entryFunction.S as string),
|
|
120
121
|
timeoutSeconds: parseInt(mockJobManifestItem.timeoutSeconds.N as string),
|
|
122
|
+
image: 'node:24',
|
|
121
123
|
type: `manifest`,
|
|
122
124
|
} as JobManifest);
|
|
123
125
|
expect(mockSend).toBeCalledTimes(1);
|
|
124
126
|
});
|
|
125
127
|
|
|
128
|
+
it(`should default missing image to legacy runtime`, async (): Promise<void> => {
|
|
129
|
+
const mockJobManifestItem = createMockJobManifestDto();
|
|
130
|
+
delete (mockJobManifestItem as Partial<typeof mockJobManifestItem>).image;
|
|
131
|
+
|
|
132
|
+
mockSend.mockImplementationOnce(
|
|
133
|
+
(): GetItemCommandOutput =>
|
|
134
|
+
createMockClientGetResponse({
|
|
135
|
+
Item: mockJobManifestItem,
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const response = await jobManifestRepository.findOne({
|
|
140
|
+
name: mockJobManifestItem.name.S as string,
|
|
141
|
+
version: mockJobManifestItem.version.S as string,
|
|
142
|
+
});
|
|
143
|
+
expect(response).toEqual(
|
|
144
|
+
expect.objectContaining({
|
|
145
|
+
image: LEGACY_RUNTIME_IMAGE,
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
126
150
|
it(`should return null if job manifest does not exist`, async (): Promise<void> => {
|
|
127
151
|
mockSend.mockImplementationOnce(
|
|
128
152
|
(): ScanCommandOutput =>
|
|
@@ -191,6 +215,14 @@ describe('JobManifestRepository', (): void => {
|
|
|
191
215
|
});
|
|
192
216
|
});
|
|
193
217
|
|
|
218
|
+
describe('convertToDynamoDbItem', () => {
|
|
219
|
+
it(`should persist image on the DynamoDB item`, () => {
|
|
220
|
+
const manifest = createMockJobManifest({ image: 'node:24' });
|
|
221
|
+
const dto = jobManifestRepository.convertToDynamoDbItem(manifest);
|
|
222
|
+
expect(dto.image).toEqual({ S: 'node:24' });
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
194
226
|
describe('delete', () => {
|
|
195
227
|
it(`should return undefined when calling the delete operation`, async (): Promise<void> => {
|
|
196
228
|
mockSend.mockImplementationOnce((): DeleteItemCommandOutput => {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
UpdateJobManifestRequest,
|
|
17
17
|
} from '../../manifest';
|
|
18
18
|
import { InjectTokens } from '../../constants/InjectTokens';
|
|
19
|
+
import { getRuntimeImageOrDefault } from '../../constants/RuntimeImage.constants';
|
|
19
20
|
import { parsePartitionKey } from '../../ParsePartitionKey';
|
|
20
21
|
|
|
21
22
|
@provideTransient(JobManifestRepository)
|
|
@@ -68,6 +69,11 @@ export class JobManifestRepository extends Repository<JobManifest, JobManifestDt
|
|
|
68
69
|
expressionAttributeValues[':timeoutSeconds'] = { N: input.timeoutSeconds.toString() };
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
if (input.image) {
|
|
73
|
+
expressionAttributeNames['#image'] = 'image';
|
|
74
|
+
expressionAttributeValues[':image'] = { S: input.image };
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
const updateExpressionPairs = Object.keys(input).map((key) => `#${key} = :${key}`);
|
|
72
78
|
const updateExpression = `SET ${updateExpressionPairs.join(', ')}`;
|
|
73
79
|
|
|
@@ -91,11 +97,13 @@ export class JobManifestRepository extends Repository<JobManifest, JobManifestDt
|
|
|
91
97
|
pk: { S: this.pk },
|
|
92
98
|
sk: { S: `${item.name}~${item.version}` },
|
|
93
99
|
timeoutSeconds: { N: item.timeoutSeconds.toString() },
|
|
100
|
+
image: { S: item.image },
|
|
94
101
|
};
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
convertToItem(dbItem: JobManifestDto): JobManifest {
|
|
98
105
|
const { pk, name, version, concurrency, entryFile, entryFunction, timeoutSeconds } = dbItem;
|
|
106
|
+
const image = (dbItem as Partial<JobManifestDto>).image?.S;
|
|
99
107
|
return {
|
|
100
108
|
concurrency: parseInt(concurrency.N as string),
|
|
101
109
|
entryFile: entryFile.S as string,
|
|
@@ -103,6 +111,7 @@ export class JobManifestRepository extends Repository<JobManifest, JobManifestDt
|
|
|
103
111
|
name: name.S as string,
|
|
104
112
|
version: version.S as string,
|
|
105
113
|
timeoutSeconds: parseInt(timeoutSeconds.N as string),
|
|
114
|
+
image: getRuntimeImageOrDefault(image),
|
|
106
115
|
type: parsePartitionKey(pk.S as string).entityType,
|
|
107
116
|
};
|
|
108
117
|
}
|
|
@@ -265,6 +265,59 @@ describe('JobExecutionTaskService', () => {
|
|
|
265
265
|
expect(response).toBe(undefined);
|
|
266
266
|
});
|
|
267
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
|
+
|
|
268
321
|
it('should re-throw unhandled errors when starting a task', async () => {
|
|
269
322
|
const expectedError = new ResourceNotFoundException({ message: faker.lorem.words(), $metadata: {} });
|
|
270
323
|
const jobContext = createMockJobContext();
|
|
@@ -121,6 +121,30 @@ export class JobExecutionTaskService {
|
|
|
121
121
|
}),
|
|
122
122
|
);
|
|
123
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
|
+
|
|
124
148
|
// This task ARN can be used to terminate the job task.
|
|
125
149
|
// If no error is returned, assume task has started.
|
|
126
150
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
@@ -87,6 +87,7 @@ describe('JobManifestService', (): void => {
|
|
|
87
87
|
name: mockJobManifestItem.name.S as string,
|
|
88
88
|
version: mockJobManifestItem.version.S as string,
|
|
89
89
|
timeoutSeconds: parseInt(mockJobManifestItem.timeoutSeconds.N as string),
|
|
90
|
+
image: mockJobManifestItem.image.S as string,
|
|
90
91
|
type: `manifest`,
|
|
91
92
|
} as JobManifest);
|
|
92
93
|
expect(mockSend).toBeCalledTimes(1);
|
|
@@ -3,6 +3,7 @@ export * from './services/JobFunctionService';
|
|
|
3
3
|
|
|
4
4
|
export * from './utils/getAndAssertJobManifestVersion';
|
|
5
5
|
export * from './utils/isValidManifestVersion';
|
|
6
|
+
export * from './utils/validateRuntimeImage';
|
|
6
7
|
|
|
7
8
|
export * from './ManifestModel';
|
|
8
9
|
export * from '../job/manifest/JobManifestV1';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ManifestServiceForDev } from './ManifestServiceForDev';
|
|
2
2
|
|
|
3
|
+
import { ResourceNotFoundError } from '@squiz/dx-common-lib';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
import { ManifestValidationError } from '../errors/ManifestValidationError';
|
|
5
|
-
import { ResourceNotFoundError } from '@squiz/dx-common-lib';
|
|
6
6
|
|
|
7
7
|
describe('ManifestServiceForDev', () => {
|
|
8
8
|
const service = new ManifestServiceForDev();
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { ResourceNotFoundError } from '@squiz/dx-common-lib';
|
|
2
|
+
import { JsonValidationService } from '@squiz/dx-json-schema-lib';
|
|
3
|
+
import fsExtra from 'fs-extra';
|
|
4
|
+
import { JobManifestV1 } from '../../job/manifest/JobManifestV1';
|
|
2
5
|
import { ManifestValidationError } from '../errors/ManifestValidationError';
|
|
3
6
|
import { JobManifestModel } from '../ManifestModel';
|
|
4
7
|
import { getAndAssertJobManifestVersion } from '../utils/getAndAssertJobManifestVersion';
|
|
5
|
-
import { JobManifestV1 } from '../../job/manifest/JobManifestV1';
|
|
6
|
-
import { JsonValidationService } from '@squiz/dx-json-schema-lib';
|
|
7
|
-
import fsExtra from 'fs-extra';
|
|
8
8
|
|
|
9
9
|
export class ManifestServiceForDev {
|
|
10
10
|
protected jobValidationService: JsonValidationService = new JsonValidationService();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright Squiz Australia Pty Ltd. All Rights Reserved.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as runtimeImageConstants from '../../job/constants/RuntimeImage.constants';
|
|
7
|
+
import { RECOMMENDED_RUNTIME_IMAGE, SUPPORTED_RUNTIME_IMAGES } from '../../job/constants/RuntimeImage.constants';
|
|
8
|
+
import { ManifestValidationError } from '../errors/ManifestValidationError';
|
|
9
|
+
import { validateAndDefaultRuntimeImage } from './validateRuntimeImage';
|
|
10
|
+
|
|
11
|
+
describe('validateAndDefaultRuntimeImage', () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
jest.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns the recommended runtime image when image is undefined', () => {
|
|
17
|
+
expect(validateAndDefaultRuntimeImage(undefined)).toEqual(RECOMMENDED_RUNTIME_IMAGE);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns the recommended runtime image when image is null', () => {
|
|
21
|
+
expect(validateAndDefaultRuntimeImage(null)).toEqual(RECOMMENDED_RUNTIME_IMAGE);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it.each(SUPPORTED_RUNTIME_IMAGES)('returns "%s" when it is in the supported list', (supportedImage) => {
|
|
25
|
+
expect(validateAndDefaultRuntimeImage(supportedImage)).toEqual(supportedImage);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('throws ManifestValidationError when the image is not supported', () => {
|
|
29
|
+
expect(() => validateAndDefaultRuntimeImage('node:18')).toThrow(ManifestValidationError);
|
|
30
|
+
expect(() => validateAndDefaultRuntimeImage('node:18')).toThrow(/Unsupported runtime image "node:18"/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('throws ManifestValidationError when the image is deprecated', () => {
|
|
34
|
+
jest.spyOn(runtimeImageConstants, 'isSupportedRuntimeImage').mockReturnValue(true);
|
|
35
|
+
jest.spyOn(runtimeImageConstants, 'isDeprecatedRuntimeImage').mockReturnValue(true);
|
|
36
|
+
|
|
37
|
+
expect(() => validateAndDefaultRuntimeImage('node:20')).toThrow(ManifestValidationError);
|
|
38
|
+
expect(() => validateAndDefaultRuntimeImage('node:20')).toThrow(/deprecated/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright Squiz Australia Pty Ltd. All Rights Reserved.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
RECOMMENDED_RUNTIME_IMAGE,
|
|
8
|
+
SUPPORTED_RUNTIME_IMAGES,
|
|
9
|
+
isDeprecatedRuntimeImage,
|
|
10
|
+
isSupportedRuntimeImage,
|
|
11
|
+
} from '../../job/constants/RuntimeImage.constants';
|
|
12
|
+
import { ManifestValidationError } from '../errors/ManifestValidationError';
|
|
13
|
+
|
|
14
|
+
export const validateAndDefaultRuntimeImage = (image: string | null | undefined): string => {
|
|
15
|
+
if (image === null || image === undefined) {
|
|
16
|
+
return RECOMMENDED_RUNTIME_IMAGE;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!isSupportedRuntimeImage(image)) {
|
|
20
|
+
throw new ManifestValidationError(
|
|
21
|
+
`Unsupported runtime image "${image}". Supported images are: ${SUPPORTED_RUNTIME_IMAGES.join(', ')}`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (isDeprecatedRuntimeImage(image)) {
|
|
26
|
+
throw new ManifestValidationError(
|
|
27
|
+
`Runtime image "${image}" is deprecated and can no longer be used for new uploads. ` +
|
|
28
|
+
`Please use the recommended runtime image "${RECOMMENDED_RUNTIME_IMAGE}".`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return image;
|
|
33
|
+
};
|