@squiz/job-runner-lib 2.5.1 → 2.7.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.
Files changed (28) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/lib/domains/job/constants/RetryConfig.constants.d.ts +4 -0
  3. package/lib/domains/job/constants/RuntimeImage.constants.d.ts +11 -0
  4. package/lib/domains/job/index.d.ts +1 -0
  5. package/lib/domains/job/manifest/JobManifestTypes.d.ts +12 -0
  6. package/lib/domains/manifest/index.d.ts +1 -0
  7. package/lib/domains/manifest/services/ManifestServiceForDev.d.ts +2 -2
  8. package/lib/domains/manifest/utils/validateRuntimeImage.d.ts +5 -0
  9. package/lib/domains/manifest/utils/validateRuntimeImage.spec.d.ts +5 -0
  10. package/lib/index.js +9 -9
  11. package/lib/index.js.map +4 -4
  12. package/package.json +15 -15
  13. package/src/domains/job/constants/RetryConfig.constants.ts +5 -0
  14. package/src/domains/job/constants/RuntimeImage.constants.ts +22 -0
  15. package/src/domains/job/index.ts +1 -0
  16. package/src/domains/job/manifest/JobManifestTypes.ts +14 -0
  17. package/src/domains/job/manifest/mocks/JobManifest.mock.ts +2 -0
  18. package/src/domains/job/manifest/mocks/JobManifestDto.mock.ts +4 -0
  19. package/src/domains/job/manifest/mocks/JobManifestRecord.mock.ts +2 -0
  20. package/src/domains/job/manifest/mocks/JobManifestRequest.mock.ts +2 -0
  21. package/src/domains/job/repository/JobManifestRepository/JobManifestRepository.spec.ts +33 -1
  22. package/src/domains/job/repository/JobManifestRepository/JobManifestRepository.ts +9 -0
  23. package/src/domains/job/services/JobManifestService/JobManifestService.spec.ts +1 -0
  24. package/src/domains/manifest/index.ts +1 -0
  25. package/src/domains/manifest/services/ManifestServiceForDev.spec.ts +1 -1
  26. package/src/domains/manifest/services/ManifestServiceForDev.ts +3 -3
  27. package/src/domains/manifest/utils/validateRuntimeImage.spec.ts +40 -0
  28. 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.5.1",
3
+ "version": "2.7.0",
4
4
  "description": "",
5
5
  "main": "lib/index.js",
6
6
  "private": false,
@@ -18,15 +18,15 @@
18
18
  "license": "ISC",
19
19
  "devDependencies": {
20
20
  "@faker-js/faker": "8.0.2",
21
- "@smithy/util-stream": "^4.2.3",
22
- "@squiz/virus-scanner-lib": "^1.68.0",
21
+ "@smithy/util-stream": "4.5.17",
22
+ "@squiz/virus-scanner-lib": "1.68.0",
23
23
  "@types/cli-color": "2.0.6",
24
24
  "@types/fs-extra": "11.0.1",
25
25
  "@types/jest": "28.1.8",
26
26
  "@types/node": "22.9.0",
27
- "aws-sdk-client-mock": "^2.2.0",
28
- "aws-sdk-client-mock-jest": "^2.2.0",
29
- "esbuild": "^0.25.8"
27
+ "aws-sdk-client-mock": "2.2.0",
28
+ "aws-sdk-client-mock-jest": "2.2.0",
29
+ "esbuild": "0.25.8"
30
30
  },
31
31
  "dependencies": {
32
32
  "@aws-sdk/client-dynamodb": "3.1003.0",
@@ -34,17 +34,17 @@
34
34
  "@aws-sdk/client-s3": "3.1003.0",
35
35
  "@aws-sdk/client-sqs": "3.1003.0",
36
36
  "@aws-sdk/s3-request-presigner": "3.1003.0",
37
- "@squiz/dx-common-lib": "^1.66.1",
38
- "@squiz/dx-json-schema-lib": "1.78.1",
39
- "@squiz/dx-logger-lib": "^1.64.0",
40
- "@squiz/optimization-logger": "^1.9.0",
41
- "axios": "^1.15.0",
42
- "cli-color": "^2.0.4",
37
+ "@squiz/dx-common-lib": "1.66.1",
38
+ "@squiz/dx-json-schema-lib": "1.85.0",
39
+ "@squiz/dx-logger-lib": "1.64.0",
40
+ "@squiz/optimization-logger": "1.11.2",
41
+ "axios": "1.16.1",
42
+ "cli-color": "2.0.4",
43
43
  "fs-extra": "11.1.0",
44
- "immer": "^10.1.3",
44
+ "immer": "10.1.3",
45
45
  "inversify": "6.0.1",
46
46
  "inversify-binding-decorators": "4.0.0",
47
- "reflect-metadata": "^0.1.13",
48
- "zod": "^4.0.17"
47
+ "reflect-metadata": "0.1.14",
48
+ "zod": "4.3.6"
49
49
  }
50
50
  }
@@ -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);
@@ -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
  }
@@ -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
+ };