@friggframework/admin-scripts 2.0.0--canary.517.8d90337.0 → 2.0.0--canary.517.095e3f6.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/index.js +2 -6
- package/package.json +6 -6
- package/src/application/__tests__/{admin-frigg-commands.test.js → admin-script-context.test.js} +1 -1
- package/src/application/__tests__/script-factory.test.js +30 -19
- package/src/application/__tests__/script-runner.test.js +7 -6
- package/src/application/script-factory.js +11 -18
- package/src/application/script-runner.js +14 -3
- package/src/infrastructure/__tests__/admin-script-router.test.js +10 -6
- package/src/infrastructure/__tests__/bootstrap.test.js +78 -0
- package/src/infrastructure/__tests__/script-executor-handler.test.js +163 -0
- package/src/infrastructure/admin-script-router.js +25 -17
- package/src/infrastructure/bootstrap.js +17 -10
- package/src/infrastructure/script-executor-handler.js +77 -47
- /package/src/application/{admin-frigg-commands.js → admin-script-context.js} +0 -0
package/index.js
CHANGED
|
@@ -6,15 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
// Application Services
|
|
9
|
-
const {
|
|
10
|
-
ScriptFactory,
|
|
11
|
-
getScriptFactory,
|
|
12
|
-
} = require('./src/application/script-factory');
|
|
9
|
+
const { ScriptFactory } = require('./src/application/script-factory');
|
|
13
10
|
const { AdminScriptBase } = require('./src/application/admin-script-base');
|
|
14
11
|
const {
|
|
15
12
|
AdminScriptContext,
|
|
16
13
|
createAdminScriptContext,
|
|
17
|
-
} = require('./src/application/admin-
|
|
14
|
+
} = require('./src/application/admin-script-context');
|
|
18
15
|
const {
|
|
19
16
|
ScriptRunner,
|
|
20
17
|
createScriptRunner,
|
|
@@ -47,7 +44,6 @@ module.exports = {
|
|
|
47
44
|
// Application layer
|
|
48
45
|
AdminScriptBase,
|
|
49
46
|
ScriptFactory,
|
|
50
|
-
getScriptFactory,
|
|
51
47
|
AdminScriptContext,
|
|
52
48
|
createAdminScriptContext,
|
|
53
49
|
ScriptRunner,
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/admin-scripts",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.517.
|
|
4
|
+
"version": "2.0.0--canary.517.095e3f6.0",
|
|
5
5
|
"description": "Admin Script Runner for Frigg - Execute maintenance and operational scripts in hosted environments",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@aws-sdk/client-scheduler": "^3.588.0",
|
|
8
|
-
"@friggframework/core": "2.0.0--canary.517.
|
|
8
|
+
"@friggframework/core": "2.0.0--canary.517.095e3f6.0",
|
|
9
9
|
"@hapi/boom": "^10.0.1",
|
|
10
10
|
"express": "^4.18.2",
|
|
11
11
|
"serverless-http": "^3.2.0"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
|
-
"@friggframework/eslint-config": "2.0.0--canary.517.
|
|
15
|
-
"@friggframework/prettier-config": "2.0.0--canary.517.
|
|
16
|
-
"@friggframework/test": "2.0.0--canary.517.
|
|
14
|
+
"@friggframework/eslint-config": "2.0.0--canary.517.095e3f6.0",
|
|
15
|
+
"@friggframework/prettier-config": "2.0.0--canary.517.095e3f6.0",
|
|
16
|
+
"@friggframework/test": "2.0.0--canary.517.095e3f6.0",
|
|
17
17
|
"eslint": "^8.22.0",
|
|
18
18
|
"jest": "^29.7.0",
|
|
19
19
|
"prettier": "^2.7.1",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"maintenance",
|
|
45
45
|
"operations"
|
|
46
46
|
],
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "095e3f64ffc59fd7afc713a537c69a0ec48eef7b"
|
|
48
48
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { ScriptFactory
|
|
1
|
+
const { ScriptFactory } = require('../script-factory');
|
|
2
2
|
const { AdminScriptBase } = require('../admin-script-base');
|
|
3
3
|
|
|
4
4
|
describe('ScriptFactory', () => {
|
|
@@ -333,15 +333,32 @@ describe('ScriptFactory', () => {
|
|
|
333
333
|
});
|
|
334
334
|
});
|
|
335
335
|
|
|
336
|
-
describe('
|
|
337
|
-
it('
|
|
338
|
-
|
|
339
|
-
|
|
336
|
+
describe('constructor', () => {
|
|
337
|
+
it('registers scripts passed to the constructor', () => {
|
|
338
|
+
class ScriptOne extends AdminScriptBase {
|
|
339
|
+
static Definition = {
|
|
340
|
+
name: 'script-one',
|
|
341
|
+
version: '1.0.0',
|
|
342
|
+
description: 'One',
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
class ScriptTwo extends AdminScriptBase {
|
|
346
|
+
static Definition = {
|
|
347
|
+
name: 'script-two',
|
|
348
|
+
version: '1.0.0',
|
|
349
|
+
description: 'Two',
|
|
350
|
+
};
|
|
351
|
+
}
|
|
340
352
|
|
|
341
|
-
|
|
342
|
-
|
|
353
|
+
const factory = new ScriptFactory([ScriptOne, ScriptTwo]);
|
|
354
|
+
|
|
355
|
+
expect(factory.size).toBe(2);
|
|
356
|
+
expect(factory.has('script-one')).toBe(true);
|
|
357
|
+
expect(factory.has('script-two')).toBe(true);
|
|
343
358
|
});
|
|
359
|
+
});
|
|
344
360
|
|
|
361
|
+
describe('Instance independence', () => {
|
|
345
362
|
it('new ScriptFactory() creates independent instances', () => {
|
|
346
363
|
const factory1 = new ScriptFactory();
|
|
347
364
|
const factory2 = new ScriptFactory();
|
|
@@ -351,7 +368,7 @@ describe('ScriptFactory', () => {
|
|
|
351
368
|
expect(factory2).toBeInstanceOf(ScriptFactory);
|
|
352
369
|
});
|
|
353
370
|
|
|
354
|
-
it('
|
|
371
|
+
it('registering into one factory does not affect another', () => {
|
|
355
372
|
class TestScript extends AdminScriptBase {
|
|
356
373
|
static Definition = {
|
|
357
374
|
name: 'test',
|
|
@@ -360,18 +377,12 @@ describe('ScriptFactory', () => {
|
|
|
360
377
|
};
|
|
361
378
|
}
|
|
362
379
|
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const globalFactory = getScriptFactory();
|
|
367
|
-
|
|
368
|
-
// Custom factory has the script
|
|
369
|
-
expect(customFactory.has('test')).toBe(true);
|
|
380
|
+
const factoryA = new ScriptFactory();
|
|
381
|
+
const factoryB = new ScriptFactory();
|
|
382
|
+
factoryA.register(TestScript);
|
|
370
383
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
// so we just verify they're different instances
|
|
374
|
-
expect(customFactory).not.toBe(globalFactory);
|
|
384
|
+
expect(factoryA.has('test')).toBe(true);
|
|
385
|
+
expect(factoryB.has('test')).toBe(false);
|
|
375
386
|
});
|
|
376
387
|
});
|
|
377
388
|
|
|
@@ -3,10 +3,10 @@ const { ScriptFactory } = require('../script-factory');
|
|
|
3
3
|
const { AdminScriptBase } = require('../admin-script-base');
|
|
4
4
|
|
|
5
5
|
// Mock dependencies
|
|
6
|
-
jest.mock('../admin-
|
|
6
|
+
jest.mock('../admin-script-context');
|
|
7
7
|
jest.mock('@friggframework/core/application/commands/admin-script-commands');
|
|
8
8
|
|
|
9
|
-
const { createAdminScriptContext } = require('../admin-
|
|
9
|
+
const { createAdminScriptContext } = require('../admin-script-context');
|
|
10
10
|
const {
|
|
11
11
|
createAdminScriptCommands,
|
|
12
12
|
} = require('@friggframework/core/application/commands/admin-script-commands');
|
|
@@ -292,12 +292,13 @@ describe('ScriptRunner', () => {
|
|
|
292
292
|
});
|
|
293
293
|
|
|
294
294
|
describe('createScriptRunner()', () => {
|
|
295
|
-
it('should
|
|
296
|
-
|
|
297
|
-
|
|
295
|
+
it('should throw when no scriptFactory is provided', () => {
|
|
296
|
+
expect(() => createScriptRunner()).toThrow(
|
|
297
|
+
'ScriptRunner requires a scriptFactory'
|
|
298
|
+
);
|
|
298
299
|
});
|
|
299
300
|
|
|
300
|
-
it('should create runner with
|
|
301
|
+
it('should create runner with an injected factory', () => {
|
|
301
302
|
const customFactory = new ScriptFactory();
|
|
302
303
|
const runner = createScriptRunner({ scriptFactory: customFactory });
|
|
303
304
|
expect(runner).toBeInstanceOf(ScriptRunner);
|
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
* Registry and factory for admin scripts.
|
|
5
5
|
* Manages script registration, validation, and instantiation.
|
|
6
6
|
*
|
|
7
|
+
* Instances are created and owned by the caller (see bootstrap.js, which builds
|
|
8
|
+
* one per process and injects it into the runner and router). There is no global
|
|
9
|
+
* instance — pass a factory explicitly wherever one is needed.
|
|
10
|
+
*
|
|
7
11
|
* Usage:
|
|
8
12
|
* ```javascript
|
|
9
|
-
* const factory = new ScriptFactory();
|
|
10
|
-
* factory.
|
|
11
|
-
*
|
|
13
|
+
* const factory = new ScriptFactory([MyScript]);
|
|
14
|
+
* const script = factory.createInstance('my-script', {
|
|
15
|
+
* context,
|
|
16
|
+
* executionId,
|
|
17
|
+
* integrationFactory,
|
|
18
|
+
* });
|
|
12
19
|
* ```
|
|
13
20
|
*/
|
|
14
21
|
class ScriptFactory {
|
|
@@ -137,18 +144,4 @@ class ScriptFactory {
|
|
|
137
144
|
}
|
|
138
145
|
}
|
|
139
146
|
|
|
140
|
-
|
|
141
|
-
let globalFactory = null;
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Get global script factory instance
|
|
145
|
-
* @returns {ScriptFactory} Global factory
|
|
146
|
-
*/
|
|
147
|
-
function getScriptFactory() {
|
|
148
|
-
if (!globalFactory) {
|
|
149
|
-
globalFactory = new ScriptFactory();
|
|
150
|
-
}
|
|
151
|
-
return globalFactory;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
module.exports = { ScriptFactory, getScriptFactory };
|
|
147
|
+
module.exports = { ScriptFactory };
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
const {
|
|
2
|
-
const { createAdminScriptContext } = require('./admin-frigg-commands');
|
|
1
|
+
const { createAdminScriptContext } = require('./admin-script-context');
|
|
3
2
|
const {
|
|
4
3
|
createAdminScriptCommands,
|
|
5
4
|
} = require('@friggframework/core/application/commands/admin-script-commands');
|
|
@@ -14,8 +13,20 @@ const {
|
|
|
14
13
|
* - Status updates
|
|
15
14
|
*/
|
|
16
15
|
class ScriptRunner {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} params
|
|
18
|
+
* @param {ScriptFactory} params.scriptFactory - Required. The registry used
|
|
19
|
+
* to resolve and instantiate scripts by name (built by bootstrap.js).
|
|
20
|
+
* @param {Object} [params.commands] - Admin process command layer; defaults
|
|
21
|
+
* to a fresh createAdminScriptCommands().
|
|
22
|
+
* @param {Object} [params.integrationFactory] - Hydrates integration
|
|
23
|
+
* instances for scripts that need them.
|
|
24
|
+
*/
|
|
17
25
|
constructor(params = {}) {
|
|
18
|
-
|
|
26
|
+
if (!params.scriptFactory) {
|
|
27
|
+
throw new Error('ScriptRunner requires a scriptFactory');
|
|
28
|
+
}
|
|
29
|
+
this.scriptFactory = params.scriptFactory;
|
|
19
30
|
this.commands = params.commands || createAdminScriptCommands();
|
|
20
31
|
this.integrationFactory = params.integrationFactory || null;
|
|
21
32
|
}
|
|
@@ -10,16 +10,13 @@ jest.mock('../admin-auth-middleware', () => ({
|
|
|
10
10
|
},
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
|
-
jest.mock('../../application/script-factory');
|
|
14
13
|
jest.mock('../../application/script-runner');
|
|
15
14
|
jest.mock('@friggframework/core/application/commands/admin-script-commands');
|
|
16
15
|
jest.mock('@friggframework/core/queues');
|
|
17
16
|
jest.mock('../../adapters/scheduler-adapter-factory');
|
|
18
|
-
jest.mock('../bootstrap'
|
|
19
|
-
bootstrapAdminScripts: () => ({ integrationFactory: {} }),
|
|
20
|
-
}));
|
|
17
|
+
jest.mock('../bootstrap');
|
|
21
18
|
|
|
22
|
-
const {
|
|
19
|
+
const { bootstrapAdminScripts } = require('../bootstrap');
|
|
23
20
|
const { createScriptRunner } = require('../../application/script-runner');
|
|
24
21
|
const {
|
|
25
22
|
createAdminScriptCommands,
|
|
@@ -72,7 +69,10 @@ describe('Admin Script Router', () => {
|
|
|
72
69
|
setScheduleEnabled: jest.fn(),
|
|
73
70
|
};
|
|
74
71
|
|
|
75
|
-
|
|
72
|
+
bootstrapAdminScripts.mockReturnValue({
|
|
73
|
+
scriptFactory: mockFactory,
|
|
74
|
+
integrationFactory: {},
|
|
75
|
+
});
|
|
76
76
|
createScriptRunner.mockReturnValue(mockRunner);
|
|
77
77
|
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
78
78
|
createSchedulerAdapter.mockReturnValue(mockSchedulerAdapter);
|
|
@@ -174,6 +174,10 @@ describe('Admin Script Router', () => {
|
|
|
174
174
|
mode: 'sync',
|
|
175
175
|
})
|
|
176
176
|
);
|
|
177
|
+
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
178
|
+
scriptFactory: mockFactory,
|
|
179
|
+
integrationFactory: {},
|
|
180
|
+
});
|
|
177
181
|
});
|
|
178
182
|
|
|
179
183
|
it('should queue script for async execution', async () => {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const { ScriptFactory } = require('../../application/script-factory');
|
|
2
|
+
const { AdminScriptBase } = require('../../application/admin-script-base');
|
|
3
|
+
|
|
4
|
+
jest.mock('@friggframework/core/handlers/app-definition-loader');
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
loadAppDefinition,
|
|
8
|
+
} = require('@friggframework/core/handlers/app-definition-loader');
|
|
9
|
+
const {
|
|
10
|
+
bootstrapAdminScripts,
|
|
11
|
+
_resetBootstrapForTests,
|
|
12
|
+
} = require('../bootstrap');
|
|
13
|
+
|
|
14
|
+
class TestScript extends AdminScriptBase {
|
|
15
|
+
static Definition = {
|
|
16
|
+
name: 'test-script',
|
|
17
|
+
version: '1.0.0',
|
|
18
|
+
description: 'Test script',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('bootstrapAdminScripts', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
_resetBootstrapForTests();
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
console.error.mockRestore();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('registers the app definition admin scripts into the returned factory', () => {
|
|
34
|
+
loadAppDefinition.mockReturnValue({ adminScripts: [TestScript] });
|
|
35
|
+
|
|
36
|
+
const { scriptFactory, integrationFactory } = bootstrapAdminScripts();
|
|
37
|
+
|
|
38
|
+
expect(scriptFactory).toBeInstanceOf(ScriptFactory);
|
|
39
|
+
expect(scriptFactory.has('test-script')).toBe(true);
|
|
40
|
+
expect(integrationFactory).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('never throws and returns an empty factory when the app definition cannot load', () => {
|
|
44
|
+
loadAppDefinition.mockImplementation(() => {
|
|
45
|
+
throw new Error('cannot load app definition');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let result;
|
|
49
|
+
expect(() => {
|
|
50
|
+
result = bootstrapAdminScripts();
|
|
51
|
+
}).not.toThrow();
|
|
52
|
+
|
|
53
|
+
expect(result.scriptFactory).toBeInstanceOf(ScriptFactory);
|
|
54
|
+
expect(result.scriptFactory.size).toBe(0);
|
|
55
|
+
expect(result.integrationFactory).toBeTruthy();
|
|
56
|
+
expect(console.error).toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('memoizes: repeated calls reuse the same factory and load the definition once', () => {
|
|
60
|
+
loadAppDefinition.mockReturnValue({ adminScripts: [TestScript] });
|
|
61
|
+
|
|
62
|
+
const first = bootstrapAdminScripts();
|
|
63
|
+
const second = bootstrapAdminScripts();
|
|
64
|
+
|
|
65
|
+
expect(second.scriptFactory).toBe(first.scriptFactory);
|
|
66
|
+
expect(second.integrationFactory).toBe(first.integrationFactory);
|
|
67
|
+
expect(loadAppDefinition).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('tolerates an app definition with no adminScripts', () => {
|
|
71
|
+
loadAppDefinition.mockReturnValue({});
|
|
72
|
+
|
|
73
|
+
const { scriptFactory } = bootstrapAdminScripts();
|
|
74
|
+
|
|
75
|
+
expect(scriptFactory).toBeInstanceOf(ScriptFactory);
|
|
76
|
+
expect(scriptFactory.size).toBe(0);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
jest.mock('../bootstrap');
|
|
2
|
+
jest.mock('../../application/script-runner');
|
|
3
|
+
jest.mock('@friggframework/core/application/commands/admin-script-commands');
|
|
4
|
+
|
|
5
|
+
const { bootstrapAdminScripts } = require('../bootstrap');
|
|
6
|
+
const { createScriptRunner } = require('../../application/script-runner');
|
|
7
|
+
const {
|
|
8
|
+
createAdminScriptCommands,
|
|
9
|
+
} = require('@friggframework/core/application/commands/admin-script-commands');
|
|
10
|
+
const { handler } = require('../script-executor-handler');
|
|
11
|
+
|
|
12
|
+
describe('Admin Script Executor Handler', () => {
|
|
13
|
+
let mockScriptFactory;
|
|
14
|
+
let mockIntegrationFactory;
|
|
15
|
+
let mockRunner;
|
|
16
|
+
let mockCommands;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mockScriptFactory = { id: 'script-factory' };
|
|
20
|
+
mockIntegrationFactory = { id: 'integration-factory' };
|
|
21
|
+
mockRunner = { execute: jest.fn() };
|
|
22
|
+
mockCommands = {
|
|
23
|
+
completeAdminProcess: jest.fn().mockResolvedValue({}),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
bootstrapAdminScripts.mockReturnValue({
|
|
27
|
+
scriptFactory: mockScriptFactory,
|
|
28
|
+
integrationFactory: mockIntegrationFactory,
|
|
29
|
+
});
|
|
30
|
+
createScriptRunner.mockReturnValue(mockRunner);
|
|
31
|
+
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
32
|
+
|
|
33
|
+
jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
34
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
console.log.mockRestore();
|
|
39
|
+
console.error.mockRestore();
|
|
40
|
+
jest.clearAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('EventBridge Scheduler direct invoke (no Records)', () => {
|
|
44
|
+
it('injects the bootstrapped factory into the runner and reports the result', async () => {
|
|
45
|
+
mockRunner.execute.mockResolvedValue({
|
|
46
|
+
status: 'COMPLETED',
|
|
47
|
+
executionId: 'exec-1',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const response = await handler({
|
|
51
|
+
scriptName: 'my-script',
|
|
52
|
+
trigger: 'SCHEDULED',
|
|
53
|
+
params: { foo: 'bar' },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
57
|
+
scriptFactory: mockScriptFactory,
|
|
58
|
+
integrationFactory: mockIntegrationFactory,
|
|
59
|
+
});
|
|
60
|
+
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
61
|
+
'my-script',
|
|
62
|
+
{ foo: 'bar' },
|
|
63
|
+
expect.objectContaining({ trigger: 'SCHEDULED', mode: 'async' })
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(response.statusCode).toBe(200);
|
|
67
|
+
const body = JSON.parse(response.body);
|
|
68
|
+
expect(body.processed).toBe(1);
|
|
69
|
+
expect(body.results[0]).toEqual({
|
|
70
|
+
scriptName: 'my-script',
|
|
71
|
+
status: 'COMPLETED',
|
|
72
|
+
executionId: 'exec-1',
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('marks the execution FAILED when the runner throws', async () => {
|
|
77
|
+
mockRunner.execute.mockRejectedValue(new Error('boom'));
|
|
78
|
+
|
|
79
|
+
const response = await handler({
|
|
80
|
+
scriptName: 'my-script',
|
|
81
|
+
executionId: 'exec-2',
|
|
82
|
+
trigger: 'SCHEDULED',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(response.statusCode).toBe(200);
|
|
86
|
+
const body = JSON.parse(response.body);
|
|
87
|
+
expect(body.results[0].status).toBe('FAILED');
|
|
88
|
+
expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith(
|
|
89
|
+
'exec-2',
|
|
90
|
+
expect.objectContaining({ state: 'FAILED' })
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('SQS batch (Records[])', () => {
|
|
96
|
+
it('injects the bootstrapped factory and processes each record', async () => {
|
|
97
|
+
mockRunner.execute.mockResolvedValue({
|
|
98
|
+
status: 'COMPLETED',
|
|
99
|
+
executionId: 'exec-3',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const response = await handler({
|
|
103
|
+
Records: [
|
|
104
|
+
{
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
scriptName: 'my-script',
|
|
107
|
+
executionId: 'exec-3',
|
|
108
|
+
params: {},
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
115
|
+
scriptFactory: mockScriptFactory,
|
|
116
|
+
integrationFactory: mockIntegrationFactory,
|
|
117
|
+
});
|
|
118
|
+
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
119
|
+
'my-script',
|
|
120
|
+
{},
|
|
121
|
+
expect.objectContaining({
|
|
122
|
+
trigger: 'QUEUE',
|
|
123
|
+
executionId: 'exec-3',
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(response.statusCode).toBe(200);
|
|
128
|
+
const body = JSON.parse(response.body);
|
|
129
|
+
expect(body.processed).toBe(1);
|
|
130
|
+
expect(body.results[0].status).toBe('COMPLETED');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('isolates a bad record without dropping the rest of the batch', async () => {
|
|
134
|
+
mockRunner.execute.mockResolvedValue({
|
|
135
|
+
status: 'COMPLETED',
|
|
136
|
+
executionId: 'exec-4',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const response = await handler({
|
|
140
|
+
Records: [
|
|
141
|
+
// Missing scriptName -> runMessage throws before the runner
|
|
142
|
+
{ body: JSON.stringify({ executionId: 'exec-bad' }) },
|
|
143
|
+
{
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
scriptName: 'my-script',
|
|
146
|
+
executionId: 'exec-4',
|
|
147
|
+
params: {},
|
|
148
|
+
}),
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const body = JSON.parse(response.body);
|
|
154
|
+
expect(body.processed).toBe(2);
|
|
155
|
+
expect(body.results[0].status).toBe('FAILED');
|
|
156
|
+
expect(body.results[1].status).toBe('COMPLETED');
|
|
157
|
+
expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith(
|
|
158
|
+
'exec-bad',
|
|
159
|
+
expect.objectContaining({ state: 'FAILED' })
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -2,7 +2,6 @@ const express = require('express');
|
|
|
2
2
|
const serverless = require('serverless-http');
|
|
3
3
|
const Boom = require('@hapi/boom');
|
|
4
4
|
const { validateAdminApiKey } = require('./admin-auth-middleware');
|
|
5
|
-
const { getScriptFactory } = require('../application/script-factory');
|
|
6
5
|
const { createScriptRunner } = require('../application/script-runner');
|
|
7
6
|
const {
|
|
8
7
|
validateScriptInput,
|
|
@@ -27,10 +26,13 @@ const router = express.Router();
|
|
|
27
26
|
// Apply auth middleware to all admin routes
|
|
28
27
|
router.use(validateAdminApiKey);
|
|
29
28
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// Build (once, memoized) and inject the per-process dependencies onto the
|
|
30
|
+
// request, so route handlers consume them explicitly instead of reaching for a
|
|
31
|
+
// global. Registers the host app's admin scripts on the first request.
|
|
32
|
+
router.use((req, _res, next) => {
|
|
33
|
+
const { scriptFactory, integrationFactory } = bootstrapAdminScripts();
|
|
34
|
+
req.scriptFactory = scriptFactory;
|
|
35
|
+
req.integrationFactory = integrationFactory;
|
|
34
36
|
next();
|
|
35
37
|
});
|
|
36
38
|
|
|
@@ -42,7 +44,9 @@ router.use((_req, _res, next) => {
|
|
|
42
44
|
*/
|
|
43
45
|
function sendError(res, error, fallbackMessage) {
|
|
44
46
|
if (error.isBoom) {
|
|
45
|
-
return res
|
|
47
|
+
return res
|
|
48
|
+
.status(error.output.statusCode)
|
|
49
|
+
.json({ error: error.message });
|
|
46
50
|
}
|
|
47
51
|
console.error(fallbackMessage, error);
|
|
48
52
|
return res.status(500).json({ error: fallbackMessage });
|
|
@@ -65,9 +69,10 @@ function buildAudit(req) {
|
|
|
65
69
|
|
|
66
70
|
/**
|
|
67
71
|
* Create schedule use case instances
|
|
72
|
+
* @param {ScriptFactory} scriptFactory - Registry injected from the request.
|
|
68
73
|
* @private
|
|
69
74
|
*/
|
|
70
|
-
function createScheduleUseCases() {
|
|
75
|
+
function createScheduleUseCases(scriptFactory) {
|
|
71
76
|
const commands = createAdminScriptCommands();
|
|
72
77
|
|
|
73
78
|
// The local adapter is in-memory only (schedules vanish on cold start), so it
|
|
@@ -88,7 +93,6 @@ function createScheduleUseCases() {
|
|
|
88
93
|
scheduleGroupName: process.env.ADMIN_SCRIPT_SCHEDULE_GROUP,
|
|
89
94
|
roleArn: process.env.SCHEDULER_ROLE_ARN,
|
|
90
95
|
});
|
|
91
|
-
const scriptFactory = getScriptFactory();
|
|
92
96
|
|
|
93
97
|
return {
|
|
94
98
|
getEffectiveSchedule: new GetEffectiveScheduleUseCase({
|
|
@@ -114,7 +118,7 @@ function createScheduleUseCases() {
|
|
|
114
118
|
*/
|
|
115
119
|
router.get('/scripts', async (req, res) => {
|
|
116
120
|
try {
|
|
117
|
-
const factory =
|
|
121
|
+
const factory = req.scriptFactory;
|
|
118
122
|
const scripts = factory.getAll();
|
|
119
123
|
|
|
120
124
|
res.json({
|
|
@@ -140,7 +144,7 @@ router.get('/scripts', async (req, res) => {
|
|
|
140
144
|
router.get('/scripts/:scriptName', async (req, res) => {
|
|
141
145
|
try {
|
|
142
146
|
const { scriptName } = req.params;
|
|
143
|
-
const factory =
|
|
147
|
+
const factory = req.scriptFactory;
|
|
144
148
|
|
|
145
149
|
if (!factory.has(scriptName)) {
|
|
146
150
|
return res.status(404).json({
|
|
@@ -175,7 +179,7 @@ router.post('/scripts/:scriptName/validate', async (req, res) => {
|
|
|
175
179
|
try {
|
|
176
180
|
const { scriptName } = req.params;
|
|
177
181
|
const { params = {} } = req.body;
|
|
178
|
-
const factory =
|
|
182
|
+
const factory = req.scriptFactory;
|
|
179
183
|
|
|
180
184
|
if (!factory.has(scriptName)) {
|
|
181
185
|
return res.status(404).json({
|
|
@@ -200,7 +204,7 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
200
204
|
try {
|
|
201
205
|
const { scriptName } = req.params;
|
|
202
206
|
const { params = {}, mode = 'async' } = req.body;
|
|
203
|
-
const factory =
|
|
207
|
+
const factory = req.scriptFactory;
|
|
204
208
|
|
|
205
209
|
if (!factory.has(scriptName)) {
|
|
206
210
|
return res.status(404).json({
|
|
@@ -237,8 +241,10 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
237
241
|
});
|
|
238
242
|
}
|
|
239
243
|
|
|
240
|
-
const
|
|
241
|
-
|
|
244
|
+
const runner = createScriptRunner({
|
|
245
|
+
scriptFactory: req.scriptFactory,
|
|
246
|
+
integrationFactory: req.integrationFactory,
|
|
247
|
+
});
|
|
242
248
|
const result = await runner.execute(scriptName, params, {
|
|
243
249
|
trigger: 'MANUAL',
|
|
244
250
|
mode: 'sync',
|
|
@@ -355,7 +361,9 @@ router.get('/scripts/:scriptName/executions', async (req, res) => {
|
|
|
355
361
|
router.get('/scripts/:scriptName/schedule', async (req, res) => {
|
|
356
362
|
try {
|
|
357
363
|
const { scriptName } = req.params;
|
|
358
|
-
const { getEffectiveSchedule } = createScheduleUseCases(
|
|
364
|
+
const { getEffectiveSchedule } = createScheduleUseCases(
|
|
365
|
+
req.scriptFactory
|
|
366
|
+
);
|
|
359
367
|
|
|
360
368
|
const result = await getEffectiveSchedule.execute(scriptName);
|
|
361
369
|
|
|
@@ -377,7 +385,7 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
|
|
|
377
385
|
try {
|
|
378
386
|
const { scriptName } = req.params;
|
|
379
387
|
const { enabled, cronExpression, timezone } = req.body;
|
|
380
|
-
const { upsertSchedule } = createScheduleUseCases();
|
|
388
|
+
const { upsertSchedule } = createScheduleUseCases(req.scriptFactory);
|
|
381
389
|
|
|
382
390
|
const result = await upsertSchedule.execute(scriptName, {
|
|
383
391
|
enabled,
|
|
@@ -407,7 +415,7 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
|
|
|
407
415
|
router.delete('/scripts/:scriptName/schedule', async (req, res) => {
|
|
408
416
|
try {
|
|
409
417
|
const { scriptName } = req.params;
|
|
410
|
-
const { deleteSchedule } = createScheduleUseCases();
|
|
418
|
+
const { deleteSchedule } = createScheduleUseCases(req.scriptFactory);
|
|
411
419
|
|
|
412
420
|
const result = await deleteSchedule.execute(scriptName);
|
|
413
421
|
|
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { ScriptFactory } = require('../application/script-factory');
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Admin Script Bootstrap
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the router and SQS worker can resolve scripts by
|
|
9
|
-
* integrationFactory used by scripts that need
|
|
6
|
+
* Composition root for the admin-scripts runtime. Loads the host app's
|
|
7
|
+
* definition at Lambda runtime, builds a ScriptFactory, and registers the app's
|
|
8
|
+
* admin scripts into it so the router and SQS worker can resolve scripts by
|
|
9
|
+
* name. Also constructs the integrationFactory used by scripts that need
|
|
10
|
+
* hydrated integration instances. Both are returned for the caller to inject.
|
|
10
11
|
*
|
|
11
12
|
* Runs once per process (memoized) and never throws — a missing/unloadable app
|
|
12
13
|
* definition is logged and leaves the factory empty rather than crashing the
|
|
13
14
|
* Lambda cold start.
|
|
14
15
|
*/
|
|
15
16
|
let bootstrapped = false;
|
|
17
|
+
let scriptFactory = null;
|
|
16
18
|
let integrationFactory = null;
|
|
17
19
|
|
|
18
20
|
function registerScripts(factory, scriptClasses) {
|
|
@@ -43,22 +45,26 @@ function createIntegrationFactory() {
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
|
-
* @returns {{ integrationFactory: object }}
|
|
48
|
+
* @returns {{ scriptFactory: ScriptFactory, integrationFactory: object }}
|
|
47
49
|
*/
|
|
48
50
|
function bootstrapAdminScripts() {
|
|
49
51
|
if (bootstrapped) {
|
|
50
|
-
return { integrationFactory };
|
|
52
|
+
return { scriptFactory, integrationFactory };
|
|
51
53
|
}
|
|
52
54
|
bootstrapped = true;
|
|
53
55
|
|
|
56
|
+
// Create the registry up front so consumers always get a (possibly empty)
|
|
57
|
+
// factory even when the app definition can't be loaded — mirrors the
|
|
58
|
+
// never-throw contract above.
|
|
59
|
+
scriptFactory = new ScriptFactory();
|
|
60
|
+
|
|
54
61
|
try {
|
|
55
62
|
const {
|
|
56
63
|
loadAppDefinition,
|
|
57
64
|
} = require('@friggframework/core/handlers/app-definition-loader');
|
|
58
65
|
const { adminScripts = [] } = loadAppDefinition();
|
|
59
66
|
|
|
60
|
-
|
|
61
|
-
registerScripts(factory, adminScripts);
|
|
67
|
+
registerScripts(scriptFactory, adminScripts);
|
|
62
68
|
} catch (error) {
|
|
63
69
|
console.error(
|
|
64
70
|
'[admin-scripts] bootstrap: could not load app definition:',
|
|
@@ -67,12 +73,13 @@ function bootstrapAdminScripts() {
|
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
integrationFactory = createIntegrationFactory();
|
|
70
|
-
return { integrationFactory };
|
|
76
|
+
return { scriptFactory, integrationFactory };
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
/** Test-only: reset memoized bootstrap state. */
|
|
74
80
|
function _resetBootstrapForTests() {
|
|
75
81
|
bootstrapped = false;
|
|
82
|
+
scriptFactory = null;
|
|
76
83
|
integrationFactory = null;
|
|
77
84
|
}
|
|
78
85
|
|
|
@@ -6,9 +6,20 @@ const { bootstrapAdminScripts } = require('./bootstrap');
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Run a single execution message through the ScriptRunner.
|
|
9
|
+
* @param {Object} message - Parsed execution message.
|
|
10
|
+
* @param {string} message.scriptName - Name of the registered script to run (required).
|
|
11
|
+
* @param {string} [message.executionId] - Existing AdminProcess id to resume; when
|
|
12
|
+
* absent, ScriptRunner creates a new record.
|
|
13
|
+
* @param {string} [message.trigger] - Execution trigger; defaults to 'QUEUE'.
|
|
14
|
+
* @param {Object} [message.params] - Parameters passed to the script.
|
|
15
|
+
* @param {string} [message.parentExecutionId] - Parent execution id for queueScript continuations.
|
|
16
|
+
* @param {Object} deps
|
|
17
|
+
* @param {ScriptFactory} deps.scriptFactory - Registry used to resolve and instantiate the script.
|
|
18
|
+
* @param {Object} deps.integrationFactory - Hydrates integration instances for scripts that need them.
|
|
19
|
+
* @returns {Promise<{ scriptName: string, status: string, executionId: string }>}
|
|
9
20
|
* @private
|
|
10
21
|
*/
|
|
11
|
-
async function runMessage(message, integrationFactory) {
|
|
22
|
+
async function runMessage(message, { scriptFactory, integrationFactory }) {
|
|
12
23
|
const { scriptName, executionId, trigger, params, parentExecutionId } =
|
|
13
24
|
message;
|
|
14
25
|
|
|
@@ -22,7 +33,7 @@ async function runMessage(message, integrationFactory) {
|
|
|
22
33
|
}`
|
|
23
34
|
);
|
|
24
35
|
|
|
25
|
-
const runner = createScriptRunner({ integrationFactory });
|
|
36
|
+
const runner = createScriptRunner({ scriptFactory, integrationFactory });
|
|
26
37
|
const result = await runner.execute(scriptName, params, {
|
|
27
38
|
trigger: trigger || 'QUEUE',
|
|
28
39
|
mode: 'async',
|
|
@@ -66,59 +77,58 @@ async function markFailed(executionId, error) {
|
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* (`{ scriptName, trigger: 'SCHEDULED', params }`) with no `Records` wrapper.
|
|
76
|
-
*
|
|
77
|
-
* Thin adapter: parses the event and delegates to ScriptRunner, which owns
|
|
78
|
-
* execution tracking, error recording, and status updates.
|
|
80
|
+
* Handle an EventBridge Scheduler direct invoke: the event itself is a single
|
|
81
|
+
* execution message (no `Records` wrapper).
|
|
82
|
+
* @param {Object} event - The execution message.
|
|
83
|
+
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object }} deps
|
|
84
|
+
* @returns {Promise<{ statusCode: number, body: string }>}
|
|
85
|
+
* @private
|
|
79
86
|
*/
|
|
80
|
-
async function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
scriptName: event.scriptName || 'unknown',
|
|
107
|
-
status: 'FAILED',
|
|
108
|
-
error: error.message,
|
|
109
|
-
},
|
|
110
|
-
],
|
|
111
|
-
}),
|
|
112
|
-
};
|
|
113
|
-
}
|
|
87
|
+
async function handleScheduledInvoke(event, deps) {
|
|
88
|
+
try {
|
|
89
|
+
const result = await runMessage(event, deps);
|
|
90
|
+
console.log(
|
|
91
|
+
`Script completed: ${result.scriptName}, status: ${result.status}`
|
|
92
|
+
);
|
|
93
|
+
return {
|
|
94
|
+
statusCode: 200,
|
|
95
|
+
body: JSON.stringify({ processed: 1, results: [result] }),
|
|
96
|
+
};
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error('Unexpected error processing scheduled invoke:', error);
|
|
99
|
+
await markFailed(event.executionId, error);
|
|
100
|
+
return {
|
|
101
|
+
statusCode: 200,
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
processed: 1,
|
|
104
|
+
results: [
|
|
105
|
+
{
|
|
106
|
+
scriptName: event.scriptName || 'unknown',
|
|
107
|
+
status: 'FAILED',
|
|
108
|
+
error: error.message,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
114
113
|
}
|
|
114
|
+
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Handle an SQS batch: each `event.Records[].body` is a JSON execution message
|
|
118
|
+
* (manual async executions and queueScript continuations). Failures are isolated
|
|
119
|
+
* per record so one bad message doesn't drop the rest of the batch.
|
|
120
|
+
* @param {Object} event - The SQS event with a `Records` array.
|
|
121
|
+
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object }} deps
|
|
122
|
+
* @returns {Promise<{ statusCode: number, body: string }>}
|
|
123
|
+
* @private
|
|
124
|
+
*/
|
|
125
|
+
async function handleSqsBatch(event, deps) {
|
|
116
126
|
const results = [];
|
|
117
127
|
for (const record of event.Records) {
|
|
118
128
|
let message = {};
|
|
119
129
|
try {
|
|
120
130
|
message = JSON.parse(record.body);
|
|
121
|
-
const result = await runMessage(message,
|
|
131
|
+
const result = await runMessage(message, deps);
|
|
122
132
|
console.log(
|
|
123
133
|
`Script completed: ${result.scriptName}, status: ${result.status}`
|
|
124
134
|
);
|
|
@@ -143,4 +153,24 @@ async function handler(event) {
|
|
|
143
153
|
};
|
|
144
154
|
}
|
|
145
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Admin Script Executor Lambda Handler
|
|
158
|
+
*
|
|
159
|
+
* Handles two invocation shapes:
|
|
160
|
+
* - SQS: `event.Records[]` — each record body is a JSON execution message
|
|
161
|
+
* (manual async executions and queueScript continuations).
|
|
162
|
+
* - EventBridge Scheduler direct invoke: the event itself is the message
|
|
163
|
+
* (`{ scriptName, trigger: 'SCHEDULED', params }`) with no `Records` wrapper.
|
|
164
|
+
*
|
|
165
|
+
* Thin adapter: parses the event and delegates to ScriptRunner, which owns
|
|
166
|
+
* execution tracking, error recording, and status updates.
|
|
167
|
+
*/
|
|
168
|
+
async function handler(event) {
|
|
169
|
+
const deps = bootstrapAdminScripts();
|
|
170
|
+
|
|
171
|
+
return event.Records
|
|
172
|
+
? handleSqsBatch(event, deps)
|
|
173
|
+
: handleScheduledInvoke(event, deps);
|
|
174
|
+
}
|
|
175
|
+
|
|
146
176
|
module.exports = { handler };
|
|
File without changes
|