@teaminabottle/domain-cdk-packer 0.1.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 +36 -0
- package/dist/DomainStack.js +360 -0
- package/dist/__tests__/domain-stack.test.d.ts +1 -0
- package/dist/__tests__/domain-stack.test.js +138 -0
- package/dist/__tests__/lambda-factory.test.d.ts +1 -0
- package/dist/__tests__/lambda-factory.test.js +30 -0
- package/dist/__tests__/pack-flows.test.d.ts +1 -0
- package/dist/__tests__/pack-flows.test.js +121 -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/constructs/action-construct.d.ts +30 -0
- package/dist/constructs/action-construct.js +32 -0
- package/dist/constructs/api-construct.d.ts +30 -0
- package/dist/constructs/api-construct.js +64 -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/webhook-construct.d.ts +33 -0
- package/dist/constructs/webhook-construct.js +50 -0
- package/dist/flow-registry.d.ts +78 -0
- package/dist/flow-registry.js +2 -0
- package/dist/grouped-lambda-factory.d.ts +34 -0
- package/dist/grouped-lambda-factory.js +40 -0
- package/dist/iam/iam-policy-builder.d.ts +64 -0
- package/dist/iam/iam-policy-builder.js +132 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +13 -0
- package/dist/lambda-factory.d.ts +46 -0
- package/dist/lambda-factory.js +72 -0
- package/dist/pack-domain.d.ts +30 -0
- package/dist/pack-domain.js +30 -0
- package/dist/pack-flows.d.ts +26 -0
- package/dist/pack-flows.js +112 -0
- package/dist/registry.d.ts +209 -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 +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @teaminabottle/domain-cdk-packer
|
|
2
|
+
|
|
3
|
+
AWS CDK L3 constructs that compile a `DomainRegistry` snapshot into CloudFormation. Takes the output of `tib-domain-module build` and generates type-safe infrastructure.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @teaminabottle/domain-cdk-packer --save-dev
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
Pack a domain registry into a CDK stack:
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { packDomain } from '@teaminabottle/domain-cdk-packer';
|
|
17
|
+
import * as cdk from 'aws-cdk-lib';
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
|
|
20
|
+
const app = new cdk.App();
|
|
21
|
+
const registry = JSON.parse(
|
|
22
|
+
fs.readFileSync('.tib/domain-registry.json', 'utf-8')
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
packDomain(registry, app, {
|
|
26
|
+
env: { region: 'eu-north-1', account: '123456789' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
app.synth();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or use `DomainStack` directly:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { DomainStack } from '@teaminabottle/domain-cdk-packer';
|
|
36
|
+
|
|
37
|
+
new DomainStack(app, 'PaymentsStack', {
|
|
38
|
+
registry,
|
|
39
|
+
env: { region: 'eu-north-1', account: '123456789' },
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Constructs Generated
|
|
44
|
+
|
|
45
|
+
For each handler in the registry, the packer provisions:
|
|
46
|
+
|
|
47
|
+
| Handler Type | Generated Constructs |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `defineApi()` | Lambda function, API Gateway route (method + path), EventBridge integration |
|
|
50
|
+
| `defineWebhook()` | Lambda function, API Gateway webhook route, GitHub webhook validation |
|
|
51
|
+
| `defineSubscriber()` | Lambda function, EventBridge rule (event filter), Lambda target |
|
|
52
|
+
| `defineSchedule()` | Lambda function, EventBridge Scheduler rule (cron expression) |
|
|
53
|
+
| `defineJob()` | Lambda function, SQS queue, Lambda event source mapping |
|
|
54
|
+
| `defineAction()` | Lambda function, Function URL (for workspace invocation) |
|
|
55
|
+
| `defineIntegration()` | Lambda function, SQS DLQ, error retry logic |
|
|
56
|
+
|
|
57
|
+
All handlers use **esbuild** for bundling and **Drizzle ORM** for database access.
|
|
58
|
+
|
|
59
|
+
## Advanced Features
|
|
60
|
+
|
|
61
|
+
**StepFunctionsCodegen** — Convert domain actions into AWS Step Functions task states for TIB Flow Designer integration:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { StepFunctionsCodegen } from '@teaminabottle/domain-cdk-packer';
|
|
65
|
+
|
|
66
|
+
const sfnCodegen = new StepFunctionsCodegen(registry);
|
|
67
|
+
const taskStates = sfnCodegen.generate();
|
|
68
|
+
// taskStates: StepFunctionsTaskState[] — ready to be serialized to Flow Designer
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Peer Dependencies
|
|
72
|
+
|
|
73
|
+
`@teaminabottle/domain-cdk-packer` requires:
|
|
74
|
+
|
|
75
|
+
- `aws-cdk-lib` (^2.0)
|
|
76
|
+
- `constructs` (^10.0)
|
|
77
|
+
|
|
78
|
+
And expects `@teaminabottle/domain-runtime` to be installed in the same project (used for type reflection).
|
|
79
|
+
|
|
80
|
+
## See Also
|
|
81
|
+
|
|
82
|
+
- `@teaminabottle/domain-runtime` — handler definitions
|
|
83
|
+
- `@teaminabottle/domain-cli` — local build and validation
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as cdk from 'aws-cdk-lib';
|
|
2
|
+
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
|
|
3
|
+
import { Construct } from 'constructs';
|
|
4
|
+
import type { DomainRegistry } from './registry.js';
|
|
5
|
+
/**
|
|
6
|
+
* Configuration properties for DomainStack.
|
|
7
|
+
*/
|
|
8
|
+
export interface DomainStackProps extends cdk.StackProps {
|
|
9
|
+
/** The compiled domain registry. */
|
|
10
|
+
registry: DomainRegistry;
|
|
11
|
+
/** ARN of the EventBridge event bus for event subscribers. */
|
|
12
|
+
eventBusArn: string;
|
|
13
|
+
/** Database connection URL passed from SharedStack. */
|
|
14
|
+
databaseUrl?: string;
|
|
15
|
+
/** Optional Cognito User Pool ARN for JWT-authenticated routes. */
|
|
16
|
+
userPoolArn?: string;
|
|
17
|
+
/** Optional Cognito User Pool Client ID — required when userPoolArn is provided. */
|
|
18
|
+
userPoolClientId?: string;
|
|
19
|
+
/** Optional Secrets Manager ARN for the database master credentials. */
|
|
20
|
+
dbSecretArn?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Top-level CDK Stack that composes all domain constructs from a single DomainRegistry input.
|
|
24
|
+
* Uses grouped Lambdas for each primitive type to reduce deployment artifact size.
|
|
25
|
+
*/
|
|
26
|
+
export declare class DomainStack extends cdk.Stack {
|
|
27
|
+
/** Shared HTTP API for routing API and webhook requests. */
|
|
28
|
+
readonly httpApi: apigwv2.HttpApi;
|
|
29
|
+
/**
|
|
30
|
+
* Create a new DomainStack instance.
|
|
31
|
+
* @param scope - Parent CDK scope.
|
|
32
|
+
* @param id - Stack identifier.
|
|
33
|
+
* @param props - Stack properties including domain registry and event bus ARN.
|
|
34
|
+
*/
|
|
35
|
+
constructor(scope: Construct, id: string, props: DomainStackProps);
|
|
36
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
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 { createGroupedLambdas } from './grouped-lambda-factory.js';
|
|
13
|
+
import { IamPolicyBuilder } from './iam/iam-policy-builder.js';
|
|
14
|
+
/**
|
|
15
|
+
* Top-level CDK Stack that composes all domain constructs from a single DomainRegistry input.
|
|
16
|
+
* Uses grouped Lambdas for each primitive type to reduce deployment artifact size.
|
|
17
|
+
*/
|
|
18
|
+
export class DomainStack extends cdk.Stack {
|
|
19
|
+
/** Shared HTTP API for routing API and webhook requests. */
|
|
20
|
+
httpApi;
|
|
21
|
+
/**
|
|
22
|
+
* Create a new DomainStack instance.
|
|
23
|
+
* @param scope - Parent CDK scope.
|
|
24
|
+
* @param id - Stack identifier.
|
|
25
|
+
* @param props - Stack properties including domain registry and event bus ARN.
|
|
26
|
+
*/
|
|
27
|
+
constructor(scope, id, props) {
|
|
28
|
+
super(scope, id, props);
|
|
29
|
+
const { registry, eventBusArn, databaseUrl, userPoolArn, userPoolClientId, dbSecretArn } = props;
|
|
30
|
+
const domainId = registry.domain.id;
|
|
31
|
+
this.httpApi = new apigwv2.HttpApi(this, 'HttpApi', {
|
|
32
|
+
apiName: `${domainId}-api`,
|
|
33
|
+
corsPreflight: {
|
|
34
|
+
allowHeaders: ['Content-Type', 'Authorization'],
|
|
35
|
+
allowMethods: [apigwv2.CorsHttpMethod.ANY],
|
|
36
|
+
allowOrigins: ['*'],
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const iamBuilder = new IamPolicyBuilder();
|
|
40
|
+
const eventBus = events.EventBus.fromEventBusArn(this, 'TibEventBus', eventBusArn);
|
|
41
|
+
// Build common environment for all Lambdas
|
|
42
|
+
const environment = {
|
|
43
|
+
TIB_DOMAIN_ID: domainId,
|
|
44
|
+
TIB_EVENT_BUS_ARN: eventBusArn,
|
|
45
|
+
};
|
|
46
|
+
if (databaseUrl) {
|
|
47
|
+
environment.DATABASE_URL = databaseUrl;
|
|
48
|
+
}
|
|
49
|
+
if (dbSecretArn) {
|
|
50
|
+
environment.DB_SECRET_ARN = dbSecretArn;
|
|
51
|
+
}
|
|
52
|
+
// Create JWT authorizer if userPoolArn is provided
|
|
53
|
+
let jwtAuthorizer;
|
|
54
|
+
if (userPoolArn && userPoolClientId) {
|
|
55
|
+
const userPoolId = userPoolArn.split('/').pop();
|
|
56
|
+
jwtAuthorizer = new apigwv2authorizers.HttpJwtAuthorizer('JwtAuthorizer', `https://cognito-idp.${this.region}.amazonaws.com/${userPoolId}`, {
|
|
57
|
+
jwtAudience: [userPoolClientId],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// Deploy API endpoints as grouped Lambdas
|
|
61
|
+
if (registry.apis.length > 0) {
|
|
62
|
+
const apiLambdas = createGroupedLambdas(this, {
|
|
63
|
+
domainId,
|
|
64
|
+
primitiveType: 'api',
|
|
65
|
+
handlerEntries: registry.apis.map(api => ({
|
|
66
|
+
id: api.id,
|
|
67
|
+
handlerFile: api.handlerFile,
|
|
68
|
+
})),
|
|
69
|
+
environment,
|
|
70
|
+
eventBusArn,
|
|
71
|
+
dedicated: registry.apis.some(api => api.deployment?.isolation === 'dedicated'),
|
|
72
|
+
});
|
|
73
|
+
// Wire each API Lambda to the HttpApi
|
|
74
|
+
const iamPolicies = iamBuilder.forApi();
|
|
75
|
+
apiLambdas.forEach((fn) => {
|
|
76
|
+
iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
77
|
+
});
|
|
78
|
+
// Add dbSecretArn grant if provided
|
|
79
|
+
if (dbSecretArn) {
|
|
80
|
+
apiLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
81
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
82
|
+
resources: [dbSecretArn],
|
|
83
|
+
})));
|
|
84
|
+
}
|
|
85
|
+
// Add routes for each API entry
|
|
86
|
+
for (const api of registry.apis) {
|
|
87
|
+
const fn = apiLambdas[0]; // Grouped Lambda or first dedicated
|
|
88
|
+
const apiRouteBase = {
|
|
89
|
+
path: api.path,
|
|
90
|
+
methods: [toHttpMethod(api.method)],
|
|
91
|
+
integration: new apigwv2integrations.HttpLambdaIntegration(`Apis${toPascalCase(api.id)}Integration`, fn),
|
|
92
|
+
};
|
|
93
|
+
let apiRoute;
|
|
94
|
+
if (api.authType === 'jwt' && jwtAuthorizer) {
|
|
95
|
+
apiRoute = { ...apiRouteBase, authorizer: jwtAuthorizer };
|
|
96
|
+
}
|
|
97
|
+
else if (api.authType === 'api-key') {
|
|
98
|
+
// TODO(#1785): wire HttpApiKeyAuthorizer
|
|
99
|
+
apiRoute = apiRouteBase;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
apiRoute = apiRouteBase;
|
|
103
|
+
}
|
|
104
|
+
this.httpApi.addRoutes(apiRoute);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Deploy webhooks as grouped Lambdas
|
|
108
|
+
if (registry.webhooks.length > 0) {
|
|
109
|
+
// Create shared dedupe table for webhooks
|
|
110
|
+
const dedupeTable = new dynamodb.Table(this, 'WebhookDedupeTable', {
|
|
111
|
+
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
|
|
112
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
113
|
+
timeToLiveAttribute: 'expiresAt',
|
|
114
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
115
|
+
});
|
|
116
|
+
const webhookLambdas = createGroupedLambdas(this, {
|
|
117
|
+
domainId,
|
|
118
|
+
primitiveType: 'webhook',
|
|
119
|
+
handlerEntries: registry.webhooks.map(webhook => ({
|
|
120
|
+
id: webhook.id,
|
|
121
|
+
handlerFile: webhook.handlerFile,
|
|
122
|
+
})),
|
|
123
|
+
environment,
|
|
124
|
+
eventBusArn,
|
|
125
|
+
dedicated: registry.webhooks.some(webhook => webhook.deployment?.isolation === 'dedicated'),
|
|
126
|
+
});
|
|
127
|
+
// Wire each webhook Lambda to the HttpApi
|
|
128
|
+
const iamPolicies = iamBuilder.forWebhook({ dedupeTableArn: dedupeTable.tableArn });
|
|
129
|
+
webhookLambdas.forEach((fn) => {
|
|
130
|
+
iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
131
|
+
dedupeTable.grantReadWriteData(fn);
|
|
132
|
+
});
|
|
133
|
+
// Add dbSecretArn grant if provided
|
|
134
|
+
if (dbSecretArn) {
|
|
135
|
+
webhookLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
136
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
137
|
+
resources: [dbSecretArn],
|
|
138
|
+
})));
|
|
139
|
+
}
|
|
140
|
+
// Add routes for each webhook entry
|
|
141
|
+
for (const webhook of registry.webhooks) {
|
|
142
|
+
const fn = webhookLambdas[0]; // Grouped Lambda or first dedicated
|
|
143
|
+
this.httpApi.addRoutes({
|
|
144
|
+
path: webhook.path,
|
|
145
|
+
methods: [apigwv2.HttpMethod.POST],
|
|
146
|
+
integration: new apigwv2integrations.HttpLambdaIntegration(`Webhooks${toPascalCase(webhook.id)}Integration`, fn),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Deploy event subscribers as grouped Lambdas
|
|
151
|
+
if (registry.subscribers.length > 0) {
|
|
152
|
+
const subscriberLambdas = createGroupedLambdas(this, {
|
|
153
|
+
domainId,
|
|
154
|
+
primitiveType: 'subscriber',
|
|
155
|
+
handlerEntries: registry.subscribers.map(subscriber => ({
|
|
156
|
+
id: subscriber.id,
|
|
157
|
+
handlerFile: subscriber.handlerFile,
|
|
158
|
+
})),
|
|
159
|
+
environment,
|
|
160
|
+
eventBusArn,
|
|
161
|
+
dedicated: registry.subscribers.some(subscriber => subscriber.deployment?.isolation === 'dedicated'),
|
|
162
|
+
});
|
|
163
|
+
// Wire EventBridge → SQS → Lambda for each subscriber
|
|
164
|
+
for (const subscriber of registry.subscribers) {
|
|
165
|
+
const pascalId = toPascalCase(subscriber.id);
|
|
166
|
+
// Create dead-letter queue
|
|
167
|
+
const dlq = new sqs.Queue(this, `${pascalId}Dlq`, {
|
|
168
|
+
retentionPeriod: cdk.Duration.days(14),
|
|
169
|
+
});
|
|
170
|
+
// Create main queue with dead-letter queue configuration
|
|
171
|
+
const queue = new sqs.Queue(this, `${pascalId}Queue`, {
|
|
172
|
+
visibilityTimeout: cdk.Duration.seconds(180),
|
|
173
|
+
deadLetterQueue: {
|
|
174
|
+
queue: dlq,
|
|
175
|
+
maxReceiveCount: 3,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
// Add SQS as event source for the first grouped Lambda
|
|
179
|
+
const fn = subscriberLambdas[0];
|
|
180
|
+
fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
|
|
181
|
+
batchSize: 10,
|
|
182
|
+
maxConcurrency: subscriber.concurrency ?? 5,
|
|
183
|
+
}));
|
|
184
|
+
// Grant IAM permissions for this queue
|
|
185
|
+
const queuePolicies = iamBuilder.forSubscriber({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
|
|
186
|
+
queuePolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
187
|
+
// Create EventBridge rule targeting SQS queue
|
|
188
|
+
new events.Rule(this, `${pascalId}Rule`, {
|
|
189
|
+
eventBus,
|
|
190
|
+
eventPattern: { detailType: [subscriber.event] },
|
|
191
|
+
targets: [new eventsTargets.SqsQueue(queue)],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
// Add dbSecretArn grant if provided
|
|
195
|
+
if (dbSecretArn) {
|
|
196
|
+
subscriberLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
197
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
198
|
+
resources: [dbSecretArn],
|
|
199
|
+
})));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Deploy scheduled tasks as grouped Lambdas
|
|
203
|
+
if (registry.schedules.length > 0) {
|
|
204
|
+
const scheduleLambdas = createGroupedLambdas(this, {
|
|
205
|
+
domainId,
|
|
206
|
+
primitiveType: 'schedule',
|
|
207
|
+
handlerEntries: registry.schedules.map(schedule => ({
|
|
208
|
+
id: schedule.id,
|
|
209
|
+
handlerFile: schedule.handlerFile,
|
|
210
|
+
})),
|
|
211
|
+
environment,
|
|
212
|
+
eventBusArn,
|
|
213
|
+
dedicated: registry.schedules.some(schedule => schedule.deployment?.isolation === 'dedicated'),
|
|
214
|
+
});
|
|
215
|
+
const iamPolicies = iamBuilder.forSchedule();
|
|
216
|
+
scheduleLambdas.forEach((fn) => {
|
|
217
|
+
iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
218
|
+
});
|
|
219
|
+
// Add dbSecretArn grant if provided
|
|
220
|
+
if (dbSecretArn) {
|
|
221
|
+
scheduleLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
222
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
223
|
+
resources: [dbSecretArn],
|
|
224
|
+
})));
|
|
225
|
+
}
|
|
226
|
+
// Wire EventBridge Scheduler rules to Lambda
|
|
227
|
+
for (const schedule of registry.schedules) {
|
|
228
|
+
const pascalId = toPascalCase(schedule.id);
|
|
229
|
+
const fn = scheduleLambdas[0]; // Grouped Lambda or first dedicated
|
|
230
|
+
// Create a dedicated IAM role for the EventBridge Scheduler to assume
|
|
231
|
+
const schedulerRole = new iam.Role(this, `${pascalId}SchedulerRole`, {
|
|
232
|
+
assumedBy: new iam.ServicePrincipal('scheduler.amazonaws.com'),
|
|
233
|
+
});
|
|
234
|
+
// Grant the scheduler role permission to invoke the Lambda
|
|
235
|
+
fn.grantInvoke(schedulerRole);
|
|
236
|
+
// Create the EventBridge Scheduler rule
|
|
237
|
+
new scheduler.CfnSchedule(this, `${pascalId}Schedule`, {
|
|
238
|
+
scheduleExpression: schedule.cron,
|
|
239
|
+
flexibleTimeWindow: { mode: 'OFF' },
|
|
240
|
+
state: schedule.enabled ? 'ENABLED' : 'DISABLED',
|
|
241
|
+
target: {
|
|
242
|
+
arn: fn.functionArn,
|
|
243
|
+
roleArn: schedulerRole.roleArn,
|
|
244
|
+
input: JSON.stringify({}),
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Deploy background jobs as grouped Lambdas
|
|
250
|
+
if (registry.jobs.length > 0) {
|
|
251
|
+
const jobLambdas = createGroupedLambdas(this, {
|
|
252
|
+
domainId,
|
|
253
|
+
primitiveType: 'job',
|
|
254
|
+
handlerEntries: registry.jobs.map(job => ({
|
|
255
|
+
id: job.id,
|
|
256
|
+
handlerFile: job.handlerFile,
|
|
257
|
+
})),
|
|
258
|
+
environment,
|
|
259
|
+
eventBusArn,
|
|
260
|
+
dedicated: registry.jobs.some(job => job.deployment?.isolation === 'dedicated'),
|
|
261
|
+
});
|
|
262
|
+
// Wire SQS → Lambda for each job
|
|
263
|
+
for (const job of registry.jobs) {
|
|
264
|
+
const pascalId = toPascalCase(job.id);
|
|
265
|
+
// Create dead-letter queue
|
|
266
|
+
const dlq = new sqs.Queue(this, `${pascalId}JobDlq`, {
|
|
267
|
+
retentionPeriod: cdk.Duration.days(14),
|
|
268
|
+
});
|
|
269
|
+
// Create main queue with dead-letter queue configuration
|
|
270
|
+
const queue = new sqs.Queue(this, `${pascalId}JobQueue`, {
|
|
271
|
+
visibilityTimeout: cdk.Duration.seconds(job.visibilityTimeoutSeconds),
|
|
272
|
+
deadLetterQueue: {
|
|
273
|
+
queue: dlq,
|
|
274
|
+
maxReceiveCount: job.maxRetries,
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
// Add SQS as event source for the first grouped Lambda
|
|
278
|
+
const fn = jobLambdas[0];
|
|
279
|
+
fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
|
|
280
|
+
batchSize: 1,
|
|
281
|
+
}));
|
|
282
|
+
// Grant IAM permissions for this queue
|
|
283
|
+
const queuePolicies = iamBuilder.forJob({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
|
|
284
|
+
queuePolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
285
|
+
}
|
|
286
|
+
// Add dbSecretArn grant if provided
|
|
287
|
+
if (dbSecretArn) {
|
|
288
|
+
jobLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
289
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
290
|
+
resources: [dbSecretArn],
|
|
291
|
+
})));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Deploy callable actions as grouped Lambdas
|
|
295
|
+
if (registry.actions.length > 0) {
|
|
296
|
+
const actionLambdas = createGroupedLambdas(this, {
|
|
297
|
+
domainId,
|
|
298
|
+
primitiveType: 'action',
|
|
299
|
+
handlerEntries: registry.actions.map(action => ({
|
|
300
|
+
id: action.id,
|
|
301
|
+
handlerFile: action.handlerFile,
|
|
302
|
+
})),
|
|
303
|
+
environment,
|
|
304
|
+
eventBusArn,
|
|
305
|
+
dedicated: registry.actions.some(action => action.deployment?.isolation === 'dedicated'),
|
|
306
|
+
});
|
|
307
|
+
const iamPolicies = iamBuilder.forAction();
|
|
308
|
+
actionLambdas.forEach((fn) => {
|
|
309
|
+
iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
|
|
310
|
+
});
|
|
311
|
+
// Add dbSecretArn grant if provided
|
|
312
|
+
if (dbSecretArn) {
|
|
313
|
+
actionLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
|
|
314
|
+
actions: ['secretsmanager:GetSecretValue'],
|
|
315
|
+
resources: [dbSecretArn],
|
|
316
|
+
})));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
new cdk.CfnOutput(this, 'HttpApiUrl', {
|
|
320
|
+
value: this.httpApi.apiEndpoint,
|
|
321
|
+
description: 'Domain HTTP API endpoint',
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Convert kebab-case or snake_case string to PascalCase.
|
|
327
|
+
* @param s - Input string.
|
|
328
|
+
* @returns PascalCase string.
|
|
329
|
+
*/
|
|
330
|
+
function toPascalCase(s) {
|
|
331
|
+
return s
|
|
332
|
+
.split(/[-_]/)
|
|
333
|
+
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
334
|
+
.join('');
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Convert HTTP method string to ApiGatewayV2 HttpMethod enum.
|
|
338
|
+
* @param method - HTTP method string (e.g., 'GET', 'POST').
|
|
339
|
+
* @returns HttpMethod enum value.
|
|
340
|
+
*/
|
|
341
|
+
function toHttpMethod(method) {
|
|
342
|
+
switch (method.toUpperCase()) {
|
|
343
|
+
case 'GET':
|
|
344
|
+
return apigwv2.HttpMethod.GET;
|
|
345
|
+
case 'POST':
|
|
346
|
+
return apigwv2.HttpMethod.POST;
|
|
347
|
+
case 'PUT':
|
|
348
|
+
return apigwv2.HttpMethod.PUT;
|
|
349
|
+
case 'PATCH':
|
|
350
|
+
return apigwv2.HttpMethod.PATCH;
|
|
351
|
+
case 'DELETE':
|
|
352
|
+
return apigwv2.HttpMethod.DELETE;
|
|
353
|
+
case 'HEAD':
|
|
354
|
+
return apigwv2.HttpMethod.HEAD;
|
|
355
|
+
case 'OPTIONS':
|
|
356
|
+
return apigwv2.HttpMethod.OPTIONS;
|
|
357
|
+
default:
|
|
358
|
+
return apigwv2.HttpMethod.ANY;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import * as cdk from 'aws-cdk-lib';
|
|
3
|
+
import { Template } from 'aws-cdk-lib/assertions';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { DomainStack } from '../DomainStack.js';
|
|
7
|
+
const DOMAIN_ROOT = '/tmp/test-domain-cdk-packer';
|
|
8
|
+
const minimalRegistry = {
|
|
9
|
+
schemaVersion: '1',
|
|
10
|
+
domainRoot: DOMAIN_ROOT,
|
|
11
|
+
domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
|
|
12
|
+
apis: [
|
|
13
|
+
{
|
|
14
|
+
id: 'get-users',
|
|
15
|
+
kind: 'api',
|
|
16
|
+
handlerFile: 'src/handlers/get-users.ts',
|
|
17
|
+
path: '/users',
|
|
18
|
+
method: 'GET',
|
|
19
|
+
authType: 'jwt',
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
webhooks: [],
|
|
23
|
+
subscribers: [],
|
|
24
|
+
schedules: [],
|
|
25
|
+
jobs: [],
|
|
26
|
+
actions: [],
|
|
27
|
+
integrations: [],
|
|
28
|
+
events: [],
|
|
29
|
+
};
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
|
|
32
|
+
fs.mkdirSync(handlerDir, { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), 'export const handler = async () => ({ statusCode: 200 });\n');
|
|
34
|
+
});
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
describe('DomainStack', () => {
|
|
39
|
+
const eventBusArn = 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus';
|
|
40
|
+
it('synthesises without error for minimal registry', () => {
|
|
41
|
+
const app = new cdk.App();
|
|
42
|
+
expect(() => new DomainStack(app, 'TestDomainStack', { registry: minimalRegistry, eventBusArn })).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
it('template contains HttpApi', () => {
|
|
45
|
+
const app = new cdk.App();
|
|
46
|
+
const stack = new DomainStack(app, 'TestDomainStack2', { registry: minimalRegistry, eventBusArn });
|
|
47
|
+
const template = Template.fromStack(stack);
|
|
48
|
+
template.resourceCountIs('AWS::ApiGatewayV2::Api', 1);
|
|
49
|
+
});
|
|
50
|
+
it('template contains one Lambda function for the api entry', () => {
|
|
51
|
+
const app = new cdk.App();
|
|
52
|
+
const stack = new DomainStack(app, 'TestDomainStack3', { registry: minimalRegistry, eventBusArn });
|
|
53
|
+
const template = Template.fromStack(stack);
|
|
54
|
+
template.resourceCountIs('AWS::Lambda::Function', 2);
|
|
55
|
+
});
|
|
56
|
+
it('attaches JWT authorizer to jwt-auth API routes when userPoolArn provided', () => {
|
|
57
|
+
const app = new cdk.App();
|
|
58
|
+
const userPoolArn = 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz';
|
|
59
|
+
const userPoolClientId = 'test-client-id';
|
|
60
|
+
const stack = new DomainStack(app, 'TestDomainStackJwt', {
|
|
61
|
+
registry: minimalRegistry,
|
|
62
|
+
eventBusArn,
|
|
63
|
+
userPoolArn,
|
|
64
|
+
userPoolClientId,
|
|
65
|
+
});
|
|
66
|
+
const template = Template.fromStack(stack);
|
|
67
|
+
template.hasResourceProperties('AWS::ApiGatewayV2::Authorizer', {
|
|
68
|
+
AuthorizerType: 'JWT',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
it('no authorizer attached when authType is none', () => {
|
|
72
|
+
const app = new cdk.App();
|
|
73
|
+
const registryNoAuth = {
|
|
74
|
+
...minimalRegistry,
|
|
75
|
+
apis: [
|
|
76
|
+
{
|
|
77
|
+
...minimalRegistry.apis[0],
|
|
78
|
+
authType: 'none',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
const stack = new DomainStack(app, 'TestDomainStackNoAuth', {
|
|
83
|
+
registry: registryNoAuth,
|
|
84
|
+
eventBusArn,
|
|
85
|
+
});
|
|
86
|
+
const template = Template.fromStack(stack);
|
|
87
|
+
template.resourceCountIs('AWS::ApiGatewayV2::Authorizer', 0);
|
|
88
|
+
});
|
|
89
|
+
it('DB_SECRET_ARN env var set on Lambdas when dbSecretArn provided', () => {
|
|
90
|
+
const app = new cdk.App();
|
|
91
|
+
const dbSecretArn = 'arn:aws:secretsmanager:eu-north-1:123456789012:secret:db-secret-abc123';
|
|
92
|
+
const stack = new DomainStack(app, 'TestDomainStackDbSecret', {
|
|
93
|
+
registry: minimalRegistry,
|
|
94
|
+
eventBusArn,
|
|
95
|
+
dbSecretArn,
|
|
96
|
+
});
|
|
97
|
+
const template = Template.fromStack(stack);
|
|
98
|
+
template.allResources('AWS::Lambda::Function', {
|
|
99
|
+
Environment: {
|
|
100
|
+
Variables: {
|
|
101
|
+
Match: {
|
|
102
|
+
stringLike: {
|
|
103
|
+
DB_SECRET_ARN: dbSecretArn,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
it('secretsmanager:GetSecretValue grant added when dbSecretArn provided', () => {
|
|
111
|
+
const app = new cdk.App();
|
|
112
|
+
const dbSecretArn = 'arn:aws:secretsmanager:eu-north-1:123456789012:secret:db-secret-abc123';
|
|
113
|
+
const stack = new DomainStack(app, 'TestDomainStackSecretGrant', {
|
|
114
|
+
registry: minimalRegistry,
|
|
115
|
+
eventBusArn,
|
|
116
|
+
dbSecretArn,
|
|
117
|
+
});
|
|
118
|
+
const template = Template.fromStack(stack);
|
|
119
|
+
template.allResources('AWS::IAM::Policy', {
|
|
120
|
+
PolicyDocument: {
|
|
121
|
+
Match: {
|
|
122
|
+
objectLike: {
|
|
123
|
+
Statement: [
|
|
124
|
+
{
|
|
125
|
+
Match: {
|
|
126
|
+
objectLike: {
|
|
127
|
+
Action: ['secretsmanager:GetSecretValue'],
|
|
128
|
+
Resource: [dbSecretArn],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import * as cdk from 'aws-cdk-lib';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { LambdaFactory } from '../lambda-factory.js';
|
|
6
|
+
const DOMAIN_ROOT = '/tmp/test-domain-cdk-packer';
|
|
7
|
+
const minimalRegistry = {
|
|
8
|
+
schemaVersion: '1',
|
|
9
|
+
domainRoot: DOMAIN_ROOT,
|
|
10
|
+
domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
|
|
11
|
+
apis: [], webhooks: [], subscribers: [], schedules: [], jobs: [], actions: [], integrations: [], events: [],
|
|
12
|
+
};
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
const handlerDir = path.join(DOMAIN_ROOT, 'src');
|
|
15
|
+
fs.mkdirSync(handlerDir, { recursive: true });
|
|
16
|
+
fs.writeFileSync(path.join(handlerDir, 'api.ts'), 'export const handler = async () => ({ statusCode: 200 });\n');
|
|
17
|
+
});
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
describe('LambdaFactory', () => {
|
|
22
|
+
it('creates a NodejsFunction construct without throwing', () => {
|
|
23
|
+
const app = new cdk.App();
|
|
24
|
+
const stack = new cdk.Stack(app, 'TestStack');
|
|
25
|
+
const factory = new LambdaFactory({ scope: stack, registry: minimalRegistry, domainRoot: minimalRegistry.domainRoot });
|
|
26
|
+
expect(() => {
|
|
27
|
+
factory.createFunction({ id: 'test-api', kind: 'api', handlerFile: 'src/api.ts' });
|
|
28
|
+
}).not.toThrow();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|