@friggframework/admin-scripts 2.0.0--canary.517.f8e0cc1.0 → 2.0.0--canary.517.44c158e.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/README.md +14 -9
- package/index.js +1 -5
- package/package.json +6 -6
- package/src/application/__tests__/admin-script-context.test.js +27 -118
- package/src/application/__tests__/script-factory.test.js +30 -19
- package/src/application/__tests__/script-runner.test.js +5 -4
- package/src/application/admin-script-context.js +12 -52
- package/src/application/script-factory.js +11 -18
- package/src/application/script-runner.js +17 -2
- package/src/infrastructure/__tests__/admin-script-router.test.js +12 -6
- package/src/infrastructure/__tests__/bootstrap.test.js +113 -0
- package/src/infrastructure/__tests__/script-executor-handler.test.js +168 -0
- package/src/infrastructure/admin-script-router.js +29 -22
- package/src/infrastructure/bootstrap.js +60 -10
- package/src/infrastructure/script-executor-handler.js +85 -47
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @friggframework/admin-scripts
|
|
2
2
|
|
|
3
|
-
Admin Script Runner for Frigg — write and run operational/maintenance scripts inside your deployed Frigg app, with VPC/KMS-secured database access
|
|
3
|
+
Admin Script Runner for Frigg — write and run operational/maintenance scripts inside your deployed Frigg app, with VPC/KMS-secured database access through the same Frigg commands your integrations use, sync or async (queued) execution, dry-run validation, and optional cron scheduling via AWS EventBridge Scheduler.
|
|
4
4
|
|
|
5
5
|
Typical use cases:
|
|
6
6
|
|
|
@@ -87,12 +87,17 @@ class AttioHealingScript extends AdminScriptBase {
|
|
|
87
87
|
|
|
88
88
|
this.context.log('info', 'Starting Attio healing', { integrationId });
|
|
89
89
|
|
|
90
|
+
// Read persisted data through Frigg commands (never repositories).
|
|
91
|
+
// Commands return the data on success, or an { error, reason } object
|
|
92
|
+
// on failure — check `.error` yourself.
|
|
90
93
|
const integration =
|
|
91
|
-
await this.context.
|
|
94
|
+
await this.context.commands.integrations.findIntegrationById(
|
|
92
95
|
integrationId
|
|
93
96
|
);
|
|
94
|
-
if (
|
|
95
|
-
throw new Error(
|
|
97
|
+
if (integration.error) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Integration ${integrationId} not found: ${integration.reason}`
|
|
100
|
+
);
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
// Call the live integration when you need to hit an external API.
|
|
@@ -117,16 +122,16 @@ module.exports = { AttioHealingScript };
|
|
|
117
122
|
| Member | Description |
|
|
118
123
|
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
119
124
|
| `log(level, message, data?)` | Records a structured log entry (`level`: `debug`/`info`/`warn`/`error`). Logs are persisted to the execution's `results.logs`. |
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
125
|
+
| `commands.users` | User commands (`findIndividualUserById`, `createUser`, `updateUser`, …). |
|
|
126
|
+
| `commands.credentials` | Credential commands (`findCredential`, `updateCredential`, …); secrets decrypted transparently. |
|
|
127
|
+
| `commands.entities` | Entity/module commands (`findEntityById`, `findEntitiesByUserId`, …). |
|
|
128
|
+
| `commands.integrations` | Integration reads (`findIntegrationById`, `listIntegrations({ type, status })`). |
|
|
124
129
|
| `instantiate(integrationId)` | Returns a hydrated integration instance for calling external APIs. Requires `config.requireIntegrationInstance: true`. |
|
|
125
130
|
| `queueScript(name, params?)` | Enqueue another script as a follow-up (tracked as a child of the current execution). |
|
|
126
131
|
| `queueScriptBatch(entries)` | Enqueue many follow-up scripts at once. |
|
|
127
132
|
| `getExecutionId()` | The current `AdminProcess` record id. |
|
|
128
133
|
|
|
129
|
-
|
|
134
|
+
Commands return **plain, decrypted data** on success (field-level encryption is handled transparently) or an `{ error, reason, code }` object on failure — scripts check `.error` themselves. Scripts have no direct repository access; all database interaction goes through the command layer.
|
|
130
135
|
|
|
131
136
|
---
|
|
132
137
|
|
package/index.js
CHANGED
|
@@ -6,10 +6,7 @@
|
|
|
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,
|
|
@@ -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.44c158e.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.44c158e.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.44c158e.0",
|
|
15
|
+
"@friggframework/prettier-config": "2.0.0--canary.517.44c158e.0",
|
|
16
|
+
"@friggframework/test": "2.0.0--canary.517.44c158e.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": "44c158e7a3bbeaac4b410c1062b0ce209bffd7f0"
|
|
48
48
|
}
|
|
@@ -1,80 +1,24 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
1
3
|
const {
|
|
2
4
|
AdminScriptContext,
|
|
3
5
|
createAdminScriptContext,
|
|
4
6
|
} = require('../admin-script-context');
|
|
5
7
|
|
|
6
|
-
// Mock all repository factories
|
|
7
|
-
jest.mock(
|
|
8
|
-
'@friggframework/core/integrations/repositories/integration-repository-factory'
|
|
9
|
-
);
|
|
10
|
-
jest.mock('@friggframework/core/user/repositories/user-repository-factory');
|
|
11
|
-
jest.mock(
|
|
12
|
-
'@friggframework/core/modules/repositories/module-repository-factory'
|
|
13
|
-
);
|
|
14
|
-
jest.mock(
|
|
15
|
-
'@friggframework/core/credential/repositories/credential-repository-factory'
|
|
16
|
-
);
|
|
17
8
|
jest.mock('@friggframework/core/queues');
|
|
18
9
|
|
|
19
10
|
describe('AdminScriptContext', () => {
|
|
20
|
-
let mockIntegrationRepo;
|
|
21
|
-
let mockUserRepo;
|
|
22
|
-
let mockModuleRepo;
|
|
23
|
-
let mockCredentialRepo;
|
|
24
11
|
let mockQueuerUtil;
|
|
25
12
|
|
|
26
13
|
beforeEach(() => {
|
|
27
14
|
jest.clearAllMocks();
|
|
28
15
|
|
|
29
|
-
mockIntegrationRepo = {
|
|
30
|
-
findIntegrations: jest.fn(),
|
|
31
|
-
findIntegrationById: jest.fn(),
|
|
32
|
-
findIntegrationsByUserId: jest.fn(),
|
|
33
|
-
updateIntegrationConfig: jest.fn(),
|
|
34
|
-
updateIntegrationStatus: jest.fn(),
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
mockUserRepo = {
|
|
38
|
-
findIndividualUserById: jest.fn(),
|
|
39
|
-
findIndividualUserByAppUserId: jest.fn(),
|
|
40
|
-
findIndividualUserByUsername: jest.fn(),
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
mockModuleRepo = {
|
|
44
|
-
findEntity: jest.fn(),
|
|
45
|
-
findEntityById: jest.fn(),
|
|
46
|
-
findEntitiesByUserId: jest.fn(),
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
mockCredentialRepo = {
|
|
50
|
-
findCredential: jest.fn(),
|
|
51
|
-
updateCredential: jest.fn(),
|
|
52
|
-
};
|
|
53
|
-
|
|
54
16
|
mockQueuerUtil = {
|
|
55
17
|
send: jest.fn().mockResolvedValue(undefined),
|
|
56
18
|
batchSend: jest.fn().mockResolvedValue(undefined),
|
|
57
19
|
};
|
|
58
20
|
|
|
59
|
-
const {
|
|
60
|
-
createIntegrationRepository,
|
|
61
|
-
} = require('@friggframework/core/integrations/repositories/integration-repository-factory');
|
|
62
|
-
const {
|
|
63
|
-
createUserRepository,
|
|
64
|
-
} = require('@friggframework/core/user/repositories/user-repository-factory');
|
|
65
|
-
const {
|
|
66
|
-
createModuleRepository,
|
|
67
|
-
} = require('@friggframework/core/modules/repositories/module-repository-factory');
|
|
68
|
-
const {
|
|
69
|
-
createCredentialRepository,
|
|
70
|
-
} = require('@friggframework/core/credential/repositories/credential-repository-factory');
|
|
71
21
|
const { QueuerUtil } = require('@friggframework/core/queues');
|
|
72
|
-
|
|
73
|
-
createIntegrationRepository.mockReturnValue(mockIntegrationRepo);
|
|
74
|
-
createUserRepository.mockReturnValue(mockUserRepo);
|
|
75
|
-
createModuleRepository.mockReturnValue(mockModuleRepo);
|
|
76
|
-
createCredentialRepository.mockReturnValue(mockCredentialRepo);
|
|
77
|
-
|
|
78
22
|
QueuerUtil.send = mockQueuerUtil.send;
|
|
79
23
|
QueuerUtil.batchSend = mockQueuerUtil.batchSend;
|
|
80
24
|
});
|
|
@@ -86,6 +30,19 @@ describe('AdminScriptContext', () => {
|
|
|
86
30
|
expect(ctx.executionId).toBe('exec_123');
|
|
87
31
|
expect(ctx.logs).toEqual([]);
|
|
88
32
|
expect(ctx.integrationFactory).toBeNull();
|
|
33
|
+
expect(ctx.commands).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('exposes the injected command bundle as context.commands', () => {
|
|
37
|
+
const commands = {
|
|
38
|
+
users: {},
|
|
39
|
+
credentials: {},
|
|
40
|
+
entities: {},
|
|
41
|
+
integrations: {},
|
|
42
|
+
};
|
|
43
|
+
const ctx = new AdminScriptContext({ commands });
|
|
44
|
+
|
|
45
|
+
expect(ctx.commands).toBe(commands);
|
|
89
46
|
});
|
|
90
47
|
|
|
91
48
|
it('creates with integrationFactory', () => {
|
|
@@ -106,71 +63,23 @@ describe('AdminScriptContext', () => {
|
|
|
106
63
|
});
|
|
107
64
|
});
|
|
108
65
|
|
|
109
|
-
describe('
|
|
110
|
-
it('
|
|
66
|
+
describe('command surface (no direct repository access)', () => {
|
|
67
|
+
it('does not expose repository getters', () => {
|
|
111
68
|
const ctx = new AdminScriptContext();
|
|
112
|
-
const {
|
|
113
|
-
createIntegrationRepository,
|
|
114
|
-
} = require('@friggframework/core/integrations/repositories/integration-repository-factory');
|
|
115
|
-
|
|
116
|
-
expect(createIntegrationRepository).not.toHaveBeenCalled();
|
|
117
69
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
expect(
|
|
121
|
-
expect(
|
|
70
|
+
expect(ctx.integrationRepository).toBeUndefined();
|
|
71
|
+
expect(ctx.userRepository).toBeUndefined();
|
|
72
|
+
expect(ctx.moduleRepository).toBeUndefined();
|
|
73
|
+
expect(ctx.credentialRepository).toBeUndefined();
|
|
122
74
|
});
|
|
123
75
|
|
|
124
|
-
it('
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
expect(repo1).toBe(repo2);
|
|
131
|
-
expect(repo1).toBe(mockIntegrationRepo);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('creates userRepository on first access', () => {
|
|
135
|
-
const ctx = new AdminScriptContext();
|
|
136
|
-
const {
|
|
137
|
-
createUserRepository,
|
|
138
|
-
} = require('@friggframework/core/user/repositories/user-repository-factory');
|
|
139
|
-
|
|
140
|
-
expect(createUserRepository).not.toHaveBeenCalled();
|
|
141
|
-
|
|
142
|
-
const repo = ctx.userRepository;
|
|
143
|
-
|
|
144
|
-
expect(createUserRepository).toHaveBeenCalledTimes(1);
|
|
145
|
-
expect(repo).toBe(mockUserRepo);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('creates moduleRepository on first access', () => {
|
|
149
|
-
const ctx = new AdminScriptContext();
|
|
150
|
-
const {
|
|
151
|
-
createModuleRepository,
|
|
152
|
-
} = require('@friggframework/core/modules/repositories/module-repository-factory');
|
|
153
|
-
|
|
154
|
-
expect(createModuleRepository).not.toHaveBeenCalled();
|
|
155
|
-
|
|
156
|
-
const repo = ctx.moduleRepository;
|
|
157
|
-
|
|
158
|
-
expect(createModuleRepository).toHaveBeenCalledTimes(1);
|
|
159
|
-
expect(repo).toBe(mockModuleRepo);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('creates credentialRepository on first access', () => {
|
|
163
|
-
const ctx = new AdminScriptContext();
|
|
164
|
-
const {
|
|
165
|
-
createCredentialRepository,
|
|
166
|
-
} = require('@friggframework/core/credential/repositories/credential-repository-factory');
|
|
167
|
-
|
|
168
|
-
expect(createCredentialRepository).not.toHaveBeenCalled();
|
|
169
|
-
|
|
170
|
-
const repo = ctx.credentialRepository;
|
|
76
|
+
it('the context module never imports a repository factory', () => {
|
|
77
|
+
const source = fs.readFileSync(
|
|
78
|
+
path.join(__dirname, '..', 'admin-script-context.js'),
|
|
79
|
+
'utf8'
|
|
80
|
+
);
|
|
171
81
|
|
|
172
|
-
expect(
|
|
173
|
-
expect(repo).toBe(mockCredentialRepo);
|
|
82
|
+
expect(source).not.toMatch(/repository-factory/);
|
|
174
83
|
});
|
|
175
84
|
});
|
|
176
85
|
|
|
@@ -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
|
|
|
@@ -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);
|
|
@@ -3,23 +3,27 @@ const { QueuerUtil } = require('@friggframework/core/queues');
|
|
|
3
3
|
/**
|
|
4
4
|
* AdminScriptContext - Execution environment for admin scripts
|
|
5
5
|
*
|
|
6
|
-
* Provides a controlled surface area for scripts to interact with
|
|
7
|
-
*
|
|
6
|
+
* Provides a controlled surface area for scripts to interact with the Frigg
|
|
7
|
+
* platform. Scripts touch the database only through injected Frigg commands
|
|
8
|
+
* (`context.commands`) — never through repositories directly. Capabilities:
|
|
8
9
|
*
|
|
10
|
+
* - **Frigg commands**: `commands.users`, `commands.credentials`,
|
|
11
|
+
* `commands.entities`, and `commands.integrations` expose the framework's
|
|
12
|
+
* command layer. Each command returns data on success or an `{ error }`
|
|
13
|
+
* object on failure — scripts check `.error` themselves.
|
|
9
14
|
* - **Admin bypass**: `instantiate()` passes `_isAdminContext: true` to
|
|
10
15
|
* skip user-ownership checks when loading integration instances
|
|
11
16
|
* - **Script chaining**: `queueScript()` / `queueScriptBatch()` let scripts
|
|
12
17
|
* enqueue follow-up work with parent execution tracking
|
|
13
18
|
* - **Execution-scoped logging**: `log()` collects structured entries tied
|
|
14
19
|
* to the current execution for post-run inspection
|
|
15
|
-
* - **Lazy-loaded repositories**: Repos are exposed directly as getters
|
|
16
|
-
* so scripts can query any data they need without wrapper indirection
|
|
17
20
|
*/
|
|
18
21
|
class AdminScriptContext {
|
|
19
22
|
/**
|
|
20
23
|
* @param {Object} [params={}] - Context configuration
|
|
21
24
|
* @param {string|number|null} [params.executionId] - ID of the AdminProcess record this context is scoped to (used for log persistence and script chaining)
|
|
22
25
|
* @param {Object|null} [params.integrationFactory] - Factory used to hydrate integration instances; required for scripts that call instantiate()
|
|
26
|
+
* @param {Object|null} [params.commands] - Frigg command bundle ({ users, credentials, entities, integrations }) injected by the composition root and exposed to scripts as context.commands
|
|
23
27
|
*/
|
|
24
28
|
constructor(params = {}) {
|
|
25
29
|
this.executionId = params.executionId || null;
|
|
@@ -27,54 +31,10 @@ class AdminScriptContext {
|
|
|
27
31
|
|
|
28
32
|
this.integrationFactory = params.integrationFactory || null;
|
|
29
33
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
this.
|
|
34
|
-
this._moduleRepository = null;
|
|
35
|
-
this._credentialRepository = null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ==================== LAZY-LOADED REPOSITORIES ====================
|
|
39
|
-
|
|
40
|
-
get integrationRepository() {
|
|
41
|
-
if (!this._integrationRepository) {
|
|
42
|
-
const {
|
|
43
|
-
createIntegrationRepository,
|
|
44
|
-
} = require('@friggframework/core/integrations/repositories/integration-repository-factory');
|
|
45
|
-
this._integrationRepository = createIntegrationRepository();
|
|
46
|
-
}
|
|
47
|
-
return this._integrationRepository;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
get userRepository() {
|
|
51
|
-
if (!this._userRepository) {
|
|
52
|
-
const {
|
|
53
|
-
createUserRepository,
|
|
54
|
-
} = require('@friggframework/core/user/repositories/user-repository-factory');
|
|
55
|
-
this._userRepository = createUserRepository();
|
|
56
|
-
}
|
|
57
|
-
return this._userRepository;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
get moduleRepository() {
|
|
61
|
-
if (!this._moduleRepository) {
|
|
62
|
-
const {
|
|
63
|
-
createModuleRepository,
|
|
64
|
-
} = require('@friggframework/core/modules/repositories/module-repository-factory');
|
|
65
|
-
this._moduleRepository = createModuleRepository();
|
|
66
|
-
}
|
|
67
|
-
return this._moduleRepository;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
get credentialRepository() {
|
|
71
|
-
if (!this._credentialRepository) {
|
|
72
|
-
const {
|
|
73
|
-
createCredentialRepository,
|
|
74
|
-
} = require('@friggframework/core/credential/repositories/credential-repository-factory');
|
|
75
|
-
this._credentialRepository = createCredentialRepository();
|
|
76
|
-
}
|
|
77
|
-
return this._credentialRepository;
|
|
34
|
+
// Scripts interact with the database only through these Frigg commands.
|
|
35
|
+
// Injected by bootstrap.js — the context never reaches for repositories
|
|
36
|
+
// or command factories itself.
|
|
37
|
+
this.commands = params.commands || null;
|
|
78
38
|
}
|
|
79
39
|
|
|
80
40
|
// ==================== INTEGRATION INSTANTIATION ====================
|
|
@@ -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,4 +1,3 @@
|
|
|
1
|
-
const { getScriptFactory } = require('./script-factory');
|
|
2
1
|
const { createAdminScriptContext } = require('./admin-script-context');
|
|
3
2
|
const {
|
|
4
3
|
createAdminScriptCommands,
|
|
@@ -14,10 +13,25 @@ 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
|
+
* @param {Object} [params.scriptCommands] - Frigg command bundle exposed to
|
|
25
|
+
* scripts as context.commands (built by bootstrap.js).
|
|
26
|
+
*/
|
|
17
27
|
constructor(params = {}) {
|
|
18
|
-
|
|
28
|
+
if (!params.scriptFactory) {
|
|
29
|
+
throw new Error('ScriptRunner requires a scriptFactory');
|
|
30
|
+
}
|
|
31
|
+
this.scriptFactory = params.scriptFactory;
|
|
19
32
|
this.commands = params.commands || createAdminScriptCommands();
|
|
20
33
|
this.integrationFactory = params.integrationFactory || null;
|
|
34
|
+
this.scriptCommands = params.scriptCommands || null;
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
/**
|
|
@@ -89,6 +103,7 @@ class ScriptRunner {
|
|
|
89
103
|
const context = createAdminScriptContext({
|
|
90
104
|
executionId,
|
|
91
105
|
integrationFactory: this.integrationFactory,
|
|
106
|
+
commands: this.scriptCommands,
|
|
92
107
|
});
|
|
93
108
|
|
|
94
109
|
let output;
|
|
@@ -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,11 @@ describe('Admin Script Router', () => {
|
|
|
72
69
|
setScheduleEnabled: jest.fn(),
|
|
73
70
|
};
|
|
74
71
|
|
|
75
|
-
|
|
72
|
+
bootstrapAdminScripts.mockReturnValue({
|
|
73
|
+
scriptFactory: mockFactory,
|
|
74
|
+
integrationFactory: {},
|
|
75
|
+
scriptCommands: {},
|
|
76
|
+
});
|
|
76
77
|
createScriptRunner.mockReturnValue(mockRunner);
|
|
77
78
|
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
78
79
|
createSchedulerAdapter.mockReturnValue(mockSchedulerAdapter);
|
|
@@ -174,6 +175,11 @@ describe('Admin Script Router', () => {
|
|
|
174
175
|
mode: 'sync',
|
|
175
176
|
})
|
|
176
177
|
);
|
|
178
|
+
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
179
|
+
scriptFactory: mockFactory,
|
|
180
|
+
integrationFactory: {},
|
|
181
|
+
scriptCommands: {},
|
|
182
|
+
});
|
|
177
183
|
});
|
|
178
184
|
|
|
179
185
|
it('should queue script for async execution', async () => {
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
// The command bundle is built from these factories; mock them so bootstrap
|
|
7
|
+
// doesn't need a configured database to construct the command groups.
|
|
8
|
+
jest.mock('@friggframework/core/application/commands/user-commands', () => ({
|
|
9
|
+
createUserCommands: jest.fn(() => ({ __kind: 'users' })),
|
|
10
|
+
}));
|
|
11
|
+
jest.mock(
|
|
12
|
+
'@friggframework/core/application/commands/credential-commands',
|
|
13
|
+
() => ({
|
|
14
|
+
createCredentialCommands: jest.fn(() => ({ __kind: 'credentials' })),
|
|
15
|
+
})
|
|
16
|
+
);
|
|
17
|
+
jest.mock('@friggframework/core/application/commands/entity-commands', () => ({
|
|
18
|
+
createEntityCommands: jest.fn(() => ({ __kind: 'entities' })),
|
|
19
|
+
}));
|
|
20
|
+
jest.mock(
|
|
21
|
+
'@friggframework/core/application/commands/integration-commands',
|
|
22
|
+
() => ({
|
|
23
|
+
createIntegrationCommands: jest.fn(() => ({ __kind: 'integrations' })),
|
|
24
|
+
})
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const {
|
|
28
|
+
loadAppDefinition,
|
|
29
|
+
} = require('@friggframework/core/handlers/app-definition-loader');
|
|
30
|
+
const {
|
|
31
|
+
bootstrapAdminScripts,
|
|
32
|
+
_resetBootstrapForTests,
|
|
33
|
+
} = require('../bootstrap');
|
|
34
|
+
|
|
35
|
+
class TestScript extends AdminScriptBase {
|
|
36
|
+
static Definition = {
|
|
37
|
+
name: 'test-script',
|
|
38
|
+
version: '1.0.0',
|
|
39
|
+
description: 'Test script',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('bootstrapAdminScripts', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
_resetBootstrapForTests();
|
|
46
|
+
jest.clearAllMocks();
|
|
47
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
console.error.mockRestore();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('registers the app definition admin scripts into the returned factory', () => {
|
|
55
|
+
loadAppDefinition.mockReturnValue({ adminScripts: [TestScript] });
|
|
56
|
+
|
|
57
|
+
const { scriptFactory, integrationFactory } = bootstrapAdminScripts();
|
|
58
|
+
|
|
59
|
+
expect(scriptFactory).toBeInstanceOf(ScriptFactory);
|
|
60
|
+
expect(scriptFactory.has('test-script')).toBe(true);
|
|
61
|
+
expect(integrationFactory).toBeTruthy();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('never throws and returns an empty factory when the app definition cannot load', () => {
|
|
65
|
+
loadAppDefinition.mockImplementation(() => {
|
|
66
|
+
throw new Error('cannot load app definition');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let result;
|
|
70
|
+
expect(() => {
|
|
71
|
+
result = bootstrapAdminScripts();
|
|
72
|
+
}).not.toThrow();
|
|
73
|
+
|
|
74
|
+
expect(result.scriptFactory).toBeInstanceOf(ScriptFactory);
|
|
75
|
+
expect(result.scriptFactory.size).toBe(0);
|
|
76
|
+
expect(result.integrationFactory).toBeTruthy();
|
|
77
|
+
expect(console.error).toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('memoizes: repeated calls reuse the same factory and load the definition once', () => {
|
|
81
|
+
loadAppDefinition.mockReturnValue({ adminScripts: [TestScript] });
|
|
82
|
+
|
|
83
|
+
const first = bootstrapAdminScripts();
|
|
84
|
+
const second = bootstrapAdminScripts();
|
|
85
|
+
|
|
86
|
+
expect(second.scriptFactory).toBe(first.scriptFactory);
|
|
87
|
+
expect(second.integrationFactory).toBe(first.integrationFactory);
|
|
88
|
+
expect(second.scriptCommands).toBe(first.scriptCommands);
|
|
89
|
+
expect(loadAppDefinition).toHaveBeenCalledTimes(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('builds the injectable command bundle (users, credentials, entities, integrations)', () => {
|
|
93
|
+
loadAppDefinition.mockReturnValue({ adminScripts: [] });
|
|
94
|
+
|
|
95
|
+
const { scriptCommands } = bootstrapAdminScripts();
|
|
96
|
+
|
|
97
|
+
expect(scriptCommands).toEqual({
|
|
98
|
+
users: { __kind: 'users' },
|
|
99
|
+
credentials: { __kind: 'credentials' },
|
|
100
|
+
entities: { __kind: 'entities' },
|
|
101
|
+
integrations: { __kind: 'integrations' },
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('tolerates an app definition with no adminScripts', () => {
|
|
106
|
+
loadAppDefinition.mockReturnValue({});
|
|
107
|
+
|
|
108
|
+
const { scriptFactory } = bootstrapAdminScripts();
|
|
109
|
+
|
|
110
|
+
expect(scriptFactory).toBeInstanceOf(ScriptFactory);
|
|
111
|
+
expect(scriptFactory.size).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
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 mockScriptCommands;
|
|
16
|
+
let mockRunner;
|
|
17
|
+
let mockCommands;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
mockScriptFactory = { id: 'script-factory' };
|
|
21
|
+
mockIntegrationFactory = { id: 'integration-factory' };
|
|
22
|
+
mockScriptCommands = { id: 'script-commands' };
|
|
23
|
+
mockRunner = { execute: jest.fn() };
|
|
24
|
+
mockCommands = {
|
|
25
|
+
completeAdminProcess: jest.fn().mockResolvedValue({}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
bootstrapAdminScripts.mockReturnValue({
|
|
29
|
+
scriptFactory: mockScriptFactory,
|
|
30
|
+
integrationFactory: mockIntegrationFactory,
|
|
31
|
+
scriptCommands: mockScriptCommands,
|
|
32
|
+
});
|
|
33
|
+
createScriptRunner.mockReturnValue(mockRunner);
|
|
34
|
+
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
35
|
+
|
|
36
|
+
jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
37
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
console.log.mockRestore();
|
|
42
|
+
console.error.mockRestore();
|
|
43
|
+
jest.clearAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('EventBridge Scheduler direct invoke (no Records)', () => {
|
|
47
|
+
it('injects the bootstrapped factory into the runner and reports the result', async () => {
|
|
48
|
+
mockRunner.execute.mockResolvedValue({
|
|
49
|
+
status: 'COMPLETED',
|
|
50
|
+
executionId: 'exec-1',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const response = await handler({
|
|
54
|
+
scriptName: 'my-script',
|
|
55
|
+
trigger: 'SCHEDULED',
|
|
56
|
+
params: { foo: 'bar' },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
60
|
+
scriptFactory: mockScriptFactory,
|
|
61
|
+
integrationFactory: mockIntegrationFactory,
|
|
62
|
+
scriptCommands: mockScriptCommands,
|
|
63
|
+
});
|
|
64
|
+
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
65
|
+
'my-script',
|
|
66
|
+
{ foo: 'bar' },
|
|
67
|
+
expect.objectContaining({ trigger: 'SCHEDULED', mode: 'async' })
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(response.statusCode).toBe(200);
|
|
71
|
+
const body = JSON.parse(response.body);
|
|
72
|
+
expect(body.processed).toBe(1);
|
|
73
|
+
expect(body.results[0]).toEqual({
|
|
74
|
+
scriptName: 'my-script',
|
|
75
|
+
status: 'COMPLETED',
|
|
76
|
+
executionId: 'exec-1',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('marks the execution FAILED when the runner throws', async () => {
|
|
81
|
+
mockRunner.execute.mockRejectedValue(new Error('boom'));
|
|
82
|
+
|
|
83
|
+
const response = await handler({
|
|
84
|
+
scriptName: 'my-script',
|
|
85
|
+
executionId: 'exec-2',
|
|
86
|
+
trigger: 'SCHEDULED',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(response.statusCode).toBe(200);
|
|
90
|
+
const body = JSON.parse(response.body);
|
|
91
|
+
expect(body.results[0].status).toBe('FAILED');
|
|
92
|
+
expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith(
|
|
93
|
+
'exec-2',
|
|
94
|
+
expect.objectContaining({ state: 'FAILED' })
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('SQS batch (Records[])', () => {
|
|
100
|
+
it('injects the bootstrapped factory and processes each record', async () => {
|
|
101
|
+
mockRunner.execute.mockResolvedValue({
|
|
102
|
+
status: 'COMPLETED',
|
|
103
|
+
executionId: 'exec-3',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const response = await handler({
|
|
107
|
+
Records: [
|
|
108
|
+
{
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
scriptName: 'my-script',
|
|
111
|
+
executionId: 'exec-3',
|
|
112
|
+
params: {},
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(createScriptRunner).toHaveBeenCalledWith({
|
|
119
|
+
scriptFactory: mockScriptFactory,
|
|
120
|
+
integrationFactory: mockIntegrationFactory,
|
|
121
|
+
scriptCommands: mockScriptCommands,
|
|
122
|
+
});
|
|
123
|
+
expect(mockRunner.execute).toHaveBeenCalledWith(
|
|
124
|
+
'my-script',
|
|
125
|
+
{},
|
|
126
|
+
expect.objectContaining({
|
|
127
|
+
trigger: 'QUEUE',
|
|
128
|
+
executionId: 'exec-3',
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(response.statusCode).toBe(200);
|
|
133
|
+
const body = JSON.parse(response.body);
|
|
134
|
+
expect(body.processed).toBe(1);
|
|
135
|
+
expect(body.results[0].status).toBe('COMPLETED');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('isolates a bad record without dropping the rest of the batch', async () => {
|
|
139
|
+
mockRunner.execute.mockResolvedValue({
|
|
140
|
+
status: 'COMPLETED',
|
|
141
|
+
executionId: 'exec-4',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const response = await handler({
|
|
145
|
+
Records: [
|
|
146
|
+
// Missing scriptName -> runMessage throws before the runner
|
|
147
|
+
{ body: JSON.stringify({ executionId: 'exec-bad' }) },
|
|
148
|
+
{
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
scriptName: 'my-script',
|
|
151
|
+
executionId: 'exec-4',
|
|
152
|
+
params: {},
|
|
153
|
+
}),
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const body = JSON.parse(response.body);
|
|
159
|
+
expect(body.processed).toBe(2);
|
|
160
|
+
expect(body.results[0].status).toBe('FAILED');
|
|
161
|
+
expect(body.results[1].status).toBe('COMPLETED');
|
|
162
|
+
expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith(
|
|
163
|
+
'exec-bad',
|
|
164
|
+
expect.objectContaining({ state: 'FAILED' })
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -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,
|
|
@@ -24,16 +23,12 @@ const {
|
|
|
24
23
|
|
|
25
24
|
const router = express.Router();
|
|
26
25
|
|
|
27
|
-
// Apply auth middleware to all admin routes
|
|
26
|
+
// Apply auth middleware to all admin routes. Each handler then calls
|
|
27
|
+
// bootstrapAdminScripts() (memoized, so it runs once per process) to obtain the
|
|
28
|
+
// scriptFactory / integrationFactory / scriptCommands it needs — no global, and
|
|
29
|
+
// endpoints that don't touch scripts skip the work entirely.
|
|
28
30
|
router.use(validateAdminApiKey);
|
|
29
31
|
|
|
30
|
-
// Register the host app's admin scripts before handling requests.
|
|
31
|
-
// Memoized, so this only does work on the first request per process.
|
|
32
|
-
router.use((_req, _res, next) => {
|
|
33
|
-
bootstrapAdminScripts();
|
|
34
|
-
next();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
32
|
/**
|
|
38
33
|
* Translate a thrown error into an HTTP response. Boom errors (thrown by the
|
|
39
34
|
* schedule use cases) carry their own status code; anything else is an
|
|
@@ -42,7 +37,9 @@ router.use((_req, _res, next) => {
|
|
|
42
37
|
*/
|
|
43
38
|
function sendError(res, error, fallbackMessage) {
|
|
44
39
|
if (error.isBoom) {
|
|
45
|
-
return res
|
|
40
|
+
return res
|
|
41
|
+
.status(error.output.statusCode)
|
|
42
|
+
.json({ error: error.message });
|
|
46
43
|
}
|
|
47
44
|
console.error(fallbackMessage, error);
|
|
48
45
|
return res.status(500).json({ error: fallbackMessage });
|
|
@@ -65,9 +62,10 @@ function buildAudit(req) {
|
|
|
65
62
|
|
|
66
63
|
/**
|
|
67
64
|
* Create schedule use case instances
|
|
65
|
+
* @param {ScriptFactory} scriptFactory - Registry injected from the request.
|
|
68
66
|
* @private
|
|
69
67
|
*/
|
|
70
|
-
function createScheduleUseCases() {
|
|
68
|
+
function createScheduleUseCases(scriptFactory) {
|
|
71
69
|
const commands = createAdminScriptCommands();
|
|
72
70
|
|
|
73
71
|
// The local adapter is in-memory only (schedules vanish on cold start), so it
|
|
@@ -88,7 +86,6 @@ function createScheduleUseCases() {
|
|
|
88
86
|
scheduleGroupName: process.env.ADMIN_SCRIPT_SCHEDULE_GROUP,
|
|
89
87
|
roleArn: process.env.SCHEDULER_ROLE_ARN,
|
|
90
88
|
});
|
|
91
|
-
const scriptFactory = getScriptFactory();
|
|
92
89
|
|
|
93
90
|
return {
|
|
94
91
|
getEffectiveSchedule: new GetEffectiveScheduleUseCase({
|
|
@@ -112,9 +109,9 @@ function createScheduleUseCases() {
|
|
|
112
109
|
* GET /admin/scripts
|
|
113
110
|
* List all registered scripts
|
|
114
111
|
*/
|
|
115
|
-
router.get('/scripts', async (
|
|
112
|
+
router.get('/scripts', async (_req, res) => {
|
|
116
113
|
try {
|
|
117
|
-
const factory =
|
|
114
|
+
const { scriptFactory: factory } = bootstrapAdminScripts();
|
|
118
115
|
const scripts = factory.getAll();
|
|
119
116
|
|
|
120
117
|
res.json({
|
|
@@ -140,7 +137,7 @@ router.get('/scripts', async (req, res) => {
|
|
|
140
137
|
router.get('/scripts/:scriptName', async (req, res) => {
|
|
141
138
|
try {
|
|
142
139
|
const { scriptName } = req.params;
|
|
143
|
-
const factory =
|
|
140
|
+
const { scriptFactory: factory } = bootstrapAdminScripts();
|
|
144
141
|
|
|
145
142
|
if (!factory.has(scriptName)) {
|
|
146
143
|
return res.status(404).json({
|
|
@@ -175,7 +172,7 @@ router.post('/scripts/:scriptName/validate', async (req, res) => {
|
|
|
175
172
|
try {
|
|
176
173
|
const { scriptName } = req.params;
|
|
177
174
|
const { params = {} } = req.body;
|
|
178
|
-
const factory =
|
|
175
|
+
const { scriptFactory: factory } = bootstrapAdminScripts();
|
|
179
176
|
|
|
180
177
|
if (!factory.has(scriptName)) {
|
|
181
178
|
return res.status(404).json({
|
|
@@ -200,7 +197,11 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
200
197
|
try {
|
|
201
198
|
const { scriptName } = req.params;
|
|
202
199
|
const { params = {}, mode = 'async' } = req.body;
|
|
203
|
-
const
|
|
200
|
+
const {
|
|
201
|
+
scriptFactory: factory,
|
|
202
|
+
integrationFactory,
|
|
203
|
+
scriptCommands,
|
|
204
|
+
} = bootstrapAdminScripts();
|
|
204
205
|
|
|
205
206
|
if (!factory.has(scriptName)) {
|
|
206
207
|
return res.status(404).json({
|
|
@@ -237,8 +238,11 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
237
238
|
});
|
|
238
239
|
}
|
|
239
240
|
|
|
240
|
-
const
|
|
241
|
-
|
|
241
|
+
const runner = createScriptRunner({
|
|
242
|
+
scriptFactory: factory,
|
|
243
|
+
integrationFactory,
|
|
244
|
+
scriptCommands,
|
|
245
|
+
});
|
|
242
246
|
const result = await runner.execute(scriptName, params, {
|
|
243
247
|
trigger: 'MANUAL',
|
|
244
248
|
mode: 'sync',
|
|
@@ -355,7 +359,8 @@ router.get('/scripts/:scriptName/executions', async (req, res) => {
|
|
|
355
359
|
router.get('/scripts/:scriptName/schedule', async (req, res) => {
|
|
356
360
|
try {
|
|
357
361
|
const { scriptName } = req.params;
|
|
358
|
-
const {
|
|
362
|
+
const { scriptFactory } = bootstrapAdminScripts();
|
|
363
|
+
const { getEffectiveSchedule } = createScheduleUseCases(scriptFactory);
|
|
359
364
|
|
|
360
365
|
const result = await getEffectiveSchedule.execute(scriptName);
|
|
361
366
|
|
|
@@ -377,7 +382,8 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
|
|
|
377
382
|
try {
|
|
378
383
|
const { scriptName } = req.params;
|
|
379
384
|
const { enabled, cronExpression, timezone } = req.body;
|
|
380
|
-
const {
|
|
385
|
+
const { scriptFactory } = bootstrapAdminScripts();
|
|
386
|
+
const { upsertSchedule } = createScheduleUseCases(scriptFactory);
|
|
381
387
|
|
|
382
388
|
const result = await upsertSchedule.execute(scriptName, {
|
|
383
389
|
enabled,
|
|
@@ -407,7 +413,8 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
|
|
|
407
413
|
router.delete('/scripts/:scriptName/schedule', async (req, res) => {
|
|
408
414
|
try {
|
|
409
415
|
const { scriptName } = req.params;
|
|
410
|
-
const {
|
|
416
|
+
const { scriptFactory } = bootstrapAdminScripts();
|
|
417
|
+
const { deleteSchedule } = createScheduleUseCases(scriptFactory);
|
|
411
418
|
|
|
412
419
|
const result = await deleteSchedule.execute(scriptName);
|
|
413
420
|
|
|
@@ -1,18 +1,21 @@
|
|
|
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;
|
|
18
|
+
let scriptCommands = null;
|
|
16
19
|
let integrationFactory = null;
|
|
17
20
|
|
|
18
21
|
function registerScripts(factory, scriptClasses) {
|
|
@@ -43,22 +46,56 @@ function createIntegrationFactory() {
|
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/**
|
|
46
|
-
*
|
|
49
|
+
* Build the command bundle injected into every AdminScriptContext. Scripts
|
|
50
|
+
* interact with the database only through these Frigg commands — never through
|
|
51
|
+
* repositories directly. The require is lazy so the package keeps no load-time
|
|
52
|
+
* coupling to core's command/repository modules.
|
|
53
|
+
*/
|
|
54
|
+
function buildScriptCommands() {
|
|
55
|
+
const {
|
|
56
|
+
createUserCommands,
|
|
57
|
+
} = require('@friggframework/core/application/commands/user-commands');
|
|
58
|
+
const {
|
|
59
|
+
createCredentialCommands,
|
|
60
|
+
} = require('@friggframework/core/application/commands/credential-commands');
|
|
61
|
+
const {
|
|
62
|
+
createEntityCommands,
|
|
63
|
+
} = require('@friggframework/core/application/commands/entity-commands');
|
|
64
|
+
const {
|
|
65
|
+
createIntegrationCommands,
|
|
66
|
+
} = require('@friggframework/core/application/commands/integration-commands');
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
users: createUserCommands(),
|
|
70
|
+
credentials: createCredentialCommands(),
|
|
71
|
+
entities: createEntityCommands(),
|
|
72
|
+
// Class-agnostic reads (findIntegrationById, listIntegrations). Scripts
|
|
73
|
+
// that need class-scoped integration ops build their own commands.
|
|
74
|
+
integrations: createIntegrationCommands(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @returns {{ scriptFactory: ScriptFactory, scriptCommands: object, integrationFactory: object }}
|
|
47
80
|
*/
|
|
48
81
|
function bootstrapAdminScripts() {
|
|
49
82
|
if (bootstrapped) {
|
|
50
|
-
return { integrationFactory };
|
|
83
|
+
return { scriptFactory, scriptCommands, integrationFactory };
|
|
51
84
|
}
|
|
52
85
|
bootstrapped = true;
|
|
53
86
|
|
|
87
|
+
// Create the registry up front so consumers always get a (possibly empty)
|
|
88
|
+
// factory even when the app definition can't be loaded — mirrors the
|
|
89
|
+
// never-throw contract above.
|
|
90
|
+
scriptFactory = new ScriptFactory();
|
|
91
|
+
|
|
54
92
|
try {
|
|
55
93
|
const {
|
|
56
94
|
loadAppDefinition,
|
|
57
95
|
} = require('@friggframework/core/handlers/app-definition-loader');
|
|
58
96
|
const { adminScripts = [] } = loadAppDefinition();
|
|
59
97
|
|
|
60
|
-
|
|
61
|
-
registerScripts(factory, adminScripts);
|
|
98
|
+
registerScripts(scriptFactory, adminScripts);
|
|
62
99
|
} catch (error) {
|
|
63
100
|
console.error(
|
|
64
101
|
'[admin-scripts] bootstrap: could not load app definition:',
|
|
@@ -66,13 +103,26 @@ function bootstrapAdminScripts() {
|
|
|
66
103
|
);
|
|
67
104
|
}
|
|
68
105
|
|
|
106
|
+
// Built in its own try so a command-layer failure never blocks script
|
|
107
|
+
// registration (and vice versa) — both honor the never-throw contract.
|
|
108
|
+
try {
|
|
109
|
+
scriptCommands = buildScriptCommands();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(
|
|
112
|
+
'[admin-scripts] bootstrap: could not build command bundle:',
|
|
113
|
+
error.message
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
69
117
|
integrationFactory = createIntegrationFactory();
|
|
70
|
-
return { integrationFactory };
|
|
118
|
+
return { scriptFactory, scriptCommands, integrationFactory };
|
|
71
119
|
}
|
|
72
120
|
|
|
73
121
|
/** Test-only: reset memoized bootstrap state. */
|
|
74
122
|
function _resetBootstrapForTests() {
|
|
75
123
|
bootstrapped = false;
|
|
124
|
+
scriptFactory = null;
|
|
125
|
+
scriptCommands = null;
|
|
76
126
|
integrationFactory = null;
|
|
77
127
|
}
|
|
78
128
|
|
|
@@ -6,9 +6,24 @@ 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
|
+
* @param {Object} deps.scriptCommands - Frigg command bundle exposed to the script as context.commands.
|
|
20
|
+
* @returns {Promise<{ scriptName: string, status: string, executionId: string }>}
|
|
9
21
|
* @private
|
|
10
22
|
*/
|
|
11
|
-
async function runMessage(
|
|
23
|
+
async function runMessage(
|
|
24
|
+
message,
|
|
25
|
+
{ scriptFactory, integrationFactory, scriptCommands }
|
|
26
|
+
) {
|
|
12
27
|
const { scriptName, executionId, trigger, params, parentExecutionId } =
|
|
13
28
|
message;
|
|
14
29
|
|
|
@@ -22,7 +37,11 @@ async function runMessage(message, integrationFactory) {
|
|
|
22
37
|
}`
|
|
23
38
|
);
|
|
24
39
|
|
|
25
|
-
const runner = createScriptRunner({
|
|
40
|
+
const runner = createScriptRunner({
|
|
41
|
+
scriptFactory,
|
|
42
|
+
integrationFactory,
|
|
43
|
+
scriptCommands,
|
|
44
|
+
});
|
|
26
45
|
const result = await runner.execute(scriptName, params, {
|
|
27
46
|
trigger: trigger || 'QUEUE',
|
|
28
47
|
mode: 'async',
|
|
@@ -66,59 +85,58 @@ async function markFailed(executionId, error) {
|
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
/**
|
|
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.
|
|
88
|
+
* Handle an EventBridge Scheduler direct invoke: the event itself is a single
|
|
89
|
+
* execution message (no `Records` wrapper).
|
|
90
|
+
* @param {Object} event - The execution message.
|
|
91
|
+
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object, scriptCommands: object }} deps
|
|
92
|
+
* @returns {Promise<{ statusCode: number, body: string }>}
|
|
93
|
+
* @private
|
|
79
94
|
*/
|
|
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
|
-
}
|
|
95
|
+
async function handleScheduledInvoke(event, deps) {
|
|
96
|
+
try {
|
|
97
|
+
const result = await runMessage(event, deps);
|
|
98
|
+
console.log(
|
|
99
|
+
`Script completed: ${result.scriptName}, status: ${result.status}`
|
|
100
|
+
);
|
|
101
|
+
return {
|
|
102
|
+
statusCode: 200,
|
|
103
|
+
body: JSON.stringify({ processed: 1, results: [result] }),
|
|
104
|
+
};
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('Unexpected error processing scheduled invoke:', error);
|
|
107
|
+
await markFailed(event.executionId, error);
|
|
108
|
+
return {
|
|
109
|
+
statusCode: 200,
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
processed: 1,
|
|
112
|
+
results: [
|
|
113
|
+
{
|
|
114
|
+
scriptName: event.scriptName || 'unknown',
|
|
115
|
+
status: 'FAILED',
|
|
116
|
+
error: error.message,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
114
121
|
}
|
|
122
|
+
}
|
|
115
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Handle an SQS batch: each `event.Records[].body` is a JSON execution message
|
|
126
|
+
* (manual async executions and queueScript continuations). Failures are isolated
|
|
127
|
+
* per record so one bad message doesn't drop the rest of the batch.
|
|
128
|
+
* @param {Object} event - The SQS event with a `Records` array.
|
|
129
|
+
* @param {{ scriptFactory: ScriptFactory, integrationFactory: object, scriptCommands: object }} deps
|
|
130
|
+
* @returns {Promise<{ statusCode: number, body: string }>}
|
|
131
|
+
* @private
|
|
132
|
+
*/
|
|
133
|
+
async function handleSqsBatch(event, deps) {
|
|
116
134
|
const results = [];
|
|
117
135
|
for (const record of event.Records) {
|
|
118
136
|
let message = {};
|
|
119
137
|
try {
|
|
120
138
|
message = JSON.parse(record.body);
|
|
121
|
-
const result = await runMessage(message,
|
|
139
|
+
const result = await runMessage(message, deps);
|
|
122
140
|
console.log(
|
|
123
141
|
`Script completed: ${result.scriptName}, status: ${result.status}`
|
|
124
142
|
);
|
|
@@ -143,4 +161,24 @@ async function handler(event) {
|
|
|
143
161
|
};
|
|
144
162
|
}
|
|
145
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Admin Script Executor Lambda Handler
|
|
166
|
+
*
|
|
167
|
+
* Handles two invocation shapes:
|
|
168
|
+
* - SQS: `event.Records[]` — each record body is a JSON execution message
|
|
169
|
+
* (manual async executions and queueScript continuations).
|
|
170
|
+
* - EventBridge Scheduler direct invoke: the event itself is the message
|
|
171
|
+
* (`{ scriptName, trigger: 'SCHEDULED', params }`) with no `Records` wrapper.
|
|
172
|
+
*
|
|
173
|
+
* Thin adapter: parses the event and delegates to ScriptRunner, which owns
|
|
174
|
+
* execution tracking, error recording, and status updates.
|
|
175
|
+
*/
|
|
176
|
+
async function handler(event) {
|
|
177
|
+
const deps = bootstrapAdminScripts();
|
|
178
|
+
|
|
179
|
+
return event.Records
|
|
180
|
+
? handleSqsBatch(event, deps)
|
|
181
|
+
: handleScheduledInvoke(event, deps);
|
|
182
|
+
}
|
|
183
|
+
|
|
146
184
|
module.exports = { handler };
|