@friggframework/admin-scripts 2.0.0-next.103 → 2.0.0-next.105
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 +14 -0
- package/package.json +6 -6
- package/src/adapters/__tests__/aws-scheduler-adapter.test.js +54 -0
- package/src/adapters/__tests__/scheduler-adapter-factory.test.js +49 -0
- package/src/adapters/aws-scheduler-adapter.js +21 -9
- package/src/adapters/scheduler-adapter-factory.js +40 -0
- package/src/application/__tests__/report-runner.test.js +428 -0
- package/src/application/admin-script-context.js +12 -0
- package/src/application/report-runner.js +273 -0
- package/src/infrastructure/__tests__/report-executor-handler.test.js +314 -0
- package/src/infrastructure/__tests__/report-router.test.js +525 -0
- package/src/infrastructure/bootstrap.js +95 -15
- package/src/infrastructure/report-executor-handler.js +202 -0
- package/src/infrastructure/report-router.js +421 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const serverless = require('serverless-http');
|
|
3
|
+
const { validateAdminApiKey } = require('./admin-auth-middleware');
|
|
4
|
+
const { createReportRunner } = require('../application/report-runner');
|
|
5
|
+
const { validateParams } = require('../application/validate-script-input');
|
|
6
|
+
const { QueuerUtil } = require('@friggframework/core/queues');
|
|
7
|
+
const {
|
|
8
|
+
createAdminScriptCommands,
|
|
9
|
+
} = require('@friggframework/core/application/commands/admin-script-commands');
|
|
10
|
+
const {
|
|
11
|
+
createReportSchedulerAdapterFromEnv,
|
|
12
|
+
} = require('../adapters/scheduler-adapter-factory');
|
|
13
|
+
const {
|
|
14
|
+
GetEffectiveScheduleUseCase,
|
|
15
|
+
UpsertScheduleUseCase,
|
|
16
|
+
DeleteScheduleUseCase,
|
|
17
|
+
} = require('../application/use-cases');
|
|
18
|
+
const { bootstrapAdminScripts } = require('./bootstrap');
|
|
19
|
+
|
|
20
|
+
const router = express.Router();
|
|
21
|
+
|
|
22
|
+
// Reports use the admin API key; the dedicated REPORTING_API_KEY is retired (ADR-010).
|
|
23
|
+
router.use(validateAdminApiKey);
|
|
24
|
+
|
|
25
|
+
const CODE_STATUS = {
|
|
26
|
+
INVALID_INPUT: 400,
|
|
27
|
+
INVALID_MODE: 400,
|
|
28
|
+
ARTIFACT_STORAGE_UNAVAILABLE: 501,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Boom errors carry their own status; runner errors carry a `.code`; anything else is a 500.
|
|
32
|
+
function sendReportError(res, error, fallbackMessage) {
|
|
33
|
+
if (error.isBoom) {
|
|
34
|
+
return res
|
|
35
|
+
.status(error.output.statusCode)
|
|
36
|
+
.json({ error: error.message });
|
|
37
|
+
}
|
|
38
|
+
if (error.code && CODE_STATUS[error.code]) {
|
|
39
|
+
return res
|
|
40
|
+
.status(CODE_STATUS[error.code])
|
|
41
|
+
.json({ error: error.message, code: error.code });
|
|
42
|
+
}
|
|
43
|
+
console.error(fallbackMessage, error);
|
|
44
|
+
return res.status(500).json({ error: fallbackMessage });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildAudit(req) {
|
|
48
|
+
const apiKey = req.headers['x-frigg-admin-api-key'];
|
|
49
|
+
const forwardedFor = (req.headers['x-forwarded-for'] || '')
|
|
50
|
+
.split(',')[0]
|
|
51
|
+
.trim();
|
|
52
|
+
return {
|
|
53
|
+
ipAddress: forwardedFor || req.ip || null,
|
|
54
|
+
apiKeyLast4: apiKey ? String(apiKey).slice(-4) : null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toDefinitionSummary(definition) {
|
|
59
|
+
return {
|
|
60
|
+
name: definition.name,
|
|
61
|
+
version: definition.version,
|
|
62
|
+
description: definition.description,
|
|
63
|
+
runModes: definition.runModes,
|
|
64
|
+
category: definition.display?.category || 'reporting',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
router.get('/', (_req, res) => {
|
|
69
|
+
try {
|
|
70
|
+
const { reportFactory } = bootstrapAdminScripts();
|
|
71
|
+
res.json({
|
|
72
|
+
service: 'frigg-core-api',
|
|
73
|
+
reports: reportFactory.getAll().map((r) =>
|
|
74
|
+
toDefinitionSummary(r.definition)
|
|
75
|
+
),
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error('Error listing reports:', error);
|
|
79
|
+
res.status(500).json({ error: 'Failed to list reports' });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Deprecated #607 back-compat. Registered before '/:name' so it wins the match.
|
|
84
|
+
router.get('/integrations', async (req, res) => {
|
|
85
|
+
try {
|
|
86
|
+
const {
|
|
87
|
+
reportFactory,
|
|
88
|
+
reportFriggCommands,
|
|
89
|
+
reportCommands,
|
|
90
|
+
integrationFactory,
|
|
91
|
+
} = bootstrapAdminScripts();
|
|
92
|
+
|
|
93
|
+
if (!reportFactory.has('integrations')) {
|
|
94
|
+
return res.status(404).json({
|
|
95
|
+
error: 'Report "integrations" not found',
|
|
96
|
+
code: 'REPORT_NOT_FOUND',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { status, type, userId } = req.query;
|
|
101
|
+
const runner = createReportRunner({
|
|
102
|
+
reportFactory,
|
|
103
|
+
reportCommands,
|
|
104
|
+
friggCommands: reportFriggCommands,
|
|
105
|
+
integrationFactory,
|
|
106
|
+
});
|
|
107
|
+
const result = await runner.execute(
|
|
108
|
+
'integrations',
|
|
109
|
+
{ status, type, userId },
|
|
110
|
+
{ mode: 'live', trigger: 'MANUAL', audit: buildAudit(req) }
|
|
111
|
+
);
|
|
112
|
+
// #607 returned the report payload directly (not the runner envelope).
|
|
113
|
+
return res.json(result.output);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return sendReportError(res, error, 'Failed to run integrations report');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Registered before '/:name' so 'executions' isn't captured as a report name.
|
|
120
|
+
router.get('/executions/:id', async (req, res) => {
|
|
121
|
+
try {
|
|
122
|
+
const { reportCommands } = bootstrapAdminScripts();
|
|
123
|
+
const execution = await reportCommands.findExecutionById(req.params.id);
|
|
124
|
+
if (execution.error) {
|
|
125
|
+
return res.status(execution.error).json({
|
|
126
|
+
error: execution.reason,
|
|
127
|
+
code: execution.code,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return res.json(execution);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return sendReportError(res, error, 'Failed to get report execution');
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
router.get('/:name/snapshots', async (req, res) => {
|
|
137
|
+
try {
|
|
138
|
+
const { name } = req.params;
|
|
139
|
+
const { reportFactory, reportCommands } = bootstrapAdminScripts();
|
|
140
|
+
if (!reportFactory.has(name)) {
|
|
141
|
+
return res.status(404).json({
|
|
142
|
+
error: `Report "${name}" not found`,
|
|
143
|
+
code: 'REPORT_NOT_FOUND',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const snapshots = await reportCommands.findSnapshotSeries(name, {
|
|
147
|
+
from: req.query.from,
|
|
148
|
+
to: req.query.to,
|
|
149
|
+
});
|
|
150
|
+
return res.json({ snapshots });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return sendReportError(res, error, 'Failed to get report snapshots');
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Default to snapshot so a scheduled report captures a time series out of the box.
|
|
157
|
+
function scheduledRunMode(definition) {
|
|
158
|
+
return definition.schedule?.mode || 'snapshot';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ScriptSchedule.scriptName is a shared operation-name namespace, so the report name keys the same row a script would.
|
|
162
|
+
router.get('/:name/schedule', async (req, res) => {
|
|
163
|
+
try {
|
|
164
|
+
const { name } = req.params;
|
|
165
|
+
const { reportFactory } = bootstrapAdminScripts();
|
|
166
|
+
if (!reportFactory.has(name)) {
|
|
167
|
+
return res.status(404).json({
|
|
168
|
+
error: `Report "${name}" not found`,
|
|
169
|
+
code: 'REPORT_NOT_FOUND',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const commands = createAdminScriptCommands();
|
|
174
|
+
const getEffectiveSchedule = new GetEffectiveScheduleUseCase({
|
|
175
|
+
commands,
|
|
176
|
+
scriptFactory: reportFactory,
|
|
177
|
+
});
|
|
178
|
+
const result = await getEffectiveSchedule.execute(name);
|
|
179
|
+
|
|
180
|
+
return res.json({
|
|
181
|
+
source: result.source,
|
|
182
|
+
reportName: name,
|
|
183
|
+
...result.schedule,
|
|
184
|
+
});
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return sendReportError(res, error, 'Failed to get report schedule');
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// The AWS scheduler targets the REPORT executor and enqueues a report-shaped message, not the script executor.
|
|
191
|
+
router.put('/:name/schedule', async (req, res) => {
|
|
192
|
+
try {
|
|
193
|
+
const { name } = req.params;
|
|
194
|
+
const { enabled, cronExpression, timezone } = req.body || {};
|
|
195
|
+
const { reportFactory } = bootstrapAdminScripts();
|
|
196
|
+
if (!reportFactory.has(name)) {
|
|
197
|
+
return res.status(404).json({
|
|
198
|
+
error: `Report "${name}" not found`,
|
|
199
|
+
code: 'REPORT_NOT_FOUND',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const definition = reportFactory.get(name).Definition;
|
|
204
|
+
const commands = createAdminScriptCommands();
|
|
205
|
+
const schedulerAdapter = createReportSchedulerAdapterFromEnv({
|
|
206
|
+
reportName: name,
|
|
207
|
+
mode: scheduledRunMode(definition),
|
|
208
|
+
});
|
|
209
|
+
const upsertSchedule = new UpsertScheduleUseCase({
|
|
210
|
+
commands,
|
|
211
|
+
schedulerAdapter,
|
|
212
|
+
scriptFactory: reportFactory,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const result = await upsertSchedule.execute(name, {
|
|
216
|
+
enabled,
|
|
217
|
+
cronExpression,
|
|
218
|
+
timezone,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return res.json({
|
|
222
|
+
success: result.success,
|
|
223
|
+
schedule: {
|
|
224
|
+
source: 'database',
|
|
225
|
+
...result.schedule,
|
|
226
|
+
},
|
|
227
|
+
...(result.schedulerWarning && {
|
|
228
|
+
schedulerWarning: result.schedulerWarning,
|
|
229
|
+
}),
|
|
230
|
+
});
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return sendReportError(res, error, 'Failed to update report schedule');
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
router.delete('/:name/schedule', async (req, res) => {
|
|
237
|
+
try {
|
|
238
|
+
const { name } = req.params;
|
|
239
|
+
const { reportFactory } = bootstrapAdminScripts();
|
|
240
|
+
if (!reportFactory.has(name)) {
|
|
241
|
+
return res.status(404).json({
|
|
242
|
+
error: `Report "${name}" not found`,
|
|
243
|
+
code: 'REPORT_NOT_FOUND',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const definition = reportFactory.get(name).Definition;
|
|
248
|
+
const commands = createAdminScriptCommands();
|
|
249
|
+
const schedulerAdapter = createReportSchedulerAdapterFromEnv({
|
|
250
|
+
reportName: name,
|
|
251
|
+
mode: scheduledRunMode(definition),
|
|
252
|
+
});
|
|
253
|
+
const deleteSchedule = new DeleteScheduleUseCase({
|
|
254
|
+
commands,
|
|
255
|
+
schedulerAdapter,
|
|
256
|
+
scriptFactory: reportFactory,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const result = await deleteSchedule.execute(name);
|
|
260
|
+
return res.json(result);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
return sendReportError(res, error, 'Failed to delete report schedule');
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
router.get('/:name', (req, res) => {
|
|
267
|
+
try {
|
|
268
|
+
const { name } = req.params;
|
|
269
|
+
const { reportFactory } = bootstrapAdminScripts();
|
|
270
|
+
|
|
271
|
+
if (!reportFactory.has(name)) {
|
|
272
|
+
return res.status(404).json({
|
|
273
|
+
error: `Report "${name}" not found`,
|
|
274
|
+
code: 'REPORT_NOT_FOUND',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const definition = reportFactory.get(name).Definition;
|
|
279
|
+
res.json({
|
|
280
|
+
name: definition.name,
|
|
281
|
+
version: definition.version,
|
|
282
|
+
description: definition.description,
|
|
283
|
+
runModes: definition.runModes,
|
|
284
|
+
inputSchema: definition.inputSchema,
|
|
285
|
+
outputSchema: definition.outputSchema,
|
|
286
|
+
output: definition.output,
|
|
287
|
+
schedule: definition.schedule,
|
|
288
|
+
display: definition.display,
|
|
289
|
+
});
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.error('Error getting report:', error);
|
|
292
|
+
res.status(500).json({ error: 'Failed to get report details' });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
router.post('/:name/run', async (req, res) => {
|
|
297
|
+
try {
|
|
298
|
+
const { name } = req.params;
|
|
299
|
+
const { mode = 'live', params = {} } = req.body || {};
|
|
300
|
+
const {
|
|
301
|
+
reportFactory,
|
|
302
|
+
reportFriggCommands,
|
|
303
|
+
reportCommands,
|
|
304
|
+
integrationFactory,
|
|
305
|
+
} = bootstrapAdminScripts();
|
|
306
|
+
|
|
307
|
+
if (!reportFactory.has(name)) {
|
|
308
|
+
return res.status(404).json({
|
|
309
|
+
error: `Report "${name}" not found`,
|
|
310
|
+
code: 'REPORT_NOT_FOUND',
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (mode === 'live') {
|
|
315
|
+
const runner = createReportRunner({
|
|
316
|
+
reportFactory,
|
|
317
|
+
reportCommands,
|
|
318
|
+
friggCommands: reportFriggCommands,
|
|
319
|
+
integrationFactory,
|
|
320
|
+
});
|
|
321
|
+
const result = await runner.execute(name, params, {
|
|
322
|
+
mode: 'live',
|
|
323
|
+
trigger: 'MANUAL',
|
|
324
|
+
audit: buildAudit(req),
|
|
325
|
+
});
|
|
326
|
+
return res.json(result);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Validate mode + params before persisting/enqueueing so a bad request gets
|
|
330
|
+
// a 400 up front instead of a 202 that only fails later in the worker.
|
|
331
|
+
const definition = reportFactory.get(name).Definition;
|
|
332
|
+
const runModes =
|
|
333
|
+
Array.isArray(definition.runModes) && definition.runModes.length
|
|
334
|
+
? definition.runModes
|
|
335
|
+
: ['live'];
|
|
336
|
+
if (!runModes.includes(mode)) {
|
|
337
|
+
return res.status(400).json({
|
|
338
|
+
error: `Report "${name}" does not support mode "${mode}". Allowed: ${runModes.join(
|
|
339
|
+
', '
|
|
340
|
+
)}`,
|
|
341
|
+
code: 'INVALID_MODE',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const validation = validateParams(definition, params);
|
|
345
|
+
if (!validation.valid) {
|
|
346
|
+
return res.status(400).json({
|
|
347
|
+
error: `Invalid input: ${validation.errors.join(', ')}`,
|
|
348
|
+
code: 'INVALID_INPUT',
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const queueUrl = process.env.REPORT_QUEUE_URL;
|
|
353
|
+
if (!queueUrl) {
|
|
354
|
+
return res.status(503).json({
|
|
355
|
+
error: 'Async report execution is not configured (REPORT_QUEUE_URL not set)',
|
|
356
|
+
code: 'QUEUE_NOT_CONFIGURED',
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const { seriesName } = req.body || {};
|
|
361
|
+
const execution = await reportCommands.createExecution({
|
|
362
|
+
reportName: name,
|
|
363
|
+
reportVersion: definition.version,
|
|
364
|
+
trigger: 'MANUAL',
|
|
365
|
+
mode,
|
|
366
|
+
input: params,
|
|
367
|
+
audit: buildAudit(req),
|
|
368
|
+
seriesName,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Commands return an error object (never throw) — don't queue a broken execution.
|
|
372
|
+
if (execution.error) {
|
|
373
|
+
return res.status(execution.error).json({
|
|
374
|
+
error: execution.reason || 'Failed to create report execution record',
|
|
375
|
+
code: execution.code,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
await QueuerUtil.send(
|
|
381
|
+
{
|
|
382
|
+
reportName: name,
|
|
383
|
+
executionId: execution.id,
|
|
384
|
+
mode,
|
|
385
|
+
seriesName,
|
|
386
|
+
trigger: 'MANUAL',
|
|
387
|
+
params,
|
|
388
|
+
},
|
|
389
|
+
queueUrl
|
|
390
|
+
);
|
|
391
|
+
} catch (enqueueError) {
|
|
392
|
+
// The record is persisted but will never be picked up — compensate
|
|
393
|
+
// so it doesn't linger non-terminal (symmetric with the
|
|
394
|
+
// createExecution-error guard above).
|
|
395
|
+
await reportCommands.completeExecution(execution.id, {
|
|
396
|
+
state: 'FAILED',
|
|
397
|
+
error: {
|
|
398
|
+
name: enqueueError.name,
|
|
399
|
+
message: enqueueError.message,
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
throw enqueueError;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return res.status(202).json({
|
|
406
|
+
executionId: execution.id,
|
|
407
|
+
status: 'QUEUED',
|
|
408
|
+
reportName: name,
|
|
409
|
+
});
|
|
410
|
+
} catch (error) {
|
|
411
|
+
return sendReportError(res, error, 'Failed to run report');
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const app = express();
|
|
416
|
+
app.use(express.json());
|
|
417
|
+
app.use('/api/v2/reports', router);
|
|
418
|
+
|
|
419
|
+
const handler = serverless(app);
|
|
420
|
+
|
|
421
|
+
module.exports = { router, app, handler };
|