@friggframework/admin-scripts 2.0.0--canary.517.44c158e.0 → 2.0.0--canary.517.b384239.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 +3 -3
- package/package.json +6 -6
- package/src/adapters/__tests__/scheduler-adapter-factory.test.js +51 -1
- package/src/adapters/scheduler-adapter-factory.js +37 -3
- package/src/application/__tests__/script-runner.test.js +15 -15
- package/src/application/admin-script-context.js +1 -1
- package/src/application/script-runner.js +9 -8
- package/src/infrastructure/__tests__/admin-script-router.test.js +13 -13
- package/src/infrastructure/__tests__/script-executor-handler.test.js +3 -3
- package/src/infrastructure/admin-script-router.js +23 -53
- package/src/infrastructure/script-executor-handler.js +3 -3
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Typical use cases:
|
|
|
8
8
|
- **Recurring maintenance** — refresh webhooks/subscriptions before they expire.
|
|
9
9
|
- **Built-in utilities** — OAuth token refresh, integration health checks.
|
|
10
10
|
|
|
11
|
-
> Admin scripts are a **high-privilege** surface. Every endpoint is protected by an admin API key (`x-frigg-admin-api-key`), scripts run in your private VPC subnets, and every execution is tracked in the `
|
|
11
|
+
> Admin scripts are a **high-privilege** surface. Every endpoint is protected by an admin API key (`x-frigg-admin-api-key`), scripts run in your private VPC subnets, and every execution is tracked in the `AdminScriptExecution` table. Never expose the admin API key to browsers or end users.
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
@@ -129,7 +129,7 @@ module.exports = { AttioHealingScript };
|
|
|
129
129
|
| `instantiate(integrationId)` | Returns a hydrated integration instance for calling external APIs. Requires `config.requireIntegrationInstance: true`. |
|
|
130
130
|
| `queueScript(name, params?)` | Enqueue another script as a follow-up (tracked as a child of the current execution). |
|
|
131
131
|
| `queueScriptBatch(entries)` | Enqueue many follow-up scripts at once. |
|
|
132
|
-
| `getExecutionId()` | The current `
|
|
132
|
+
| `getExecutionId()` | The current `AdminScriptExecution` record id. |
|
|
133
133
|
|
|
134
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.
|
|
135
135
|
|
|
@@ -247,7 +247,7 @@ async execute(params) {
|
|
|
247
247
|
- **Sync** (`mode: 'sync'`) — runs in the API Lambda, result returned in the response. Capped by the API timeout.
|
|
248
248
|
- **Async** (`mode: 'async'`, default) — queued to SQS and run by the worker Lambda (15-min budget). The SQS queue has a redrive policy (up to 3 receives) to a dead-letter queue, so a crashed invocation is retried at the infrastructure level. There is no application-level per-script retry — model idempotency accordingly.
|
|
249
249
|
|
|
250
|
-
Every execution is persisted as an `
|
|
250
|
+
Every execution is persisted as an `AdminScriptExecution` record with its `state` (`PENDING` → `RUNNING` → `COMPLETED`/`FAILED`), input, output, metrics, and logs.
|
|
251
251
|
|
|
252
252
|
---
|
|
253
253
|
|
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.b384239.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.b384239.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.b384239.0",
|
|
15
|
+
"@friggframework/prettier-config": "2.0.0--canary.517.b384239.0",
|
|
16
|
+
"@friggframework/test": "2.0.0--canary.517.b384239.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": "b384239f327baa975cb82c0f63888e67f40160c3"
|
|
48
48
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
createSchedulerAdapter,
|
|
3
|
+
createSchedulerAdapterFromEnv,
|
|
4
|
+
} = require('../scheduler-adapter-factory');
|
|
2
5
|
const { AWSSchedulerAdapter } = require('../aws-scheduler-adapter');
|
|
3
6
|
const { LocalSchedulerAdapter } = require('../local-scheduler-adapter');
|
|
4
7
|
|
|
@@ -135,4 +138,51 @@ describe('Scheduler Adapter Factory', () => {
|
|
|
135
138
|
).toThrow();
|
|
136
139
|
});
|
|
137
140
|
});
|
|
141
|
+
|
|
142
|
+
describe('createSchedulerAdapterFromEnv()', () => {
|
|
143
|
+
it('falls back to the local adapter off-AWS when SCHEDULER_PROVIDER is unset', () => {
|
|
144
|
+
delete process.env.SCHEDULER_PROVIDER;
|
|
145
|
+
delete process.env.AWS_LAMBDA_FUNCTION_NAME;
|
|
146
|
+
|
|
147
|
+
const adapter = createSchedulerAdapterFromEnv();
|
|
148
|
+
|
|
149
|
+
expect(adapter).toBeInstanceOf(LocalSchedulerAdapter);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('throws 503 when SCHEDULER_PROVIDER is unset in a deployed Lambda', () => {
|
|
153
|
+
delete process.env.SCHEDULER_PROVIDER;
|
|
154
|
+
process.env.AWS_LAMBDA_FUNCTION_NAME = 'admin-script-router';
|
|
155
|
+
|
|
156
|
+
let error;
|
|
157
|
+
try {
|
|
158
|
+
createSchedulerAdapterFromEnv();
|
|
159
|
+
} catch (e) {
|
|
160
|
+
error = e;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
expect(error).toBeDefined();
|
|
164
|
+
expect(error.isBoom).toBe(true);
|
|
165
|
+
expect(error.output.statusCode).toBe(503);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('builds the AWS adapter from SCHEDULER_PROVIDER + ADMIN_SCRIPT_* env', () => {
|
|
169
|
+
process.env.SCHEDULER_PROVIDER = 'aws';
|
|
170
|
+
process.env.ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN =
|
|
171
|
+
awsAdapterParams.targetLambdaArn;
|
|
172
|
+
process.env.ADMIN_SCRIPT_SCHEDULE_GROUP =
|
|
173
|
+
awsAdapterParams.scheduleGroupName;
|
|
174
|
+
process.env.SCHEDULER_ROLE_ARN = awsAdapterParams.roleArn;
|
|
175
|
+
|
|
176
|
+
const adapter = createSchedulerAdapterFromEnv();
|
|
177
|
+
|
|
178
|
+
expect(adapter).toBeInstanceOf(AWSSchedulerAdapter);
|
|
179
|
+
expect(adapter.targetLambdaArn).toBe(
|
|
180
|
+
awsAdapterParams.targetLambdaArn
|
|
181
|
+
);
|
|
182
|
+
expect(adapter.scheduleGroupName).toBe(
|
|
183
|
+
awsAdapterParams.scheduleGroupName
|
|
184
|
+
);
|
|
185
|
+
expect(adapter.roleArn).toBe(awsAdapterParams.roleArn);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
138
188
|
});
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
const Boom = require('@hapi/boom');
|
|
1
2
|
const { AWSSchedulerAdapter } = require('./aws-scheduler-adapter');
|
|
2
3
|
const { LocalSchedulerAdapter } = require('./local-scheduler-adapter');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Scheduler Adapter Factory
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
+
* Infrastructure Layer - Hexagonal Architecture
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* `createSchedulerAdapter` builds an adapter from an explicit `type` (no env
|
|
11
|
+
* reads). `createSchedulerAdapterFromEnv` resolves the type from the runtime
|
|
12
|
+
* environment and enforces that a deployed Lambda never silently falls back to
|
|
13
|
+
* the in-memory local adapter.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -46,6 +49,37 @@ function createSchedulerAdapter(options = {}) {
|
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Resolve and build the scheduler adapter from the runtime environment.
|
|
54
|
+
*
|
|
55
|
+
* The local adapter is in-memory only (schedules vanish on cold start), so it
|
|
56
|
+
* must never be the silent default in a deployed Lambda: require an explicit
|
|
57
|
+
* SCHEDULER_PROVIDER when running on AWS, and fall back to 'local' only for
|
|
58
|
+
* local dev/tests.
|
|
59
|
+
*
|
|
60
|
+
* @returns {SchedulerAdapter}
|
|
61
|
+
* @throws {Boom.Boom} 503 (serverUnavailable) when SCHEDULER_PROVIDER is unset
|
|
62
|
+
* in a deployed Lambda.
|
|
63
|
+
*/
|
|
64
|
+
function createSchedulerAdapterFromEnv() {
|
|
65
|
+
const type =
|
|
66
|
+
process.env.SCHEDULER_PROVIDER ||
|
|
67
|
+
(process.env.AWS_LAMBDA_FUNCTION_NAME ? null : 'local');
|
|
68
|
+
if (!type) {
|
|
69
|
+
throw Boom.serverUnavailable(
|
|
70
|
+
'SCHEDULER_PROVIDER is not configured. Set it (e.g. "aws") via appDefinition.admin.enableScheduling.'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return createSchedulerAdapter({
|
|
75
|
+
type,
|
|
76
|
+
targetLambdaArn: process.env.ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN,
|
|
77
|
+
scheduleGroupName: process.env.ADMIN_SCRIPT_SCHEDULE_GROUP,
|
|
78
|
+
roleArn: process.env.SCHEDULER_ROLE_ARN,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
49
82
|
module.exports = {
|
|
50
83
|
createSchedulerAdapter,
|
|
84
|
+
createSchedulerAdapterFromEnv,
|
|
51
85
|
};
|
|
@@ -38,9 +38,9 @@ describe('ScriptRunner', () => {
|
|
|
38
38
|
scriptFactory = new ScriptFactory([TestScript]);
|
|
39
39
|
|
|
40
40
|
mockCommands = {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
createExecution: jest.fn(),
|
|
42
|
+
updateExecutionState: jest.fn(),
|
|
43
|
+
completeExecution: jest.fn(),
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
mockContext = {
|
|
@@ -52,11 +52,11 @@ describe('ScriptRunner', () => {
|
|
|
52
52
|
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
53
53
|
createAdminScriptContext.mockReturnValue(mockContext);
|
|
54
54
|
|
|
55
|
-
mockCommands.
|
|
55
|
+
mockCommands.createExecution.mockResolvedValue({
|
|
56
56
|
id: 'exec-123',
|
|
57
57
|
});
|
|
58
|
-
mockCommands.
|
|
59
|
-
mockCommands.
|
|
58
|
+
mockCommands.updateExecutionState.mockResolvedValue({});
|
|
59
|
+
mockCommands.completeExecution.mockResolvedValue({ success: true });
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
afterEach(() => {
|
|
@@ -89,7 +89,7 @@ describe('ScriptRunner', () => {
|
|
|
89
89
|
expect(result.executionId).toBe('exec-123');
|
|
90
90
|
expect(result.metrics.durationMs).toBeGreaterThanOrEqual(0);
|
|
91
91
|
|
|
92
|
-
expect(mockCommands.
|
|
92
|
+
expect(mockCommands.createExecution).toHaveBeenCalledWith({
|
|
93
93
|
scriptName: 'test-script',
|
|
94
94
|
scriptVersion: '1.0.0',
|
|
95
95
|
trigger: 'MANUAL',
|
|
@@ -98,12 +98,12 @@ describe('ScriptRunner', () => {
|
|
|
98
98
|
audit: { apiKeyName: 'test-key' },
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
expect(mockCommands.
|
|
101
|
+
expect(mockCommands.updateExecutionState).toHaveBeenCalledWith(
|
|
102
102
|
'exec-123',
|
|
103
103
|
'RUNNING'
|
|
104
104
|
);
|
|
105
105
|
|
|
106
|
-
expect(mockCommands.
|
|
106
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
107
107
|
'exec-123',
|
|
108
108
|
expect.objectContaining({
|
|
109
109
|
state: 'COMPLETED',
|
|
@@ -170,7 +170,7 @@ describe('ScriptRunner', () => {
|
|
|
170
170
|
expect(result.scriptName).toBe('failing-script');
|
|
171
171
|
expect(result.error.message).toBe('Script failed');
|
|
172
172
|
|
|
173
|
-
expect(mockCommands.
|
|
173
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
174
174
|
'exec-123',
|
|
175
175
|
expect.objectContaining({
|
|
176
176
|
state: 'FAILED',
|
|
@@ -227,8 +227,8 @@ describe('ScriptRunner', () => {
|
|
|
227
227
|
);
|
|
228
228
|
|
|
229
229
|
expect(result.executionId).toBe('existing-exec-456');
|
|
230
|
-
expect(mockCommands.
|
|
231
|
-
expect(mockCommands.
|
|
230
|
+
expect(mockCommands.createExecution).not.toHaveBeenCalled();
|
|
231
|
+
expect(mockCommands.updateExecutionState).toHaveBeenCalledWith(
|
|
232
232
|
'existing-exec-456',
|
|
233
233
|
'RUNNING'
|
|
234
234
|
);
|
|
@@ -237,7 +237,7 @@ describe('ScriptRunner', () => {
|
|
|
237
237
|
it('reports COMPLETED even when persisting completion fails', async () => {
|
|
238
238
|
// Commands return an error object (never throw). A successful script
|
|
239
239
|
// must not be misreported as FAILED if the completion write fails.
|
|
240
|
-
mockCommands.
|
|
240
|
+
mockCommands.completeExecution.mockResolvedValue({
|
|
241
241
|
error: 500,
|
|
242
242
|
reason: 'DB write failed',
|
|
243
243
|
});
|
|
@@ -257,7 +257,7 @@ describe('ScriptRunner', () => {
|
|
|
257
257
|
});
|
|
258
258
|
|
|
259
259
|
it('throws when the execution record cannot be created', async () => {
|
|
260
|
-
mockCommands.
|
|
260
|
+
mockCommands.createExecution.mockResolvedValue({
|
|
261
261
|
error: 500,
|
|
262
262
|
reason: 'DB down',
|
|
263
263
|
});
|
|
@@ -282,7 +282,7 @@ describe('ScriptRunner', () => {
|
|
|
282
282
|
|
|
283
283
|
await runner.execute('test-script', {}, { trigger: 'MANUAL' });
|
|
284
284
|
|
|
285
|
-
expect(mockCommands.
|
|
285
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
286
286
|
'exec-123',
|
|
287
287
|
expect.objectContaining({
|
|
288
288
|
logs: [{ level: 'info', message: 'hi' }],
|
|
@@ -21,7 +21,7 @@ const { QueuerUtil } = require('@friggframework/core/queues');
|
|
|
21
21
|
class AdminScriptContext {
|
|
22
22
|
/**
|
|
23
23
|
* @param {Object} [params={}] - Context configuration
|
|
24
|
-
* @param {string|number|null} [params.executionId] - ID of the
|
|
24
|
+
* @param {string|number|null} [params.executionId] - ID of the AdminScriptExecution record this context is scoped to (used for log persistence and script chaining)
|
|
25
25
|
* @param {Object|null} [params.integrationFactory] - Factory used to hydrate integration instances; required for scripts that call instantiate()
|
|
26
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
|
|
27
27
|
*/
|
|
@@ -17,7 +17,7 @@ class ScriptRunner {
|
|
|
17
17
|
* @param {Object} params
|
|
18
18
|
* @param {ScriptFactory} params.scriptFactory - Required. The registry used
|
|
19
19
|
* to resolve and instantiate scripts by name (built by bootstrap.js).
|
|
20
|
-
* @param {Object} [params.commands] - Admin
|
|
20
|
+
* @param {Object} [params.commands] - Admin script execution command layer; defaults
|
|
21
21
|
* to a fresh createAdminScriptCommands().
|
|
22
22
|
* @param {Object} [params.integrationFactory] - Hydrates integration
|
|
23
23
|
* instances for scripts that need them.
|
|
@@ -42,8 +42,8 @@ class ScriptRunner {
|
|
|
42
42
|
* @param {string} options.trigger - 'MANUAL' | 'SCHEDULED' | 'QUEUE'
|
|
43
43
|
* @param {string} options.mode - 'sync' | 'async'
|
|
44
44
|
* @param {Object} options.audit - Audit info { apiKeyName, apiKeyLast4, ipAddress }
|
|
45
|
-
* @param {string} options.executionId - Reuse existing
|
|
46
|
-
* This is the database ID from the
|
|
45
|
+
* @param {string} options.executionId - Reuse existing AdminScriptExecution record ID (NOT the Lambda execution ID).
|
|
46
|
+
* This is the database ID from the AdminScriptExecution collection/table that tracks script executions.
|
|
47
47
|
* Pass this when resuming a queued execution to continue using the same execution record.
|
|
48
48
|
*/
|
|
49
49
|
async execute(scriptName, params = {}, options = {}) {
|
|
@@ -78,7 +78,7 @@ class ScriptRunner {
|
|
|
78
78
|
|
|
79
79
|
// Create execution record if not provided
|
|
80
80
|
if (!executionId) {
|
|
81
|
-
const execution = await this.commands.
|
|
81
|
+
const execution = await this.commands.createExecution({
|
|
82
82
|
scriptName,
|
|
83
83
|
scriptVersion: definition.version,
|
|
84
84
|
trigger,
|
|
@@ -91,7 +91,8 @@ class ScriptRunner {
|
|
|
91
91
|
// than tracking an `undefined` execution id.
|
|
92
92
|
if (execution.error) {
|
|
93
93
|
throw new Error(
|
|
94
|
-
execution.reason ||
|
|
94
|
+
execution.reason ||
|
|
95
|
+
'Failed to create admin script execution record'
|
|
95
96
|
);
|
|
96
97
|
}
|
|
97
98
|
executionId = execution.id;
|
|
@@ -108,7 +109,7 @@ class ScriptRunner {
|
|
|
108
109
|
|
|
109
110
|
let output;
|
|
110
111
|
try {
|
|
111
|
-
await this.commands.
|
|
112
|
+
await this.commands.updateExecutionState(executionId, 'RUNNING');
|
|
112
113
|
|
|
113
114
|
// Create script instance with context injected via constructor
|
|
114
115
|
const script = this.scriptFactory.createInstance(scriptName, {
|
|
@@ -122,7 +123,7 @@ class ScriptRunner {
|
|
|
122
123
|
} catch (error) {
|
|
123
124
|
const durationMs = new Date() - startTime;
|
|
124
125
|
|
|
125
|
-
const completion = await this.commands.
|
|
126
|
+
const completion = await this.commands.completeExecution(
|
|
126
127
|
executionId,
|
|
127
128
|
{
|
|
128
129
|
state: 'FAILED',
|
|
@@ -169,7 +170,7 @@ class ScriptRunner {
|
|
|
169
170
|
metrics: { durationMs },
|
|
170
171
|
};
|
|
171
172
|
|
|
172
|
-
const completion = await this.commands.
|
|
173
|
+
const completion = await this.commands.completeExecution(
|
|
173
174
|
executionId,
|
|
174
175
|
{
|
|
175
176
|
state: 'COMPLETED',
|
|
@@ -23,7 +23,7 @@ const {
|
|
|
23
23
|
} = require('@friggframework/core/application/commands/admin-script-commands');
|
|
24
24
|
const { QueuerUtil } = require('@friggframework/core/queues');
|
|
25
25
|
const {
|
|
26
|
-
|
|
26
|
+
createSchedulerAdapterFromEnv,
|
|
27
27
|
} = require('../../adapters/scheduler-adapter-factory');
|
|
28
28
|
|
|
29
29
|
describe('Admin Script Router', () => {
|
|
@@ -58,9 +58,9 @@ describe('Admin Script Router', () => {
|
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
mockCommands = {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
createExecution: jest.fn(),
|
|
62
|
+
findExecutionById: jest.fn(),
|
|
63
|
+
findExecutionsByName: jest.fn(),
|
|
64
64
|
};
|
|
65
65
|
|
|
66
66
|
mockSchedulerAdapter = {
|
|
@@ -76,7 +76,7 @@ describe('Admin Script Router', () => {
|
|
|
76
76
|
});
|
|
77
77
|
createScriptRunner.mockReturnValue(mockRunner);
|
|
78
78
|
createAdminScriptCommands.mockReturnValue(mockCommands);
|
|
79
|
-
|
|
79
|
+
createSchedulerAdapterFromEnv.mockReturnValue(mockSchedulerAdapter);
|
|
80
80
|
QueuerUtil.send = jest.fn().mockResolvedValue({});
|
|
81
81
|
|
|
82
82
|
// Default mock implementations
|
|
@@ -185,7 +185,7 @@ describe('Admin Script Router', () => {
|
|
|
185
185
|
it('should queue script for async execution', async () => {
|
|
186
186
|
process.env.ADMIN_SCRIPT_QUEUE_URL =
|
|
187
187
|
'https://sqs.us-east-1.amazonaws.com/123/test-queue';
|
|
188
|
-
mockCommands.
|
|
188
|
+
mockCommands.createExecution.mockResolvedValue({
|
|
189
189
|
id: 'exec-456',
|
|
190
190
|
});
|
|
191
191
|
|
|
@@ -212,7 +212,7 @@ describe('Admin Script Router', () => {
|
|
|
212
212
|
it('should default to async mode', async () => {
|
|
213
213
|
process.env.ADMIN_SCRIPT_QUEUE_URL =
|
|
214
214
|
'https://sqs.us-east-1.amazonaws.com/123/test-queue';
|
|
215
|
-
mockCommands.
|
|
215
|
+
mockCommands.createExecution.mockResolvedValue({
|
|
216
216
|
id: 'exec-789',
|
|
217
217
|
});
|
|
218
218
|
|
|
@@ -257,7 +257,7 @@ describe('Admin Script Router', () => {
|
|
|
257
257
|
|
|
258
258
|
describe('GET /admin/scripts/:scriptName/executions/:executionId', () => {
|
|
259
259
|
it('should return execution details', async () => {
|
|
260
|
-
mockCommands.
|
|
260
|
+
mockCommands.findExecutionById.mockResolvedValue({
|
|
261
261
|
id: 'exec-123',
|
|
262
262
|
scriptName: 'test-script',
|
|
263
263
|
status: 'COMPLETED',
|
|
@@ -273,7 +273,7 @@ describe('Admin Script Router', () => {
|
|
|
273
273
|
});
|
|
274
274
|
|
|
275
275
|
it('should return 404 for non-existent execution', async () => {
|
|
276
|
-
mockCommands.
|
|
276
|
+
mockCommands.findExecutionById.mockResolvedValue({
|
|
277
277
|
error: 404,
|
|
278
278
|
reason: 'Execution not found',
|
|
279
279
|
code: 'EXECUTION_NOT_FOUND',
|
|
@@ -290,7 +290,7 @@ describe('Admin Script Router', () => {
|
|
|
290
290
|
|
|
291
291
|
describe('GET /admin/scripts/:scriptName/executions', () => {
|
|
292
292
|
it('should list executions for specific script', async () => {
|
|
293
|
-
mockCommands.
|
|
293
|
+
mockCommands.findExecutionsByName.mockResolvedValue([
|
|
294
294
|
{ id: 'exec-1', name: 'test-script', state: 'COMPLETED' },
|
|
295
295
|
{ id: 'exec-2', name: 'test-script', state: 'RUNNING' },
|
|
296
296
|
]);
|
|
@@ -301,7 +301,7 @@ describe('Admin Script Router', () => {
|
|
|
301
301
|
|
|
302
302
|
expect(response.status).toBe(200);
|
|
303
303
|
expect(response.body.executions).toHaveLength(2);
|
|
304
|
-
expect(mockCommands.
|
|
304
|
+
expect(mockCommands.findExecutionsByName).toHaveBeenCalledWith(
|
|
305
305
|
'test-script',
|
|
306
306
|
{
|
|
307
307
|
limit: 50,
|
|
@@ -310,13 +310,13 @@ describe('Admin Script Router', () => {
|
|
|
310
310
|
});
|
|
311
311
|
|
|
312
312
|
it('should accept query parameters (status maps to state, limit is bounded)', async () => {
|
|
313
|
-
mockCommands.
|
|
313
|
+
mockCommands.findExecutionsByName.mockResolvedValue([]);
|
|
314
314
|
|
|
315
315
|
await request(app).get(
|
|
316
316
|
'/admin/scripts/test-script/executions?status=COMPLETED&limit=10'
|
|
317
317
|
);
|
|
318
318
|
|
|
319
|
-
expect(mockCommands.
|
|
319
|
+
expect(mockCommands.findExecutionsByName).toHaveBeenCalledWith(
|
|
320
320
|
'test-script',
|
|
321
321
|
{
|
|
322
322
|
limit: 10,
|
|
@@ -22,7 +22,7 @@ describe('Admin Script Executor Handler', () => {
|
|
|
22
22
|
mockScriptCommands = { id: 'script-commands' };
|
|
23
23
|
mockRunner = { execute: jest.fn() };
|
|
24
24
|
mockCommands = {
|
|
25
|
-
|
|
25
|
+
completeExecution: jest.fn().mockResolvedValue({}),
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
bootstrapAdminScripts.mockReturnValue({
|
|
@@ -89,7 +89,7 @@ describe('Admin Script Executor Handler', () => {
|
|
|
89
89
|
expect(response.statusCode).toBe(200);
|
|
90
90
|
const body = JSON.parse(response.body);
|
|
91
91
|
expect(body.results[0].status).toBe('FAILED');
|
|
92
|
-
expect(mockCommands.
|
|
92
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
93
93
|
'exec-2',
|
|
94
94
|
expect.objectContaining({ state: 'FAILED' })
|
|
95
95
|
);
|
|
@@ -159,7 +159,7 @@ describe('Admin Script Executor Handler', () => {
|
|
|
159
159
|
expect(body.processed).toBe(2);
|
|
160
160
|
expect(body.results[0].status).toBe('FAILED');
|
|
161
161
|
expect(body.results[1].status).toBe('COMPLETED');
|
|
162
|
-
expect(mockCommands.
|
|
162
|
+
expect(mockCommands.completeExecution).toHaveBeenCalledWith(
|
|
163
163
|
'exec-bad',
|
|
164
164
|
expect.objectContaining({ state: 'FAILED' })
|
|
165
165
|
);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const serverless = require('serverless-http');
|
|
3
|
-
const Boom = require('@hapi/boom');
|
|
4
3
|
const { validateAdminApiKey } = require('./admin-auth-middleware');
|
|
5
4
|
const { createScriptRunner } = require('../application/script-runner');
|
|
6
5
|
const {
|
|
@@ -12,7 +11,7 @@ const {
|
|
|
12
11
|
} = require('@friggframework/core/application/commands/admin-script-commands');
|
|
13
12
|
const { QueuerUtil } = require('@friggframework/core/queues');
|
|
14
13
|
const {
|
|
15
|
-
|
|
14
|
+
createSchedulerAdapterFromEnv,
|
|
16
15
|
} = require('../adapters/scheduler-adapter-factory');
|
|
17
16
|
const { bootstrapAdminScripts } = require('./bootstrap');
|
|
18
17
|
const {
|
|
@@ -60,51 +59,6 @@ function buildAudit(req) {
|
|
|
60
59
|
};
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
/**
|
|
64
|
-
* Create schedule use case instances
|
|
65
|
-
* @param {ScriptFactory} scriptFactory - Registry injected from the request.
|
|
66
|
-
* @private
|
|
67
|
-
*/
|
|
68
|
-
function createScheduleUseCases(scriptFactory) {
|
|
69
|
-
const commands = createAdminScriptCommands();
|
|
70
|
-
|
|
71
|
-
// The local adapter is in-memory only (schedules vanish on cold start), so it
|
|
72
|
-
// must never be the silent default in a deployed Lambda. Require an explicit
|
|
73
|
-
// provider when running on AWS; fall back to 'local' only for local dev/tests.
|
|
74
|
-
const schedulerType =
|
|
75
|
-
process.env.SCHEDULER_PROVIDER ||
|
|
76
|
-
(process.env.AWS_LAMBDA_FUNCTION_NAME ? null : 'local');
|
|
77
|
-
if (!schedulerType) {
|
|
78
|
-
throw Boom.serverUnavailable(
|
|
79
|
-
'SCHEDULER_PROVIDER is not configured. Set it (e.g. "aws") via appDefinition.admin.enableScheduling.'
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const schedulerAdapter = createSchedulerAdapter({
|
|
84
|
-
type: schedulerType,
|
|
85
|
-
targetLambdaArn: process.env.ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN,
|
|
86
|
-
scheduleGroupName: process.env.ADMIN_SCRIPT_SCHEDULE_GROUP,
|
|
87
|
-
roleArn: process.env.SCHEDULER_ROLE_ARN,
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
getEffectiveSchedule: new GetEffectiveScheduleUseCase({
|
|
92
|
-
commands,
|
|
93
|
-
scriptFactory,
|
|
94
|
-
}),
|
|
95
|
-
upsertSchedule: new UpsertScheduleUseCase({
|
|
96
|
-
commands,
|
|
97
|
-
schedulerAdapter,
|
|
98
|
-
scriptFactory,
|
|
99
|
-
}),
|
|
100
|
-
deleteSchedule: new DeleteScheduleUseCase({
|
|
101
|
-
commands,
|
|
102
|
-
schedulerAdapter,
|
|
103
|
-
scriptFactory,
|
|
104
|
-
}),
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
62
|
/**
|
|
109
63
|
* GET /admin/scripts
|
|
110
64
|
* List all registered scripts
|
|
@@ -261,7 +215,7 @@ router.post('/scripts/:scriptName', async (req, res) => {
|
|
|
261
215
|
}
|
|
262
216
|
|
|
263
217
|
const commands = createAdminScriptCommands();
|
|
264
|
-
const execution = await commands.
|
|
218
|
+
const execution = await commands.createExecution({
|
|
265
219
|
scriptName,
|
|
266
220
|
scriptVersion: definition.version,
|
|
267
221
|
trigger: 'MANUAL',
|
|
@@ -309,7 +263,7 @@ router.get('/scripts/:scriptName/executions/:executionId', async (req, res) => {
|
|
|
309
263
|
try {
|
|
310
264
|
const { executionId } = req.params;
|
|
311
265
|
const commands = createAdminScriptCommands();
|
|
312
|
-
const execution = await commands.
|
|
266
|
+
const execution = await commands.findExecutionById(executionId);
|
|
313
267
|
|
|
314
268
|
if (execution.error) {
|
|
315
269
|
return res.status(execution.error).json({
|
|
@@ -340,7 +294,7 @@ router.get('/scripts/:scriptName/executions', async (req, res) => {
|
|
|
340
294
|
? 50
|
|
341
295
|
: Math.min(Math.max(parsedLimit, 1), 200);
|
|
342
296
|
|
|
343
|
-
const executions = await commands.
|
|
297
|
+
const executions = await commands.findExecutionsByName(scriptName, {
|
|
344
298
|
limit: safeLimit,
|
|
345
299
|
...(status && { state: status }),
|
|
346
300
|
});
|
|
@@ -360,7 +314,11 @@ router.get('/scripts/:scriptName/schedule', async (req, res) => {
|
|
|
360
314
|
try {
|
|
361
315
|
const { scriptName } = req.params;
|
|
362
316
|
const { scriptFactory } = bootstrapAdminScripts();
|
|
363
|
-
const
|
|
317
|
+
const commands = createAdminScriptCommands();
|
|
318
|
+
const getEffectiveSchedule = new GetEffectiveScheduleUseCase({
|
|
319
|
+
commands,
|
|
320
|
+
scriptFactory,
|
|
321
|
+
});
|
|
364
322
|
|
|
365
323
|
const result = await getEffectiveSchedule.execute(scriptName);
|
|
366
324
|
|
|
@@ -383,7 +341,13 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => {
|
|
|
383
341
|
const { scriptName } = req.params;
|
|
384
342
|
const { enabled, cronExpression, timezone } = req.body;
|
|
385
343
|
const { scriptFactory } = bootstrapAdminScripts();
|
|
386
|
-
const
|
|
344
|
+
const commands = createAdminScriptCommands();
|
|
345
|
+
const schedulerAdapter = createSchedulerAdapterFromEnv();
|
|
346
|
+
const upsertSchedule = new UpsertScheduleUseCase({
|
|
347
|
+
commands,
|
|
348
|
+
schedulerAdapter,
|
|
349
|
+
scriptFactory,
|
|
350
|
+
});
|
|
387
351
|
|
|
388
352
|
const result = await upsertSchedule.execute(scriptName, {
|
|
389
353
|
enabled,
|
|
@@ -414,7 +378,13 @@ router.delete('/scripts/:scriptName/schedule', async (req, res) => {
|
|
|
414
378
|
try {
|
|
415
379
|
const { scriptName } = req.params;
|
|
416
380
|
const { scriptFactory } = bootstrapAdminScripts();
|
|
417
|
-
const
|
|
381
|
+
const commands = createAdminScriptCommands();
|
|
382
|
+
const schedulerAdapter = createSchedulerAdapterFromEnv();
|
|
383
|
+
const deleteSchedule = new DeleteScheduleUseCase({
|
|
384
|
+
commands,
|
|
385
|
+
schedulerAdapter,
|
|
386
|
+
scriptFactory,
|
|
387
|
+
});
|
|
418
388
|
|
|
419
389
|
const result = await deleteSchedule.execute(scriptName);
|
|
420
390
|
|
|
@@ -8,7 +8,7 @@ const { bootstrapAdminScripts } = require('./bootstrap');
|
|
|
8
8
|
* Run a single execution message through the ScriptRunner.
|
|
9
9
|
* @param {Object} message - Parsed execution message.
|
|
10
10
|
* @param {string} message.scriptName - Name of the registered script to run (required).
|
|
11
|
-
* @param {string} [message.executionId] - Existing
|
|
11
|
+
* @param {string} [message.executionId] - Existing AdminScriptExecution id to resume; when
|
|
12
12
|
* absent, ScriptRunner creates a new record.
|
|
13
13
|
* @param {string} [message.trigger] - Execution trigger; defaults to 'QUEUE'.
|
|
14
14
|
* @param {Object} [message.params] - Parameters passed to the script.
|
|
@@ -59,7 +59,7 @@ async function runMessage(
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* Mark an admin
|
|
62
|
+
* Mark an admin script execution FAILED when the worker itself blows up (parse error,
|
|
63
63
|
* runner construction), so the record doesn't stay stuck in a non-terminal
|
|
64
64
|
* state. No-op when there is no execution id.
|
|
65
65
|
* @private
|
|
@@ -68,7 +68,7 @@ async function markFailed(executionId, error) {
|
|
|
68
68
|
if (!executionId) return;
|
|
69
69
|
try {
|
|
70
70
|
const commands = createAdminScriptCommands();
|
|
71
|
-
await commands.
|
|
71
|
+
await commands.completeExecution(executionId, {
|
|
72
72
|
state: 'FAILED',
|
|
73
73
|
error: {
|
|
74
74
|
name: error.name,
|