@mettlecast/domain-cdk-packer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/dist/DomainStack.d.ts +68 -0
- package/dist/DomainStack.js +567 -0
- package/dist/__tests__/domain-stack.test.d.ts +1 -0
- package/dist/__tests__/domain-stack.test.js +223 -0
- package/dist/__tests__/lambda-factory.test.d.ts +1 -0
- package/dist/__tests__/lambda-factory.test.js +76 -0
- package/dist/__tests__/pack-flows.test.d.ts +1 -0
- package/dist/__tests__/pack-flows.test.js +283 -0
- package/dist/__tests__/registry.test.d.ts +1 -0
- package/dist/__tests__/registry.test.js +34 -0
- package/dist/__tests__/step-functions-codegen.test.d.ts +1 -0
- package/dist/__tests__/step-functions-codegen.test.js +159 -0
- package/dist/aspects/iam-boundaries-aspect.d.ts +11 -0
- package/dist/aspects/iam-boundaries-aspect.js +15 -0
- package/dist/aspects/index.d.ts +5 -0
- package/dist/aspects/index.js +3 -0
- package/dist/aspects/log-retention-aspect.d.ts +8 -0
- package/dist/aspects/log-retention-aspect.js +24 -0
- package/dist/aspects/tagging-aspect.d.ts +13 -0
- package/dist/aspects/tagging-aspect.js +17 -0
- package/dist/constructs/action-construct.d.ts +35 -0
- package/dist/constructs/action-construct.js +35 -0
- package/dist/constructs/alarm-construct.d.ts +19 -0
- package/dist/constructs/alarm-construct.js +58 -0
- package/dist/constructs/alarms-construct.d.ts +17 -0
- package/dist/constructs/alarms-construct.js +42 -0
- package/dist/constructs/api-construct.d.ts +30 -0
- package/dist/constructs/api-construct.js +64 -0
- package/dist/constructs/canary-construct.d.ts +13 -0
- package/dist/constructs/canary-construct.js +30 -0
- package/dist/constructs/cmk-construct.d.ts +9 -0
- package/dist/constructs/cmk-construct.js +20 -0
- package/dist/constructs/dashboard-construct.d.ts +15 -0
- package/dist/constructs/dashboard-construct.js +62 -0
- package/dist/constructs/health-construct.d.ts +11 -0
- package/dist/constructs/health-construct.js +29 -0
- package/dist/constructs/job-construct.d.ts +33 -0
- package/dist/constructs/job-construct.js +54 -0
- package/dist/constructs/schedule-construct.d.ts +27 -0
- package/dist/constructs/schedule-construct.js +54 -0
- package/dist/constructs/subscriber-construct.d.ts +30 -0
- package/dist/constructs/subscriber-construct.js +63 -0
- package/dist/constructs/waf-construct.d.ts +13 -0
- package/dist/constructs/waf-construct.js +75 -0
- package/dist/constructs/webhook-construct.d.ts +33 -0
- package/dist/constructs/webhook-construct.js +50 -0
- package/dist/flow-registry.d.ts +81 -0
- package/dist/flow-registry.js +2 -0
- package/dist/grouped-lambda-factory.d.ts +43 -0
- package/dist/grouped-lambda-factory.js +71 -0
- package/dist/iam/iam-policy-builder.d.ts +95 -0
- package/dist/iam/iam-policy-builder.js +232 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +17 -0
- package/dist/lambda-factory.d.ts +46 -0
- package/dist/lambda-factory.js +80 -0
- package/dist/pack-domain.d.ts +35 -0
- package/dist/pack-domain.js +32 -0
- package/dist/pack-flows.d.ts +28 -0
- package/dist/pack-flows.js +154 -0
- package/dist/registry.d.ts +224 -0
- package/dist/registry.js +1 -0
- package/dist/step-functions-codegen.d.ts +65 -0
- package/dist/step-functions-codegen.js +55 -0
- package/package.json +36 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import * as cdk from 'aws-cdk-lib';
|
|
2
|
+
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
|
|
3
|
+
import * as apigwv2integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
|
4
|
+
import * as apigwv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
|
|
5
|
+
import * as events from 'aws-cdk-lib/aws-events';
|
|
6
|
+
import * as eventsTargets from 'aws-cdk-lib/aws-events-targets';
|
|
7
|
+
import * as sqs from 'aws-cdk-lib/aws-sqs';
|
|
8
|
+
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
|
|
9
|
+
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
|
|
10
|
+
import * as iam from 'aws-cdk-lib/aws-iam';
|
|
11
|
+
import * as scheduler from 'aws-cdk-lib/aws-scheduler';
|
|
12
|
+
import * as s3 from 'aws-cdk-lib/aws-s3';
|
|
13
|
+
import { createGroupedLambdas } from './grouped-lambda-factory.js';
|
|
14
|
+
import { IamPolicyBuilder } from './iam/iam-policy-builder.js';
|
|
15
|
+
import { HealthConstruct } from './constructs/health-construct.js';
|
|
16
|
+
import { DashboardConstruct } from './constructs/dashboard-construct.js';
|
|
17
|
+
import { CmkConstruct } from './constructs/cmk-construct.js';
|
|
18
|
+
import { WafConstruct } from './constructs/waf-construct.js';
|
|
19
|
+
import { AlarmConstruct } from './constructs/alarm-construct.js';
|
|
20
|
+
import { CanaryConstruct } from './constructs/canary-construct.js';
|
|
21
|
+
/**
|
|
22
|
+
* Top-level CDK Stack that composes all domain constructs from a single DomainRegistry input.
|
|
23
|
+
* Uses grouped Lambdas for each primitive type to reduce deployment artifact size.
|
|
24
|
+
*/
|
|
25
|
+
export class DomainStack extends cdk.Stack {
|
|
26
|
+
/** Shared HTTP API for routing API and webhook requests. */
|
|
27
|
+
httpApi;
|
|
28
|
+
/** Map of primitive-type key to Lambda function ARN, used by FlowsStack for direct invocation. */
|
|
29
|
+
lambdaArns = {};
|
|
30
|
+
/**
|
|
31
|
+
* Create a new DomainStack instance.
|
|
32
|
+
* @param scope - Parent CDK scope.
|
|
33
|
+
* @param id - Stack identifier.
|
|
34
|
+
* @param props - Stack properties including domain registry and event bus ARN.
|
|
35
|
+
*/
|
|
36
|
+
constructor(scope, id, props) {
|
|
37
|
+
super(scope, id, props);
|
|
38
|
+
const { registry, eventBusArn, databaseUrl, dbSecretArn, userPoolArn, userPoolClientId, vpc, lambdaSg, enableCmk, enableWaf, enableAlarms, alarmSnsTopicArn, enableCanaryDeploy, reservedConcurrency, logRetentionDays, corsAllowedOrigins, allowCredentials } = props;
|
|
39
|
+
const domainId = registry.domain.id;
|
|
40
|
+
const vpcProps = vpc ? { vpc, securityGroups: lambdaSg ? [lambdaSg] : undefined } : {};
|
|
41
|
+
const allowedOrigins = corsAllowedOrigins ?? ['*'];
|
|
42
|
+
this.httpApi = new apigwv2.HttpApi(this, 'HttpApi', {
|
|
43
|
+
apiName: `${domainId}-api`,
|
|
44
|
+
corsPreflight: {
|
|
45
|
+
allowHeaders: ['Content-Type', 'Authorization'],
|
|
46
|
+
allowMethods: [apigwv2.CorsHttpMethod.ANY],
|
|
47
|
+
allowOrigins: allowedOrigins,
|
|
48
|
+
...(allowCredentials ? { allowCredentials: true } : {}),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
// Wire Cognito JWT authorizer when user pool is provided
|
|
52
|
+
let jwtAuthorizer;
|
|
53
|
+
if (userPoolArn && userPoolClientId) {
|
|
54
|
+
const region = cdk.Stack.of(this).region;
|
|
55
|
+
const userPoolId = cdk.Fn.select(1, cdk.Fn.split('/', userPoolArn));
|
|
56
|
+
jwtAuthorizer = new apigwv2Authorizers.HttpJwtAuthorizer('CognitoAuthorizer', `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`, {
|
|
57
|
+
jwtAudience: [userPoolClientId],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// Optional WAF
|
|
61
|
+
if (enableWaf) {
|
|
62
|
+
new WafConstruct(this, 'Waf', { domainId, httpApi: this.httpApi });
|
|
63
|
+
}
|
|
64
|
+
// Optional CMK — customer-managed encryption key for DynamoDB, S3, SQS
|
|
65
|
+
let cmkKey;
|
|
66
|
+
if (enableCmk) {
|
|
67
|
+
const cmk = new CmkConstruct(this, 'Cmk', { domainId });
|
|
68
|
+
cmkKey = cmk.key;
|
|
69
|
+
}
|
|
70
|
+
const iamBuilder = new IamPolicyBuilder();
|
|
71
|
+
const eventBus = events.EventBus.fromEventBusArn(this, 'TibEventBus', eventBusArn);
|
|
72
|
+
// Build common environment for all Lambdas
|
|
73
|
+
const environment = {
|
|
74
|
+
TIB_DOMAIN_ID: domainId,
|
|
75
|
+
TIB_EVENT_BUS_ARN: eventBusArn,
|
|
76
|
+
};
|
|
77
|
+
if (databaseUrl) {
|
|
78
|
+
environment.DATABASE_URL = databaseUrl;
|
|
79
|
+
}
|
|
80
|
+
if (dbSecretArn) {
|
|
81
|
+
environment.DB_SECRET_ARN = dbSecretArn;
|
|
82
|
+
}
|
|
83
|
+
// Per-domain DynamoDB table — PK=tenantId (tenant is primary partition), SK=entity key
|
|
84
|
+
const domainTable = new dynamodb.Table(this, 'DomainTable', {
|
|
85
|
+
tableName: `tib-${domainId}`,
|
|
86
|
+
partitionKey: { name: 'tenantId', type: dynamodb.AttributeType.STRING },
|
|
87
|
+
sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
|
|
88
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
89
|
+
encryption: enableCmk ? dynamodb.TableEncryption.CUSTOMER_MANAGED : dynamodb.TableEncryption.AWS_MANAGED,
|
|
90
|
+
encryptionKey: cmkKey,
|
|
91
|
+
timeToLiveAttribute: 'expiresAt',
|
|
92
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
93
|
+
pointInTimeRecovery: true,
|
|
94
|
+
});
|
|
95
|
+
// Per-domain S3 bucket — all objects prefixed {tenantId}/ enforced in runtime
|
|
96
|
+
const bucketName = `tib-${domainId}`.length <= 63
|
|
97
|
+
? `tib-${domainId}`
|
|
98
|
+
: `tib-${domainId.slice(0, 55)}-${cdk.Fn.select(0, cdk.Fn.split('-', cdk.Names.uniqueId(this))).toLowerCase()}`;
|
|
99
|
+
const domainBucket = new s3.Bucket(this, 'DomainBucket', {
|
|
100
|
+
bucketName,
|
|
101
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
102
|
+
encryption: enableCmk ? s3.BucketEncryption.KMS : s3.BucketEncryption.S3_MANAGED,
|
|
103
|
+
encryptionKey: cmkKey,
|
|
104
|
+
versioned: true,
|
|
105
|
+
enforceSSL: true,
|
|
106
|
+
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
107
|
+
lifecycleRules: [{
|
|
108
|
+
noncurrentVersionExpiration: cdk.Duration.days(90),
|
|
109
|
+
}],
|
|
110
|
+
});
|
|
111
|
+
environment['DOMAIN_TABLE_NAME'] = domainTable.tableName;
|
|
112
|
+
environment['DOMAIN_BUCKET_NAME'] = domainBucket.bucketName;
|
|
113
|
+
// Per-domain tenant-scoped IAM role for defence-in-depth storage segregation
|
|
114
|
+
const tenantScopedRole = new iam.Role(this, 'DomainTenantScopedRole', {
|
|
115
|
+
roleName: `tib-${domainId}-tenant-scoped`,
|
|
116
|
+
assumedBy: new iam.AccountPrincipal(cdk.Stack.of(this).account),
|
|
117
|
+
description: `Tenant-scoped role for ${domainId} domain. Only assumable with tenantId session tag.`,
|
|
118
|
+
maxSessionDuration: cdk.Duration.hours(1),
|
|
119
|
+
});
|
|
120
|
+
environment['DOMAIN_TENANT_ROLE_ARN'] = tenantScopedRole.roleArn;
|
|
121
|
+
// Pre-declare lambda group variables
|
|
122
|
+
let apiLambdas = [];
|
|
123
|
+
let webhookLambdas = [];
|
|
124
|
+
let subscriberLambdas = [];
|
|
125
|
+
let scheduleLambdas = [];
|
|
126
|
+
let jobLambdas = [];
|
|
127
|
+
let actionLambdas = [];
|
|
128
|
+
// Track all DLQs for alarm construct
|
|
129
|
+
const allDlqs = [];
|
|
130
|
+
// Deploy API endpoints as grouped Lambdas
|
|
131
|
+
if (registry.apis.length > 0) {
|
|
132
|
+
apiLambdas = createGroupedLambdas(this, {
|
|
133
|
+
domainId,
|
|
134
|
+
primitiveType: 'api',
|
|
135
|
+
handlerEntries: registry.apis.map(api => ({
|
|
136
|
+
id: api.id,
|
|
137
|
+
handlerFile: api.handlerFile,
|
|
138
|
+
})),
|
|
139
|
+
environment,
|
|
140
|
+
eventBusArn,
|
|
141
|
+
dedicated: registry.apis.some(api => api.deployment?.isolation === 'dedicated'),
|
|
142
|
+
reservedConcurrency,
|
|
143
|
+
logRetentionDays: logRetentionDays ?? 30,
|
|
144
|
+
...vpcProps,
|
|
145
|
+
});
|
|
146
|
+
this.lambdaArns[`${domainId}-api`] = apiLambdas[0].functionArn;
|
|
147
|
+
// Wire each API Lambda to the HttpApi
|
|
148
|
+
const iamPolicies = iamBuilder.forApi();
|
|
149
|
+
apiLambdas.forEach((fn) => {
|
|
150
|
+
iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
151
|
+
});
|
|
152
|
+
// Add dbSecretArn grant if provided
|
|
153
|
+
if (dbSecretArn) {
|
|
154
|
+
apiLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
155
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
156
|
+
resources: [dbSecretArn],
|
|
157
|
+
})));
|
|
158
|
+
}
|
|
159
|
+
// Add routes for each API entry
|
|
160
|
+
for (const api of registry.apis) {
|
|
161
|
+
const fn = apiLambdas[0]; // Grouped Lambda or first dedicated
|
|
162
|
+
const apiRouteBase = {
|
|
163
|
+
path: api.path,
|
|
164
|
+
methods: [toHttpMethod(api.method)],
|
|
165
|
+
integration: new apigwv2integrations.HttpLambdaIntegration(`Apis${toPascalCase(api.id)}Integration`, fn),
|
|
166
|
+
};
|
|
167
|
+
let apiRoute;
|
|
168
|
+
if (api.authType === 'jwt' && jwtAuthorizer) {
|
|
169
|
+
apiRoute = { ...apiRouteBase, authorizer: jwtAuthorizer };
|
|
170
|
+
}
|
|
171
|
+
else if (api.authType === 'api-key') {
|
|
172
|
+
// TODO(#1785): wire HttpApiKeyAuthorizer
|
|
173
|
+
apiRoute = apiRouteBase;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
apiRoute = apiRouteBase;
|
|
177
|
+
}
|
|
178
|
+
this.httpApi.addRoutes(apiRoute);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Deploy webhooks as grouped Lambdas
|
|
182
|
+
if (registry.webhooks.length > 0) {
|
|
183
|
+
// Create shared dedupe table for webhooks
|
|
184
|
+
const dedupeTable = new dynamodb.Table(this, 'WebhookDedupeTable', {
|
|
185
|
+
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
|
|
186
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
187
|
+
timeToLiveAttribute: 'expiresAt',
|
|
188
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
189
|
+
});
|
|
190
|
+
webhookLambdas = createGroupedLambdas(this, {
|
|
191
|
+
domainId,
|
|
192
|
+
primitiveType: 'webhook',
|
|
193
|
+
handlerEntries: registry.webhooks.map(webhook => ({
|
|
194
|
+
id: webhook.id,
|
|
195
|
+
handlerFile: webhook.handlerFile,
|
|
196
|
+
})),
|
|
197
|
+
environment,
|
|
198
|
+
eventBusArn,
|
|
199
|
+
dedicated: registry.webhooks.some(webhook => webhook.deployment?.isolation === 'dedicated'),
|
|
200
|
+
reservedConcurrency,
|
|
201
|
+
logRetentionDays: logRetentionDays ?? 30,
|
|
202
|
+
...vpcProps,
|
|
203
|
+
});
|
|
204
|
+
this.lambdaArns[`${domainId}-webhook`] = webhookLambdas[0].functionArn;
|
|
205
|
+
// Wire each webhook Lambda to the HttpApi
|
|
206
|
+
const iamPolicies = iamBuilder.forWebhook({ dedupeTableArn: dedupeTable.tableArn });
|
|
207
|
+
webhookLambdas.forEach((fn) => {
|
|
208
|
+
iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
209
|
+
dedupeTable.grantReadWriteData(fn);
|
|
210
|
+
});
|
|
211
|
+
// Add dbSecretArn grant if provided
|
|
212
|
+
if (dbSecretArn) {
|
|
213
|
+
webhookLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
214
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
215
|
+
resources: [dbSecretArn],
|
|
216
|
+
})));
|
|
217
|
+
}
|
|
218
|
+
// Add routes for each webhook entry
|
|
219
|
+
for (const webhook of registry.webhooks) {
|
|
220
|
+
const fn = webhookLambdas[0]; // Grouped Lambda or first dedicated
|
|
221
|
+
this.httpApi.addRoutes({
|
|
222
|
+
path: webhook.path,
|
|
223
|
+
methods: [apigwv2.HttpMethod.POST],
|
|
224
|
+
integration: new apigwv2integrations.HttpLambdaIntegration(`Webhooks${toPascalCase(webhook.id)}Integration`, fn),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Deploy event subscribers as grouped Lambdas
|
|
229
|
+
if (registry.subscribers.length > 0) {
|
|
230
|
+
subscriberLambdas = createGroupedLambdas(this, {
|
|
231
|
+
domainId,
|
|
232
|
+
primitiveType: 'subscriber',
|
|
233
|
+
handlerEntries: registry.subscribers.map(subscriber => ({
|
|
234
|
+
id: subscriber.id,
|
|
235
|
+
handlerFile: subscriber.handlerFile,
|
|
236
|
+
})),
|
|
237
|
+
environment,
|
|
238
|
+
eventBusArn,
|
|
239
|
+
dedicated: registry.subscribers.some(subscriber => subscriber.deployment?.isolation === 'dedicated'),
|
|
240
|
+
reservedConcurrency,
|
|
241
|
+
logRetentionDays: logRetentionDays ?? 30,
|
|
242
|
+
...vpcProps,
|
|
243
|
+
});
|
|
244
|
+
this.lambdaArns[`${domainId}-subscriber`] = subscriberLambdas[0].functionArn;
|
|
245
|
+
// Wire EventBridge → SQS → Lambda for each subscriber
|
|
246
|
+
for (const subscriber of registry.subscribers) {
|
|
247
|
+
const pascalId = toPascalCase(subscriber.id);
|
|
248
|
+
// Create dead-letter queue
|
|
249
|
+
const dlq = new sqs.Queue(this, `${pascalId}Dlq`, {
|
|
250
|
+
retentionPeriod: cdk.Duration.days(14),
|
|
251
|
+
encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
|
|
252
|
+
encryptionMasterKey: enableCmk ? cmkKey : undefined,
|
|
253
|
+
});
|
|
254
|
+
allDlqs.push(dlq);
|
|
255
|
+
// Create main queue with dead-letter queue configuration
|
|
256
|
+
const queue = new sqs.Queue(this, `${pascalId}Queue`, {
|
|
257
|
+
visibilityTimeout: cdk.Duration.seconds(180),
|
|
258
|
+
deadLetterQueue: {
|
|
259
|
+
queue: dlq,
|
|
260
|
+
maxReceiveCount: 5,
|
|
261
|
+
},
|
|
262
|
+
encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
|
|
263
|
+
encryptionMasterKey: enableCmk ? cmkKey : undefined,
|
|
264
|
+
});
|
|
265
|
+
// Add SQS as event source for the first grouped Lambda
|
|
266
|
+
const fn = subscriberLambdas[0];
|
|
267
|
+
fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
|
|
268
|
+
batchSize: 10,
|
|
269
|
+
maxConcurrency: subscriber.concurrency ?? 5,
|
|
270
|
+
}));
|
|
271
|
+
// Grant IAM permissions for this queue
|
|
272
|
+
const queuePolicies = iamBuilder.forSubscriber({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
|
|
273
|
+
queuePolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
274
|
+
// Create EventBridge rule targeting SQS queue
|
|
275
|
+
new events.Rule(this, `${pascalId}Rule`, {
|
|
276
|
+
eventBus,
|
|
277
|
+
eventPattern: { detailType: [subscriber.event] },
|
|
278
|
+
targets: [new eventsTargets.SqsQueue(queue)],
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// Add dbSecretArn grant if provided
|
|
282
|
+
if (dbSecretArn) {
|
|
283
|
+
subscriberLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
284
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
285
|
+
resources: [dbSecretArn],
|
|
286
|
+
})));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Deploy scheduled tasks as grouped Lambdas
|
|
290
|
+
if (registry.schedules.length > 0) {
|
|
291
|
+
scheduleLambdas = createGroupedLambdas(this, {
|
|
292
|
+
domainId,
|
|
293
|
+
primitiveType: 'schedule',
|
|
294
|
+
handlerEntries: registry.schedules.map(schedule => ({
|
|
295
|
+
id: schedule.id,
|
|
296
|
+
handlerFile: schedule.handlerFile,
|
|
297
|
+
})),
|
|
298
|
+
environment,
|
|
299
|
+
eventBusArn,
|
|
300
|
+
dedicated: registry.schedules.some(schedule => schedule.deployment?.isolation === 'dedicated'),
|
|
301
|
+
reservedConcurrency,
|
|
302
|
+
logRetentionDays: logRetentionDays ?? 30,
|
|
303
|
+
...vpcProps,
|
|
304
|
+
});
|
|
305
|
+
this.lambdaArns[`${domainId}-schedule`] = scheduleLambdas[0].functionArn;
|
|
306
|
+
const iamPolicies = iamBuilder.forSchedule();
|
|
307
|
+
scheduleLambdas.forEach((fn) => {
|
|
308
|
+
iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
309
|
+
});
|
|
310
|
+
// Add dbSecretArn grant if provided
|
|
311
|
+
if (dbSecretArn) {
|
|
312
|
+
scheduleLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
313
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
314
|
+
resources: [dbSecretArn],
|
|
315
|
+
})));
|
|
316
|
+
}
|
|
317
|
+
// Wire EventBridge Scheduler rules to Lambda
|
|
318
|
+
for (const schedule of registry.schedules) {
|
|
319
|
+
const pascalId = toPascalCase(schedule.id);
|
|
320
|
+
const fn = scheduleLambdas[0]; // Grouped Lambda or first dedicated
|
|
321
|
+
// Create a dedicated IAM role for the EventBridge Scheduler to assume
|
|
322
|
+
const schedulerRole = new iam.Role(this, `${pascalId}SchedulerRole`, {
|
|
323
|
+
assumedBy: new iam.ServicePrincipal('scheduler.amazonaws.com'),
|
|
324
|
+
});
|
|
325
|
+
// Grant the scheduler role permission to invoke the Lambda
|
|
326
|
+
fn.grantInvoke(schedulerRole);
|
|
327
|
+
// Create the EventBridge Scheduler rule
|
|
328
|
+
new scheduler.CfnSchedule(this, `${pascalId}Schedule`, {
|
|
329
|
+
scheduleExpression: schedule.cron,
|
|
330
|
+
flexibleTimeWindow: { mode: 'OFF' },
|
|
331
|
+
state: schedule.enabled ? 'ENABLED' : 'DISABLED',
|
|
332
|
+
target: {
|
|
333
|
+
arn: fn.functionArn,
|
|
334
|
+
roleArn: schedulerRole.roleArn,
|
|
335
|
+
input: JSON.stringify({}),
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Deploy background jobs as grouped Lambdas
|
|
341
|
+
if (registry.jobs.length > 0) {
|
|
342
|
+
jobLambdas = createGroupedLambdas(this, {
|
|
343
|
+
domainId,
|
|
344
|
+
primitiveType: 'job',
|
|
345
|
+
handlerEntries: registry.jobs.map(job => ({
|
|
346
|
+
id: job.id,
|
|
347
|
+
handlerFile: job.handlerFile,
|
|
348
|
+
})),
|
|
349
|
+
environment,
|
|
350
|
+
eventBusArn,
|
|
351
|
+
dedicated: registry.jobs.some(job => job.deployment?.isolation === 'dedicated'),
|
|
352
|
+
reservedConcurrency,
|
|
353
|
+
logRetentionDays: logRetentionDays ?? 30,
|
|
354
|
+
...vpcProps,
|
|
355
|
+
});
|
|
356
|
+
this.lambdaArns[`${domainId}-job`] = jobLambdas[0].functionArn;
|
|
357
|
+
// Wire SQS → Lambda for each job
|
|
358
|
+
for (const job of registry.jobs) {
|
|
359
|
+
const pascalId = toPascalCase(job.id);
|
|
360
|
+
// Create dead-letter queue
|
|
361
|
+
const dlq = new sqs.Queue(this, `${pascalId}JobDlq`, {
|
|
362
|
+
retentionPeriod: cdk.Duration.days(14),
|
|
363
|
+
encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
|
|
364
|
+
encryptionMasterKey: enableCmk ? cmkKey : undefined,
|
|
365
|
+
});
|
|
366
|
+
allDlqs.push(dlq);
|
|
367
|
+
// Create main queue with dead-letter queue configuration
|
|
368
|
+
const queue = new sqs.Queue(this, `${pascalId}JobQueue`, {
|
|
369
|
+
visibilityTimeout: cdk.Duration.seconds(job.visibilityTimeoutSeconds),
|
|
370
|
+
deadLetterQueue: {
|
|
371
|
+
queue: dlq,
|
|
372
|
+
maxReceiveCount: 5,
|
|
373
|
+
},
|
|
374
|
+
encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
|
|
375
|
+
encryptionMasterKey: enableCmk ? cmkKey : undefined,
|
|
376
|
+
});
|
|
377
|
+
// Add SQS as event source for the first grouped Lambda
|
|
378
|
+
const fn = jobLambdas[0];
|
|
379
|
+
fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
|
|
380
|
+
batchSize: 1,
|
|
381
|
+
}));
|
|
382
|
+
// Grant IAM permissions for this queue
|
|
383
|
+
const queuePolicies = iamBuilder.forJob({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
|
|
384
|
+
queuePolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
385
|
+
}
|
|
386
|
+
// Add dbSecretArn grant if provided
|
|
387
|
+
if (dbSecretArn) {
|
|
388
|
+
jobLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
389
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
390
|
+
resources: [dbSecretArn],
|
|
391
|
+
})));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Deploy callable actions as grouped Lambdas
|
|
395
|
+
if (registry.actions.length > 0) {
|
|
396
|
+
const actionLambdas = createGroupedLambdas(this, {
|
|
397
|
+
domainId,
|
|
398
|
+
primitiveType: 'action',
|
|
399
|
+
handlerEntries: registry.actions.map(action => ({
|
|
400
|
+
id: action.id,
|
|
401
|
+
handlerFile: action.handlerFile,
|
|
402
|
+
})),
|
|
403
|
+
environment,
|
|
404
|
+
eventBusArn,
|
|
405
|
+
dedicated: registry.actions.some(action => action.deployment?.isolation === 'dedicated'),
|
|
406
|
+
reservedConcurrency,
|
|
407
|
+
logRetentionDays: logRetentionDays ?? 30,
|
|
408
|
+
...vpcProps,
|
|
409
|
+
});
|
|
410
|
+
this.lambdaArns[`${domainId}-action`] = actionLambdas[0].functionArn;
|
|
411
|
+
const iamPolicies = iamBuilder.forAction([
|
|
412
|
+
`arn:aws:lambda:${this.region}:${this.account}:function:TIB-*-domain-*-action-*`,
|
|
413
|
+
]);
|
|
414
|
+
actionLambdas.forEach((fn) => {
|
|
415
|
+
iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
416
|
+
});
|
|
417
|
+
// Add dbSecretArn grant if provided
|
|
418
|
+
if (dbSecretArn) {
|
|
419
|
+
actionLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
420
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
421
|
+
resources: [dbSecretArn],
|
|
422
|
+
})));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Grant all domain Lambdas read/write access to the per-domain table and bucket
|
|
426
|
+
const allDomainLambdas = [
|
|
427
|
+
...apiLambdas, ...webhookLambdas, ...subscriberLambdas,
|
|
428
|
+
...scheduleLambdas, ...jobLambdas, ...actionLambdas,
|
|
429
|
+
];
|
|
430
|
+
for (const fn of allDomainLambdas) {
|
|
431
|
+
domainTable.grantReadWriteData(fn);
|
|
432
|
+
domainBucket.grantReadWrite(fn);
|
|
433
|
+
}
|
|
434
|
+
// Replace the tenant-scoped role trust policy — only domain Lambdas can assume with tenantId tag
|
|
435
|
+
const cfnRole = tenantScopedRole.node.defaultChild;
|
|
436
|
+
cfnRole.assumeRolePolicyDocument = cdk.Lazy.any({
|
|
437
|
+
produce: () => ({
|
|
438
|
+
Version: '2012-10-17',
|
|
439
|
+
Statement: allDomainLambdas.map(fn => ({
|
|
440
|
+
Effect: 'Allow',
|
|
441
|
+
Principal: { AWS: fn.role.roleArn },
|
|
442
|
+
Action: ['sts:AssumeRole', 'sts:TagSession'],
|
|
443
|
+
Condition: {
|
|
444
|
+
StringLike: { 'aws:RequestTag/tenantId': '*' },
|
|
445
|
+
'ForAllValues:StringEquals': { 'sts:TransitiveTagKeys': ['tenantId'] },
|
|
446
|
+
},
|
|
447
|
+
})),
|
|
448
|
+
}),
|
|
449
|
+
});
|
|
450
|
+
// Attach tenant-scoped storage policy to the tenant-scoped role
|
|
451
|
+
const tenantStoragePolicies = iamBuilder.forTenantScopedStorage({
|
|
452
|
+
tableArn: domainTable.tableArn,
|
|
453
|
+
bucketArn: domainBucket.bucketArn,
|
|
454
|
+
});
|
|
455
|
+
tenantStoragePolicies.forEach(statement => tenantScopedRole.addToPolicy(statement));
|
|
456
|
+
// Grant each Lambda execution role permission to assume the tenant-scoped role
|
|
457
|
+
allDomainLambdas.forEach(fn => {
|
|
458
|
+
fn.role.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
459
|
+
actions: ['sts:AssumeRole', 'sts:TagSession'],
|
|
460
|
+
resources: [tenantScopedRole.roleArn],
|
|
461
|
+
}));
|
|
462
|
+
});
|
|
463
|
+
// Optional CloudWatch alarms
|
|
464
|
+
let alarmConstruct;
|
|
465
|
+
if (enableAlarms) {
|
|
466
|
+
alarmConstruct = new AlarmConstruct(this, 'Alarms', {
|
|
467
|
+
domainId,
|
|
468
|
+
lambdaFunctions: allDomainLambdas,
|
|
469
|
+
dlqs: allDlqs,
|
|
470
|
+
snsTopicArn: alarmSnsTopicArn,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
// Optional canary deployments — 10% traffic shift, auto-rollback on alarm breach
|
|
474
|
+
// Note: requires enableAlarms to be true for auto-rollback to work
|
|
475
|
+
if (enableCanaryDeploy) {
|
|
476
|
+
new CanaryConstruct(this, 'Canary', {
|
|
477
|
+
domainId,
|
|
478
|
+
lambdaFunctions: allDomainLambdas,
|
|
479
|
+
rollbackAlarms: alarmConstruct?.alarms ?? [],
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
// Deploy built-in health and readiness checks
|
|
483
|
+
const healthConstruct = new HealthConstruct(this, 'Health', {
|
|
484
|
+
domainId,
|
|
485
|
+
environment: {
|
|
486
|
+
EVENT_BUS_ARN: eventBusArn,
|
|
487
|
+
DB_SECRET_ARN: dbSecretArn ?? '',
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
this.httpApi.addRoutes({
|
|
491
|
+
path: '/_health',
|
|
492
|
+
methods: [apigwv2.HttpMethod.GET],
|
|
493
|
+
integration: new apigwv2integrations.HttpLambdaIntegration('HealthIntegration', healthConstruct.healthFn),
|
|
494
|
+
});
|
|
495
|
+
this.httpApi.addRoutes({
|
|
496
|
+
path: '/_ready',
|
|
497
|
+
methods: [apigwv2.HttpMethod.GET],
|
|
498
|
+
integration: new apigwv2integrations.HttpLambdaIntegration('ReadyIntegration', healthConstruct.readyFn),
|
|
499
|
+
});
|
|
500
|
+
// Create CloudWatch dashboard for all primitives
|
|
501
|
+
const allEndpoints = [
|
|
502
|
+
...registry.apis.map(a => ({ id: a.id, primitiveClass: 'api' })),
|
|
503
|
+
...registry.actions.map(a => ({ id: a.id, primitiveClass: 'action' })),
|
|
504
|
+
...registry.subscribers.map(s => ({ id: s.id, primitiveClass: 'subscriber' })),
|
|
505
|
+
...registry.jobs.map(j => ({ id: j.id, primitiveClass: 'job' })),
|
|
506
|
+
...registry.webhooks.map(w => ({ id: w.id, primitiveClass: 'webhook' })),
|
|
507
|
+
...registry.schedules.map(sc => ({ id: sc.id, primitiveClass: 'schedule' })),
|
|
508
|
+
];
|
|
509
|
+
new DashboardConstruct(this, 'Dashboard', {
|
|
510
|
+
domainId,
|
|
511
|
+
envCode: this.stackName.split('-')[1] ?? 'dev',
|
|
512
|
+
endpoints: allEndpoints,
|
|
513
|
+
});
|
|
514
|
+
new cdk.CfnOutput(this, 'DomainTableName', {
|
|
515
|
+
value: domainTable.tableName,
|
|
516
|
+
description: 'Per-domain DynamoDB table name',
|
|
517
|
+
});
|
|
518
|
+
new cdk.CfnOutput(this, 'DomainBucketName', {
|
|
519
|
+
value: domainBucket.bucketName,
|
|
520
|
+
description: 'Per-domain S3 bucket name',
|
|
521
|
+
});
|
|
522
|
+
new cdk.CfnOutput(this, 'HttpApiUrl', {
|
|
523
|
+
value: this.httpApi.apiEndpoint,
|
|
524
|
+
description: 'Domain HTTP API endpoint',
|
|
525
|
+
});
|
|
526
|
+
new cdk.CfnOutput(this, 'DomainTenantScopedRoleArn', {
|
|
527
|
+
value: tenantScopedRole.roleArn,
|
|
528
|
+
description: 'Per-domain tenant-scoped IAM role ARN',
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Convert kebab-case or snake_case string to PascalCase.
|
|
534
|
+
* @param s - Input string.
|
|
535
|
+
* @returns PascalCase string.
|
|
536
|
+
*/
|
|
537
|
+
function toPascalCase(s) {
|
|
538
|
+
return s
|
|
539
|
+
.split(/[-_]/)
|
|
540
|
+
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
541
|
+
.join('');
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Convert HTTP method string to ApiGatewayV2 HttpMethod enum.
|
|
545
|
+
* @param method - HTTP method string (e.g., 'GET', 'POST').
|
|
546
|
+
* @returns HttpMethod enum value.
|
|
547
|
+
*/
|
|
548
|
+
function toHttpMethod(method) {
|
|
549
|
+
switch (method.toUpperCase()) {
|
|
550
|
+
case 'GET':
|
|
551
|
+
return apigwv2.HttpMethod.GET;
|
|
552
|
+
case 'POST':
|
|
553
|
+
return apigwv2.HttpMethod.POST;
|
|
554
|
+
case 'PUT':
|
|
555
|
+
return apigwv2.HttpMethod.PUT;
|
|
556
|
+
case 'PATCH':
|
|
557
|
+
return apigwv2.HttpMethod.PATCH;
|
|
558
|
+
case 'DELETE':
|
|
559
|
+
return apigwv2.HttpMethod.DELETE;
|
|
560
|
+
case 'HEAD':
|
|
561
|
+
return apigwv2.HttpMethod.HEAD;
|
|
562
|
+
case 'OPTIONS':
|
|
563
|
+
return apigwv2.HttpMethod.OPTIONS;
|
|
564
|
+
default:
|
|
565
|
+
return apigwv2.HttpMethod.ANY;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|