@friggframework/devtools 2.0.0-next.104 → 2.0.0-next.106
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.
|
@@ -23,7 +23,15 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
shouldExecute(appDefinition) {
|
|
26
|
-
|
|
26
|
+
const hasScripts =
|
|
27
|
+
Array.isArray(appDefinition.adminScripts) &&
|
|
28
|
+
appDefinition.adminScripts.length > 0;
|
|
29
|
+
const hasReports =
|
|
30
|
+
Array.isArray(appDefinition.reports) &&
|
|
31
|
+
appDefinition.reports.length > 0;
|
|
32
|
+
const hasBuiltinReports =
|
|
33
|
+
appDefinition.admin?.includeBuiltinReports === true;
|
|
34
|
+
return hasScripts || hasReports || hasBuiltinReports;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
getDependencies() {
|
|
@@ -33,31 +41,53 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
33
41
|
validate(appDefinition) {
|
|
34
42
|
const result = new ValidationResult();
|
|
35
43
|
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
if (appDefinition.adminScripts !== undefined) {
|
|
45
|
+
if (!Array.isArray(appDefinition.adminScripts)) {
|
|
46
|
+
result.addError('adminScripts must be an array');
|
|
47
|
+
} else {
|
|
48
|
+
appDefinition.adminScripts.forEach((script, index) => {
|
|
49
|
+
if (!script?.Definition?.name) {
|
|
50
|
+
result.addError(`Admin script at index ${index} is missing Definition or name`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
43
54
|
}
|
|
44
55
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
if (appDefinition.reports !== undefined) {
|
|
57
|
+
if (!Array.isArray(appDefinition.reports)) {
|
|
58
|
+
result.addError('reports must be an array');
|
|
59
|
+
} else {
|
|
60
|
+
appDefinition.reports.forEach((report, index) => {
|
|
61
|
+
if (!report?.Definition?.name) {
|
|
62
|
+
result.addError(`Report at index ${index} is missing Definition or name`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
49
65
|
}
|
|
50
|
-
}
|
|
66
|
+
}
|
|
51
67
|
|
|
52
68
|
return result;
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
async build(appDefinition, discoveredResources) {
|
|
56
|
-
console.log(`\n[${this.name}] Configuring admin
|
|
57
|
-
console.log(` Processing ${appDefinition.adminScripts.length} scripts...`);
|
|
72
|
+
console.log(`\n[${this.name}] Configuring admin operations...`);
|
|
58
73
|
|
|
59
74
|
const usePrismaLayer = appDefinition.usePrismaLambdaLayer !== false;
|
|
60
75
|
const adminConfig = appDefinition.admin || {};
|
|
76
|
+
const adminScripts = Array.isArray(appDefinition.adminScripts)
|
|
77
|
+
? appDefinition.adminScripts
|
|
78
|
+
: [];
|
|
79
|
+
const reports = Array.isArray(appDefinition.reports)
|
|
80
|
+
? appDefinition.reports
|
|
81
|
+
: [];
|
|
82
|
+
const hasReports =
|
|
83
|
+
reports.length > 0 || adminConfig.includeBuiltinReports === true;
|
|
84
|
+
|
|
85
|
+
// Only non-JSON report output is stored in S3, so provision the bucket
|
|
86
|
+
// only for that. Built-in reports emit JSON, so they don't trigger it.
|
|
87
|
+
const reportsNeedArtifacts = reports.some((report) => {
|
|
88
|
+
const format = report?.Definition?.output?.format;
|
|
89
|
+
return Boolean(format) && format !== 'json';
|
|
90
|
+
});
|
|
61
91
|
|
|
62
92
|
const result = {
|
|
63
93
|
functions: {},
|
|
@@ -67,27 +97,45 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
67
97
|
iamStatements: [],
|
|
68
98
|
};
|
|
69
99
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
100
|
+
if (adminScripts.length > 0) {
|
|
101
|
+
console.log(` Processing ${adminScripts.length} scripts...`);
|
|
102
|
+
this.createAdminScriptQueue(result, appDefinition);
|
|
103
|
+
this.createScriptExecutorFunction(appDefinition, result, usePrismaLayer);
|
|
104
|
+
this.createAdminScriptRoutes(appDefinition, result, usePrismaLayer);
|
|
75
105
|
|
|
76
|
-
|
|
77
|
-
|
|
106
|
+
adminScripts.forEach(script => {
|
|
107
|
+
const name = script.Definition?.name || 'unknown';
|
|
108
|
+
console.log(` ✓ Registered script: ${name}`);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
78
111
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.
|
|
112
|
+
if (hasReports) {
|
|
113
|
+
this.createReportQueue(result, appDefinition);
|
|
114
|
+
this.createReportExecutorFunction(appDefinition, result, usePrismaLayer);
|
|
115
|
+
this.createReportRoutes(appDefinition, result, usePrismaLayer);
|
|
116
|
+
if (reportsNeedArtifacts) {
|
|
117
|
+
this.createReportArtifactBucket(result, appDefinition);
|
|
118
|
+
}
|
|
119
|
+
reports.forEach(report => {
|
|
120
|
+
const name = report.Definition?.name || 'unknown';
|
|
121
|
+
console.log(` ✓ Registered report: ${name}`);
|
|
122
|
+
});
|
|
123
|
+
if (adminConfig.includeBuiltinReports) {
|
|
124
|
+
console.log(' ✓ Built-in reports enabled');
|
|
125
|
+
}
|
|
82
126
|
}
|
|
83
127
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
128
|
+
if (
|
|
129
|
+
adminConfig.enableScheduling &&
|
|
130
|
+
(adminScripts.length > 0 || hasReports)
|
|
131
|
+
) {
|
|
132
|
+
this.createSchedulerResources(appDefinition, result, {
|
|
133
|
+
scriptsPresent: adminScripts.length > 0,
|
|
134
|
+
hasReports,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
89
137
|
|
|
90
|
-
console.log(`[${this.name}] ✅ Admin
|
|
138
|
+
console.log(`[${this.name}] ✅ Admin operations configuration completed`);
|
|
91
139
|
return result;
|
|
92
140
|
}
|
|
93
141
|
|
|
@@ -193,6 +241,151 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
193
241
|
console.log(' ✓ Created adminScriptRouter function');
|
|
194
242
|
}
|
|
195
243
|
|
|
244
|
+
createReportRoutes(appDefinition, result, usePrismaLayer) {
|
|
245
|
+
result.functions.reportRouter = {
|
|
246
|
+
handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/report-router.handler',
|
|
247
|
+
skipEsbuild: true,
|
|
248
|
+
package: this.skipEsbuildPackageConfig(appDefinition, usePrismaLayer),
|
|
249
|
+
...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }),
|
|
250
|
+
timeout: 30,
|
|
251
|
+
events: [
|
|
252
|
+
{ httpApi: { path: '/api/v2/reports', method: 'GET' } },
|
|
253
|
+
// Definition detail, snapshots, executions, schedule, back-compat alias
|
|
254
|
+
{ httpApi: { path: '/api/v2/reports/{proxy+}', method: 'GET' } },
|
|
255
|
+
// Run a report ({name}/run)
|
|
256
|
+
{ httpApi: { path: '/api/v2/reports/{proxy+}', method: 'POST' } },
|
|
257
|
+
// Schedule management (PUT/DELETE {name}/schedule)
|
|
258
|
+
{ httpApi: { path: '/api/v2/reports/{proxy+}', method: 'PUT' } },
|
|
259
|
+
{ httpApi: { path: '/api/v2/reports/{proxy+}', method: 'DELETE' } },
|
|
260
|
+
],
|
|
261
|
+
};
|
|
262
|
+
console.log(' ✓ Created reportRouter function');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
createReportQueue(result, appDefinition) {
|
|
266
|
+
result.resources.ReportQueue = {
|
|
267
|
+
Type: 'AWS::SQS::Queue',
|
|
268
|
+
Properties: {
|
|
269
|
+
QueueName: '${self:service}-${self:provider.stage}-ReportQueue',
|
|
270
|
+
MessageRetentionPeriod: 86400, // 1 day
|
|
271
|
+
VisibilityTimeout: 900, // 15 minutes (Lambda max)
|
|
272
|
+
RedrivePolicy: {
|
|
273
|
+
maxReceiveCount: 3,
|
|
274
|
+
deadLetterTargetArn: {
|
|
275
|
+
'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (isScopedEnvironmentActive(appDefinition)) {
|
|
282
|
+
// Only the report functions read this queue URL
|
|
283
|
+
result.functionEnvironments = result.functionEnvironments || {};
|
|
284
|
+
for (const fnName of ['reportRouter', 'reportExecutor']) {
|
|
285
|
+
result.functionEnvironments[fnName] = {
|
|
286
|
+
...result.functionEnvironments[fnName],
|
|
287
|
+
REPORT_QUEUE_URL: { Ref: 'ReportQueue' },
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
result.environment.REPORT_QUEUE_URL = { Ref: 'ReportQueue' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// The report router enqueues async recorded/snapshot runs. The base
|
|
295
|
+
// role's wildcard does not cover this queue's name, so grant
|
|
296
|
+
// SendMessage explicitly.
|
|
297
|
+
result.iamStatements.push({
|
|
298
|
+
Effect: 'Allow',
|
|
299
|
+
Action: [
|
|
300
|
+
'sqs:SendMessage',
|
|
301
|
+
'sqs:SendMessageBatch',
|
|
302
|
+
'sqs:GetQueueUrl',
|
|
303
|
+
'sqs:GetQueueAttributes',
|
|
304
|
+
],
|
|
305
|
+
Resource: { 'Fn::GetAtt': ['ReportQueue', 'Arn'] },
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
console.log(' ✓ Created ReportQueue');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
createReportExecutorFunction(appDefinition, result, usePrismaLayer) {
|
|
312
|
+
result.functions.reportExecutor = {
|
|
313
|
+
handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/report-executor-handler.handler',
|
|
314
|
+
skipEsbuild: true,
|
|
315
|
+
package: this.skipEsbuildPackageConfig(appDefinition, usePrismaLayer),
|
|
316
|
+
...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }),
|
|
317
|
+
timeout: 900, // 15 minutes max
|
|
318
|
+
memorySize: 1024,
|
|
319
|
+
events: [
|
|
320
|
+
{
|
|
321
|
+
sqs: {
|
|
322
|
+
arn: { 'Fn::GetAtt': ['ReportQueue', 'Arn'] },
|
|
323
|
+
batchSize: 1,
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
};
|
|
328
|
+
console.log(' ✓ Created reportExecutor function');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Non-JSON report output; the router mints short-lived presigned URLs for reads.
|
|
332
|
+
createReportArtifactBucket(result, appDefinition) {
|
|
333
|
+
result.resources.ReportArtifactBucket = {
|
|
334
|
+
Type: 'AWS::S3::Bucket',
|
|
335
|
+
Properties: {
|
|
336
|
+
BucketName:
|
|
337
|
+
'${self:service}-${self:provider.stage}-report-artifacts',
|
|
338
|
+
BucketEncryption: {
|
|
339
|
+
ServerSideEncryptionConfiguration: [
|
|
340
|
+
{
|
|
341
|
+
ServerSideEncryptionByDefault: {
|
|
342
|
+
SSEAlgorithm: 'AES256',
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
PublicAccessBlockConfiguration: {
|
|
348
|
+
BlockPublicAcls: true,
|
|
349
|
+
BlockPublicPolicy: true,
|
|
350
|
+
IgnorePublicAcls: true,
|
|
351
|
+
RestrictPublicBuckets: true,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
if (isScopedEnvironmentActive(appDefinition)) {
|
|
357
|
+
result.functionEnvironments = result.functionEnvironments || {};
|
|
358
|
+
for (const fnName of ['reportRouter', 'reportExecutor']) {
|
|
359
|
+
result.functionEnvironments[fnName] = {
|
|
360
|
+
...result.functionEnvironments[fnName],
|
|
361
|
+
REPORT_ARTIFACT_BUCKET: { Ref: 'ReportArtifactBucket' },
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
result.environment.REPORT_ARTIFACT_BUCKET = {
|
|
366
|
+
Ref: 'ReportArtifactBucket',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Executor writes and router reads (via presign); scope to this bucket's objects.
|
|
371
|
+
result.iamStatements.push({
|
|
372
|
+
Effect: 'Allow',
|
|
373
|
+
Action: ['s3:PutObject', 's3:GetObject'],
|
|
374
|
+
Resource: {
|
|
375
|
+
'Fn::Sub': [
|
|
376
|
+
'${BucketArn}/*',
|
|
377
|
+
{
|
|
378
|
+
BucketArn: {
|
|
379
|
+
'Fn::GetAtt': ['ReportArtifactBucket', 'Arn'],
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
console.log(' ✓ Created ReportArtifactBucket');
|
|
387
|
+
}
|
|
388
|
+
|
|
196
389
|
// Without this, the skipEsbuild functions package the whole node_modules
|
|
197
390
|
// closure (aws-sdk, Prisma, dev deps) and blow past Lambda's 250 MB limit.
|
|
198
391
|
// Mirrors the exclusions the framework's other node_modules handlers use.
|
|
@@ -248,13 +441,25 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
248
441
|
};
|
|
249
442
|
}
|
|
250
443
|
|
|
251
|
-
createSchedulerResources(
|
|
252
|
-
|
|
444
|
+
createSchedulerResources(
|
|
445
|
+
appDefinition,
|
|
446
|
+
result,
|
|
447
|
+
{ scriptsPresent = true, hasReports = false } = {}
|
|
448
|
+
) {
|
|
449
|
+
// Constructed ARNs, not Fn::GetAtt: a GetAtt edge to an executor closes a
|
|
253
450
|
// CloudFormation circular dependency via the shared Lambda execution role.
|
|
254
|
-
const
|
|
255
|
-
'Fn::Sub':
|
|
256
|
-
|
|
257
|
-
|
|
451
|
+
const fnArn = (logicalName) => ({
|
|
452
|
+
'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:\${self:service}-\${self:provider.stage}-${logicalName}`,
|
|
453
|
+
});
|
|
454
|
+
const scriptExecutorArn = fnArn('adminScriptExecutor');
|
|
455
|
+
const reportExecutorArn = fnArn('reportExecutor');
|
|
456
|
+
|
|
457
|
+
const invokeResources = [
|
|
458
|
+
...(scriptsPresent ? [scriptExecutorArn] : []),
|
|
459
|
+
...(hasReports ? [reportExecutorArn] : []),
|
|
460
|
+
];
|
|
461
|
+
const invokeResource =
|
|
462
|
+
invokeResources.length === 1 ? invokeResources[0] : invokeResources;
|
|
258
463
|
|
|
259
464
|
// Create IAM role for EventBridge Scheduler
|
|
260
465
|
result.resources.AdminScriptSchedulerRole = {
|
|
@@ -276,7 +481,7 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
276
481
|
Statement: [{
|
|
277
482
|
Effect: 'Allow',
|
|
278
483
|
Action: 'lambda:InvokeFunction',
|
|
279
|
-
Resource:
|
|
484
|
+
Resource: invokeResource,
|
|
280
485
|
}],
|
|
281
486
|
},
|
|
282
487
|
}],
|
|
@@ -293,18 +498,35 @@ class AdminScriptBuilder extends InfrastructureBuilder {
|
|
|
293
498
|
|
|
294
499
|
// Router-scoped, not shared provider env. Two reasons: broadcasting the
|
|
295
500
|
// resource references to every function creates CloudFormation circular
|
|
296
|
-
// deps; and SCHEDULER_PROVIDER='aws' is only valid for the admin-
|
|
297
|
-
// adapter (the
|
|
298
|
-
// by integration Lambdas, rejects 'aws', so it must not
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
501
|
+
// deps; and SCHEDULER_PROVIDER='aws' is only valid for the admin-scripts
|
|
502
|
+
// adapter (the routers are its sole consumers) — core's scheduler
|
|
503
|
+
// factory, used by integration Lambdas, rejects 'aws', so it must not
|
|
504
|
+
// leak app-wide.
|
|
505
|
+
if (scriptsPresent) {
|
|
506
|
+
result.functions.adminScriptRouter.environment = {
|
|
507
|
+
...(result.functions.adminScriptRouter.environment || {}),
|
|
508
|
+
SCHEDULER_PROVIDER: 'aws',
|
|
509
|
+
SCHEDULER_ROLE_ARN: {
|
|
510
|
+
'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
|
|
511
|
+
},
|
|
512
|
+
ADMIN_SCRIPT_SCHEDULE_GROUP: { Ref: 'AdminScriptScheduleGroup' },
|
|
513
|
+
ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN: scriptExecutorArn,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Report schedules reuse the shared role/group but must target the
|
|
518
|
+
// report executor, not the script one (REPORT_EXECUTOR_LAMBDA_ARN).
|
|
519
|
+
if (hasReports) {
|
|
520
|
+
result.functions.reportRouter.environment = {
|
|
521
|
+
...(result.functions.reportRouter.environment || {}),
|
|
522
|
+
SCHEDULER_PROVIDER: 'aws',
|
|
523
|
+
SCHEDULER_ROLE_ARN: {
|
|
524
|
+
'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
|
|
525
|
+
},
|
|
526
|
+
REPORT_SCHEDULE_GROUP: { Ref: 'AdminScriptScheduleGroup' },
|
|
527
|
+
REPORT_EXECUTOR_LAMBDA_ARN: reportExecutorArn,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
308
530
|
|
|
309
531
|
// The router manages schedules through the AWS scheduler adapter, so it
|
|
310
532
|
// needs scheduler:* on this group plus iam:PassRole for the role it hands
|
|
@@ -620,6 +620,319 @@ describe('AdminScriptBuilder', () => {
|
|
|
620
620
|
});
|
|
621
621
|
});
|
|
622
622
|
|
|
623
|
+
describe('reports (ReportQueue + reportExecutor)', () => {
|
|
624
|
+
it('creates ReportQueue + reportExecutor and wires REPORT_QUEUE_URL when reports are present', async () => {
|
|
625
|
+
const appDefinition = {
|
|
626
|
+
reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
const result = await adminScriptBuilder.build(appDefinition, {});
|
|
630
|
+
|
|
631
|
+
// Queue
|
|
632
|
+
expect(result.resources.ReportQueue).toBeDefined();
|
|
633
|
+
expect(result.resources.ReportQueue.Type).toBe('AWS::SQS::Queue');
|
|
634
|
+
expect(
|
|
635
|
+
result.resources.ReportQueue.Properties.MessageRetentionPeriod
|
|
636
|
+
).toBe(86400);
|
|
637
|
+
expect(
|
|
638
|
+
result.resources.ReportQueue.Properties.VisibilityTimeout
|
|
639
|
+
).toBe(900);
|
|
640
|
+
expect(
|
|
641
|
+
result.resources.ReportQueue.Properties.RedrivePolicy
|
|
642
|
+
).toEqual({
|
|
643
|
+
maxReceiveCount: 3,
|
|
644
|
+
deadLetterTargetArn: {
|
|
645
|
+
'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// Executor function
|
|
650
|
+
expect(result.functions.reportExecutor).toBeDefined();
|
|
651
|
+
expect(result.functions.reportExecutor.handler).toBe(
|
|
652
|
+
'node_modules/@friggframework/admin-scripts/src/infrastructure/report-executor-handler.handler'
|
|
653
|
+
);
|
|
654
|
+
expect(result.functions.reportExecutor.timeout).toBe(900);
|
|
655
|
+
expect(result.functions.reportExecutor.memorySize).toBe(1024);
|
|
656
|
+
expect(result.functions.reportExecutor.events).toEqual([
|
|
657
|
+
{
|
|
658
|
+
sqs: {
|
|
659
|
+
arn: { 'Fn::GetAtt': ['ReportQueue', 'Arn'] },
|
|
660
|
+
batchSize: 1,
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
]);
|
|
664
|
+
expect(result.functions.reportExecutor.skipEsbuild).toBe(true);
|
|
665
|
+
expect(result.functions.reportExecutor.layers).toEqual([
|
|
666
|
+
{ Ref: 'PrismaLambdaLayer' },
|
|
667
|
+
]);
|
|
668
|
+
|
|
669
|
+
// Env wired app-wide (scoped flag off)
|
|
670
|
+
expect(result.environment.REPORT_QUEUE_URL).toEqual({
|
|
671
|
+
Ref: 'ReportQueue',
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// IAM SendMessage grant on the queue Arn
|
|
675
|
+
const grant = result.iamStatements.find(
|
|
676
|
+
(s) =>
|
|
677
|
+
Array.isArray(s.Action) &&
|
|
678
|
+
s.Action.includes('sqs:SendMessage') &&
|
|
679
|
+
s.Resource &&
|
|
680
|
+
s.Resource['Fn::GetAtt'] &&
|
|
681
|
+
s.Resource['Fn::GetAtt'][0] === 'ReportQueue'
|
|
682
|
+
);
|
|
683
|
+
expect(grant).toBeDefined();
|
|
684
|
+
expect(grant.Action).toContain('sqs:SendMessageBatch');
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('scopes REPORT_QUEUE_URL to reportRouter + reportExecutor when scopedEnvironment is on', async () => {
|
|
688
|
+
const appDefinition = {
|
|
689
|
+
lambda: { scopedEnvironment: true },
|
|
690
|
+
reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const result = await adminScriptBuilder.build(appDefinition, {});
|
|
694
|
+
|
|
695
|
+
expect(result.environment.REPORT_QUEUE_URL).toBeUndefined();
|
|
696
|
+
for (const fnName of ['reportRouter', 'reportExecutor']) {
|
|
697
|
+
expect(
|
|
698
|
+
result.functionEnvironments[fnName].REPORT_QUEUE_URL
|
|
699
|
+
).toEqual({ Ref: 'ReportQueue' });
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('creates ReportQueue + reportExecutor when only builtin reports are enabled', async () => {
|
|
704
|
+
const appDefinition = {
|
|
705
|
+
admin: { includeBuiltinReports: true },
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const result = await adminScriptBuilder.build(appDefinition, {});
|
|
709
|
+
|
|
710
|
+
expect(result.resources.ReportQueue).toBeDefined();
|
|
711
|
+
expect(result.functions.reportExecutor).toBeDefined();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('does NOT create ReportQueue or reportExecutor when only adminScripts are present', async () => {
|
|
715
|
+
const appDefinition = {
|
|
716
|
+
adminScripts: [{ Definition: { name: 'test-script' } }],
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
const result = await adminScriptBuilder.build(appDefinition, {});
|
|
720
|
+
|
|
721
|
+
expect(result.resources.ReportQueue).toBeUndefined();
|
|
722
|
+
expect(result.functions.reportExecutor).toBeUndefined();
|
|
723
|
+
expect(result.functions.reportRouter).toBeUndefined();
|
|
724
|
+
expect(result.environment.REPORT_QUEUE_URL).toBeUndefined();
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
describe('report artifacts (ReportArtifactBucket for non-JSON output)', () => {
|
|
729
|
+
it('provisions a private encrypted bucket + IAM + env when a report emits non-JSON', async () => {
|
|
730
|
+
const appDefinition = {
|
|
731
|
+
reports: [
|
|
732
|
+
{
|
|
733
|
+
Definition: {
|
|
734
|
+
name: 'sales-csv',
|
|
735
|
+
version: '1.0.0',
|
|
736
|
+
output: { format: 'csv' },
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
],
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
const result = await adminScriptBuilder.build(appDefinition, {});
|
|
743
|
+
|
|
744
|
+
const bucket = result.resources.ReportArtifactBucket;
|
|
745
|
+
expect(bucket).toBeDefined();
|
|
746
|
+
expect(bucket.Type).toBe('AWS::S3::Bucket');
|
|
747
|
+
expect(
|
|
748
|
+
bucket.Properties.BucketEncryption
|
|
749
|
+
.ServerSideEncryptionConfiguration[0]
|
|
750
|
+
.ServerSideEncryptionByDefault.SSEAlgorithm
|
|
751
|
+
).toBe('AES256');
|
|
752
|
+
expect(bucket.Properties.PublicAccessBlockConfiguration).toEqual({
|
|
753
|
+
BlockPublicAcls: true,
|
|
754
|
+
BlockPublicPolicy: true,
|
|
755
|
+
IgnorePublicAcls: true,
|
|
756
|
+
RestrictPublicBuckets: true,
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// Env wired app-wide (scoped flag off).
|
|
760
|
+
expect(result.environment.REPORT_ARTIFACT_BUCKET).toEqual({
|
|
761
|
+
Ref: 'ReportArtifactBucket',
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// IAM: object-level Put/Get scoped to the bucket keys.
|
|
765
|
+
const grant = result.iamStatements.find(
|
|
766
|
+
(s) =>
|
|
767
|
+
Array.isArray(s.Action) &&
|
|
768
|
+
s.Action.includes('s3:PutObject')
|
|
769
|
+
);
|
|
770
|
+
expect(grant).toBeDefined();
|
|
771
|
+
expect(grant.Action).toContain('s3:GetObject');
|
|
772
|
+
expect(grant.Resource['Fn::Sub'][0]).toBe('${BucketArn}/*');
|
|
773
|
+
expect(grant.Resource['Fn::Sub'][1]).toEqual({
|
|
774
|
+
BucketArn: { 'Fn::GetAtt': ['ReportArtifactBucket', 'Arn'] },
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('does NOT provision the bucket for JSON-only reports', async () => {
|
|
779
|
+
const appDefinition = {
|
|
780
|
+
reports: [
|
|
781
|
+
{
|
|
782
|
+
Definition: {
|
|
783
|
+
name: 'json-report',
|
|
784
|
+
version: '1.0.0',
|
|
785
|
+
output: { format: 'json' },
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
// No output field defaults to JSON.
|
|
789
|
+
{ Definition: { name: 'plain', version: '1.0.0' } },
|
|
790
|
+
],
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const result = await adminScriptBuilder.build(appDefinition, {});
|
|
794
|
+
|
|
795
|
+
expect(result.resources.ReportArtifactBucket).toBeUndefined();
|
|
796
|
+
expect(result.environment.REPORT_ARTIFACT_BUCKET).toBeUndefined();
|
|
797
|
+
const grant = result.iamStatements.find(
|
|
798
|
+
(s) =>
|
|
799
|
+
Array.isArray(s.Action) &&
|
|
800
|
+
s.Action.includes('s3:PutObject')
|
|
801
|
+
);
|
|
802
|
+
expect(grant).toBeUndefined();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('scopes REPORT_ARTIFACT_BUCKET to report functions when scopedEnvironment is on', async () => {
|
|
806
|
+
const appDefinition = {
|
|
807
|
+
lambda: { scopedEnvironment: true },
|
|
808
|
+
reports: [
|
|
809
|
+
{
|
|
810
|
+
Definition: {
|
|
811
|
+
name: 'sales-csv',
|
|
812
|
+
version: '1.0.0',
|
|
813
|
+
output: { format: 'csv' },
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
],
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
const result = await adminScriptBuilder.build(appDefinition, {});
|
|
820
|
+
|
|
821
|
+
expect(result.environment.REPORT_ARTIFACT_BUCKET).toBeUndefined();
|
|
822
|
+
for (const fnName of ['reportRouter', 'reportExecutor']) {
|
|
823
|
+
expect(
|
|
824
|
+
result.functionEnvironments[fnName].REPORT_ARTIFACT_BUCKET
|
|
825
|
+
).toEqual({ Ref: 'ReportArtifactBucket' });
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
describe('report scheduling (enableScheduling && reports)', () => {
|
|
831
|
+
it('wires the report scheduler env onto reportRouter targeting the report executor', async () => {
|
|
832
|
+
const appDefinition = {
|
|
833
|
+
reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
|
|
834
|
+
admin: { enableScheduling: true },
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const result = await adminScriptBuilder.build(appDefinition, {});
|
|
838
|
+
|
|
839
|
+
// Shared scheduler role + group are created even without scripts.
|
|
840
|
+
expect(result.resources.AdminScriptSchedulerRole).toBeDefined();
|
|
841
|
+
expect(result.resources.AdminScriptScheduleGroup).toBeDefined();
|
|
842
|
+
|
|
843
|
+
const routerEnv = result.functions.reportRouter.environment;
|
|
844
|
+
expect(routerEnv.SCHEDULER_PROVIDER).toBe('aws');
|
|
845
|
+
expect(routerEnv.SCHEDULER_ROLE_ARN).toEqual({
|
|
846
|
+
'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
|
|
847
|
+
});
|
|
848
|
+
expect(routerEnv.REPORT_SCHEDULE_GROUP).toEqual({
|
|
849
|
+
Ref: 'AdminScriptScheduleGroup',
|
|
850
|
+
});
|
|
851
|
+
// Constructed ARN (Fn::Sub), not Fn::GetAtt — avoids a circular dep.
|
|
852
|
+
expect(routerEnv.REPORT_EXECUTOR_LAMBDA_ARN).toEqual({
|
|
853
|
+
'Fn::Sub':
|
|
854
|
+
'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-reportExecutor',
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// Must NOT leak onto the shared provider env.
|
|
858
|
+
expect(result.environment.SCHEDULER_PROVIDER).toBeUndefined();
|
|
859
|
+
expect(result.environment.REPORT_EXECUTOR_LAMBDA_ARN).toBeUndefined();
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it('grants the scheduler role invoke on the report executor (reports-only)', async () => {
|
|
863
|
+
const appDefinition = {
|
|
864
|
+
reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
|
|
865
|
+
admin: { enableScheduling: true },
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
const result = await adminScriptBuilder.build(appDefinition, {});
|
|
869
|
+
|
|
870
|
+
const statement =
|
|
871
|
+
result.resources.AdminScriptSchedulerRole.Properties.Policies[0]
|
|
872
|
+
.PolicyDocument.Statement[0];
|
|
873
|
+
// Only reports present -> single Resource, the report executor ARN.
|
|
874
|
+
expect(statement.Resource).toEqual({
|
|
875
|
+
'Fn::Sub':
|
|
876
|
+
'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-reportExecutor',
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
// scheduler:* + iam:PassRole grants present.
|
|
880
|
+
const schedulerGrant = result.iamStatements.find(
|
|
881
|
+
(s) =>
|
|
882
|
+
Array.isArray(s.Action) &&
|
|
883
|
+
s.Action.includes('scheduler:CreateSchedule')
|
|
884
|
+
);
|
|
885
|
+
expect(schedulerGrant).toBeDefined();
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('lets the scheduler role invoke BOTH executors when scripts and reports coexist', async () => {
|
|
889
|
+
const appDefinition = {
|
|
890
|
+
adminScripts: [{ Definition: { name: 'test-script' } }],
|
|
891
|
+
reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
|
|
892
|
+
admin: { enableScheduling: true },
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
const result = await adminScriptBuilder.build(appDefinition, {});
|
|
896
|
+
|
|
897
|
+
const statement =
|
|
898
|
+
result.resources.AdminScriptSchedulerRole.Properties.Policies[0]
|
|
899
|
+
.PolicyDocument.Statement[0];
|
|
900
|
+
expect(statement.Resource).toEqual([
|
|
901
|
+
{
|
|
902
|
+
'Fn::Sub':
|
|
903
|
+
'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-adminScriptExecutor',
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
'Fn::Sub':
|
|
907
|
+
'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-reportExecutor',
|
|
908
|
+
},
|
|
909
|
+
]);
|
|
910
|
+
|
|
911
|
+
// Both routers get their own scheduler env.
|
|
912
|
+
expect(
|
|
913
|
+
result.functions.adminScriptRouter.environment
|
|
914
|
+
.ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN
|
|
915
|
+
).toBeDefined();
|
|
916
|
+
expect(
|
|
917
|
+
result.functions.reportRouter.environment
|
|
918
|
+
.REPORT_EXECUTOR_LAMBDA_ARN
|
|
919
|
+
).toBeDefined();
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it('does NOT wire report scheduler env when enableScheduling is off', async () => {
|
|
923
|
+
const appDefinition = {
|
|
924
|
+
reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
const result = await adminScriptBuilder.build(appDefinition, {});
|
|
928
|
+
|
|
929
|
+
expect(result.resources.AdminScriptSchedulerRole).toBeUndefined();
|
|
930
|
+
expect(
|
|
931
|
+
result.functions.reportRouter.environment
|
|
932
|
+
).toBeUndefined();
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
623
936
|
describe('getName()', () => {
|
|
624
937
|
it('should return AdminScriptBuilder', () => {
|
|
625
938
|
expect(adminScriptBuilder.getName()).toBe('AdminScriptBuilder');
|
|
@@ -311,16 +311,7 @@ function createBaseDefinition(
|
|
|
311
311
|
{ httpApi: { path: '/health/{proxy+}', method: 'GET' } },
|
|
312
312
|
],
|
|
313
313
|
},
|
|
314
|
-
|
|
315
|
-
handler: 'node_modules/@friggframework/core/handlers/routers/reporting.handler',
|
|
316
|
-
...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }),
|
|
317
|
-
skipEsbuild: true, // Handlers in node_modules don't need bundling
|
|
318
|
-
package: skipEsbuildPackageConfig,
|
|
319
|
-
events: [
|
|
320
|
-
{ httpApi: { path: '/api/v2/reports', method: 'GET' } },
|
|
321
|
-
{ httpApi: { path: '/api/v2/reports/{proxy+}', method: 'GET' } },
|
|
322
|
-
],
|
|
323
|
-
},
|
|
314
|
+
// Reporting is an admin operation (ADR-010): the report router runs on the admin-scripts Lambda, not as a standalone function here.
|
|
324
315
|
// Note: dbMigrate removed - MigrationBuilder now handles migration infrastructure
|
|
325
316
|
// See: packages/devtools/infrastructure/domains/database/migration-builder.js
|
|
326
317
|
},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/devtools",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0-next.
|
|
4
|
+
"version": "2.0.0-next.106",
|
|
5
5
|
"bin": {
|
|
6
6
|
"frigg": "./frigg-cli/index.js"
|
|
7
7
|
},
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
"@babel/eslint-parser": "^7.18.9",
|
|
27
27
|
"@babel/parser": "^7.25.3",
|
|
28
28
|
"@babel/traverse": "^7.25.3",
|
|
29
|
-
"@friggframework/core": "2.0.0-next.
|
|
30
|
-
"@friggframework/schemas": "2.0.0-next.
|
|
31
|
-
"@friggframework/test": "2.0.0-next.
|
|
29
|
+
"@friggframework/core": "2.0.0-next.106",
|
|
30
|
+
"@friggframework/schemas": "2.0.0-next.106",
|
|
31
|
+
"@friggframework/test": "2.0.0-next.106",
|
|
32
32
|
"@hapi/boom": "^10.0.1",
|
|
33
33
|
"@inquirer/prompts": "^5.3.8",
|
|
34
34
|
"axios": "^1.18.0",
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"validate-npm-package-name": "^5.0.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@friggframework/eslint-config": "2.0.0-next.
|
|
60
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
59
|
+
"@friggframework/eslint-config": "2.0.0-next.106",
|
|
60
|
+
"@friggframework/prettier-config": "2.0.0-next.106",
|
|
61
61
|
"aws-sdk-client-mock": "^4.1.0",
|
|
62
62
|
"aws-sdk-client-mock-jest": "^4.1.0",
|
|
63
63
|
"jest": "^30.1.3",
|
|
@@ -89,5 +89,5 @@
|
|
|
89
89
|
"publishConfig": {
|
|
90
90
|
"access": "public"
|
|
91
91
|
},
|
|
92
|
-
"gitHead": "
|
|
92
|
+
"gitHead": "979c1a62fe226206ec3801d1d7aacbfe2a8fac78"
|
|
93
93
|
}
|