@emarketeer/ts-microservice-commons 8.2.2 → 9.0.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/dist/build-handlers.js +149 -48
- package/dist/cdk/cjs/index.js +760 -557
- package/dist/cdk/cjs/index.js.map +1 -1
- package/dist/cdk/index.js +739 -536
- package/dist/cdk/index.js.map +1 -1
- package/dist/lib/em-commons.js +44 -43
- package/dist/lib/jest.config.js +2 -2
- package/dist/types/cdk/__tests__/config.test.d.ts +1 -0
- package/dist/types/cdk/__tests__/dlq-alarm.test.d.ts +1 -0
- package/dist/types/cdk/__tests__/handler-path.test.d.ts +1 -0
- package/dist/types/cdk/__tests__/logs.test.d.ts +1 -0
- package/dist/types/cdk/__tests__/rds-vpc.test.d.ts +1 -0
- package/dist/types/cdk/constructs/dlq-alarm.d.ts +6 -0
- package/dist/types/cdk/constructs/lambda-with-http-api.d.ts +2 -2
- package/dist/types/cdk/constructs/lambda-with-queue.d.ts +28 -3
- package/dist/types/cdk/constructs/stack.d.ts +142 -33
- package/dist/types/cdk/constructs/topic-queue-consumer.d.ts +5 -0
- package/dist/types/cdk/types/common.d.ts +18 -26
- package/dist/types/cdk/utils/config.d.ts +11 -62
- package/dist/types/cdk/utils/handler-path.d.ts +26 -4
- package/dist/types/cdk/utils/iam.d.ts +1 -64
- package/dist/types/cdk/utils/logs.d.ts +11 -9
- package/dist/types/cdk/utils/rds-vpc.d.ts +8 -1
- package/dist/types/cdk/utils/serverless-migration.d.ts +187 -3
- package/dist/types/find-entry-points.d.ts +20 -0
- package/dist/types/find-entry-points.test.d.ts +1 -0
- package/dist/types/utils.d.ts +1 -1
- package/package.json +9 -3
- package/dist/types/cdk/jest.config.d.ts +0 -2
- package/dist/types/esbuild-plugins.d.ts +0 -5
package/dist/cdk/cjs/index.js
CHANGED
|
@@ -4,10 +4,11 @@ Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
4
4
|
|
|
5
5
|
var cdk = require('aws-cdk-lib');
|
|
6
6
|
var awsLambda = require('aws-cdk-lib/aws-lambda');
|
|
7
|
+
var awsIam = require('aws-cdk-lib/aws-iam');
|
|
7
8
|
var awsLogs = require('aws-cdk-lib/aws-logs');
|
|
8
9
|
var constructs = require('constructs');
|
|
9
|
-
var awsIam = require('aws-cdk-lib/aws-iam');
|
|
10
10
|
var awsSsm = require('aws-cdk-lib/aws-ssm');
|
|
11
|
+
var path = require('path');
|
|
11
12
|
var awsDynamodb = require('aws-cdk-lib/aws-dynamodb');
|
|
12
13
|
var awsApigateway = require('aws-cdk-lib/aws-apigateway');
|
|
13
14
|
var awsApigatewayv2 = require('aws-cdk-lib/aws-apigatewayv2');
|
|
@@ -21,11 +22,32 @@ var awsEvents = require('aws-cdk-lib/aws-events');
|
|
|
21
22
|
var awsEventsTargets = require('aws-cdk-lib/aws-events-targets');
|
|
22
23
|
var awsLambdaEventSources = require('aws-cdk-lib/aws-lambda-event-sources');
|
|
23
24
|
var awsCloudwatch = require('aws-cdk-lib/aws-cloudwatch');
|
|
24
|
-
var path = require('path');
|
|
25
25
|
var awsCloudwatchActions = require('aws-cdk-lib/aws-cloudwatch-actions');
|
|
26
26
|
var ec2 = require('aws-cdk-lib/aws-ec2');
|
|
27
27
|
var cxApi = require('aws-cdk-lib/cx-api');
|
|
28
28
|
|
|
29
|
+
function _interopNamespace(e) {
|
|
30
|
+
if (e && e.__esModule) return e;
|
|
31
|
+
var n = Object.create(null);
|
|
32
|
+
if (e) {
|
|
33
|
+
Object.keys(e).forEach(function (k) {
|
|
34
|
+
if (k !== 'default') {
|
|
35
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
36
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
37
|
+
enumerable: true,
|
|
38
|
+
get: function () { return e[k]; }
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
n["default"] = e;
|
|
44
|
+
return Object.freeze(n);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
var cdk__namespace = /*#__PURE__*/_interopNamespace(cdk);
|
|
48
|
+
var path__namespace = /*#__PURE__*/_interopNamespace(path);
|
|
49
|
+
var ec2__namespace = /*#__PURE__*/_interopNamespace(ec2);
|
|
50
|
+
|
|
29
51
|
/**
|
|
30
52
|
* Stack naming conventions and utilities
|
|
31
53
|
*/
|
|
@@ -273,39 +295,40 @@ const getLogRetentionDays = (stage) => {
|
|
|
273
295
|
case 'dev':
|
|
274
296
|
return awsLogs.RetentionDays.THREE_DAYS;
|
|
275
297
|
default:
|
|
276
|
-
|
|
298
|
+
// Stage is a compile-time union. A runtime value outside it means a
|
|
299
|
+
// caller widened the type — fail loud rather than silently returning a
|
|
300
|
+
// dev-shaped default for what may be a production deployment.
|
|
301
|
+
throw new Error(`getLogRetentionDays: unknown stage "${stage}"`);
|
|
277
302
|
}
|
|
278
303
|
};
|
|
279
304
|
/**
|
|
280
|
-
* Convert retention days number to RetentionDays enum
|
|
305
|
+
* Convert retention days number to RetentionDays enum.
|
|
306
|
+
*
|
|
307
|
+
* Validates `days` against `RetentionDays`'s TS enum reverse-mapping (every
|
|
308
|
+
* numeric enum member exposes its name as a string-keyed property — e.g.
|
|
309
|
+
* `RetentionDays[1] === 'ONE_DAY'`). This catches typos without
|
|
310
|
+
* hand-maintaining a switch in parallel with the SDK enum.
|
|
311
|
+
*
|
|
312
|
+
* `0` is rejected even though `RetentionDays.INFINITE === 0`: passing 0 from
|
|
313
|
+
* a config object almost always means "unset" rather than "retain forever".
|
|
314
|
+
* Callers that genuinely want INFINITE must pass `RetentionDays.INFINITE`
|
|
315
|
+
* explicitly via a non-numeric path (or update this guard with a clear test).
|
|
281
316
|
*/
|
|
282
317
|
const convertRetentionDays = (days) => {
|
|
283
|
-
if (
|
|
318
|
+
if (days === undefined || days === null)
|
|
284
319
|
return undefined;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
150: awsLogs.RetentionDays.FIVE_MONTHS,
|
|
296
|
-
180: awsLogs.RetentionDays.SIX_MONTHS,
|
|
297
|
-
365: awsLogs.RetentionDays.ONE_YEAR,
|
|
298
|
-
400: awsLogs.RetentionDays.THIRTEEN_MONTHS,
|
|
299
|
-
545: awsLogs.RetentionDays.EIGHTEEN_MONTHS,
|
|
300
|
-
731: awsLogs.RetentionDays.TWO_YEARS,
|
|
301
|
-
1827: awsLogs.RetentionDays.FIVE_YEARS,
|
|
302
|
-
3653: awsLogs.RetentionDays.TEN_YEARS
|
|
303
|
-
};
|
|
304
|
-
const result = retentionMap[days];
|
|
305
|
-
if (!result) {
|
|
306
|
-
throw new Error(`Unsupported logRetentionDays value: ${days}. Supported values: ${Object.keys(retentionMap).join(', ')}`);
|
|
320
|
+
if (days === 0) {
|
|
321
|
+
throw new Error('logRetentionDays: 0 is not accepted (would map to RetentionDays.INFINITE). '
|
|
322
|
+
+ 'Pass RetentionDays.INFINITE explicitly if infinite retention is intended.');
|
|
323
|
+
}
|
|
324
|
+
if (typeof awsLogs.RetentionDays[days] !== 'string') {
|
|
325
|
+
const supported = Object.values(awsLogs.RetentionDays)
|
|
326
|
+
.filter((v) => typeof v === 'number')
|
|
327
|
+
.sort((a, b) => a - b)
|
|
328
|
+
.join(', ');
|
|
329
|
+
throw new Error(`Unsupported logRetentionDays value: ${days}. Supported values: ${supported}`);
|
|
307
330
|
}
|
|
308
|
-
return
|
|
331
|
+
return days;
|
|
309
332
|
};
|
|
310
333
|
/**
|
|
311
334
|
* Create a CloudWatch log group with standard configuration
|
|
@@ -320,17 +343,6 @@ const createLogGroup = (scope, id, config) => {
|
|
|
320
343
|
removalPolicy: config.stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY
|
|
321
344
|
});
|
|
322
345
|
};
|
|
323
|
-
/**
|
|
324
|
-
* Create a Lambda function log group
|
|
325
|
-
*/
|
|
326
|
-
const createLambdaLogGroup = (scope, id, stage, serviceName, functionName, retentionDays) => {
|
|
327
|
-
const logGroupName = `/aws/lambda/${generateLogGroupName(stage, serviceName, functionName)}`;
|
|
328
|
-
return createLogGroup(scope, id, {
|
|
329
|
-
logGroupName,
|
|
330
|
-
stage,
|
|
331
|
-
retentionDays
|
|
332
|
-
});
|
|
333
|
-
};
|
|
334
346
|
/**
|
|
335
347
|
* Create an API Gateway log group
|
|
336
348
|
*/
|
|
@@ -346,18 +358,20 @@ const createApiGatewayLogGroup = (scope, id, stage, serviceName, apiName, retent
|
|
|
346
358
|
* Get removal policy based on stage
|
|
347
359
|
*/
|
|
348
360
|
const getRemovalPolicy = (stage) => {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
361
|
+
switch (stage) {
|
|
362
|
+
case 'prod':
|
|
363
|
+
return cdk.RemovalPolicy.RETAIN;
|
|
364
|
+
case 'staging':
|
|
365
|
+
case 'test':
|
|
366
|
+
case 'dev':
|
|
367
|
+
return cdk.RemovalPolicy.DESTROY;
|
|
368
|
+
default:
|
|
369
|
+
// Same rationale as getLogRetentionDays: a typo like 'production' must
|
|
370
|
+
// not silently produce DESTROY for what should be a retained resource.
|
|
371
|
+
throw new Error(`getRemovalPolicy: unknown stage "${stage}"`);
|
|
372
|
+
}
|
|
356
373
|
};
|
|
357
374
|
|
|
358
|
-
/**
|
|
359
|
-
* IAM role and policy generation helpers
|
|
360
|
-
*/
|
|
361
375
|
/**
|
|
362
376
|
* Create a Lambda execution role with standard permissions
|
|
363
377
|
*/
|
|
@@ -366,115 +380,14 @@ const createLambdaExecutionRole = (scope, id, config) => {
|
|
|
366
380
|
const role = new awsIam.Role(scope, id, {
|
|
367
381
|
roleName,
|
|
368
382
|
assumedBy: new awsIam.ServicePrincipal('lambda.amazonaws.com'),
|
|
369
|
-
description: `Lambda execution role for ${config.serviceName}
|
|
383
|
+
description: `Lambda execution role for ${config.serviceName}`,
|
|
384
|
+
...(config.inlinePolicies && { inlinePolicies: config.inlinePolicies })
|
|
370
385
|
});
|
|
371
|
-
// Add basic Lambda execution permissions
|
|
372
386
|
role.addManagedPolicy(awsIam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'));
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
role.addManagedPolicy(awsIam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
|
|
376
|
-
}
|
|
377
|
-
return role;
|
|
378
|
-
};
|
|
379
|
-
/**
|
|
380
|
-
* Create a DynamoDB access policy statement
|
|
381
|
-
*/
|
|
382
|
-
const createDynamoDBAccessPolicy = (tableArn, actions = [
|
|
383
|
-
'dynamodb:GetItem',
|
|
384
|
-
'dynamodb:PutItem',
|
|
385
|
-
'dynamodb:UpdateItem',
|
|
386
|
-
'dynamodb:DeleteItem',
|
|
387
|
-
'dynamodb:Query',
|
|
388
|
-
'dynamodb:Scan'
|
|
389
|
-
]) => {
|
|
390
|
-
return new awsIam.PolicyStatement({
|
|
391
|
-
effect: awsIam.Effect.ALLOW,
|
|
392
|
-
actions,
|
|
393
|
-
resources: [tableArn, `${tableArn}/index/*`]
|
|
394
|
-
});
|
|
395
|
-
};
|
|
396
|
-
/**
|
|
397
|
-
* Create a read-only DynamoDB access policy statement
|
|
398
|
-
*/
|
|
399
|
-
const createDynamoDBReadPolicy = (tableArn) => {
|
|
400
|
-
return createDynamoDBAccessPolicy(tableArn, [
|
|
401
|
-
'dynamodb:GetItem',
|
|
402
|
-
'dynamodb:Query',
|
|
403
|
-
'dynamodb:Scan',
|
|
404
|
-
'dynamodb:BatchGetItem'
|
|
405
|
-
]);
|
|
406
|
-
};
|
|
407
|
-
/**
|
|
408
|
-
* Create a write-only DynamoDB access policy statement
|
|
409
|
-
*/
|
|
410
|
-
const createDynamoDBWritePolicy = (tableArn) => {
|
|
411
|
-
return createDynamoDBAccessPolicy(tableArn, [
|
|
412
|
-
'dynamodb:PutItem',
|
|
413
|
-
'dynamodb:UpdateItem',
|
|
414
|
-
'dynamodb:DeleteItem',
|
|
415
|
-
'dynamodb:BatchWriteItem'
|
|
416
|
-
]);
|
|
417
|
-
};
|
|
418
|
-
/**
|
|
419
|
-
* Create an SQS access policy statement
|
|
420
|
-
*/
|
|
421
|
-
const createSQSAccessPolicy = (queueArn, actions = [
|
|
422
|
-
'sqs:SendMessage',
|
|
423
|
-
'sqs:ReceiveMessage',
|
|
424
|
-
'sqs:DeleteMessage',
|
|
425
|
-
'sqs:GetQueueAttributes'
|
|
426
|
-
]) => {
|
|
427
|
-
return new awsIam.PolicyStatement({
|
|
428
|
-
effect: awsIam.Effect.ALLOW,
|
|
429
|
-
actions,
|
|
430
|
-
resources: [queueArn]
|
|
431
|
-
});
|
|
432
|
-
};
|
|
433
|
-
/**
|
|
434
|
-
* Create an SNS publish policy statement
|
|
435
|
-
*/
|
|
436
|
-
const createSNSPublishPolicy = (topicArn) => {
|
|
437
|
-
return new awsIam.PolicyStatement({
|
|
438
|
-
effect: awsIam.Effect.ALLOW,
|
|
439
|
-
actions: ['sns:Publish'],
|
|
440
|
-
resources: [topicArn]
|
|
441
|
-
});
|
|
442
|
-
};
|
|
443
|
-
/**
|
|
444
|
-
* Create an S3 access policy statement
|
|
445
|
-
*/
|
|
446
|
-
const createS3AccessPolicy = (bucketArn, actions = ['s3:GetObject', 's3:PutObject', 's3:DeleteObject']) => {
|
|
447
|
-
return new awsIam.PolicyStatement({
|
|
448
|
-
effect: awsIam.Effect.ALLOW,
|
|
449
|
-
actions,
|
|
450
|
-
resources: [bucketArn, `${bucketArn}/*`]
|
|
451
|
-
});
|
|
452
|
-
};
|
|
453
|
-
/**
|
|
454
|
-
* Create an S3 read-only policy statement
|
|
455
|
-
*/
|
|
456
|
-
const createS3ReadPolicy = (bucketArn) => {
|
|
457
|
-
return createS3AccessPolicy(bucketArn, ['s3:GetObject', 's3:ListBucket']);
|
|
458
|
-
};
|
|
459
|
-
/**
|
|
460
|
-
* Create a Secrets Manager access policy statement
|
|
461
|
-
*/
|
|
462
|
-
const createSecretsManagerPolicy = (secretArn) => {
|
|
463
|
-
return new awsIam.PolicyStatement({
|
|
464
|
-
effect: awsIam.Effect.ALLOW,
|
|
465
|
-
actions: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'],
|
|
466
|
-
resources: [secretArn]
|
|
467
|
-
});
|
|
468
|
-
};
|
|
469
|
-
/**
|
|
470
|
-
* Create a Systems Manager Parameter Store access policy statement
|
|
471
|
-
*/
|
|
472
|
-
const createSSMParameterPolicy = (parameterArn) => {
|
|
473
|
-
return new awsIam.PolicyStatement({
|
|
474
|
-
effect: awsIam.Effect.ALLOW,
|
|
475
|
-
actions: ['ssm:GetParameter', 'ssm:GetParameters', 'ssm:GetParametersByPath'],
|
|
476
|
-
resources: [parameterArn]
|
|
387
|
+
config.managedPolicies?.forEach(policy => {
|
|
388
|
+
role.addManagedPolicy(policy);
|
|
477
389
|
});
|
|
390
|
+
return role;
|
|
478
391
|
};
|
|
479
392
|
/**
|
|
480
393
|
* Create an X-Ray tracing policy statement
|
|
@@ -486,230 +399,40 @@ const createXRayTracingPolicy = () => {
|
|
|
486
399
|
resources: ['*']
|
|
487
400
|
});
|
|
488
401
|
};
|
|
489
|
-
/**
|
|
490
|
-
* Create an EventBridge put events policy statement
|
|
491
|
-
*/
|
|
492
|
-
const createEventBridgePutEventsPolicy = (eventBusArn) => {
|
|
493
|
-
return new awsIam.PolicyStatement({
|
|
494
|
-
effect: awsIam.Effect.ALLOW,
|
|
495
|
-
actions: ['events:PutEvents'],
|
|
496
|
-
resources: [eventBusArn]
|
|
497
|
-
});
|
|
498
|
-
};
|
|
499
|
-
/**
|
|
500
|
-
* Create a CloudWatch Logs policy statement
|
|
501
|
-
*/
|
|
502
|
-
const createCloudWatchLogsPolicy = (logGroupArn) => {
|
|
503
|
-
return new awsIam.PolicyStatement({
|
|
504
|
-
effect: awsIam.Effect.ALLOW,
|
|
505
|
-
actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
|
|
506
|
-
resources: [logGroupArn]
|
|
507
|
-
});
|
|
508
|
-
};
|
|
509
|
-
/**
|
|
510
|
-
* Create a KMS decrypt policy statement
|
|
511
|
-
*/
|
|
512
|
-
const createKMSDecryptPolicy = (keyArn) => {
|
|
513
|
-
return new awsIam.PolicyStatement({
|
|
514
|
-
effect: awsIam.Effect.ALLOW,
|
|
515
|
-
actions: ['kms:Decrypt', 'kms:DescribeKey'],
|
|
516
|
-
resources: [keyArn]
|
|
517
|
-
});
|
|
518
|
-
};
|
|
519
|
-
/**
|
|
520
|
-
* Create a Lambda invoke policy statement
|
|
521
|
-
*/
|
|
522
|
-
const createLambdaInvokePolicy = (functionArn) => {
|
|
523
|
-
return new awsIam.PolicyStatement({
|
|
524
|
-
effect: awsIam.Effect.ALLOW,
|
|
525
|
-
actions: ['lambda:InvokeFunction'],
|
|
526
|
-
resources: [functionArn]
|
|
527
|
-
});
|
|
528
|
-
};
|
|
529
|
-
/**
|
|
530
|
-
* Create a Step Functions start execution policy statement
|
|
531
|
-
*/
|
|
532
|
-
const createStepFunctionsExecutionPolicy = (stateMachineArn) => {
|
|
533
|
-
return new awsIam.PolicyStatement({
|
|
534
|
-
effect: awsIam.Effect.ALLOW,
|
|
535
|
-
actions: ['states:StartExecution', 'states:DescribeExecution', 'states:StopExecution'],
|
|
536
|
-
resources: [stateMachineArn]
|
|
537
|
-
});
|
|
538
|
-
};
|
|
539
|
-
/**
|
|
540
|
-
* Combine multiple policy statements into a policy document
|
|
541
|
-
*/
|
|
542
|
-
const createPolicyDocument = (statements) => {
|
|
543
|
-
return new awsIam.PolicyDocument({
|
|
544
|
-
statements
|
|
545
|
-
});
|
|
546
|
-
};
|
|
547
402
|
|
|
548
|
-
/**
|
|
549
|
-
* Environment-specific configuration management utilities
|
|
550
|
-
*/
|
|
551
403
|
const RECAP_DEV_SSM_KEY = 'recap-dev-sync-endpoint';
|
|
552
404
|
const recapDevEndpointCache = new WeakMap();
|
|
405
|
+
const RECAP_DEV_TIMEOUT_WINDOW_SECONDS = 300;
|
|
553
406
|
/**
|
|
554
|
-
*
|
|
555
|
-
|
|
556
|
-
const STAGE_DEFAULTS = {
|
|
557
|
-
dev: {
|
|
558
|
-
region: 'eu-west-1',
|
|
559
|
-
tags: {
|
|
560
|
-
Environment: 'development'
|
|
561
|
-
}
|
|
562
|
-
},
|
|
563
|
-
test: {
|
|
564
|
-
region: 'eu-west-1',
|
|
565
|
-
tags: {
|
|
566
|
-
Environment: 'test'
|
|
567
|
-
}
|
|
568
|
-
},
|
|
569
|
-
staging: {
|
|
570
|
-
region: 'eu-west-1',
|
|
571
|
-
tags: {
|
|
572
|
-
Environment: 'staging'
|
|
573
|
-
}
|
|
574
|
-
},
|
|
575
|
-
prod: {
|
|
576
|
-
region: 'eu-west-1',
|
|
577
|
-
tags: {
|
|
578
|
-
Environment: 'production'
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
};
|
|
582
|
-
/**
|
|
583
|
-
* Get environment configuration with defaults
|
|
584
|
-
*/
|
|
585
|
-
const getEnvironmentConfig = (stage, overrides) => {
|
|
586
|
-
const defaults = STAGE_DEFAULTS[stage];
|
|
587
|
-
return {
|
|
588
|
-
stage,
|
|
589
|
-
region: overrides?.region || defaults.region || 'eu-west-1',
|
|
590
|
-
account: overrides?.account || process.env.CDK_DEFAULT_ACCOUNT,
|
|
591
|
-
tags: {
|
|
592
|
-
...defaults.tags,
|
|
593
|
-
...overrides?.tags
|
|
594
|
-
}
|
|
595
|
-
};
|
|
596
|
-
};
|
|
597
|
-
/**
|
|
598
|
-
* Get environment variable with stage prefix
|
|
599
|
-
*/
|
|
600
|
-
const getStageEnvVar = (stage, key, defaultValue) => {
|
|
601
|
-
const stageKey = `${stage.toUpperCase()}_${key}`;
|
|
602
|
-
return process.env[stageKey] || process.env[key] || defaultValue;
|
|
603
|
-
};
|
|
604
|
-
/**
|
|
605
|
-
* Get required environment variable
|
|
606
|
-
*/
|
|
607
|
-
const getRequiredEnvVar = (key) => {
|
|
608
|
-
const value = process.env[key];
|
|
609
|
-
if (!value) {
|
|
610
|
-
throw new Error(`Required environment variable ${key} is not set`);
|
|
611
|
-
}
|
|
612
|
-
return value;
|
|
613
|
-
};
|
|
614
|
-
/**
|
|
615
|
-
* Get environment variables for a Lambda function
|
|
616
|
-
*/
|
|
617
|
-
const getLambdaEnvironmentVariables = (stage, additionalVars) => {
|
|
618
|
-
return {
|
|
619
|
-
STAGE: stage,
|
|
620
|
-
NODE_ENV: stage === 'prod' ? 'production' : 'development',
|
|
621
|
-
REGION: process.env.AWS_REGION || 'eu-west-1',
|
|
622
|
-
...additionalVars
|
|
623
|
-
};
|
|
624
|
-
};
|
|
625
|
-
/**
|
|
626
|
-
* Check if running in production stage
|
|
627
|
-
*/
|
|
628
|
-
const isProduction = (stage) => {
|
|
629
|
-
return stage === 'prod';
|
|
630
|
-
};
|
|
631
|
-
/**
|
|
632
|
-
* Check if running in development stage
|
|
633
|
-
*/
|
|
634
|
-
const isDevelopment = (stage) => {
|
|
635
|
-
return stage === 'dev';
|
|
636
|
-
};
|
|
637
|
-
/**
|
|
638
|
-
* Get stage from environment or default
|
|
407
|
+
* Returns the standard base environment variables injected into every Lambda.
|
|
408
|
+
* Centralised here so both EmLambdaFunction and LambdaWithQueue stay in sync.
|
|
639
409
|
*/
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
return stage;
|
|
646
|
-
};
|
|
647
|
-
/**
|
|
648
|
-
* Get stage-specific resource limits
|
|
649
|
-
*/
|
|
650
|
-
const getResourceLimits = (stage) => {
|
|
651
|
-
switch (stage) {
|
|
652
|
-
case 'prod':
|
|
653
|
-
return {
|
|
654
|
-
lambdaMemory: 1024,
|
|
655
|
-
lambdaTimeout: 30,
|
|
656
|
-
apiThrottleRate: 10000,
|
|
657
|
-
apiThrottleBurst: 5000,
|
|
658
|
-
dynamoDbReadCapacity: 5,
|
|
659
|
-
dynamoDbWriteCapacity: 5,
|
|
660
|
-
logRetentionDays: 30
|
|
661
|
-
};
|
|
662
|
-
case 'staging':
|
|
663
|
-
return {
|
|
664
|
-
lambdaMemory: 1024,
|
|
665
|
-
lambdaTimeout: 30,
|
|
666
|
-
apiThrottleRate: 5000,
|
|
667
|
-
apiThrottleBurst: 2500,
|
|
668
|
-
dynamoDbReadCapacity: 3,
|
|
669
|
-
dynamoDbWriteCapacity: 3,
|
|
670
|
-
logRetentionDays: 14
|
|
671
|
-
};
|
|
672
|
-
case 'test':
|
|
673
|
-
return {
|
|
674
|
-
lambdaMemory: 512,
|
|
675
|
-
lambdaTimeout: 15,
|
|
676
|
-
apiThrottleRate: 1000,
|
|
677
|
-
apiThrottleBurst: 500,
|
|
678
|
-
dynamoDbReadCapacity: 1,
|
|
679
|
-
dynamoDbWriteCapacity: 1,
|
|
680
|
-
logRetentionDays: 7
|
|
681
|
-
};
|
|
682
|
-
case 'dev':
|
|
683
|
-
return {
|
|
684
|
-
lambdaMemory: 512,
|
|
685
|
-
lambdaTimeout: 15,
|
|
686
|
-
apiThrottleRate: 100,
|
|
687
|
-
apiThrottleBurst: 50,
|
|
688
|
-
dynamoDbReadCapacity: 1,
|
|
689
|
-
dynamoDbWriteCapacity: 1,
|
|
690
|
-
logRetentionDays: 3
|
|
691
|
-
};
|
|
692
|
-
}
|
|
693
|
-
};
|
|
694
|
-
const RECAP_DEV_TIMEOUT_WINDOW_SECONDS = 300;
|
|
410
|
+
const buildBaseEnvironment = (stage, scope) => ({
|
|
411
|
+
STAGE: stage,
|
|
412
|
+
NODE_ENV: stage === 'prod' ? 'production' : 'development',
|
|
413
|
+
REGION: cdk.Stack.of(scope).region,
|
|
414
|
+
});
|
|
695
415
|
/**
|
|
696
416
|
* Returns the env var block to inject for recap.dev, or an empty object.
|
|
697
|
-
*
|
|
417
|
+
*
|
|
418
|
+
* The `dummy-value-` early-return guards against CDK's SSM lookup placeholder:
|
|
419
|
+
* `valueFromLookup` returns `dummy-value-…` strings during the first synth
|
|
420
|
+
* before the cache file is populated, and we must not bake those into the
|
|
421
|
+
* Lambda's environment.
|
|
698
422
|
*/
|
|
699
423
|
const buildRecapDevEnvironment = (endpoint) => {
|
|
700
424
|
if (!endpoint || endpoint.startsWith('dummy-value-')) {
|
|
701
425
|
return {};
|
|
702
426
|
}
|
|
427
|
+
let parsed;
|
|
703
428
|
try {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
}
|
|
429
|
+
parsed = new URL(endpoint);
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
throw new Error(`recap.dev endpoint is not a valid URL: "${endpoint}"`);
|
|
708
433
|
}
|
|
709
|
-
|
|
710
|
-
throw
|
|
711
|
-
? err
|
|
712
|
-
: new Error(`recap.dev endpoint is not a valid URL: ${endpoint}`);
|
|
434
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
435
|
+
throw new Error(`recap.dev endpoint must use http or https, got: ${parsed.protocol}`);
|
|
713
436
|
}
|
|
714
437
|
return {
|
|
715
438
|
RECAP_DEV_SYNC_ENDPOINT: endpoint,
|
|
@@ -723,63 +446,90 @@ const buildRecapDevEnvironment = (endpoint) => {
|
|
|
723
446
|
*/
|
|
724
447
|
const resolveRecapDevEndpoint = (scope) => {
|
|
725
448
|
const stack = cdk.Stack.of(scope);
|
|
449
|
+
if (cdk.Token.isUnresolved(stack.account) || cdk.Token.isUnresolved(stack.region)) {
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
726
452
|
if (!recapDevEndpointCache.has(stack)) {
|
|
727
453
|
recapDevEndpointCache.set(stack, awsSsm.StringParameter.valueFromLookup(stack, RECAP_DEV_SSM_KEY));
|
|
728
454
|
}
|
|
729
455
|
return recapDevEndpointCache.get(stack);
|
|
730
456
|
};
|
|
457
|
+
|
|
458
|
+
const DEFAULT_LAMBDA_RUNTIME = awsLambda.Runtime.NODEJS_24_X;
|
|
459
|
+
|
|
460
|
+
const DEFAULT_HANDLERS_DIR = 'src/handlers';
|
|
461
|
+
const DEFAULT_OUT_DIR = 'dist/handlers';
|
|
731
462
|
/**
|
|
732
|
-
*
|
|
463
|
+
* Resolve `handlerPath` into `codePath`, `handler`, and optionally `functionName`.
|
|
464
|
+
*
|
|
465
|
+
* Given `handlerPath: 'src/handlers/capture-screenshot/capture-screenshot-from-url'`:
|
|
466
|
+
* - `codePath` → `'dist/handlers/capture-screenshot/capture-screenshot-from-url'`
|
|
467
|
+
* - `handler` → `'index.handler'`
|
|
468
|
+
* - `functionName` → `'capture-screenshot-from-url'` (only when not explicitly provided)
|
|
469
|
+
*
|
|
470
|
+
* When `handlerPath` is not provided, `functionName` is required.
|
|
471
|
+
* `handler` and `codePath` may remain undefined if the caller has its own defaults.
|
|
733
472
|
*/
|
|
734
|
-
|
|
473
|
+
function resolveHandlerPath(config) {
|
|
474
|
+
const { handlerPath } = config;
|
|
475
|
+
if (handlerPath) {
|
|
476
|
+
const normalised = handlerPath.replace(/\.ts$/, '');
|
|
477
|
+
const startsWithHandlersDir = normalised.startsWith(DEFAULT_HANDLERS_DIR + '/');
|
|
478
|
+
const containsSeparator = normalised.includes('/') || normalised.includes(path__namespace.sep);
|
|
479
|
+
if (!startsWithHandlersDir && (path__namespace.isAbsolute(normalised) || containsSeparator)) {
|
|
480
|
+
// A bare basename like 'get-data' is fine (treated as relative to
|
|
481
|
+
// src/handlers). Anything with directory components must be rooted at
|
|
482
|
+
// DEFAULT_HANDLERS_DIR — otherwise we'd silently produce e.g.
|
|
483
|
+
// 'dist/handlers/src/lambdas/foo' and fail at synth with an opaque
|
|
484
|
+
// Code.fromAsset error far from the call site.
|
|
485
|
+
throw new Error(`resolveHandlerPath: handlerPath "${handlerPath}" must either be a bare basename or start with "${DEFAULT_HANDLERS_DIR}/".`);
|
|
486
|
+
}
|
|
487
|
+
const relative = startsWithHandlersDir
|
|
488
|
+
? normalised.slice(DEFAULT_HANDLERS_DIR.length + 1)
|
|
489
|
+
: normalised;
|
|
490
|
+
return {
|
|
491
|
+
functionName: config.functionName ?? path__namespace.basename(relative),
|
|
492
|
+
handler: config.handler ?? 'index.handler',
|
|
493
|
+
codePath: config.codePath ?? path__namespace.join(DEFAULT_OUT_DIR, relative)
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
if (!config.functionName) {
|
|
497
|
+
throw new Error('Either `handlerPath` or `functionName` must be provided.');
|
|
498
|
+
}
|
|
735
499
|
return {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
500
|
+
functionName: config.functionName,
|
|
501
|
+
handler: config.handler,
|
|
502
|
+
codePath: config.codePath
|
|
739
503
|
};
|
|
740
|
-
}
|
|
741
|
-
/**
|
|
742
|
-
* Get stage-specific alarm thresholds
|
|
743
|
-
*/
|
|
744
|
-
const getAlarmThresholds = (stage) => {
|
|
745
|
-
switch (stage) {
|
|
746
|
-
case 'prod':
|
|
747
|
-
return {
|
|
748
|
-
errorRate: 1,
|
|
749
|
-
throttleRate: 5,
|
|
750
|
-
durationP99: 5000,
|
|
751
|
-
concurrentExecutions: 900
|
|
752
|
-
};
|
|
753
|
-
case 'staging':
|
|
754
|
-
return {
|
|
755
|
-
errorRate: 5,
|
|
756
|
-
throttleRate: 10,
|
|
757
|
-
durationP99: 10000,
|
|
758
|
-
concurrentExecutions: 500
|
|
759
|
-
};
|
|
760
|
-
default:
|
|
761
|
-
return {
|
|
762
|
-
errorRate: 10,
|
|
763
|
-
throttleRate: 20,
|
|
764
|
-
durationP99: 15000,
|
|
765
|
-
concurrentExecutions: 100
|
|
766
|
-
};
|
|
767
|
-
}
|
|
768
|
-
};
|
|
769
|
-
|
|
770
|
-
const DEFAULT_LAMBDA_RUNTIME = awsLambda.Runtime.NODEJS_24_X;
|
|
504
|
+
}
|
|
771
505
|
|
|
772
506
|
class EmLambdaFunction extends constructs.Construct {
|
|
773
507
|
constructor(scope, id, config) {
|
|
774
508
|
super(scope, id);
|
|
775
|
-
const
|
|
509
|
+
const resolved = resolveHandlerPath(config);
|
|
510
|
+
const functionName = config.physicalName ??
|
|
511
|
+
generateLambdaName(config.stage, config.serviceName, resolved.functionName);
|
|
512
|
+
const extraPolicies = [];
|
|
513
|
+
if (config.vpcConfig) {
|
|
514
|
+
extraPolicies.push(awsIam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
|
|
515
|
+
}
|
|
516
|
+
if (config.enableTracing) {
|
|
517
|
+
extraPolicies.push(awsIam.ManagedPolicy.fromAwsManagedPolicyName('AWSXRayDaemonWriteAccess'));
|
|
518
|
+
}
|
|
776
519
|
const role = config.role ??
|
|
777
520
|
createLambdaExecutionRole(this, 'Role', {
|
|
778
|
-
roleName:
|
|
521
|
+
roleName: resolved.functionName,
|
|
779
522
|
stage: config.stage,
|
|
780
523
|
serviceName: config.serviceName,
|
|
781
|
-
managedPolicies:
|
|
524
|
+
managedPolicies: extraPolicies.length ? extraPolicies : undefined
|
|
782
525
|
});
|
|
526
|
+
// When the caller supplies a role (typically EmStack's sharedRole during
|
|
527
|
+
// Serverless migration) createLambdaExecutionRole is bypassed and the
|
|
528
|
+
// VPC/X-Ray policies above are not attached. Attach them here so
|
|
529
|
+
// vpcConfig/enableTracing produce a working role regardless of source.
|
|
530
|
+
if (config.role) {
|
|
531
|
+
extraPolicies.forEach(policy => role.addManagedPolicy(policy));
|
|
532
|
+
}
|
|
783
533
|
const logGroup = config.importExistingLogGroup
|
|
784
534
|
? awsLogs.LogGroup.fromLogGroupName(this, `${id}LogGroup`, `/aws/lambda/${functionName}`)
|
|
785
535
|
: new awsLogs.LogGroup(this, `${id}LogGroup`, {
|
|
@@ -787,15 +537,20 @@ class EmLambdaFunction extends constructs.Construct {
|
|
|
787
537
|
retention: convertRetentionDays(config.logRetentionDays) ?? getLogRetentionDays(config.stage),
|
|
788
538
|
removalPolicy: getRemovalPolicy(config.stage)
|
|
789
539
|
});
|
|
540
|
+
const handler = resolved.handler ?? config.handler;
|
|
541
|
+
const codePath = resolved.codePath ?? config.codePath;
|
|
542
|
+
if (!handler || !codePath) {
|
|
543
|
+
throw new Error(`EmLambdaFunction requires either \`handlerPath\` or both \`handler\` and \`codePath\` for "${resolved.functionName}".`);
|
|
544
|
+
}
|
|
790
545
|
this.function = new awsLambda.Function(this, 'Function', {
|
|
791
546
|
functionName,
|
|
792
547
|
runtime: config.runtime ?? DEFAULT_LAMBDA_RUNTIME,
|
|
793
|
-
handler
|
|
794
|
-
code: awsLambda.Code.fromAsset(
|
|
548
|
+
handler,
|
|
549
|
+
code: awsLambda.Code.fromAsset(codePath),
|
|
795
550
|
memorySize: config.memorySize ?? 1024,
|
|
796
551
|
timeout: config.timeout ?? cdk.Duration.seconds(15),
|
|
797
552
|
environment: {
|
|
798
|
-
...
|
|
553
|
+
...buildBaseEnvironment(config.stage, this),
|
|
799
554
|
...(config.environment ?? {}),
|
|
800
555
|
...buildRecapDevEnvironment(resolveRecapDevEndpoint(this))
|
|
801
556
|
},
|
|
@@ -806,7 +561,7 @@ class EmLambdaFunction extends constructs.Construct {
|
|
|
806
561
|
retryAttempts: config.retryAttempts,
|
|
807
562
|
logGroup,
|
|
808
563
|
layers: config.layers,
|
|
809
|
-
description: `${config.serviceName} - ${
|
|
564
|
+
description: `${config.serviceName} - ${resolved.functionName}`,
|
|
810
565
|
...(config.vpcConfig && {
|
|
811
566
|
vpc: config.vpcConfig.vpc,
|
|
812
567
|
vpcSubnets: config.vpcConfig.vpcSubnets,
|
|
@@ -1700,40 +1455,26 @@ const EVENT_PATTERNS = {
|
|
|
1700
1455
|
})
|
|
1701
1456
|
};
|
|
1702
1457
|
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
: normalised;
|
|
1723
|
-
return {
|
|
1724
|
-
functionName: config.functionName ?? path.basename(relative),
|
|
1725
|
-
handler: config.handler ?? 'index.handler',
|
|
1726
|
-
codePath: config.codePath ?? path.join(DEFAULT_OUT_DIR, relative)
|
|
1727
|
-
};
|
|
1728
|
-
}
|
|
1729
|
-
if (!config.functionName) {
|
|
1730
|
-
throw new Error('Either `handlerPath` or `functionName` must be provided.');
|
|
1458
|
+
class DlqAlarm extends constructs.Construct {
|
|
1459
|
+
constructor(scope, id, props) {
|
|
1460
|
+
super(scope, id);
|
|
1461
|
+
this.alarm = new awsCloudwatch.Alarm(this, 'Alarm', {
|
|
1462
|
+
alarmName: props.alarmName,
|
|
1463
|
+
metric: props.dlq.metricApproximateNumberOfMessagesVisible(),
|
|
1464
|
+
threshold: 0,
|
|
1465
|
+
comparisonOperator: awsCloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
|
1466
|
+
evaluationPeriods: 1,
|
|
1467
|
+
treatMissingData: awsCloudwatch.TreatMissingData.NOT_BREACHING
|
|
1468
|
+
});
|
|
1469
|
+
this.alarm.addAlarmAction(new awsCloudwatchActions.SnsAction(props.alarmTopic));
|
|
1470
|
+
if (props.alarmLogicalId) {
|
|
1471
|
+
const cfnAlarm = this.alarm.node.defaultChild;
|
|
1472
|
+
if (!(cfnAlarm instanceof awsCloudwatch.CfnAlarm)) {
|
|
1473
|
+
throw new Error(`Cannot override alarm logical ID "${props.alarmLogicalId}": defaultChild is not a CfnAlarm.`);
|
|
1474
|
+
}
|
|
1475
|
+
cfnAlarm.overrideLogicalId(props.alarmLogicalId);
|
|
1476
|
+
}
|
|
1731
1477
|
}
|
|
1732
|
-
return {
|
|
1733
|
-
functionName: config.functionName,
|
|
1734
|
-
handler: config.handler,
|
|
1735
|
-
codePath: config.codePath
|
|
1736
|
-
};
|
|
1737
1478
|
}
|
|
1738
1479
|
|
|
1739
1480
|
/**
|
|
@@ -1799,7 +1540,13 @@ const overrideLogGroupLogicalId = (logGroup, serverlessFunctionName) => {
|
|
|
1799
1540
|
*/
|
|
1800
1541
|
const overrideFunctionLogicalIds = (fn, serverlessFunctionName) => {
|
|
1801
1542
|
overrideLambdaLogicalId(fn, serverlessFunctionName);
|
|
1802
|
-
|
|
1543
|
+
const logGroup = fn.logGroup;
|
|
1544
|
+
if (!(logGroup instanceof constructs.Construct)) {
|
|
1545
|
+
throw new Error(`Cannot override log group logical ID for "${serverlessFunctionName}": ` +
|
|
1546
|
+
'fn.logGroup is not a Construct. ' +
|
|
1547
|
+
'Imported log groups (importExistingLogGroup: true) cannot have their logical IDs overridden.');
|
|
1548
|
+
}
|
|
1549
|
+
overrideLogGroupLogicalId(logGroup, serverlessFunctionName);
|
|
1803
1550
|
};
|
|
1804
1551
|
/**
|
|
1805
1552
|
* Override a Lambda layer's logical ID.
|
|
@@ -1826,39 +1573,250 @@ const overrideLayerLogicalId = (layer, logicalId) => {
|
|
|
1826
1573
|
*
|
|
1827
1574
|
* Only works with roles created in this stack, not imported roles.
|
|
1828
1575
|
*/
|
|
1829
|
-
const overrideRoleLogicalId = (role, logicalId = 'IamRoleLambdaExecution') => {
|
|
1576
|
+
const overrideRoleLogicalId = (role, logicalId = 'IamRoleLambdaExecution', options) => {
|
|
1830
1577
|
const defaultChild = role.node.defaultChild;
|
|
1831
1578
|
if (!(defaultChild instanceof awsIam.CfnRole)) {
|
|
1832
1579
|
throw new Error('Cannot override role logical ID: the role does not have a CfnRole default child. ' +
|
|
1833
1580
|
'Imported roles (e.g. via Role.fromRoleArn) cannot have their logical IDs overridden.');
|
|
1834
1581
|
}
|
|
1835
1582
|
defaultChild.overrideLogicalId(logicalId);
|
|
1583
|
+
if (options?.roleName) {
|
|
1584
|
+
defaultChild.addPropertyOverride('RoleName', options.roleName);
|
|
1585
|
+
}
|
|
1586
|
+
if (options?.deletePath) {
|
|
1587
|
+
defaultChild.addPropertyDeletionOverride('Path');
|
|
1588
|
+
}
|
|
1836
1589
|
};
|
|
1837
1590
|
/**
|
|
1838
1591
|
* Create a CfnOutput with a Serverless Framework-compatible export name.
|
|
1839
1592
|
* Export pattern: sls-{serviceName}-{stage}-{outputKey}
|
|
1840
1593
|
*/
|
|
1841
|
-
const createServerlessCompatibleOutput = (scope, id, props) => {
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1594
|
+
const createServerlessCompatibleOutput = (scope, id, props) => new cdk.CfnOutput(scope, id, {
|
|
1595
|
+
value: props.value,
|
|
1596
|
+
description: props.description,
|
|
1597
|
+
exportName: `sls-${props.serviceName}-${props.stage}-${props.outputKey}`
|
|
1598
|
+
});
|
|
1599
|
+
/**
|
|
1600
|
+
* Creates an SQS queue with a dead-letter queue using the names and logical IDs
|
|
1601
|
+
* from the existing Serverless stack.
|
|
1602
|
+
*
|
|
1603
|
+
* Use this during Serverless to CDK migrations when the queues already exist.
|
|
1604
|
+
* The queue and DLQ logical IDs must match the CloudFormation resources created
|
|
1605
|
+
* by Serverless, otherwise CloudFormation can replace the queues during deploy.
|
|
1606
|
+
*
|
|
1607
|
+
* Production queues are retained. Other stages use the shared removal policy
|
|
1608
|
+
* from `getRemovalPolicy()`. The DLQ message retention is set to 14 days.
|
|
1609
|
+
*
|
|
1610
|
+
* @example
|
|
1611
|
+
* ```typescript
|
|
1612
|
+
* const alarmTopic = (scope as EmStack).alarmTopic()
|
|
1613
|
+
* const { queue, dlq } = makeServerlessQueue(
|
|
1614
|
+
* scope, 'MqlEventsQueue', 'MqlEventsQueueDLQ',
|
|
1615
|
+
* `${svc}-mql-event-queue`, `${svc}-mql-event-queue-dlq`,
|
|
1616
|
+
* stage,
|
|
1617
|
+
* { alarm: { name: 'MqlEventsQueueDLQAlarm', topic: alarmTopic } },
|
|
1618
|
+
* )
|
|
1619
|
+
* ```
|
|
1620
|
+
*/
|
|
1621
|
+
function makeServerlessQueue(scope, queueLogicalId, dlqLogicalId, queueName, dlqName, stage, opts = {}) {
|
|
1622
|
+
const removalPolicy = getRemovalPolicy(stage);
|
|
1623
|
+
const dlq = new awsSqs.Queue(scope, dlqLogicalId, {
|
|
1624
|
+
queueName: dlqName,
|
|
1625
|
+
retentionPeriod: cdk.Duration.days(14),
|
|
1626
|
+
removalPolicy
|
|
1846
1627
|
});
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1628
|
+
const cfnDlq = dlq.node.defaultChild;
|
|
1629
|
+
if (!(cfnDlq instanceof awsSqs.CfnQueue)) {
|
|
1630
|
+
throw new Error(`Cannot override DLQ logical ID "${dlqLogicalId}": defaultChild is not a CfnQueue.`);
|
|
1631
|
+
}
|
|
1632
|
+
cfnDlq.overrideLogicalId(dlqLogicalId);
|
|
1633
|
+
const queue = new awsSqs.Queue(scope, queueLogicalId, {
|
|
1634
|
+
queueName,
|
|
1635
|
+
visibilityTimeout: opts.visibilityTimeout ?? cdk.Duration.seconds(900),
|
|
1636
|
+
deadLetterQueue: { queue: dlq, maxReceiveCount: opts.maxReceiveCount ?? 3 },
|
|
1637
|
+
removalPolicy
|
|
1638
|
+
});
|
|
1639
|
+
const cfnQueue = queue.node.defaultChild;
|
|
1640
|
+
if (!(cfnQueue instanceof awsSqs.CfnQueue)) {
|
|
1641
|
+
throw new Error(`Cannot override queue logical ID "${queueLogicalId}": defaultChild is not a CfnQueue.`);
|
|
1642
|
+
}
|
|
1643
|
+
cfnQueue.overrideLogicalId(queueLogicalId);
|
|
1644
|
+
if (opts.alarm) {
|
|
1645
|
+
new DlqAlarm(scope, `${queueLogicalId}DLQAlarm`, {
|
|
1646
|
+
dlq,
|
|
1647
|
+
alarmName: opts.alarm.name,
|
|
1648
|
+
alarmTopic: opts.alarm.topic,
|
|
1649
|
+
alarmLogicalId: opts.alarm.logicalId
|
|
1859
1650
|
});
|
|
1860
|
-
this.alarm.addAlarmAction(new awsCloudwatchActions.SnsAction(props.alarmTopic));
|
|
1861
1651
|
}
|
|
1652
|
+
return { queue, dlq };
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Creates an SNS to SQS subscription with the same logical ID as the existing
|
|
1656
|
+
* Serverless resource.
|
|
1657
|
+
*
|
|
1658
|
+
* Use this when migrating an existing subscription from Serverless to CDK.
|
|
1659
|
+
* `topic.addSubscription(new SqsSubscription(queue))` lets CDK generate the
|
|
1660
|
+
* logical ID, which usually does not match the resource already in CloudFormation.
|
|
1661
|
+
* That can cause the subscription to be replaced during deployment.
|
|
1662
|
+
*
|
|
1663
|
+
* Both the construct ID and CloudFormation logical ID are set from `logicalId`.
|
|
1664
|
+
*
|
|
1665
|
+
* **Queue policy not included.** Unlike CDK's `SqsSubscription`, this helper only
|
|
1666
|
+
* creates the `AWS::SNS::Subscription`. It does NOT create an `AWS::SQS::QueuePolicy`
|
|
1667
|
+
* allowing SNS to send to the queue. On migrated stacks the policy was already created
|
|
1668
|
+
* by Serverless Framework — use `makeServerlessQueuePolicy` to preserve it with the
|
|
1669
|
+
* correct logical ID. On new queues call `makeServerlessQueuePolicy` (or use the CDK
|
|
1670
|
+
* `SqsSubscription` helper instead) to ensure SNS can deliver messages.
|
|
1671
|
+
*
|
|
1672
|
+
* @example
|
|
1673
|
+
* ```typescript
|
|
1674
|
+
* makeSnsToSqsSubscription(scope, 'TenantPurgeSubscription', {
|
|
1675
|
+
* topicArn: `arn:aws:sns:${region}:${accountId}:${stage}-emarketeer-event-purge-tenant-data`,
|
|
1676
|
+
* endpoint: queues.tenantPurgeQueue.queue.queueArn,
|
|
1677
|
+
* protocol: 'sqs',
|
|
1678
|
+
* rawMessageDelivery: true,
|
|
1679
|
+
* })
|
|
1680
|
+
* // Also preserve (or create) the matching queue policy:
|
|
1681
|
+
* makeServerlessQueuePolicy(scope, 'TenantPurgeSQSPolicy', { ... })
|
|
1682
|
+
* ```
|
|
1683
|
+
*/
|
|
1684
|
+
function makeSnsToSqsSubscription(scope, logicalId, props) {
|
|
1685
|
+
const sub = new awsSns.CfnSubscription(scope, logicalId, props);
|
|
1686
|
+
sub.overrideLogicalId(logicalId);
|
|
1687
|
+
return sub;
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Creates an SNS to Lambda subscription and matching invoke permission using
|
|
1691
|
+
* logical IDs from the existing Serverless stack.
|
|
1692
|
+
*
|
|
1693
|
+
* Use this when migrating an existing SNS Lambda trigger to CDK. The normal CDK
|
|
1694
|
+
* subscription helper generates its own logical IDs, so it can replace the
|
|
1695
|
+
* Serverless-created subscription and permission during deploy. For SNS Lambda
|
|
1696
|
+
* subscriptions that means events published during the replacement window are lost.
|
|
1697
|
+
*
|
|
1698
|
+
* Pass literal ARN strings for both the topic and Lambda function. Avoid using
|
|
1699
|
+
* `topic.topicArn` or `function.functionArn` here, since changes in the generated
|
|
1700
|
+
* CloudFormation expression can still force replacement even when the final ARN is
|
|
1701
|
+
* the same.
|
|
1702
|
+
*
|
|
1703
|
+
* The Lambda function must have a fixed `physicalName`, otherwise the function ARN
|
|
1704
|
+
* cannot be written safely by hand.
|
|
1705
|
+
*
|
|
1706
|
+
* Check the live CloudFormation stack for the subscription and permission logical
|
|
1707
|
+
* IDs before using this helper:
|
|
1708
|
+
*
|
|
1709
|
+
* ```bash
|
|
1710
|
+
* aws cloudformation list-stack-resources --stack-name <stack-name> \
|
|
1711
|
+
* --query "StackResourceSummaries[?ResourceType=='AWS::SNS::Subscription' ||
|
|
1712
|
+
* ResourceType=='AWS::Lambda::Permission'].[LogicalResourceId,ResourceType]" \
|
|
1713
|
+
* --output table
|
|
1714
|
+
* ```
|
|
1715
|
+
*
|
|
1716
|
+
* @example
|
|
1717
|
+
* ```typescript
|
|
1718
|
+
* const stagePrefix = stage.charAt(0).toUpperCase() + stage.slice(1)
|
|
1719
|
+
* makeSnsToLambdaSubscription(
|
|
1720
|
+
* scope,
|
|
1721
|
+
* `HandleDashorderDashcreatedSnsSubscription${stagePrefix}myserviceeventordercreated`,
|
|
1722
|
+
* `HandleDashorderDashcreatedLambdaPermission${stagePrefix}myserviceeventordercreatedSNS`,
|
|
1723
|
+
* {
|
|
1724
|
+
* topicArn: `arn:aws:sns:eu-west-1:${accountId}:${stage}-my-service-event-order-created`,
|
|
1725
|
+
* functionArn: `arn:aws:lambda:eu-west-1:${accountId}:function:my-service-${stage}-handle-order-created`,
|
|
1726
|
+
* }
|
|
1727
|
+
* )
|
|
1728
|
+
* ```
|
|
1729
|
+
*/
|
|
1730
|
+
function makeSnsToLambdaSubscription(scope, subscriptionLogicalId, permissionLogicalId, props) {
|
|
1731
|
+
if (cdk.Token.isUnresolved(props.topicArn) || cdk.Token.isUnresolved(props.functionArn)) {
|
|
1732
|
+
throw new Error('makeSnsToLambdaSubscription requires literal ARN strings, not CDK tokens. ' +
|
|
1733
|
+
'Using topic.topicArn or function.functionArn produces a CloudFormation expression whose ' +
|
|
1734
|
+
'rendered form can change independently of the resolved ARN, forcing subscription replacement. ' +
|
|
1735
|
+
'Pass the physical ARN as a string literal (e.g. `arn:aws:sns:eu-west-1:${accountId}:${stage}-my-topic`).');
|
|
1736
|
+
}
|
|
1737
|
+
const subscription = new awsSns.CfnSubscription(scope, subscriptionLogicalId, {
|
|
1738
|
+
topicArn: props.topicArn,
|
|
1739
|
+
protocol: 'lambda',
|
|
1740
|
+
endpoint: props.functionArn,
|
|
1741
|
+
});
|
|
1742
|
+
subscription.overrideLogicalId(subscriptionLogicalId);
|
|
1743
|
+
const permission = new awsLambda.CfnPermission(scope, permissionLogicalId, {
|
|
1744
|
+
action: 'lambda:InvokeFunction',
|
|
1745
|
+
functionName: props.functionArn,
|
|
1746
|
+
principal: 'sns.amazonaws.com',
|
|
1747
|
+
sourceArn: props.topicArn,
|
|
1748
|
+
});
|
|
1749
|
+
permission.overrideLogicalId(permissionLogicalId);
|
|
1750
|
+
subscription.addDependency(permission);
|
|
1751
|
+
return { subscription, permission };
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Creates an SQS queue policy using the logical ID from the existing Serverless
|
|
1755
|
+
* stack.
|
|
1756
|
+
*
|
|
1757
|
+
* Use this for migrated queues instead of `queue.addToResourcePolicy()`. The CDK
|
|
1758
|
+
* helper creates its own logical ID, which usually does not match the
|
|
1759
|
+
* `AWS::SQS::QueuePolicy` resource already managed by CloudFormation.
|
|
1760
|
+
*
|
|
1761
|
+
* Both the construct ID and CloudFormation logical ID are set from `logicalId`.
|
|
1762
|
+
*
|
|
1763
|
+
* @example
|
|
1764
|
+
* ```typescript
|
|
1765
|
+
* makeServerlessQueuePolicy(scope, 'MqlEventSQSPolicy', {
|
|
1766
|
+
* queues: [queues.mqlEventsQueue.queue.queueUrl],
|
|
1767
|
+
* policyDocument: {
|
|
1768
|
+
* Version: '2012-10-17',
|
|
1769
|
+
* Statement: [{
|
|
1770
|
+
* Effect: 'Allow', Principal: '*', Action: 'sqs:SendMessage', Resource: '*',
|
|
1771
|
+
* // Keep the policy limited to the SNS topic that sends to this queue.
|
|
1772
|
+
* Condition: { ArnEquals: { 'aws:SourceArn': `arn:aws:sns:...` } },
|
|
1773
|
+
* }],
|
|
1774
|
+
* },
|
|
1775
|
+
* })
|
|
1776
|
+
* ```
|
|
1777
|
+
*/
|
|
1778
|
+
function makeServerlessQueuePolicy(scope, logicalId, props) {
|
|
1779
|
+
const policy = new awsSqs.CfnQueuePolicy(scope, logicalId, props);
|
|
1780
|
+
policy.overrideLogicalId(logicalId);
|
|
1781
|
+
return policy;
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Creates a DynamoDB table with the physical name and logical ID used by the
|
|
1785
|
+
* existing Serverless stack.
|
|
1786
|
+
*
|
|
1787
|
+
* Use this when moving an existing Serverless-managed table to CDK. The logical
|
|
1788
|
+
* ID should be copied from the CloudFormation resource, usually the resource key
|
|
1789
|
+
* from `serverless.yml`. If it does not match, CloudFormation may replace the
|
|
1790
|
+
* table during deploy.
|
|
1791
|
+
*
|
|
1792
|
+
* Defaults used by this helper:
|
|
1793
|
+
* - billing mode is PAY_PER_REQUEST
|
|
1794
|
+
* - point-in-time recovery is enabled only in prod
|
|
1795
|
+
* - removal policy is RETAIN in prod and DESTROY in other stages
|
|
1796
|
+
*
|
|
1797
|
+
* @example
|
|
1798
|
+
* ```typescript
|
|
1799
|
+
* const table = makeServerlessDynamoTable(scope, 'LeadCountTable', `${stage}-em-my-service-lead-count`, stage, {
|
|
1800
|
+
* partitionKey: { name: 'streamId', type: AttributeType.STRING },
|
|
1801
|
+
* sortKey: { name: 'day', type: AttributeType.NUMBER },
|
|
1802
|
+
* })
|
|
1803
|
+
* ```
|
|
1804
|
+
*/
|
|
1805
|
+
function makeServerlessDynamoTable(scope, logicalId, tableName, stage, options) {
|
|
1806
|
+
const table = new awsDynamodb.Table(scope, logicalId, {
|
|
1807
|
+
tableName,
|
|
1808
|
+
billingMode: awsDynamodb.BillingMode.PAY_PER_REQUEST,
|
|
1809
|
+
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: stage === 'prod' },
|
|
1810
|
+
removalPolicy: getRemovalPolicy(stage),
|
|
1811
|
+
...options
|
|
1812
|
+
});
|
|
1813
|
+
const cfnTable = table.node.defaultChild;
|
|
1814
|
+
if (!(cfnTable instanceof awsDynamodb.CfnTable)) {
|
|
1815
|
+
throw new Error(`Cannot override DynamoDB table logical ID "${logicalId}": defaultChild is not a CfnTable. ` +
|
|
1816
|
+
'Imported tables cannot have their logical IDs overridden.');
|
|
1817
|
+
}
|
|
1818
|
+
cfnTable.overrideLogicalId(logicalId);
|
|
1819
|
+
return table;
|
|
1862
1820
|
}
|
|
1863
1821
|
|
|
1864
1822
|
class LambdaWithQueue extends constructs.Construct {
|
|
@@ -1867,7 +1825,7 @@ class LambdaWithQueue extends constructs.Construct {
|
|
|
1867
1825
|
const resolved = resolveHandlerPath(props);
|
|
1868
1826
|
const shortName = resolved.functionName;
|
|
1869
1827
|
const resourceName = props.resourceName ?? shortName;
|
|
1870
|
-
const functionName = generateLambdaName(props.stage, props.serviceName, shortName);
|
|
1828
|
+
const functionName = props.physicalName ?? generateLambdaName(props.stage, props.serviceName, shortName);
|
|
1871
1829
|
const handler = resolved.handler ?? props.handler ?? 'index.handler';
|
|
1872
1830
|
const codePath = resolved.codePath ?? props.codePath ?? `./dist/handlers/${resourceName}`;
|
|
1873
1831
|
if (props.reservedConcurrency === 0) {
|
|
@@ -1892,7 +1850,7 @@ class LambdaWithQueue extends constructs.Construct {
|
|
|
1892
1850
|
},
|
|
1893
1851
|
removalPolicy: getRemovalPolicy(props.stage)
|
|
1894
1852
|
});
|
|
1895
|
-
const role = props.role ?? this.createRole(props);
|
|
1853
|
+
const role = props.role ?? this.createRole(props, enableTracing);
|
|
1896
1854
|
const logGroup = new awsLogs.LogGroup(this, 'LogGroup', {
|
|
1897
1855
|
logGroupName: `/aws/lambda/${functionName}`,
|
|
1898
1856
|
retention: getLogRetentionDays(props.stage),
|
|
@@ -1907,6 +1865,7 @@ class LambdaWithQueue extends constructs.Construct {
|
|
|
1907
1865
|
memorySize,
|
|
1908
1866
|
timeout,
|
|
1909
1867
|
environment: {
|
|
1868
|
+
...buildBaseEnvironment(props.stage, this),
|
|
1910
1869
|
...(props.environment ?? {}),
|
|
1911
1870
|
...buildRecapDevEnvironment(resolveRecapDevEndpoint(this))
|
|
1912
1871
|
},
|
|
@@ -1944,7 +1903,8 @@ class LambdaWithQueue extends constructs.Construct {
|
|
|
1944
1903
|
this.dlqAlarm = new DlqAlarm(this, 'DLQAlarm', {
|
|
1945
1904
|
dlq: this.dlq,
|
|
1946
1905
|
alarmName: props.alarmName ?? `${props.stage}-${props.serviceName}-${resourceName}-dlq-alarm`,
|
|
1947
|
-
alarmTopic: props.alarmTopic
|
|
1906
|
+
alarmTopic: props.alarmTopic,
|
|
1907
|
+
alarmLogicalId: props.overrideLogicalIds?.alarm
|
|
1948
1908
|
});
|
|
1949
1909
|
additionalQueues.forEach(queue => {
|
|
1950
1910
|
queue.grantSendMessages(this.function);
|
|
@@ -1968,15 +1928,8 @@ class LambdaWithQueue extends constructs.Construct {
|
|
|
1968
1928
|
}
|
|
1969
1929
|
cfnDlq.overrideLogicalId(props.overrideLogicalIds.dlq);
|
|
1970
1930
|
}
|
|
1971
|
-
if (props.overrideLogicalIds?.alarm) {
|
|
1972
|
-
const cfnAlarm = this.dlqAlarm.alarm.node.defaultChild;
|
|
1973
|
-
if (!(cfnAlarm instanceof awsCloudwatch.CfnAlarm)) {
|
|
1974
|
-
throw new Error(`Cannot override alarm logical ID to "${props.overrideLogicalIds.alarm}": alarm does not have a CfnAlarm default child.`);
|
|
1975
|
-
}
|
|
1976
|
-
cfnAlarm.overrideLogicalId(props.overrideLogicalIds.alarm);
|
|
1977
|
-
}
|
|
1978
1931
|
}
|
|
1979
|
-
createRole(props) {
|
|
1932
|
+
createRole(props, enableTracing) {
|
|
1980
1933
|
if (!props.roleName) {
|
|
1981
1934
|
throw new Error('LambdaWithQueue requires either `role` or `roleName` to be provided.');
|
|
1982
1935
|
}
|
|
@@ -1989,10 +1942,45 @@ class LambdaWithQueue extends constructs.Construct {
|
|
|
1989
1942
|
if (props.vpcConfig) {
|
|
1990
1943
|
role.addManagedPolicy(awsIam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
|
|
1991
1944
|
}
|
|
1945
|
+
if (enableTracing) {
|
|
1946
|
+
role.addManagedPolicy(awsIam.ManagedPolicy.fromAwsManagedPolicyName('AWSXRayDaemonWriteAccess'));
|
|
1947
|
+
}
|
|
1992
1948
|
return role;
|
|
1993
1949
|
}
|
|
1994
|
-
subscribeToTopic(topic, options) {
|
|
1995
|
-
|
|
1950
|
+
subscribeToTopic(topic, options, serverlessSubscriptionLogicalId) {
|
|
1951
|
+
if (serverlessSubscriptionLogicalId !== undefined) {
|
|
1952
|
+
const trimmed = serverlessSubscriptionLogicalId.trim();
|
|
1953
|
+
if (!trimmed) {
|
|
1954
|
+
throw new Error('subscribeToTopic: serverlessSubscriptionLogicalId must not be an empty string — ' +
|
|
1955
|
+
'omit the argument to use the standard CDK subscription path.');
|
|
1956
|
+
}
|
|
1957
|
+
if (options?.filterPolicy) {
|
|
1958
|
+
throw new Error('subscribeToTopic: filterPolicy is not supported with serverlessSubscriptionLogicalId. ' +
|
|
1959
|
+
'Use the standard CDK subscription path instead.');
|
|
1960
|
+
}
|
|
1961
|
+
if (options?.filterPolicyWithMessageBody) {
|
|
1962
|
+
throw new Error('subscribeToTopic: filterPolicyWithMessageBody is not supported with serverlessSubscriptionLogicalId. ' +
|
|
1963
|
+
'Use the standard CDK subscription path instead.');
|
|
1964
|
+
}
|
|
1965
|
+
if (options?.deadLetterQueue) {
|
|
1966
|
+
throw new Error('subscribeToTopic: deadLetterQueue is not supported with serverlessSubscriptionLogicalId. ' +
|
|
1967
|
+
'Use the standard CDK subscription path instead.');
|
|
1968
|
+
}
|
|
1969
|
+
cdk.Annotations.of(this).addWarning(`subscribeToTopic with serverlessSubscriptionLogicalId="${trimmed}" ` +
|
|
1970
|
+
'creates only the SNS subscription — no SQS queue policy is created. ' +
|
|
1971
|
+
'On migrated stacks the queue policy already exists in CloudFormation; ' +
|
|
1972
|
+
'preserve it with makeServerlessQueuePolicy(). ' +
|
|
1973
|
+
'On new queues you must also call makeServerlessQueuePolicy() or SNS delivery will fail silently.');
|
|
1974
|
+
makeSnsToSqsSubscription(this, trimmed, {
|
|
1975
|
+
topicArn: topic.topicArn,
|
|
1976
|
+
endpoint: this.queue.queueArn,
|
|
1977
|
+
protocol: 'sqs',
|
|
1978
|
+
rawMessageDelivery: options?.rawMessageDelivery
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
else {
|
|
1982
|
+
topic.addSubscription(new awsSnsSubscriptions.SqsSubscription(this.queue, options));
|
|
1983
|
+
}
|
|
1996
1984
|
}
|
|
1997
1985
|
}
|
|
1998
1986
|
|
|
@@ -2045,12 +2033,12 @@ class EmLambdaWithHttpApi extends constructs.Construct {
|
|
|
2045
2033
|
*/
|
|
2046
2034
|
class TopicQueueConsumer extends LambdaWithQueue {
|
|
2047
2035
|
constructor(scope, id, props) {
|
|
2048
|
-
const { topic: topicOrArn, subscriptionOptions, ...queueProps } = props;
|
|
2036
|
+
const { topic: topicOrArn, subscriptionOptions, serverlessSubscriptionLogicalId, ...queueProps } = props;
|
|
2049
2037
|
super(scope, id, queueProps);
|
|
2050
2038
|
const topic = typeof topicOrArn === 'string'
|
|
2051
2039
|
? awsSns.Topic.fromTopicArn(this, 'SubscribedTopic', topicOrArn)
|
|
2052
2040
|
: topicOrArn;
|
|
2053
|
-
this.subscribeToTopic(topic, subscriptionOptions);
|
|
2041
|
+
this.subscribeToTopic(topic, subscriptionOptions, serverlessSubscriptionLogicalId);
|
|
2054
2042
|
}
|
|
2055
2043
|
}
|
|
2056
2044
|
|
|
@@ -2088,7 +2076,7 @@ class TopicQueueConsumer extends LambdaWithQueue {
|
|
|
2088
2076
|
* }
|
|
2089
2077
|
* ```
|
|
2090
2078
|
*/
|
|
2091
|
-
class EmStack extends
|
|
2079
|
+
class EmStack extends cdk__namespace.Stack {
|
|
2092
2080
|
constructor(scope, id, props) {
|
|
2093
2081
|
super(scope, id, {
|
|
2094
2082
|
...props,
|
|
@@ -2128,7 +2116,7 @@ class EmStack extends cdk.Stack {
|
|
|
2128
2116
|
/**
|
|
2129
2117
|
* Update default function config after construction.
|
|
2130
2118
|
* Use this when defaults depend on resources created after `super()`.
|
|
2131
|
-
*
|
|
2119
|
+
* The environment map is shallow-merged with existing defaults; all other keys are replaced.
|
|
2132
2120
|
*
|
|
2133
2121
|
* @example
|
|
2134
2122
|
* ```typescript
|
|
@@ -2149,19 +2137,17 @@ class EmStack extends cdk.Stack {
|
|
|
2149
2137
|
}
|
|
2150
2138
|
};
|
|
2151
2139
|
}
|
|
2152
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2153
2140
|
mergeConfig(config) {
|
|
2154
|
-
const defaults = this.defaultFunctionConfig;
|
|
2155
|
-
const merged = { ...defaults, ...config };
|
|
2156
2141
|
const defaultEnv = this.defaultFunctionConfig.environment;
|
|
2157
2142
|
const configEnv = config.environment;
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2143
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
2144
|
+
return {
|
|
2145
|
+
...this.defaultFunctionConfig,
|
|
2146
|
+
...config,
|
|
2147
|
+
...(defaultEnv || configEnv
|
|
2148
|
+
? { environment: { ...(defaultEnv ?? {}), ...(configEnv ?? {}) } }
|
|
2149
|
+
: {})
|
|
2150
|
+
};
|
|
2165
2151
|
}
|
|
2166
2152
|
/**
|
|
2167
2153
|
* Create a Lambda function. Defaults `stage` and `serviceName` from the stack.
|
|
@@ -2169,8 +2155,11 @@ class EmStack extends cdk.Stack {
|
|
|
2169
2155
|
* When `useSharedRole: true` (Serverless migration mode), also:
|
|
2170
2156
|
* - Overrides Lambda logical ID to `{prefix}LambdaFunction`
|
|
2171
2157
|
* - Overrides log group logical ID to `{prefix}LogGroup`
|
|
2172
|
-
* - Sets log group removal policy
|
|
2158
|
+
* - Sets log group removal policy via `getRemovalPolicy(stage)` (RETAIN in
|
|
2159
|
+
* prod, DESTROY otherwise — see utils/logs.ts)
|
|
2173
2160
|
* - Uses the shared role unless `config.role` is provided
|
|
2161
|
+
* - Throws if `importExistingLogGroup` is set — migration mode requires managed log groups
|
|
2162
|
+
* to override their logical IDs
|
|
2174
2163
|
*/
|
|
2175
2164
|
createFunction(id, config) {
|
|
2176
2165
|
const merged = this.mergeConfig(config);
|
|
@@ -2197,6 +2186,7 @@ class EmStack extends cdk.Stack {
|
|
|
2197
2186
|
}
|
|
2198
2187
|
overrideFunctionLogicalIds(fn.function, functionName);
|
|
2199
2188
|
}
|
|
2189
|
+
this.errorIfRoleOverrideInMigrationMode(fn, 'createFunction', functionName, merged.role);
|
|
2200
2190
|
return fn;
|
|
2201
2191
|
}
|
|
2202
2192
|
/**
|
|
@@ -2222,7 +2212,7 @@ class EmStack extends cdk.Stack {
|
|
|
2222
2212
|
createQueueConsumer(id, config) {
|
|
2223
2213
|
const merged = this.mergeConfig(config);
|
|
2224
2214
|
const { functionName } = resolveHandlerPath(merged);
|
|
2225
|
-
|
|
2215
|
+
const consumer = new LambdaWithQueue(this, id, {
|
|
2226
2216
|
...merged,
|
|
2227
2217
|
stage: merged.stage ?? this.stage,
|
|
2228
2218
|
serviceName: merged.serviceName ?? this.serviceName,
|
|
@@ -2231,6 +2221,8 @@ class EmStack extends cdk.Stack {
|
|
|
2231
2221
|
serverlessFunctionName: merged.serverlessFunctionName ?? functionName
|
|
2232
2222
|
})
|
|
2233
2223
|
});
|
|
2224
|
+
this.errorIfRoleOverrideInMigrationMode(consumer, 'createQueueConsumer', functionName ?? id, merged.role);
|
|
2225
|
+
return consumer;
|
|
2234
2226
|
}
|
|
2235
2227
|
/**
|
|
2236
2228
|
* Create a Lambda function consuming messages from an SNS topic via SQS.
|
|
@@ -2252,20 +2244,23 @@ class EmStack extends cdk.Stack {
|
|
|
2252
2244
|
* ```
|
|
2253
2245
|
*/
|
|
2254
2246
|
createTopicQueueConsumer(id, config) {
|
|
2255
|
-
const { topic, subscriptionOptions, ...queueConfig } = config;
|
|
2247
|
+
const { topic, subscriptionOptions, serverlessSubscriptionLogicalId, ...queueConfig } = config;
|
|
2256
2248
|
const merged = this.mergeConfig(queueConfig);
|
|
2257
2249
|
const { functionName } = resolveHandlerPath(merged);
|
|
2258
|
-
|
|
2250
|
+
const consumer = new TopicQueueConsumer(this, id, {
|
|
2259
2251
|
...merged,
|
|
2260
2252
|
stage: merged.stage ?? this.stage,
|
|
2261
2253
|
serviceName: merged.serviceName ?? this.serviceName,
|
|
2262
2254
|
role: merged.role ?? this.sharedRole,
|
|
2263
2255
|
topic,
|
|
2264
2256
|
subscriptionOptions,
|
|
2257
|
+
serverlessSubscriptionLogicalId,
|
|
2265
2258
|
...(this.sharedRole && {
|
|
2266
2259
|
serverlessFunctionName: merged.serverlessFunctionName ?? functionName
|
|
2267
2260
|
})
|
|
2268
2261
|
});
|
|
2262
|
+
this.errorIfRoleOverrideInMigrationMode(consumer, 'createTopicQueueConsumer', functionName ?? id, merged.role);
|
|
2263
|
+
return consumer;
|
|
2269
2264
|
}
|
|
2270
2265
|
/**
|
|
2271
2266
|
* Create a scheduled Lambda function with an EventBridge rule.
|
|
@@ -2281,11 +2276,13 @@ class EmStack extends cdk.Stack {
|
|
|
2281
2276
|
*/
|
|
2282
2277
|
createScheduledFunction(id, config) {
|
|
2283
2278
|
const { schedule, ruleName, ruleDescription, ...functionConfig } = config;
|
|
2284
|
-
const
|
|
2279
|
+
const merged = this.mergeConfig(functionConfig);
|
|
2280
|
+
const fn = this.createFunction(id, merged);
|
|
2281
|
+
const { functionName } = resolveHandlerPath(merged);
|
|
2285
2282
|
const rule = new EmEventBridgeRule(this, `${id}Rule`, {
|
|
2286
|
-
stage:
|
|
2287
|
-
serviceName:
|
|
2288
|
-
ruleName: ruleName ??
|
|
2283
|
+
stage: functionConfig.stage ?? this.stage,
|
|
2284
|
+
serviceName: functionConfig.serviceName ?? this.serviceName,
|
|
2285
|
+
ruleName: ruleName ?? functionName,
|
|
2289
2286
|
description: ruleDescription,
|
|
2290
2287
|
schedule
|
|
2291
2288
|
});
|
|
@@ -2316,35 +2313,51 @@ class EmStack extends cdk.Stack {
|
|
|
2316
2313
|
/**
|
|
2317
2314
|
* Import the alarm email topic by convention.
|
|
2318
2315
|
* ARN: `arn:{partition}:sns:{region}:{account}:{stage}-alarm-email`
|
|
2316
|
+
*
|
|
2317
|
+
* Memoised: `Topic.fromTopicArn` registers a new construct on each call and
|
|
2318
|
+
* CDK errors on duplicate construct IDs in the same scope, so multiple
|
|
2319
|
+
* call sites would otherwise fail synth.
|
|
2319
2320
|
*/
|
|
2320
2321
|
alarmTopic() {
|
|
2321
|
-
|
|
2322
|
-
|
|
2322
|
+
if (!this._alarmTopic) {
|
|
2323
|
+
const arn = `arn:${cdk.Aws.PARTITION}:sns:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${this.stage}-alarm-email`;
|
|
2324
|
+
this._alarmTopic = awsSns.Topic.fromTopicArn(this, 'AlarmTopic', arn);
|
|
2325
|
+
}
|
|
2326
|
+
return this._alarmTopic;
|
|
2323
2327
|
}
|
|
2324
2328
|
/**
|
|
2325
2329
|
* Add a Lambda invoke policy to the shared role.
|
|
2326
2330
|
* @param functionPattern - Optional function name pattern. Defaults to `{stage}-{serviceName}-*`.
|
|
2327
2331
|
* Pass `'*'` for account-wide access.
|
|
2332
|
+
*
|
|
2333
|
+
* **Note:** The default pattern only matches functions named with the CDK convention
|
|
2334
|
+
* (`{stage}-{serviceName}-{fn}`). Functions created with `physicalName` using the legacy
|
|
2335
|
+
* Serverless convention (`{service}-{stage}-{fn}`) will not be matched. Pass `'*'` when
|
|
2336
|
+
* the service has any `physicalName` functions that must be invocable.
|
|
2328
2337
|
*/
|
|
2329
2338
|
addLambdaInvokePolicy(functionPattern) {
|
|
2330
|
-
|
|
2339
|
+
if (functionPattern !== undefined && functionPattern.trim() === '') {
|
|
2340
|
+
throw new Error('addLambdaInvokePolicy: functionPattern must not be empty.');
|
|
2341
|
+
}
|
|
2342
|
+
const role = this.requireSharedRole('addLambdaInvokePolicy');
|
|
2331
2343
|
const pattern = functionPattern ?? `${this.stage}-${this.serviceName}-*`;
|
|
2332
|
-
|
|
2344
|
+
const resources = pattern === '*'
|
|
2345
|
+
? ['*']
|
|
2346
|
+
: [`arn:${cdk.Aws.PARTITION}:lambda:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:function:${pattern}`];
|
|
2347
|
+
role.addToPolicy(new awsIam.PolicyStatement({
|
|
2333
2348
|
effect: awsIam.Effect.ALLOW,
|
|
2334
2349
|
actions: ['lambda:InvokeFunction'],
|
|
2335
|
-
resources
|
|
2336
|
-
`arn:${cdk.Aws.PARTITION}:lambda:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:function:${pattern}`
|
|
2337
|
-
]
|
|
2350
|
+
resources
|
|
2338
2351
|
}));
|
|
2339
2352
|
}
|
|
2340
2353
|
/**
|
|
2341
2354
|
* Add a Kinesis PutRecord/PutRecords policy to the shared role.
|
|
2342
|
-
* @param
|
|
2355
|
+
* @param streamOrName - An IStream reference, or a short stream name (prefixed with `{stage}-`).
|
|
2343
2356
|
*/
|
|
2344
|
-
addKinesisPolicy(
|
|
2345
|
-
this.requireSharedRole('addKinesisPolicy');
|
|
2346
|
-
const arn = `arn:${cdk.Aws.PARTITION}:kinesis:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:stream/${this.stage}-${
|
|
2347
|
-
|
|
2357
|
+
addKinesisPolicy(streamOrName) {
|
|
2358
|
+
const role = this.requireSharedRole('addKinesisPolicy');
|
|
2359
|
+
const arn = this.resolveResourceArn(streamOrName, 'addKinesisPolicy', 'streamName', name => `arn:${cdk.Aws.PARTITION}:kinesis:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:stream/${this.stage}-${name}`, stream => stream.streamArn);
|
|
2360
|
+
role.addToPolicy(new awsIam.PolicyStatement({
|
|
2348
2361
|
effect: awsIam.Effect.ALLOW,
|
|
2349
2362
|
actions: ['kinesis:PutRecord', 'kinesis:PutRecords'],
|
|
2350
2363
|
resources: [arn]
|
|
@@ -2355,11 +2368,9 @@ class EmStack extends cdk.Stack {
|
|
|
2355
2368
|
* @param topicOrName - An ITopic reference, or a short topic name (prefixed with `{stage}-`).
|
|
2356
2369
|
*/
|
|
2357
2370
|
addSnsPublishPolicy(topicOrName) {
|
|
2358
|
-
this.requireSharedRole('addSnsPublishPolicy');
|
|
2359
|
-
const arn =
|
|
2360
|
-
|
|
2361
|
-
: topicOrName.topicArn;
|
|
2362
|
-
this.sharedRole.addToPolicy(new awsIam.PolicyStatement({
|
|
2371
|
+
const role = this.requireSharedRole('addSnsPublishPolicy');
|
|
2372
|
+
const arn = this.resolveResourceArn(topicOrName, 'addSnsPublishPolicy', 'topicName', name => `arn:${cdk.Aws.PARTITION}:sns:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${this.stage}-${name}`, topic => topic.topicArn);
|
|
2373
|
+
role.addToPolicy(new awsIam.PolicyStatement({
|
|
2363
2374
|
effect: awsIam.Effect.ALLOW,
|
|
2364
2375
|
actions: ['sns:Publish'],
|
|
2365
2376
|
resources: [arn]
|
|
@@ -2367,21 +2378,220 @@ class EmStack extends cdk.Stack {
|
|
|
2367
2378
|
}
|
|
2368
2379
|
/**
|
|
2369
2380
|
* Add an SQS SendMessage policy to the shared role.
|
|
2370
|
-
* @param
|
|
2381
|
+
* @param queueOrNames - One or more IQueue references and/or short queue names
|
|
2382
|
+
* (string names are prefixed with `{stage}-`).
|
|
2371
2383
|
*/
|
|
2372
|
-
addSqsSendPolicy(
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2384
|
+
addSqsSendPolicy(queueOrNames) {
|
|
2385
|
+
const items = Array.isArray(queueOrNames) ? queueOrNames : [queueOrNames];
|
|
2386
|
+
if (items.length === 0) {
|
|
2387
|
+
throw new Error('addSqsSendPolicy: queueOrNames must not be empty.');
|
|
2388
|
+
}
|
|
2389
|
+
const role = this.requireSharedRole('addSqsSendPolicy');
|
|
2390
|
+
const resources = items.map(item => this.resolveResourceArn(item, 'addSqsSendPolicy', 'queueName', name => `arn:${cdk.Aws.PARTITION}:sqs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${this.stage}-${name}`, queue => queue.queueArn));
|
|
2391
|
+
role.addToPolicy(new awsIam.PolicyStatement({
|
|
2376
2392
|
effect: awsIam.Effect.ALLOW,
|
|
2377
2393
|
actions: ['sqs:SendMessage', 'sqs:GetQueueAttributes', 'sqs:GetQueueUrl'],
|
|
2378
|
-
resources
|
|
2394
|
+
resources
|
|
2395
|
+
}));
|
|
2396
|
+
}
|
|
2397
|
+
/** Add an XRay tracing policy to the shared role. */
|
|
2398
|
+
addXRayPolicy() {
|
|
2399
|
+
const role = this.requireSharedRole('addXRayPolicy');
|
|
2400
|
+
role.addToPolicy(createXRayTracingPolicy());
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* Add a CloudWatch Logs policy to the shared role.
|
|
2404
|
+
* Grants create, write, describe, read, and query access on all log groups.
|
|
2405
|
+
*/
|
|
2406
|
+
addCloudWatchLogsPolicy() {
|
|
2407
|
+
const role = this.requireSharedRole('addCloudWatchLogsPolicy');
|
|
2408
|
+
role.addToPolicy(new awsIam.PolicyStatement({
|
|
2409
|
+
effect: awsIam.Effect.ALLOW,
|
|
2410
|
+
actions: [
|
|
2411
|
+
'logs:CreateLogGroup',
|
|
2412
|
+
'logs:CreateLogStream',
|
|
2413
|
+
'logs:PutLogEvents',
|
|
2414
|
+
'logs:DescribeLogGroups',
|
|
2415
|
+
'logs:DescribeLogStreams',
|
|
2416
|
+
'logs:FilterLogEvents',
|
|
2417
|
+
'logs:GetLogEvents',
|
|
2418
|
+
'logs:StartQuery',
|
|
2419
|
+
'logs:StopQuery',
|
|
2420
|
+
'logs:GetQueryResults'
|
|
2421
|
+
],
|
|
2422
|
+
resources: ['*']
|
|
2423
|
+
}));
|
|
2424
|
+
}
|
|
2425
|
+
/**
|
|
2426
|
+
* Add an SNS policy to the shared role.
|
|
2427
|
+
* @param options.actions - SNS actions (e.g. ['sns:Publish', 'sns:Subscribe']).
|
|
2428
|
+
* @param options.resources - Resource ARNs. When omitted, defaults to `['*']`
|
|
2429
|
+
* (account-wide access). Prefer `addSnsPublishPolicy(topicArn)` for scoped
|
|
2430
|
+
* publish-only access to a specific topic.
|
|
2431
|
+
*/
|
|
2432
|
+
addSnsPolicy(options) {
|
|
2433
|
+
if (options.actions.length === 0) {
|
|
2434
|
+
throw new Error('addSnsPolicy: actions must not be empty.');
|
|
2435
|
+
}
|
|
2436
|
+
if (options.resources && options.resources.length === 0) {
|
|
2437
|
+
throw new Error('addSnsPolicy: resources must not be empty. Omit the field to grant account-wide access explicitly.');
|
|
2438
|
+
}
|
|
2439
|
+
const role = this.requireSharedRole('addSnsPolicy');
|
|
2440
|
+
if (!options.resources) {
|
|
2441
|
+
cdk.Annotations.of(this).addWarning('addSnsPolicy: no resources specified — policy grants account-wide SNS access on all topics. ' +
|
|
2442
|
+
'Pass resources to scope the policy, or use addSnsPublishPolicy(topicArn) for publish access.');
|
|
2443
|
+
}
|
|
2444
|
+
role.addToPolicy(new awsIam.PolicyStatement({
|
|
2445
|
+
effect: awsIam.Effect.ALLOW,
|
|
2446
|
+
actions: options.actions,
|
|
2447
|
+
resources: options.resources ?? ['*']
|
|
2448
|
+
}));
|
|
2449
|
+
}
|
|
2450
|
+
/**
|
|
2451
|
+
* Add an SQS consumer policy to the shared role.
|
|
2452
|
+
* Grants ChangeMessageVisibility, DeleteMessage, ReceiveMessage, and GetQueueAttributes on each queue.
|
|
2453
|
+
* Use `addSqsSendPolicy` separately for queues the service also produces to.
|
|
2454
|
+
* @param queues - One or more IQueue references and/or short queue names
|
|
2455
|
+
* (string names are prefixed with `{stage}-`).
|
|
2456
|
+
*/
|
|
2457
|
+
addSqsConsumerPolicy(queues) {
|
|
2458
|
+
if (queues.length === 0) {
|
|
2459
|
+
throw new Error('addSqsConsumerPolicy: queues must not be empty.');
|
|
2460
|
+
}
|
|
2461
|
+
const role = this.requireSharedRole('addSqsConsumerPolicy');
|
|
2462
|
+
const resources = queues.map(item => this.resolveResourceArn(item, 'addSqsConsumerPolicy', 'queueName', name => `arn:${cdk.Aws.PARTITION}:sqs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${this.stage}-${name}`, queue => queue.queueArn));
|
|
2463
|
+
role.addToPolicy(new awsIam.PolicyStatement({
|
|
2464
|
+
effect: awsIam.Effect.ALLOW,
|
|
2465
|
+
actions: [
|
|
2466
|
+
'sqs:ChangeMessageVisibility',
|
|
2467
|
+
'sqs:DeleteMessage',
|
|
2468
|
+
'sqs:ReceiveMessage',
|
|
2469
|
+
'sqs:GetQueueAttributes'
|
|
2470
|
+
],
|
|
2471
|
+
resources
|
|
2472
|
+
}));
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Add an execute-api:Invoke policy to the shared role.
|
|
2476
|
+
* Resources are scoped to `*` — API Gateway requires the full execution ARN
|
|
2477
|
+
* (`arn:{partition}:execute-api:{region}:{account}:{api-id}/{stage}/{method}/{path}`)
|
|
2478
|
+
* which is not available as a stable value in migration stacks.
|
|
2479
|
+
*/
|
|
2480
|
+
addExecuteApiPolicy() {
|
|
2481
|
+
const role = this.requireSharedRole('addExecuteApiPolicy');
|
|
2482
|
+
role.addToPolicy(new awsIam.PolicyStatement({
|
|
2483
|
+
effect: awsIam.Effect.ALLOW,
|
|
2484
|
+
actions: ['execute-api:Invoke'],
|
|
2485
|
+
resources: ['*']
|
|
2379
2486
|
}));
|
|
2380
2487
|
}
|
|
2488
|
+
/**
|
|
2489
|
+
* Add an S3 policy to the shared role.
|
|
2490
|
+
* @param bucketOrName - An IBucket reference, or a full bucket name (no stage prefix added).
|
|
2491
|
+
* @param actions - S3 actions to grant. Defaults to `['s3:GetObject', 's3:PutObject',
|
|
2492
|
+
* 's3:DeleteObject', 's3:ListBucket']`. Pass an explicit non-empty list for narrower or broader access.
|
|
2493
|
+
*/
|
|
2494
|
+
addS3Policy(bucketOrName, actions) {
|
|
2495
|
+
if (actions && actions.length === 0) {
|
|
2496
|
+
throw new Error('addS3Policy: actions must not be empty.');
|
|
2497
|
+
}
|
|
2498
|
+
const role = this.requireSharedRole('addS3Policy');
|
|
2499
|
+
const bucketArn = this.resolveResourceArn(bucketOrName, 'addS3Policy', 'bucketName', name => `arn:${cdk.Aws.PARTITION}:s3:::${name}`, bucket => bucket.bucketArn);
|
|
2500
|
+
role.addToPolicy(new awsIam.PolicyStatement({
|
|
2501
|
+
effect: awsIam.Effect.ALLOW,
|
|
2502
|
+
actions: actions ?? ['s3:GetObject', 's3:PutObject', 's3:DeleteObject', 's3:ListBucket'],
|
|
2503
|
+
resources: [bucketArn, `${bucketArn}/*`]
|
|
2504
|
+
}));
|
|
2505
|
+
}
|
|
2506
|
+
/**
|
|
2507
|
+
* Add a DynamoDB policy to the shared role.
|
|
2508
|
+
* Grants read, write, transact, and describe actions on each table.
|
|
2509
|
+
*
|
|
2510
|
+
* String-name ARNs use literal `*` for region and account. This matches the
|
|
2511
|
+
* Serverless Framework's source policy on migrated stacks; replacing the `*`
|
|
2512
|
+
* with `${Aws.REGION}/${Aws.ACCOUNT_ID}` would generate a noisy CloudFormation
|
|
2513
|
+
* diff on first deploy without changing effective access.
|
|
2514
|
+
*
|
|
2515
|
+
* @param tables - One or more ITable references and/or short table names
|
|
2516
|
+
* (string names are prefixed with `{stage}-`).
|
|
2517
|
+
* @param options.streamTables - Tables to also grant stream read access.
|
|
2518
|
+
* Accepts ITable refs (uses tableStreamArn) or string short names.
|
|
2519
|
+
* @param options.indexTables - Tables to also grant Query/Scan on all indexes.
|
|
2520
|
+
* Uses a separate policy statement scoped to only dynamodb:Query and dynamodb:Scan.
|
|
2521
|
+
*/
|
|
2522
|
+
addDynamoDbPolicy(tables, options) {
|
|
2523
|
+
if (tables.length === 0) {
|
|
2524
|
+
throw new Error('addDynamoDbPolicy: tables must not be empty.');
|
|
2525
|
+
}
|
|
2526
|
+
const role = this.requireSharedRole('addDynamoDbPolicy');
|
|
2527
|
+
const tableArns = tables.map(item => this.resolveResourceArn(item, 'addDynamoDbPolicy', 'tableName', name => `arn:${cdk.Aws.PARTITION}:dynamodb:*:*:table/${this.stage}-${name}`, table => table.tableArn));
|
|
2528
|
+
const streamArns = (options?.streamTables ?? []).map(item => this.resolveResourceArn(item, 'addDynamoDbPolicy(streamTables)', 'tableName', name => `arn:${cdk.Aws.PARTITION}:dynamodb:*:*:table/${this.stage}-${name}/stream/*`, table => table.tableStreamArn ?? `${table.tableArn}/stream/*`));
|
|
2529
|
+
role.addToPolicy(new awsIam.PolicyStatement({
|
|
2530
|
+
effect: awsIam.Effect.ALLOW,
|
|
2531
|
+
actions: [
|
|
2532
|
+
'dynamodb:GetItem',
|
|
2533
|
+
'dynamodb:PutItem',
|
|
2534
|
+
'dynamodb:UpdateItem',
|
|
2535
|
+
'dynamodb:DeleteItem',
|
|
2536
|
+
'dynamodb:Query',
|
|
2537
|
+
'dynamodb:Scan',
|
|
2538
|
+
'dynamodb:BatchGetItem',
|
|
2539
|
+
'dynamodb:BatchWriteItem',
|
|
2540
|
+
'dynamodb:TransactGetItems',
|
|
2541
|
+
'dynamodb:TransactWriteItems',
|
|
2542
|
+
'dynamodb:DescribeTable',
|
|
2543
|
+
'dynamodb:DescribeStream',
|
|
2544
|
+
'dynamodb:GetRecords',
|
|
2545
|
+
'dynamodb:GetShardIterator',
|
|
2546
|
+
'dynamodb:ListStreams'
|
|
2547
|
+
],
|
|
2548
|
+
resources: [...tableArns, ...streamArns]
|
|
2549
|
+
}));
|
|
2550
|
+
const indexArns = (options?.indexTables ?? []).map(item => this.resolveResourceArn(item, 'addDynamoDbPolicy(indexTables)', 'tableName', name => `arn:${cdk.Aws.PARTITION}:dynamodb:*:*:table/${this.stage}-${name}/index/*`, table => `${table.tableArn}/index/*`));
|
|
2551
|
+
if (indexArns.length > 0) {
|
|
2552
|
+
role.addToPolicy(new awsIam.PolicyStatement({
|
|
2553
|
+
effect: awsIam.Effect.ALLOW,
|
|
2554
|
+
actions: ['dynamodb:Query', 'dynamodb:Scan'],
|
|
2555
|
+
resources: indexArns,
|
|
2556
|
+
}));
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2381
2559
|
requireSharedRole(methodName) {
|
|
2382
2560
|
if (!this.sharedRole) {
|
|
2383
2561
|
throw new Error(`${methodName}() requires useSharedRole: true on the stack.`);
|
|
2384
2562
|
}
|
|
2563
|
+
return this.sharedRole;
|
|
2564
|
+
}
|
|
2565
|
+
/**
|
|
2566
|
+
* Resolve a CDK resource reference or short name into an ARN string.
|
|
2567
|
+
* Centralises empty-input validation so callers (add*Policy methods)
|
|
2568
|
+
* fail loud at synth instead of producing a malformed ARN that
|
|
2569
|
+
* CloudFormation later rejects with a generic error.
|
|
2570
|
+
*/
|
|
2571
|
+
resolveResourceArn(refOrName, methodName, nameLabel, fromName, fromRef) {
|
|
2572
|
+
if (typeof refOrName === 'string') {
|
|
2573
|
+
if (refOrName.trim() === '') {
|
|
2574
|
+
throw new Error(`${methodName}: ${nameLabel} must not be empty.`);
|
|
2575
|
+
}
|
|
2576
|
+
return fromName(refOrName);
|
|
2577
|
+
}
|
|
2578
|
+
return fromRef(refOrName);
|
|
2579
|
+
}
|
|
2580
|
+
/**
|
|
2581
|
+
* Blocks synth with an error when a custom role is passed in migration mode.
|
|
2582
|
+
* The Lambda logical ID is overridden to match Serverless naming, but the
|
|
2583
|
+
* custom role's logical ID will NOT be pinned — CloudFormation may replace it.
|
|
2584
|
+
* Uses addError (not addWarning) so the misconfiguration is caught before a
|
|
2585
|
+
* changeset is created.
|
|
2586
|
+
* Called from createFunction, createQueueConsumer, and createTopicQueueConsumer.
|
|
2587
|
+
*/
|
|
2588
|
+
errorIfRoleOverrideInMigrationMode(scope, callerMethod, functionName, role) {
|
|
2589
|
+
if (this.sharedRole && role && role !== this.sharedRole) {
|
|
2590
|
+
cdk.Annotations.of(scope).addError(`${callerMethod}("${functionName}"): a custom role was provided in migration mode (useSharedRole: true). ` +
|
|
2591
|
+
'The Lambda logical ID will be overridden to match Serverless naming, but the custom role\'s logical ID ' +
|
|
2592
|
+
'will NOT be pinned to IamRoleLambdaExecution — CloudFormation may replace the role on deploy. ' +
|
|
2593
|
+
'Pass the shared role via the stack\'s sharedRole property instead, or omit config.role.');
|
|
2594
|
+
}
|
|
2385
2595
|
}
|
|
2386
2596
|
/**
|
|
2387
2597
|
* Create a CfnOutput with a stable export name.
|
|
@@ -2403,31 +2613,36 @@ class EmStack extends cdk.Stack {
|
|
|
2403
2613
|
}
|
|
2404
2614
|
|
|
2405
2615
|
function createRdsVpcConfig(scope, stage, config) {
|
|
2406
|
-
const vpc =
|
|
2616
|
+
const vpc = ec2__namespace.Vpc.fromLookup(scope, `RdsVpc-${stage}`, {
|
|
2407
2617
|
vpcId: config.vpcId
|
|
2408
2618
|
});
|
|
2409
2619
|
// The imported subnets are only handed to Lambda's VpcConfig. We never read
|
|
2410
2620
|
// `subnet.routeTable.routeTableId`, so acknowledge the CDK warning that
|
|
2411
2621
|
// `fromSubnetId` emits for imports without route-table metadata.
|
|
2412
2622
|
const privateSubnets = config.privateSubnetIds.map((subnetId, index) => {
|
|
2413
|
-
const subnet =
|
|
2623
|
+
const subnet = ec2__namespace.Subnet.fromSubnetId(scope, `RdsPrivateSubnet${index}-${stage}`, subnetId);
|
|
2414
2624
|
cdk.Annotations.of(subnet).acknowledgeWarning('@aws-cdk/aws-ec2:noSubnetRouteTableId', 'This construct only passes imported subnets to Lambda VpcConfig and does not require routeTableId metadata.');
|
|
2415
2625
|
return subnet;
|
|
2416
2626
|
});
|
|
2417
|
-
const lambdaSecurityGroup = new
|
|
2627
|
+
const lambdaSecurityGroup = new ec2__namespace.SecurityGroup(scope, `RdsLambdaSecurityGroup-${stage}`, {
|
|
2418
2628
|
vpc,
|
|
2419
2629
|
description: config.securityGroupDescription ?? 'Lambda security group for RDS access',
|
|
2420
2630
|
allowAllOutbound: true
|
|
2421
2631
|
});
|
|
2422
|
-
if (config.overrideLogicalIds?.securityGroup) {
|
|
2632
|
+
if (config.overrideLogicalIds?.securityGroup || config.manageSgEgress === false) {
|
|
2423
2633
|
const cfnSg = lambdaSecurityGroup.node.defaultChild;
|
|
2424
|
-
if (!(cfnSg instanceof
|
|
2425
|
-
throw new Error(
|
|
2426
|
-
'
|
|
2634
|
+
if (!(cfnSg instanceof ec2__namespace.CfnSecurityGroup)) {
|
|
2635
|
+
throw new Error('Security group does not have a CfnSecurityGroup default child — ' +
|
|
2636
|
+
'cannot apply overrideLogicalIds.securityGroup or manageSgEgress.');
|
|
2637
|
+
}
|
|
2638
|
+
if (config.overrideLogicalIds?.securityGroup) {
|
|
2639
|
+
cfnSg.overrideLogicalId(config.overrideLogicalIds.securityGroup);
|
|
2640
|
+
}
|
|
2641
|
+
if (config.manageSgEgress === false) {
|
|
2642
|
+
cfnSg.addPropertyDeletionOverride('SecurityGroupEgress');
|
|
2427
2643
|
}
|
|
2428
|
-
cfnSg.overrideLogicalId(config.overrideLogicalIds.securityGroup);
|
|
2429
2644
|
}
|
|
2430
|
-
const ingress = new
|
|
2645
|
+
const ingress = new ec2__namespace.CfnSecurityGroupIngress(scope, `RdsIngress-${stage}`, {
|
|
2431
2646
|
groupId: config.dbSecurityGroupId,
|
|
2432
2647
|
ipProtocol: 'tcp',
|
|
2433
2648
|
fromPort: config.dbPort ?? 3306,
|
|
@@ -2438,6 +2653,15 @@ function createRdsVpcConfig(scope, stage, config) {
|
|
|
2438
2653
|
if (config.overrideLogicalIds?.ingress) {
|
|
2439
2654
|
ingress.overrideLogicalId(config.overrideLogicalIds.ingress);
|
|
2440
2655
|
}
|
|
2656
|
+
if (config.overrideLogicalIds?.securityGroup) {
|
|
2657
|
+
// When the SG logical ID is pinned, CDK's auto-generated `Ref` on the
|
|
2658
|
+
// ingress points at the original (CDK-generated) logical ID. CloudFormation
|
|
2659
|
+
// can't resolve that reference at deploy and the changeset fails. Rewrite
|
|
2660
|
+
// the Ref to the overridden ID.
|
|
2661
|
+
ingress.addPropertyOverride('SourceSecurityGroupId', {
|
|
2662
|
+
Ref: config.overrideLogicalIds.securityGroup
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2441
2665
|
if (config.sharedRole) {
|
|
2442
2666
|
config.sharedRole.addManagedPolicy(awsIam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
|
|
2443
2667
|
}
|
|
@@ -2462,7 +2686,7 @@ function createRdsVpcConfig(scope, stage, config) {
|
|
|
2462
2686
|
* ```
|
|
2463
2687
|
*/
|
|
2464
2688
|
const createEmApp = (options) => {
|
|
2465
|
-
const app = new
|
|
2689
|
+
const app = new cdk__namespace.App({
|
|
2466
2690
|
context: {
|
|
2467
2691
|
...cxApi.CURRENTLY_RECOMMENDED_FLAGS,
|
|
2468
2692
|
...options?.context
|
|
@@ -2508,7 +2732,7 @@ function getAccountRdsVpcConfig(stage) {
|
|
|
2508
2732
|
dbSecurityGroupId: 'sg-427bda39'
|
|
2509
2733
|
};
|
|
2510
2734
|
default:
|
|
2511
|
-
throw new Error(`
|
|
2735
|
+
throw new Error(`getAccountRdsVpcConfig: unsupported stage "${stage}". Only 'dev' and 'prod' are supported.`);
|
|
2512
2736
|
}
|
|
2513
2737
|
}
|
|
2514
2738
|
|
|
@@ -2531,41 +2755,26 @@ exports.ServiceLambdaWithQueue = ServiceLambdaWithQueue;
|
|
|
2531
2755
|
exports.TopicQueueConsumer = TopicQueueConsumer;
|
|
2532
2756
|
exports.applyStandardTags = applyStandardTags;
|
|
2533
2757
|
exports.applyTags = applyTags;
|
|
2758
|
+
exports.buildBaseEnvironment = buildBaseEnvironment;
|
|
2534
2759
|
exports.buildRecapDevEnvironment = buildRecapDevEnvironment;
|
|
2535
2760
|
exports.convertRetentionDays = convertRetentionDays;
|
|
2536
2761
|
exports.createApiGatewayLogGroup = createApiGatewayLogGroup;
|
|
2537
|
-
exports.createCloudWatchLogsPolicy = createCloudWatchLogsPolicy;
|
|
2538
|
-
exports.createDynamoDBAccessPolicy = createDynamoDBAccessPolicy;
|
|
2539
|
-
exports.createDynamoDBReadPolicy = createDynamoDBReadPolicy;
|
|
2540
|
-
exports.createDynamoDBWritePolicy = createDynamoDBWritePolicy;
|
|
2541
2762
|
exports.createEmApp = createEmApp;
|
|
2542
|
-
exports.createEventBridgePutEventsPolicy = createEventBridgePutEventsPolicy;
|
|
2543
2763
|
exports.createEventBridgeRule = createEventBridgeRule;
|
|
2544
2764
|
exports.createEventPatternRule = createEventPatternRule;
|
|
2545
2765
|
exports.createFifoQueue = createFifoQueue;
|
|
2546
2766
|
exports.createFifoTopic = createFifoTopic;
|
|
2547
2767
|
exports.createHttpApi = createHttpApi;
|
|
2548
|
-
exports.createKMSDecryptPolicy = createKMSDecryptPolicy;
|
|
2549
2768
|
exports.createKeyValueTable = createKeyValueTable;
|
|
2550
2769
|
exports.createLambdaExecutionRole = createLambdaExecutionRole;
|
|
2551
|
-
exports.createLambdaInvokePolicy = createLambdaInvokePolicy;
|
|
2552
|
-
exports.createLambdaLogGroup = createLambdaLogGroup;
|
|
2553
2770
|
exports.createLogGroup = createLogGroup;
|
|
2554
|
-
exports.createPolicyDocument = createPolicyDocument;
|
|
2555
2771
|
exports.createQueue = createQueue;
|
|
2556
2772
|
exports.createQueueWithDLQ = createQueueWithDLQ;
|
|
2557
2773
|
exports.createRdsVpcConfig = createRdsVpcConfig;
|
|
2558
2774
|
exports.createRestApi = createRestApi;
|
|
2559
|
-
exports.createS3AccessPolicy = createS3AccessPolicy;
|
|
2560
|
-
exports.createS3ReadPolicy = createS3ReadPolicy;
|
|
2561
|
-
exports.createSNSPublishPolicy = createSNSPublishPolicy;
|
|
2562
|
-
exports.createSQSAccessPolicy = createSQSAccessPolicy;
|
|
2563
|
-
exports.createSSMParameterPolicy = createSSMParameterPolicy;
|
|
2564
2775
|
exports.createScheduledRule = createScheduledRule;
|
|
2565
|
-
exports.createSecretsManagerPolicy = createSecretsManagerPolicy;
|
|
2566
2776
|
exports.createServerlessCompatibleOutput = createServerlessCompatibleOutput;
|
|
2567
2777
|
exports.createSingleTable = createSingleTable;
|
|
2568
|
-
exports.createStepFunctionsExecutionPolicy = createStepFunctionsExecutionPolicy;
|
|
2569
2778
|
exports.createTopic = createTopic;
|
|
2570
2779
|
exports.createXRayTracingPolicy = createXRayTracingPolicy;
|
|
2571
2780
|
exports.generateApiName = generateApiName;
|
|
@@ -2580,29 +2789,23 @@ exports.generateStandardTags = generateStandardTags;
|
|
|
2580
2789
|
exports.generateTableName = generateTableName;
|
|
2581
2790
|
exports.generateTopicName = generateTopicName;
|
|
2582
2791
|
exports.getAccountRdsVpcConfig = getAccountRdsVpcConfig;
|
|
2583
|
-
exports.getAlarmThresholds = getAlarmThresholds;
|
|
2584
2792
|
exports.getComplianceTags = getComplianceTags;
|
|
2585
2793
|
exports.getCostAllocationTags = getCostAllocationTags;
|
|
2586
|
-
exports.getEnvironmentConfig = getEnvironmentConfig;
|
|
2587
2794
|
exports.getEnvironmentTags = getEnvironmentTags;
|
|
2588
|
-
exports.getLambdaEnvironmentVariables = getLambdaEnvironmentVariables;
|
|
2589
2795
|
exports.getLogRetentionDays = getLogRetentionDays;
|
|
2590
2796
|
exports.getRemovalPolicy = getRemovalPolicy;
|
|
2591
|
-
exports.getRequiredEnvVar = getRequiredEnvVar;
|
|
2592
|
-
exports.getResourceLimits = getResourceLimits;
|
|
2593
|
-
exports.getStageEnvVar = getStageEnvVar;
|
|
2594
|
-
exports.getStageFromEnv = getStageFromEnv;
|
|
2595
|
-
exports.getTracingConfig = getTracingConfig;
|
|
2596
|
-
exports.isDevelopment = isDevelopment;
|
|
2597
|
-
exports.isProduction = isProduction;
|
|
2598
2797
|
exports.isValidStage = isValidStage;
|
|
2798
|
+
exports.makeServerlessDynamoTable = makeServerlessDynamoTable;
|
|
2799
|
+
exports.makeServerlessQueue = makeServerlessQueue;
|
|
2800
|
+
exports.makeServerlessQueuePolicy = makeServerlessQueuePolicy;
|
|
2801
|
+
exports.makeSnsToLambdaSubscription = makeSnsToLambdaSubscription;
|
|
2802
|
+
exports.makeSnsToSqsSubscription = makeSnsToSqsSubscription;
|
|
2599
2803
|
exports.mergeTags = mergeTags;
|
|
2600
2804
|
exports.overrideFunctionLogicalIds = overrideFunctionLogicalIds;
|
|
2601
2805
|
exports.overrideLayerLogicalId = overrideLayerLogicalId;
|
|
2602
2806
|
exports.overrideRoleLogicalId = overrideRoleLogicalId;
|
|
2603
2807
|
exports.resolveHandlerPath = resolveHandlerPath;
|
|
2604
2808
|
exports.resolveRecapDevEndpoint = resolveRecapDevEndpoint;
|
|
2605
|
-
exports.shouldEnableLogInsights = shouldEnableLogInsights;
|
|
2606
2809
|
exports.stageToUpperCase = stageToUpperCase;
|
|
2607
2810
|
exports.toServerlessLogicalIdPrefix = toServerlessLogicalIdPrefix;
|
|
2608
2811
|
//# sourceMappingURL=index.js.map
|