@venturekit/infra 0.0.0-dev.20260515022321 → 0.0.0-dev.20260515033134
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/cdk/app-stack.d.ts.map +1 -1
- package/dist/cdk/app-stack.js +1 -1
- package/dist/cdk/app-stack.js.map +1 -1
- package/dist/cdk/index.d.ts +2 -2
- package/dist/cdk/index.d.ts.map +1 -1
- package/dist/cdk/index.js +2 -4
- package/dist/cdk/index.js.map +1 -1
- package/dist/cdk/shared/route-discovery.d.ts +49 -0
- package/dist/cdk/shared/route-discovery.d.ts.map +1 -0
- package/dist/cdk/shared/route-discovery.js +84 -0
- package/dist/cdk/shared/route-discovery.js.map +1 -0
- package/package.json +2 -2
- package/dist/cdk/stack.d.ts +0 -389
- package/dist/cdk/stack.d.ts.map +0 -1
- package/dist/cdk/stack.js +0 -3084
- package/dist/cdk/stack.js.map +0 -1
package/dist/cdk/stack.js
DELETED
|
@@ -1,3084 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VentureStack — CDK Stack for VentureKit applications
|
|
3
|
-
*
|
|
4
|
-
* Translates VentureKit infrastructure intents into CloudFormation resources.
|
|
5
|
-
* This is the single entry point for `vk deploy`.
|
|
6
|
-
*
|
|
7
|
-
* Consumers never see this file — it's invoked by the generated CDK app in .vk/cdk/.
|
|
8
|
-
*/
|
|
9
|
-
import * as cdk from 'aws-cdk-lib';
|
|
10
|
-
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
11
|
-
import * as rds from 'aws-cdk-lib/aws-rds';
|
|
12
|
-
import * as s3 from 'aws-cdk-lib/aws-s3';
|
|
13
|
-
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
|
|
14
|
-
import * as cloudfrontOrigins from 'aws-cdk-lib/aws-cloudfront-origins';
|
|
15
|
-
import * as sqs from 'aws-cdk-lib/aws-sqs';
|
|
16
|
-
import * as cognito from 'aws-cdk-lib/aws-cognito';
|
|
17
|
-
import * as elasticache from 'aws-cdk-lib/aws-elasticache';
|
|
18
|
-
import * as events from 'aws-cdk-lib/aws-events';
|
|
19
|
-
import * as targets from 'aws-cdk-lib/aws-events-targets';
|
|
20
|
-
import * as lambda from 'aws-cdk-lib/aws-lambda';
|
|
21
|
-
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
|
|
22
|
-
import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
|
|
23
|
-
import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
|
24
|
-
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
|
|
25
|
-
import * as logs from 'aws-cdk-lib/aws-logs';
|
|
26
|
-
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
|
|
27
|
-
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
|
|
28
|
-
import * as sns from 'aws-cdk-lib/aws-sns';
|
|
29
|
-
import * as snsSubscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
|
|
30
|
-
import * as ses from 'aws-cdk-lib/aws-ses';
|
|
31
|
-
import * as ssm from 'aws-cdk-lib/aws-ssm';
|
|
32
|
-
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
|
33
|
-
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
|
|
34
|
-
import * as route53 from 'aws-cdk-lib/aws-route53';
|
|
35
|
-
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';
|
|
36
|
-
import * as iam from 'aws-cdk-lib/aws-iam';
|
|
37
|
-
import { MigrationRunner, hasMigrations } from './migration-runner.js';
|
|
38
|
-
import { resolveEnvConfig, DATA_SAFETY_CONFIG } from '@venturekit/core';
|
|
39
|
-
import * as path from 'path';
|
|
40
|
-
import * as fs from 'fs';
|
|
41
|
-
import * as os from 'os';
|
|
42
|
-
import { createRequire } from 'module';
|
|
43
|
-
/**
|
|
44
|
-
* Filename → HTTP method mapping. Aliases (`list`, `create`, `update`,
|
|
45
|
-
* `remove`) match the local dev server (`@venturekit/cli` →
|
|
46
|
-
* `local-server.ts#fileNameToMethod`) so route discovery is consistent
|
|
47
|
-
* between `vk dev` and `vk deploy`.
|
|
48
|
-
*/
|
|
49
|
-
const ROUTE_FILENAME_TO_METHOD = {
|
|
50
|
-
get: 'GET',
|
|
51
|
-
list: 'GET',
|
|
52
|
-
post: 'POST',
|
|
53
|
-
create: 'POST',
|
|
54
|
-
put: 'PUT',
|
|
55
|
-
update: 'PUT',
|
|
56
|
-
patch: 'PATCH',
|
|
57
|
-
delete: 'DELETE',
|
|
58
|
-
remove: 'DELETE',
|
|
59
|
-
};
|
|
60
|
-
/**
|
|
61
|
-
* Recursively discover HTTP route files under `src/routes/`.
|
|
62
|
-
*
|
|
63
|
-
* Walks the directory tree applying the same filesystem convention as the
|
|
64
|
-
* local dev server:
|
|
65
|
-
* - File name (without extension) determines the HTTP method via
|
|
66
|
-
* {@link ROUTE_FILENAME_TO_METHOD}. Files whose name doesn't match a
|
|
67
|
-
* known method (e.g. `helpers.ts`, `_middleware.ts`) are silently
|
|
68
|
-
* skipped — they're treated as colocated implementation detail.
|
|
69
|
-
* - Directory `[param]` becomes `{param}` in the API path and `param`
|
|
70
|
-
* in the slug. Plain directories appear verbatim in both.
|
|
71
|
-
* - Test/spec/declaration files (`*.test.ts`, `*.spec.ts`, `*.d.ts`)
|
|
72
|
-
* are skipped.
|
|
73
|
-
*
|
|
74
|
-
* Exported so unit tests can assert the convention without spinning up a
|
|
75
|
-
* full CDK stack, and so external tooling can reuse it.
|
|
76
|
-
*/
|
|
77
|
-
export function discoverRouteFiles(routesDir) {
|
|
78
|
-
const routes = [];
|
|
79
|
-
if (!fs.existsSync(routesDir))
|
|
80
|
-
return routes;
|
|
81
|
-
function scan(dir, apiPath, slug) {
|
|
82
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
83
|
-
for (const entry of entries) {
|
|
84
|
-
const fullPath = path.join(dir, entry.name);
|
|
85
|
-
if (entry.isDirectory()) {
|
|
86
|
-
const isParam = entry.name.startsWith('[') && entry.name.endsWith(']');
|
|
87
|
-
const paramName = isParam
|
|
88
|
-
? entry.name.slice(1, -1)
|
|
89
|
-
: entry.name;
|
|
90
|
-
const apiSegment = isParam ? `{${paramName}}` : entry.name;
|
|
91
|
-
scan(fullPath, `${apiPath}/${apiSegment}`, slug ? `${slug}-${paramName}` : paramName);
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
if (!entry.isFile())
|
|
95
|
-
continue;
|
|
96
|
-
if (!/\.(ts|js)$/.test(entry.name))
|
|
97
|
-
continue;
|
|
98
|
-
if (entry.name.endsWith('.d.ts'))
|
|
99
|
-
continue;
|
|
100
|
-
const baseName = entry.name.replace(/\.(ts|js)$/, '');
|
|
101
|
-
// Skip colocated tests/specs to keep the deploy flow honest.
|
|
102
|
-
if (baseName.endsWith('.test') || baseName.endsWith('.spec'))
|
|
103
|
-
continue;
|
|
104
|
-
const method = ROUTE_FILENAME_TO_METHOD[baseName.toLowerCase()];
|
|
105
|
-
if (!method)
|
|
106
|
-
continue;
|
|
107
|
-
routes.push({
|
|
108
|
-
method,
|
|
109
|
-
apiPath: apiPath || '/',
|
|
110
|
-
slug: slug ? `${slug}-${baseName.toLowerCase()}` : baseName.toLowerCase(),
|
|
111
|
-
handlerFile: fullPath,
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
scan(routesDir, '', '');
|
|
116
|
-
return routes;
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* djb2 string hash. Stable across Node versions and platforms because
|
|
120
|
-
* it operates on UTF-16 code units returned by `charCodeAt`. Used for
|
|
121
|
-
* route → nested-stack slot assignment, where the only requirement
|
|
122
|
-
* is a deterministic, well-distributed mapping. Do not change the
|
|
123
|
-
* algorithm: every existing deployed VentureKit stack has its
|
|
124
|
-
* route-Lambda slots derived from this exact hash, and altering it
|
|
125
|
-
* would force a one-shot mass migration of every nested-stack route.
|
|
126
|
-
*/
|
|
127
|
-
function djb2Hash(input) {
|
|
128
|
-
let h = 5381;
|
|
129
|
-
for (let i = 0; i < input.length; i++) {
|
|
130
|
-
h = ((h << 5) + h + input.charCodeAt(i)) | 0;
|
|
131
|
-
}
|
|
132
|
-
return h >>> 0;
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Map a route slug to a nested-stack slot index in `[0, numSlots)`.
|
|
136
|
-
* `numSlots` MUST be a power of two so the bitwise-AND modulo
|
|
137
|
-
* distributes the hash output evenly. Exported for unit tests
|
|
138
|
-
* asserting bucketing stability across route-set changes.
|
|
139
|
-
*/
|
|
140
|
-
export function stableSlotFor(slug, numSlots) {
|
|
141
|
-
return djb2Hash(slug) & (numSlots - 1);
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Lazily-loaded esbuild module. We avoid a top-level import so unit
|
|
145
|
-
* tests that set `_skipBundling: true` (which short-circuits before any
|
|
146
|
-
* bundling happens) don't pay the import cost or require esbuild to be
|
|
147
|
-
* installed in their sandbox. `createRequire` works around the fact
|
|
148
|
-
* that this file is ESM and esbuild's package exports are CJS-shaped.
|
|
149
|
-
*/
|
|
150
|
-
let esbuildModule;
|
|
151
|
-
function loadEsbuild() {
|
|
152
|
-
if (!esbuildModule) {
|
|
153
|
-
const requireFromHere = createRequire(import.meta.url);
|
|
154
|
-
esbuildModule = requireFromHere('esbuild');
|
|
155
|
-
}
|
|
156
|
-
return esbuildModule;
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Bundle a Lambda handler in-process via esbuild's synchronous API.
|
|
160
|
-
*
|
|
161
|
-
* Replaces an earlier Docker-based bundling pipeline that spun up a
|
|
162
|
-
* SAM Node 20 image, ran `npm install -g esbuild`, and bundled the
|
|
163
|
-
* handler — taking ~30 s per Lambda. With 100+ handlers in a project
|
|
164
|
-
* that meant over an hour of synth time and a long tail of Docker-
|
|
165
|
-
* related failure modes (UID/HOME/permission edge cases on GitHub-
|
|
166
|
-
* hosted runners). In-process bundling collapses the entire sequence
|
|
167
|
-
* to a single `esbuild.buildSync` call: sub-second per handler,
|
|
168
|
-
* native Node module resolution against the consumer's `node_modules`,
|
|
169
|
-
* no Docker daemon dependency.
|
|
170
|
-
*
|
|
171
|
-
* The asset is the temp directory holding the bundled `index.js`, so
|
|
172
|
-
* CDK uploads only the bundled output to S3 — never the source tree.
|
|
173
|
-
*
|
|
174
|
-
* Modules baked into the Lambda runtime (`@aws-sdk/*`) are kept
|
|
175
|
-
* external so they aren't redundantly included in the bundle.
|
|
176
|
-
*/
|
|
177
|
-
function bundleHandlerCodeWithEsbuild(handlerFile, projectDir) {
|
|
178
|
-
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'venturekit-bundle-'));
|
|
179
|
-
const outfile = path.join(outDir, 'index.js');
|
|
180
|
-
loadEsbuild().buildSync({
|
|
181
|
-
entryPoints: [handlerFile],
|
|
182
|
-
bundle: true,
|
|
183
|
-
platform: 'node',
|
|
184
|
-
target: 'node20',
|
|
185
|
-
outfile,
|
|
186
|
-
external: ['@aws-sdk/*'],
|
|
187
|
-
absWorkingDir: projectDir,
|
|
188
|
-
logLevel: 'error',
|
|
189
|
-
});
|
|
190
|
-
return lambda.Code.fromAsset(outDir);
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Inline Lambda stub used in unit tests when `_skipBundling: true`.
|
|
194
|
-
* Satisfies API Gateway's contract; tests assert on resource shape,
|
|
195
|
-
* not on Lambda execution.
|
|
196
|
-
*/
|
|
197
|
-
function inlineStubLambdaCode() {
|
|
198
|
-
return lambda.Code.fromInline("exports.main = async () => ({ statusCode: 200, body: '{\"stub\":true}' });");
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Holds a chunk of HTTP route Lambdas in a separate CloudFormation
|
|
202
|
-
* template so the parent VentureStack stays under CloudFormation's hard
|
|
203
|
-
* 500-resource per-stack limit.
|
|
204
|
-
*
|
|
205
|
-
* Each route translates to ~5 CFN resources (Function, Permission,
|
|
206
|
-
* Integration, Route, log-retention custom resource), so projects with
|
|
207
|
-
* more than ~80 routes need this sharding to be deployable at all.
|
|
208
|
-
*
|
|
209
|
-
* The route Lambdas reference the parent stack's API Gateway, IAM role,
|
|
210
|
-
* security group, and VPC via standard CDK cross-stack references —
|
|
211
|
-
* CloudFormation wires the Outputs/Parameters automatically. The
|
|
212
|
-
* helpers (`toLambdaRuntime`, `toLogRetention`, `shortHash`) are
|
|
213
|
-
* replicated here intentionally rather than extracted to a shared
|
|
214
|
-
* module: they are short, change rarely, and keeping the diff localized
|
|
215
|
-
* makes the fix easier to audit and revert.
|
|
216
|
-
*/
|
|
217
|
-
class RoutesNestedStack extends cdk.NestedStack {
|
|
218
|
-
constructor(scope, id, props) {
|
|
219
|
-
super(scope, id, props);
|
|
220
|
-
for (const route of props.routes) {
|
|
221
|
-
this.createRouteFunction(route, props);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
createRouteFunction(route, props) {
|
|
225
|
-
const { api, role, securityGroup, vpc, envConfig, ventureBaseEnv } = props;
|
|
226
|
-
const lambdaConfig = envConfig.lambda;
|
|
227
|
-
const resourceId = `route-${route.slug}`;
|
|
228
|
-
// Deliberately omit `functionName` for routes living in a nested
|
|
229
|
-
// stack. The slot a route lands in is derived from a hash of its
|
|
230
|
-
// slug (see the bucketing logic in `VentureStack`), but the slot
|
|
231
|
-
// count itself grows monotonically as the project's route count
|
|
232
|
-
// grows. Each time the slot count changes — and any time the hash
|
|
233
|
-
// distribution moves a route across an existing slot boundary —
|
|
234
|
-
// CloudFormation has to migrate the route's Lambda from one
|
|
235
|
-
// nested stack to another. With an explicit physical name CFN
|
|
236
|
-
// refuses to create the new copy until the old one is deleted, but
|
|
237
|
-
// CFN updates nested stacks in parallel, so we'd deadlock with a
|
|
238
|
-
// `route-foo already exists in stack ...` error. Letting CDK
|
|
239
|
-
// auto-generate the function name avoids the collision: the new
|
|
240
|
-
// Lambda gets a unique name, comes up cleanly, and the old one is
|
|
241
|
-
// deleted as part of the same change set.
|
|
242
|
-
//
|
|
243
|
-
// Operators discover route Lambdas via the `Description` field
|
|
244
|
-
// below (`<METHOD> <api-path>`) and CloudWatch log groups still
|
|
245
|
-
// resolve via the function ARN attached to API Gateway, so the
|
|
246
|
-
// loss of the predictable name is mostly cosmetic.
|
|
247
|
-
const fn = new lambda.Function(this, resourceId, {
|
|
248
|
-
runtime: this.toLambdaRuntime(envConfig),
|
|
249
|
-
architecture: this.toLambdaArchitecture(envConfig),
|
|
250
|
-
handler: 'index.main',
|
|
251
|
-
code: props.skipBundling
|
|
252
|
-
? inlineStubLambdaCode()
|
|
253
|
-
: bundleHandlerCodeWithEsbuild(route.handlerFile, props.projectDir),
|
|
254
|
-
memorySize: lambdaConfig.memoryMb,
|
|
255
|
-
timeout: cdk.Duration.seconds(lambdaConfig.timeoutSec),
|
|
256
|
-
description: `${route.method} ${route.apiPath}`,
|
|
257
|
-
environment: {
|
|
258
|
-
...ventureBaseEnv,
|
|
259
|
-
...lambdaConfig.environmentVariables,
|
|
260
|
-
},
|
|
261
|
-
logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
|
|
262
|
-
tracing: lambdaConfig.tracingEnabled
|
|
263
|
-
? lambda.Tracing.ACTIVE
|
|
264
|
-
: lambda.Tracing.DISABLED,
|
|
265
|
-
reservedConcurrentExecutions: lambdaConfig.reservedConcurrency,
|
|
266
|
-
role,
|
|
267
|
-
...(vpc && securityGroup
|
|
268
|
-
? {
|
|
269
|
-
vpc,
|
|
270
|
-
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
271
|
-
securityGroups: [securityGroup],
|
|
272
|
-
}
|
|
273
|
-
: {}),
|
|
274
|
-
});
|
|
275
|
-
new apigatewayv2.HttpRoute(this, `${resourceId}-route`, {
|
|
276
|
-
httpApi: api,
|
|
277
|
-
routeKey: apigatewayv2.HttpRouteKey.with(route.apiPath, apigatewayv2.HttpMethod[route.method]),
|
|
278
|
-
integration: new apigatewayv2Integrations.HttpLambdaIntegration(`${resourceId}-integration`, fn),
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
toLambdaRuntime(envConfig) {
|
|
282
|
-
switch (envConfig.lambda.runtime) {
|
|
283
|
-
case 'nodejs22.x':
|
|
284
|
-
return lambda.Runtime.NODEJS_22_X;
|
|
285
|
-
case 'nodejs18.x':
|
|
286
|
-
return lambda.Runtime.NODEJS_18_X;
|
|
287
|
-
default:
|
|
288
|
-
return lambda.Runtime.NODEJS_20_X;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
toLambdaArchitecture(envConfig) {
|
|
292
|
-
return envConfig.lambda.architecture === 'x86_64'
|
|
293
|
-
? lambda.Architecture.X86_64
|
|
294
|
-
: lambda.Architecture.ARM_64;
|
|
295
|
-
}
|
|
296
|
-
toLogRetention(days) {
|
|
297
|
-
if (days <= 1)
|
|
298
|
-
return logs.RetentionDays.ONE_DAY;
|
|
299
|
-
if (days <= 3)
|
|
300
|
-
return logs.RetentionDays.THREE_DAYS;
|
|
301
|
-
if (days <= 5)
|
|
302
|
-
return logs.RetentionDays.FIVE_DAYS;
|
|
303
|
-
if (days <= 7)
|
|
304
|
-
return logs.RetentionDays.ONE_WEEK;
|
|
305
|
-
if (days <= 14)
|
|
306
|
-
return logs.RetentionDays.TWO_WEEKS;
|
|
307
|
-
if (days <= 30)
|
|
308
|
-
return logs.RetentionDays.ONE_MONTH;
|
|
309
|
-
if (days <= 60)
|
|
310
|
-
return logs.RetentionDays.TWO_MONTHS;
|
|
311
|
-
if (days <= 90)
|
|
312
|
-
return logs.RetentionDays.THREE_MONTHS;
|
|
313
|
-
if (days <= 120)
|
|
314
|
-
return logs.RetentionDays.FOUR_MONTHS;
|
|
315
|
-
if (days <= 150)
|
|
316
|
-
return logs.RetentionDays.FIVE_MONTHS;
|
|
317
|
-
if (days <= 180)
|
|
318
|
-
return logs.RetentionDays.SIX_MONTHS;
|
|
319
|
-
if (days <= 365)
|
|
320
|
-
return logs.RetentionDays.ONE_YEAR;
|
|
321
|
-
return logs.RetentionDays.INFINITE;
|
|
322
|
-
}
|
|
323
|
-
shortHash(input) {
|
|
324
|
-
let h = 5381;
|
|
325
|
-
for (let i = 0; i < input.length; i++) {
|
|
326
|
-
h = ((h << 5) + h + input.charCodeAt(i)) | 0;
|
|
327
|
-
}
|
|
328
|
-
return (h >>> 0).toString(16).padStart(6, '0').slice(0, 6);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
/**
|
|
332
|
-
* Discover migration directories shipped by installed `@venturekit/*`
|
|
333
|
-
* packages.
|
|
334
|
-
*
|
|
335
|
-
* Convention: a package opts in by declaring a
|
|
336
|
-
* `"vk": { "migrations": "<relative-dir>" }` field in its own
|
|
337
|
-
* `package.json` (e.g. `@venturekit/notify`, `@venturekit/auth`).
|
|
338
|
-
*
|
|
339
|
-
* Resolution walks the project's `package.json` dependencies, then
|
|
340
|
-
* uses `createRequire(projectDir/package.json)` to locate each
|
|
341
|
-
* `@venturekit/*` package's own `package.json`. This works under pnpm
|
|
342
|
-
* (hoisted), npm (flat), and yarn pnp.
|
|
343
|
-
*
|
|
344
|
-
* Returns absolute paths in declaration order. Missing packages,
|
|
345
|
-
* missing `vk.migrations` field, and non-existent dirs are silently
|
|
346
|
-
* skipped — we only add a dir that actually exists on disk so a
|
|
347
|
-
* stripped publish (e.g. without the `migrations/` artifact) doesn't
|
|
348
|
-
* break `vk deploy`. A warning is logged for the "opted-in but missing
|
|
349
|
-
* on disk" case so the operator notices.
|
|
350
|
-
*
|
|
351
|
-
* MUST stay in lockstep with `@venturekit/cli`'s
|
|
352
|
-
* `discoverPackageMigrationDirs` in `commands/migrate.ts` so a local
|
|
353
|
-
* `vk migrate` and a `vk deploy` apply the exact same merged schema.
|
|
354
|
-
*/
|
|
355
|
-
/**
|
|
356
|
-
* Locate `<name>/package.json` for a venturekit package installed in
|
|
357
|
-
* `projectDir`'s dependency tree.
|
|
358
|
-
*
|
|
359
|
-
* We can't use `require.resolve(`${name}/package.json`)` because most
|
|
360
|
-
* venturekit packages ship a strict `"exports"` map that doesn't include
|
|
361
|
-
* `"./package.json"`, which makes Node's resolver throw
|
|
362
|
-
* `ERR_PACKAGE_PATH_NOT_EXPORTED`. Instead we walk `node_modules`
|
|
363
|
-
* directories upward from `projectDir` and check for a real file on
|
|
364
|
-
* disk — works under pnpm (hoisted + symlinked), npm flat, and yarn
|
|
365
|
-
* classic.
|
|
366
|
-
*
|
|
367
|
-
* MUST stay in lockstep with the same helper in `@venturekit/cli`'s
|
|
368
|
-
* `commands/migrate.ts` so a `vk deploy` and a local `vk migrate`
|
|
369
|
-
* discover the same set of package migrations.
|
|
370
|
-
*/
|
|
371
|
-
function resolvePackageJsonPath(projectDir, name) {
|
|
372
|
-
let dir = projectDir;
|
|
373
|
-
// eslint-disable-next-line no-constant-condition
|
|
374
|
-
while (true) {
|
|
375
|
-
const candidate = path.join(dir, 'node_modules', name, 'package.json');
|
|
376
|
-
if (fs.existsSync(candidate))
|
|
377
|
-
return candidate;
|
|
378
|
-
const parent = path.dirname(dir);
|
|
379
|
-
if (parent === dir)
|
|
380
|
-
return null;
|
|
381
|
-
dir = parent;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
function discoverPackageMigrationDirs(projectDir) {
|
|
385
|
-
const dirs = [];
|
|
386
|
-
const projectPkgPath = path.join(projectDir, 'package.json');
|
|
387
|
-
let projectPkg;
|
|
388
|
-
try {
|
|
389
|
-
projectPkg = JSON.parse(fs.readFileSync(projectPkgPath, 'utf8'));
|
|
390
|
-
}
|
|
391
|
-
catch {
|
|
392
|
-
return dirs;
|
|
393
|
-
}
|
|
394
|
-
const candidates = new Set([
|
|
395
|
-
...Object.keys(projectPkg.dependencies ?? {}),
|
|
396
|
-
...Object.keys(projectPkg.devDependencies ?? {}),
|
|
397
|
-
]);
|
|
398
|
-
for (const name of candidates) {
|
|
399
|
-
if (!name.startsWith('@venturekit/'))
|
|
400
|
-
continue;
|
|
401
|
-
const pkgJsonPath = resolvePackageJsonPath(projectDir, name);
|
|
402
|
-
if (!pkgJsonPath)
|
|
403
|
-
continue;
|
|
404
|
-
let pkgJson;
|
|
405
|
-
try {
|
|
406
|
-
pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
407
|
-
}
|
|
408
|
-
catch {
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
const rel = pkgJson.vk?.migrations;
|
|
412
|
-
if (typeof rel !== 'string' || rel.length === 0)
|
|
413
|
-
continue;
|
|
414
|
-
const abs = path.join(path.dirname(pkgJsonPath), rel);
|
|
415
|
-
if (!fs.existsSync(abs)) {
|
|
416
|
-
console.warn(`[venturekit] ${name} declares vk.migrations='${rel}' but ${abs} does not exist — skipping.`);
|
|
417
|
-
continue;
|
|
418
|
-
}
|
|
419
|
-
dirs.push(abs);
|
|
420
|
-
}
|
|
421
|
-
return dirs;
|
|
422
|
-
}
|
|
423
|
-
export class VentureStack extends cdk.Stack {
|
|
424
|
-
outputs = {};
|
|
425
|
-
/** Resolved environment configuration driving all resource sizing */
|
|
426
|
-
envConfig;
|
|
427
|
-
/** Data safety configuration for removal policies */
|
|
428
|
-
dataSafetyConfig;
|
|
429
|
-
/** Whether this is a free-tier deployment */
|
|
430
|
-
isFreeTier;
|
|
431
|
-
/** Standard tags applied to all taggable resources */
|
|
432
|
-
standardTags;
|
|
433
|
-
/**
|
|
434
|
-
* Per-project HMAC secret used to sign internal Lambda-to-Lambda calls.
|
|
435
|
-
* Auto-generated on first deploy and preserved across subsequent deploys by
|
|
436
|
-
* CloudFormation. Injected as `VENTURE_INTERNAL_HMAC_SECRET` into every
|
|
437
|
-
* Lambda function the stack creates, so `invoke()` callers and handler
|
|
438
|
-
* receivers share the same key.
|
|
439
|
-
*/
|
|
440
|
-
internalHmacSecret;
|
|
441
|
-
/** Base environment variables applied to every VentureKit Lambda. */
|
|
442
|
-
ventureBaseEnv;
|
|
443
|
-
/** @internal — when true, all Lambda code uses an inline stub instead of Docker bundling. Tests only. */
|
|
444
|
-
skipBundling;
|
|
445
|
-
/**
|
|
446
|
-
* Absolute path to the consumer project's root (the directory that
|
|
447
|
-
* holds `package.json` + `node_modules`). Used as the bundling asset
|
|
448
|
-
* root so esbuild can resolve handler imports against the project's
|
|
449
|
-
* installed dependencies.
|
|
450
|
-
*/
|
|
451
|
-
projectDir;
|
|
452
|
-
/**
|
|
453
|
-
* Cognito User Pools created by `createAuth`, kept around so the
|
|
454
|
-
* shared Lambda execution role can be granted admin actions on them
|
|
455
|
-
* once the role exists (the role is created later in the constructor,
|
|
456
|
-
* after every infrastructure intent has been processed).
|
|
457
|
-
*/
|
|
458
|
-
userPools = [];
|
|
459
|
-
/**
|
|
460
|
-
* Secrets Manager placeholders provisioned by `createAuth` for
|
|
461
|
-
* `AuthIntent.federated` providers (Google / Facebook / Apple OAuth
|
|
462
|
-
* client id + secret). Tracked so the shared Lambda role can be
|
|
463
|
-
* granted `secretsmanager:GetSecretValue` on each one once the role
|
|
464
|
-
* exists. The ARNs are also injected as
|
|
465
|
-
* `COGNITO_FEDERATED_<PROVIDER>_SECRET_ARN` env vars on every Lambda.
|
|
466
|
-
*/
|
|
467
|
-
federatedAuthSecrets = [];
|
|
468
|
-
/**
|
|
469
|
-
* S3 buckets created by `createStorage`, kept around so the shared
|
|
470
|
-
* Lambda execution role can be granted `s3:Get*` / `s3:Put*` on them
|
|
471
|
-
* once the role exists, and so the *first* bucket's name can be
|
|
472
|
-
* injected as `VENTURE_STORAGE_BUCKET` for `createStorageClientFromEnv()`.
|
|
473
|
-
* Without this wiring, every Lambda calling `@venturekit/storage` would
|
|
474
|
-
* throw `Storage bucket not configured` at runtime even though the
|
|
475
|
-
* bucket exists in CloudFormation.
|
|
476
|
-
*/
|
|
477
|
-
storageBuckets = [];
|
|
478
|
-
/**
|
|
479
|
-
* Notify configurations created by `createNotify`. Tracked so the
|
|
480
|
-
* shared Lambda role can be granted `ses:SendEmail` on the
|
|
481
|
-
* configuration set + identity ARNs once the role exists, and so
|
|
482
|
-
* env vars (`VENTURE_NOTIFY_FROM`, `VENTURE_NOTIFY_CONFIG_SET`, …)
|
|
483
|
-
* land on every Lambda — `@venturekit/notify`'s
|
|
484
|
-
* `createSesEmailProvider` reads them at cold start. Also drives
|
|
485
|
-
* the auto-provisioning of dispatcher + bounce-handler Lambdas
|
|
486
|
-
* after the rest of the stack is wired (we need the shared role
|
|
487
|
-
* + VPC + SG to exist first).
|
|
488
|
-
*/
|
|
489
|
-
notifyConfigs = [];
|
|
490
|
-
/**
|
|
491
|
-
* Absolute paths to `migrations/` directories shipped by installed
|
|
492
|
-
* `@venturekit/*` packages (e.g. `@venturekit/notify`,
|
|
493
|
-
* `@venturekit/auth`). Resolved once at the top of the constructor
|
|
494
|
-
* via declarative `vk.migrations` discovery so `createDatabase` can
|
|
495
|
-
* pass the merged list to `MigrationRunner` without re-walking
|
|
496
|
-
* `node_modules` per database.
|
|
497
|
-
*
|
|
498
|
-
* Discovery mirrors the CLI side
|
|
499
|
-
* (`@venturekit/cli`'s `discoverPackageMigrationDirs`) so a `vk deploy`
|
|
500
|
-
* and a local `vk migrate` always materialize the same merged schema.
|
|
501
|
-
* Empty when no installed venturekit package opts in via
|
|
502
|
-
* `"vk": { "migrations": "<dir>" }` in its `package.json`.
|
|
503
|
-
*/
|
|
504
|
-
packageMigrationsDirs = [];
|
|
505
|
-
constructor(scope, id, props) {
|
|
506
|
-
super(scope, id, props);
|
|
507
|
-
const { projectName, stage, routesDir, functionsDir, queuesDir, cronsDir, infrastructure, projectDir, preset } = props;
|
|
508
|
-
this.skipBundling = props._skipBundling === true;
|
|
509
|
-
this.projectDir = projectDir;
|
|
510
|
-
// --- Resolve environment configuration ---
|
|
511
|
-
// Priority: explicit envConfig > resolve from preset > fallback to 'micro'.
|
|
512
|
-
// We pass the literal `stage` to resolveEnvConfig — any stage name
|
|
513
|
-
// the project declared (including custom ones like `preview`) is
|
|
514
|
-
// valid. Unknown stages get conservative defaults (dataSafety
|
|
515
|
-
// 'standard') inside resolveEnvConfig, not here.
|
|
516
|
-
this.envConfig = props.envConfig ?? resolveEnvConfig(stage, { preset: preset ?? 'micro' });
|
|
517
|
-
this.isFreeTier = preset === 'free' || this.envConfig.dataSafety === 'relaxed' && preset === 'free';
|
|
518
|
-
this.dataSafetyConfig = DATA_SAFETY_CONFIG[this.envConfig.dataSafety];
|
|
519
|
-
// --- Well-Architected: Operational Excellence — consistent tagging ---
|
|
520
|
-
this.standardTags = {
|
|
521
|
-
Project: projectName,
|
|
522
|
-
Stage: stage,
|
|
523
|
-
ManagedBy: 'venturekit',
|
|
524
|
-
Preset: preset ?? 'micro',
|
|
525
|
-
};
|
|
526
|
-
Object.entries(this.standardTags).forEach(([key, value]) => {
|
|
527
|
-
cdk.Tags.of(this).add(key, value);
|
|
528
|
-
});
|
|
529
|
-
// --- Well-Architected: Security — per-project HMAC secret for internal calls ---
|
|
530
|
-
// CloudFormation auto-generates a 64-char alphanumeric value on first deploy
|
|
531
|
-
// and preserves it across stack updates, so warm Lambdas don't get kicked off
|
|
532
|
-
// by a secret rotation every deploy. The secret's VALUE is injected as an
|
|
533
|
-
// env var on every Lambda — not just a reference — so no runtime IAM call.
|
|
534
|
-
this.internalHmacSecret = new secretsmanager.Secret(this, 'internalHmacSecret', {
|
|
535
|
-
secretName: `venturekit/${projectName}/${stage}/internal-hmac`,
|
|
536
|
-
description: 'VentureKit internal Lambda-to-Lambda HMAC signing key. Auto-generated.',
|
|
537
|
-
generateSecretString: {
|
|
538
|
-
passwordLength: 64,
|
|
539
|
-
excludePunctuation: true,
|
|
540
|
-
excludeCharacters: '"\'\\/',
|
|
541
|
-
requireEachIncludedType: false,
|
|
542
|
-
includeSpace: false,
|
|
543
|
-
},
|
|
544
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
545
|
-
});
|
|
546
|
-
// Centralized base env vars applied to every Lambda in the stack.
|
|
547
|
-
this.ventureBaseEnv = {
|
|
548
|
-
VENTURE_PROJECT_NAME: projectName,
|
|
549
|
-
VENTURE_STAGE: stage,
|
|
550
|
-
VENTURE_INTERNAL_HMAC_SECRET: this.internalHmacSecret.secretValue.unsafeUnwrap(),
|
|
551
|
-
NODE_OPTIONS: '--enable-source-maps',
|
|
552
|
-
};
|
|
553
|
-
const lambdaConfig = this.envConfig.lambda;
|
|
554
|
-
const vpcConfig = this.envConfig.vpc;
|
|
555
|
-
const apiConfig = this.envConfig.api;
|
|
556
|
-
const obsConfig = this.envConfig.observability;
|
|
557
|
-
// --- Shared VPC ---
|
|
558
|
-
// Well-Architected: Security — network isolation for databases and caches
|
|
559
|
-
// Free tier: skip VPC entirely (NAT gateways are not free)
|
|
560
|
-
// Stable logical ID 'Vpc' ensures preset changes don't replace the VPC
|
|
561
|
-
let vpc;
|
|
562
|
-
const needsVpc = !this.isFreeTier && (lambdaConfig.vpcEnabled ||
|
|
563
|
-
(infrastructure?.databases && infrastructure.databases.length > 0) ||
|
|
564
|
-
(infrastructure?.caches && infrastructure.caches.length > 0));
|
|
565
|
-
if (needsVpc) {
|
|
566
|
-
const subnetConfig = [
|
|
567
|
-
{ name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: vpcConfig.publicSubnets.cidrMask },
|
|
568
|
-
{ name: 'private', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: vpcConfig.privateSubnets.cidrMask },
|
|
569
|
-
];
|
|
570
|
-
if (vpcConfig.isolatedSubnets) {
|
|
571
|
-
subnetConfig.push({
|
|
572
|
-
name: 'isolated',
|
|
573
|
-
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
|
|
574
|
-
cidrMask: vpcConfig.isolatedSubnets.cidrMask,
|
|
575
|
-
});
|
|
576
|
-
}
|
|
577
|
-
// Well-Architected: Cost — pick the NAT provider per preset. Dev
|
|
578
|
-
// and small-prod presets (nano/micro/medium) use `fck-nat` EC2
|
|
579
|
-
// instances (~$3-25/mo per AZ) instead of managed NAT Gateways
|
|
580
|
-
// (~$33/mo per AZ + $0.045/GB). Large defaults to NAT GW for SLA
|
|
581
|
-
// and zero-ops reasons. `none` skips NAT entirely (free preset).
|
|
582
|
-
//
|
|
583
|
-
// fck-nat is the community-maintained AL2023 NAT AMI, owned by
|
|
584
|
-
// AWS account `568608671756`. The AMI is published in every
|
|
585
|
-
// commercial region (verified against the fck-nat packer
|
|
586
|
-
// manifest). We pin to ARM64 — 20% cheaper and matches the
|
|
587
|
-
// `t4g.*` default for `natInstanceType`.
|
|
588
|
-
const natGateways = vpcConfig.natType === 'none' ? 0 : vpcConfig.natGateways;
|
|
589
|
-
let natGatewayProvider;
|
|
590
|
-
if (vpcConfig.natType === 'instance' && natGateways > 0) {
|
|
591
|
-
natGatewayProvider = ec2.NatProvider.instanceV2({
|
|
592
|
-
instanceType: new ec2.InstanceType(vpcConfig.natInstanceType ?? 't4g.nano'),
|
|
593
|
-
machineImage: ec2.MachineImage.lookup({
|
|
594
|
-
name: 'fck-nat-al2023-*-arm64-ebs',
|
|
595
|
-
owners: ['568608671756'],
|
|
596
|
-
}),
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
|
-
// natType: 'gateway' leaves natGatewayProvider undefined, which
|
|
600
|
-
// is CDK's signal to provision managed NAT Gateways.
|
|
601
|
-
vpc = new ec2.Vpc(this, 'Vpc', {
|
|
602
|
-
maxAzs: vpcConfig.maxAzs,
|
|
603
|
-
natGateways,
|
|
604
|
-
natGatewayProvider,
|
|
605
|
-
ipAddresses: ec2.IpAddresses.cidr(vpcConfig.cidr),
|
|
606
|
-
subnetConfiguration: subnetConfig,
|
|
607
|
-
// Well-Architected: Security — restrict default SG
|
|
608
|
-
restrictDefaultSecurityGroup: true,
|
|
609
|
-
});
|
|
610
|
-
// Well-Architected: Security — VPC flow logs for network monitoring
|
|
611
|
-
if (vpcConfig.flowLogsEnabled) {
|
|
612
|
-
vpc.addFlowLog('FlowLog', {
|
|
613
|
-
destination: ec2.FlowLogDestination.toCloudWatchLogs(new logs.LogGroup(this, 'VpcFlowLogGroup', {
|
|
614
|
-
logGroupName: `/venturekit/${projectName}/${stage}/vpc-flow-logs`,
|
|
615
|
-
retention: this.toLogRetention(vpcConfig.flowLogsRetentionDays),
|
|
616
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
617
|
-
})),
|
|
618
|
-
trafficType: ec2.FlowLogTrafficType.ALL,
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
// Gateway VPC endpoints (S3, DynamoDB) — always on.
|
|
622
|
-
//
|
|
623
|
-
// These are *free* (no hourly charge, no per-GB charge) and are
|
|
624
|
-
// the only correct way to keep VPC-internal S3/DynamoDB traffic
|
|
625
|
-
// off the NAT path. The migration runner's CodeBuild source
|
|
626
|
-
// download targets `cdk-hnb659fds-assets-<account>-<region>` in
|
|
627
|
-
// S3 and previously routed through the `t4g.nano` fck-nat
|
|
628
|
-
// instance shipped with the `nano` preset — a single small EC2
|
|
629
|
-
// that's a single point of failure and timed out under
|
|
630
|
-
// realistic CDK deploys (tracked as the "DOWNLOAD_SOURCE i/o
|
|
631
|
-
// timeout" report). With the gateway endpoint installed, the
|
|
632
|
-
// route table sends S3-prefix-list traffic straight to the
|
|
633
|
-
// gateway endpoint, bypassing NAT entirely.
|
|
634
|
-
//
|
|
635
|
-
// The `enableVpcEndpoints` flag below still gates the
|
|
636
|
-
// *expensive* Interface endpoints (Secrets Manager, ECR, Logs,
|
|
637
|
-
// ~$7.20/mo each) — that's a real cost knob worth keeping off
|
|
638
|
-
// the cheap presets.
|
|
639
|
-
vpc.addGatewayEndpoint('S3Endpoint', {
|
|
640
|
-
service: ec2.GatewayVpcEndpointAwsService.S3,
|
|
641
|
-
});
|
|
642
|
-
vpc.addGatewayEndpoint('DynamoEndpoint', {
|
|
643
|
-
service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
|
|
644
|
-
});
|
|
645
|
-
// Interface endpoints (paid) — opt-in via preset.
|
|
646
|
-
//
|
|
647
|
-
// Each interface endpoint costs ~$0.01/hr per AZ (~$7.20/mo per AZ)
|
|
648
|
-
// plus $0.01/GB processed, so the flag stays off on `free` and
|
|
649
|
-
// `nano` and on for `micro`/`medium`/`large`. The single most
|
|
650
|
-
// important endpoint to wire is Secrets Manager: every VPC-attached
|
|
651
|
-
// route Lambda calls `applyDbSecretToEnv()` on cold start, and
|
|
652
|
-
// without an SM endpoint that call has to traverse NAT — which
|
|
653
|
-
// is a single-AZ `t4g.nano` fck-nat instance on the small presets
|
|
654
|
-
// and a real reliability hazard (a hung NAT eats the entire
|
|
655
|
-
// Lambda timeout with no diagnostic output).
|
|
656
|
-
if (vpcConfig.enableVpcEndpoints) {
|
|
657
|
-
// Dedicated SG for the endpoints. Allow inbound HTTPS from the
|
|
658
|
-
// VPC CIDR only — interface endpoints are addressable via
|
|
659
|
-
// private DNS names that resolve to ENIs inside this VPC, so
|
|
660
|
-
// restricting ingress to the CIDR is both sufficient and
|
|
661
|
-
// tighter than the alternative of sharing the Lambda SG (which
|
|
662
|
-
// doesn't exist yet at this point in `constructor()` anyway).
|
|
663
|
-
const endpointSg = new ec2.SecurityGroup(this, 'VpcEndpointsSg', {
|
|
664
|
-
vpc,
|
|
665
|
-
description: `Interface endpoint SG for ${projectName}-${stage}`,
|
|
666
|
-
allowAllOutbound: false,
|
|
667
|
-
});
|
|
668
|
-
endpointSg.addIngressRule(ec2.Peer.ipv4(vpc.vpcCidrBlock), ec2.Port.tcp(443), 'HTTPS from anywhere in the VPC');
|
|
669
|
-
// Secrets Manager — the confirmed cold-start dependency for
|
|
670
|
-
// every Lambda that does any DB access. `privateDnsEnabled`
|
|
671
|
-
// makes `secretsmanager.<region>.amazonaws.com` resolve to the
|
|
672
|
-
// endpoint ENI inside the VPC, so existing AWS SDK clients
|
|
673
|
-
// (with no code changes) automatically use the endpoint.
|
|
674
|
-
vpc.addInterfaceEndpoint('SecretsManagerEndpoint', {
|
|
675
|
-
service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
|
|
676
|
-
securityGroups: [endpointSg],
|
|
677
|
-
subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
678
|
-
privateDnsEnabled: true,
|
|
679
|
-
});
|
|
680
|
-
// Future endpoints to consider here, gated by feature flags so
|
|
681
|
-
// small environments don't pay for what they don't use:
|
|
682
|
-
// - `LOGS`: only needed if your handlers call CloudWatch Logs
|
|
683
|
-
// APIs directly. Lambda's own log delivery does not flow
|
|
684
|
-
// through the function's network.
|
|
685
|
-
// - `SQS`: only needed if you call SQS from inside the VPC.
|
|
686
|
-
// Queue consumers are *triggered* by the Lambda service
|
|
687
|
-
// itself, which doesn't need a VPC endpoint either.
|
|
688
|
-
// - `KMS`: needed if you call KMS from handler code (e.g. for
|
|
689
|
-
// envelope encryption). Cognito + RDS encryption use KMS
|
|
690
|
-
// internally without traversing your network.
|
|
691
|
-
// - `STS`: needed if handlers assume cross-account roles.
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
// --- Resolve VentureKit-shipped migrations ---
|
|
695
|
-
// Discover every installed `@venturekit/*` package that opts in to
|
|
696
|
-
// shipping migrations via a `"vk": { "migrations": "<dir>" }` field
|
|
697
|
-
// in its own `package.json` (e.g. `@venturekit/notify` ships
|
|
698
|
-
// `vk_notify_*.sql`, `@venturekit/auth` ships `vk_auth_*.sql`).
|
|
699
|
-
//
|
|
700
|
-
// We resolve the dirs once here so every database below receives
|
|
701
|
-
// the same merged set — declaring multiple databases doesn't
|
|
702
|
-
// multiply the schema.
|
|
703
|
-
//
|
|
704
|
-
// Resolution: walk the project's `package.json` dependencies,
|
|
705
|
-
// then ask `createRequire(projectDir)` where each `@venturekit/*`
|
|
706
|
-
// package's own `package.json` lives. This survives pnpm hoisted
|
|
707
|
-
// layouts, npm flat layouts, and yarn pnp (which returns virtual
|
|
708
|
-
// paths require can still stat).
|
|
709
|
-
//
|
|
710
|
-
// This is the deploy-side mirror of `@venturekit/cli`'s
|
|
711
|
-
// `discoverPackageMigrationDirs` — keeping them in lockstep means
|
|
712
|
-
// `vk migrate` (local) and `vk deploy` always apply the same
|
|
713
|
-
// merged schema.
|
|
714
|
-
this.packageMigrationsDirs.push(...discoverPackageMigrationDirs(projectDir));
|
|
715
|
-
// --- Databases ---
|
|
716
|
-
// We collect the RDS instances (alongside the source intent) so the
|
|
717
|
-
// route-Lambda block below can:
|
|
718
|
-
// 1. grant ingress on the DB port via a shared security group, and
|
|
719
|
-
// 2. wire the *primary* DB's secret ARN + endpoint into the Lambda
|
|
720
|
-
// env so handlers can authenticate (`@venturekit/data`'s
|
|
721
|
-
// `applyDbSecretToEnv` resolves user/password at cold start).
|
|
722
|
-
// Without #2, every route handler would 503 the moment it tried to
|
|
723
|
-
// open a pg.Pool.
|
|
724
|
-
// Both DatabaseInstance and DatabaseInstanceFromSnapshot extend the
|
|
725
|
-
// (un-exported) DatabaseInstanceNew base, sharing the surface we use
|
|
726
|
-
// here: `.secret`, `.connections`, `.dbInstanceEndpoint{Address,Port}`,
|
|
727
|
-
// `.node`. The union keeps the snapshot-restore branch type-safe
|
|
728
|
-
// without needing a cast.
|
|
729
|
-
const dbInstances = [];
|
|
730
|
-
if (infrastructure?.databases) {
|
|
731
|
-
for (const db of infrastructure.databases) {
|
|
732
|
-
if (this.isFreeTier) {
|
|
733
|
-
this.createDynamoDBTable(db, projectName, stage);
|
|
734
|
-
}
|
|
735
|
-
else {
|
|
736
|
-
const instance = this.createDatabase(db, projectName, stage, vpc, projectDir, props.vkCliVersion ?? 'latest');
|
|
737
|
-
dbInstances.push({ instance, intent: db });
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
// --- Storage ---
|
|
742
|
-
if (infrastructure?.storage) {
|
|
743
|
-
for (const storage of infrastructure.storage) {
|
|
744
|
-
this.createStorage(storage, projectName, stage);
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
// --- Auth ---
|
|
748
|
-
if (infrastructure?.auth) {
|
|
749
|
-
for (const auth of infrastructure.auth) {
|
|
750
|
-
this.createAuth(auth, projectName, stage);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
// --- Notify (SES + outbox + auto-provisioned dispatcher cron) ---
|
|
754
|
-
// We provision SES identity + configuration set + events SNS topic
|
|
755
|
-
// here, but defer IAM grants and the dispatcher / bounce-handler
|
|
756
|
-
// Lambda creation until after the shared Lambda role exists below
|
|
757
|
-
// (so we can reuse the same role for least-privilege wiring).
|
|
758
|
-
if (infrastructure?.notify) {
|
|
759
|
-
for (const notify of infrastructure.notify) {
|
|
760
|
-
this.createNotify(notify, projectName, stage);
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
// --- Queues ---
|
|
764
|
-
if (infrastructure?.queues) {
|
|
765
|
-
for (const queue of infrastructure.queues) {
|
|
766
|
-
this.createQueue(queue, projectName, stage);
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
// --- Caches ---
|
|
770
|
-
// Free tier: skip ElastiCache (not free). Use DynamoDB-based caching
|
|
771
|
-
// via createDefaultRateLimiter() from @venturekit/runtime instead.
|
|
772
|
-
if (infrastructure?.caches && !this.isFreeTier) {
|
|
773
|
-
for (const cache of infrastructure.caches) {
|
|
774
|
-
this.createCache(cache, projectName, stage, vpc);
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
// Always create the rate-limit DynamoDB table. It's on the always-free
|
|
778
|
-
// tier, has zero cost at zero load, and is what `createDefaultRateLimiter()`
|
|
779
|
-
// falls back to in every environment. Shipping the table unconditionally
|
|
780
|
-
// means `rateLimitMiddleware()` "just works" on first deploy regardless of
|
|
781
|
-
// preset, and the fail-closed default on store errors (SEC-S15) won't
|
|
782
|
-
// surprise anyone with a 503 because the table was missing.
|
|
783
|
-
this.createRateLimitTable(projectName, stage);
|
|
784
|
-
// --- Monitoring ---
|
|
785
|
-
if (infrastructure?.monitoring) {
|
|
786
|
-
for (const mon of infrastructure.monitoring) {
|
|
787
|
-
this.createMonitoring(mon, projectName, stage);
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
// --- Secrets ---
|
|
791
|
-
if (infrastructure?.secrets) {
|
|
792
|
-
for (const secret of infrastructure.secrets) {
|
|
793
|
-
this.createSecret(secret, projectName, stage);
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
// --- API Gateway + Lambda routes ---
|
|
797
|
-
// Well-Architected: Performance — throttling protects downstream services
|
|
798
|
-
// Well-Architected: Security — CORS preflight with explicit origins in prod
|
|
799
|
-
const api = new apigatewayv2.HttpApi(this, 'Api', {
|
|
800
|
-
apiName: `${projectName}-${stage}`,
|
|
801
|
-
corsPreflight: apiConfig.cors ? {
|
|
802
|
-
allowOrigins: apiConfig.cors.allowOrigins,
|
|
803
|
-
allowMethods: apiConfig.cors.allowMethods.map((m) => apigatewayv2.CorsHttpMethod[m.toUpperCase()] ?? apigatewayv2.CorsHttpMethod.ANY),
|
|
804
|
-
allowHeaders: apiConfig.cors.allowHeaders,
|
|
805
|
-
allowCredentials: apiConfig.cors.allowCredentials,
|
|
806
|
-
maxAge: cdk.Duration.seconds(apiConfig.cors.maxAgeSec),
|
|
807
|
-
} : {
|
|
808
|
-
allowOrigins: ['*'],
|
|
809
|
-
allowMethods: [
|
|
810
|
-
apigatewayv2.CorsHttpMethod.GET,
|
|
811
|
-
apigatewayv2.CorsHttpMethod.POST,
|
|
812
|
-
apigatewayv2.CorsHttpMethod.PUT,
|
|
813
|
-
apigatewayv2.CorsHttpMethod.DELETE,
|
|
814
|
-
apigatewayv2.CorsHttpMethod.PATCH,
|
|
815
|
-
apigatewayv2.CorsHttpMethod.OPTIONS,
|
|
816
|
-
],
|
|
817
|
-
allowHeaders: ['*'],
|
|
818
|
-
},
|
|
819
|
-
disableExecuteApiEndpoint: false,
|
|
820
|
-
});
|
|
821
|
-
// Well-Architected: Reliability — RETAIN the HttpApi under strict
|
|
822
|
-
// dataSafety. The `*.execute-api.{region}.amazonaws.com` URL is unique
|
|
823
|
-
// to the API ID, so deleting the API invalidates every consumer that
|
|
824
|
-
// hardcoded that URL (mobile apps shipped with the URL baked in,
|
|
825
|
-
// partner integrations, webhook subscribers, etc.). Custom-domain
|
|
826
|
-
// mappings live on this same API ID — losing the API also breaks the
|
|
827
|
-
// mapping until manually re-attached. RETAIN means a deleted CFN
|
|
828
|
-
// stack leaves the API in place for `cdk import` recovery.
|
|
829
|
-
const cfnApi = api.node.defaultChild;
|
|
830
|
-
if (this.dataSafetyConfig.removalPolicy === 'retain') {
|
|
831
|
-
cfnApi.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
832
|
-
cfnApi.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
833
|
-
}
|
|
834
|
-
// Well-Architected: Operational Excellence — access logging for API
|
|
835
|
-
const apiLogGroup = new logs.LogGroup(this, 'ApiAccessLog', {
|
|
836
|
-
logGroupName: `/venturekit/${projectName}/${stage}/api-access`,
|
|
837
|
-
retention: this.toLogRetention(obsConfig.logs.retentionDays),
|
|
838
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
839
|
-
});
|
|
840
|
-
const defaultStage = api.defaultStage?.node.defaultChild;
|
|
841
|
-
if (defaultStage) {
|
|
842
|
-
defaultStage.accessLogSettings = {
|
|
843
|
-
destinationArn: apiLogGroup.logGroupArn,
|
|
844
|
-
format: JSON.stringify({
|
|
845
|
-
requestId: '$context.requestId',
|
|
846
|
-
ip: '$context.identity.sourceIp',
|
|
847
|
-
method: '$context.httpMethod',
|
|
848
|
-
path: '$context.path',
|
|
849
|
-
status: '$context.status',
|
|
850
|
-
latency: '$context.responseLatency',
|
|
851
|
-
integrationLatency: '$context.integrationLatency',
|
|
852
|
-
}),
|
|
853
|
-
};
|
|
854
|
-
defaultStage.defaultRouteSettings = {
|
|
855
|
-
detailedMetricsEnabled: apiConfig.detailedMetricsEnabled,
|
|
856
|
-
throttlingRateLimit: apiConfig.throttleRateLimit,
|
|
857
|
-
throttlingBurstLimit: apiConfig.throttleBurstLimit,
|
|
858
|
-
};
|
|
859
|
-
}
|
|
860
|
-
new cdk.CfnOutput(this, 'ApiUrl', {
|
|
861
|
-
value: api.apiEndpoint,
|
|
862
|
-
description: 'API Gateway URL',
|
|
863
|
-
});
|
|
864
|
-
// --- Custom Domains ---
|
|
865
|
-
//
|
|
866
|
-
// An empty-string `certificateArn` is the "pending" sentinel:
|
|
867
|
-
// `vk cert request` writes the ARN into the user's config file
|
|
868
|
-
// after DNS-based validation completes, and the first deploy
|
|
869
|
-
// (before the cert is ready) wants to succeed without trying to
|
|
870
|
-
// create a zombie ACM cert. Skipping the domain lets the API
|
|
871
|
-
// remain reachable at the auto-generated API Gateway URL; the
|
|
872
|
-
// next deploy — after `vk cert request` persists the ARN — wires
|
|
873
|
-
// the custom domain in.
|
|
874
|
-
if (infrastructure?.domains) {
|
|
875
|
-
for (const domain of infrastructure.domains) {
|
|
876
|
-
if (domain.certificateArn === '') {
|
|
877
|
-
new cdk.CfnOutput(this, `${projectName}-${domain.id}-domain-pending`, {
|
|
878
|
-
value: `Run \`vk cert request --domain ${domain.domain}\` to activate.`,
|
|
879
|
-
description: `Custom domain ${domain.domain} is pending — no ACM cert ARN yet.`,
|
|
880
|
-
});
|
|
881
|
-
continue;
|
|
882
|
-
}
|
|
883
|
-
this.createDomain(domain, projectName, stage, api);
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
// --- File-system Lambda discovery (routes + functions + queues + crons) ---
|
|
887
|
-
//
|
|
888
|
-
// Convention (mirrors `cli/src/utils/local-server.ts`):
|
|
889
|
-
// src/routes/health/get.ts → GET /health
|
|
890
|
-
// src/routes/users/[id]/put.ts → PUT /users/{id}
|
|
891
|
-
// src/functions/process-orders.ts → standalone Lambda
|
|
892
|
-
// src/queues/<name>.ts → SQS queue + consumer Lambda
|
|
893
|
-
// src/crons/<name>.ts → EventBridge rule + Lambda
|
|
894
|
-
//
|
|
895
|
-
// Every discovered handler becomes its own Lambda function. All four
|
|
896
|
-
// kinds share a single IAM role and (when there's a VPC) a single
|
|
897
|
-
// security group — both because it keeps the CFN resource count
|
|
898
|
-
// linear in route count rather than O(N × constants), and because
|
|
899
|
-
// *every* Lambda kind needs the same baseline access to RDS and
|
|
900
|
-
// Secrets Manager. Routes serve user requests; queue consumers and
|
|
901
|
-
// cron handlers run async background work that's at least as DB-
|
|
902
|
-
// hungry. Splitting role/SG by kind would force operators to wire
|
|
903
|
-
// the DB SG ingress N times instead of once.
|
|
904
|
-
//
|
|
905
|
-
// Trade-off: the shared role accumulates per-Lambda grants from CDK
|
|
906
|
-
// helpers like `queue.grantConsumeMessages(fn)`. A route Lambda
|
|
907
|
-
// therefore *technically* has SQS receive perms even though it
|
|
908
|
-
// never tries to use them. We accept that least-privilege drift
|
|
909
|
-
// because the alternative — one role per Lambda — multiplies role
|
|
910
|
-
// count by ~Nx and pushes large projects (~80 routes) over CFN's
|
|
911
|
-
// 500-resource limit per stack.
|
|
912
|
-
const resolvedRoutesDir = path.resolve(projectDir, routesDir);
|
|
913
|
-
const resolvedFunctionsDir = functionsDir
|
|
914
|
-
? path.resolve(projectDir, functionsDir)
|
|
915
|
-
: path.resolve(projectDir, 'src/functions');
|
|
916
|
-
const resolvedQueuesDir = queuesDir
|
|
917
|
-
? path.resolve(projectDir, queuesDir)
|
|
918
|
-
: path.resolve(projectDir, 'src/queues');
|
|
919
|
-
const resolvedCronsDir = cronsDir
|
|
920
|
-
? path.resolve(projectDir, cronsDir)
|
|
921
|
-
: path.resolve(projectDir, 'src/crons');
|
|
922
|
-
const discoveredRoutes = fs.existsSync(resolvedRoutesDir)
|
|
923
|
-
? discoverRouteFiles(resolvedRoutesDir)
|
|
924
|
-
: [];
|
|
925
|
-
const discoveredFunctions = fs.existsSync(resolvedFunctionsDir)
|
|
926
|
-
? this.discoverFunctionFiles(resolvedFunctionsDir)
|
|
927
|
-
: [];
|
|
928
|
-
const discoveredQueues = fs.existsSync(resolvedQueuesDir)
|
|
929
|
-
? this.discoverFunctionFiles(resolvedQueuesDir)
|
|
930
|
-
: [];
|
|
931
|
-
const discoveredCrons = fs.existsSync(resolvedCronsDir)
|
|
932
|
-
? this.discoverFunctionFiles(resolvedCronsDir)
|
|
933
|
-
: [];
|
|
934
|
-
const hasAnyLambda = discoveredRoutes.length > 0
|
|
935
|
-
|| discoveredFunctions.length > 0
|
|
936
|
-
|| discoveredQueues.length > 0
|
|
937
|
-
|| discoveredCrons.length > 0;
|
|
938
|
-
let lambdaRole;
|
|
939
|
-
let lambdaSg;
|
|
940
|
-
if (hasAnyLambda) {
|
|
941
|
-
// Stack-wide execution role. `AWSLambdaBasicExecutionRole` covers
|
|
942
|
-
// CloudWatch Logs writes; the VPC variant is bolted on below when
|
|
943
|
-
// a VPC exists so the Lambda can attach an ENI on cold start.
|
|
944
|
-
lambdaRole = new iam.Role(this, 'LambdaRole', {
|
|
945
|
-
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
|
|
946
|
-
description: `Shared execution role for ${projectName}-${stage} Lambdas`,
|
|
947
|
-
managedPolicies: [
|
|
948
|
-
iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
|
|
949
|
-
],
|
|
950
|
-
});
|
|
951
|
-
if (vpc) {
|
|
952
|
-
lambdaRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
|
|
953
|
-
lambdaSg = new ec2.SecurityGroup(this, 'LambdaSg', {
|
|
954
|
-
vpc,
|
|
955
|
-
description: `Shared SG for ${projectName}-${stage} Lambdas`,
|
|
956
|
-
allowAllOutbound: true,
|
|
957
|
-
});
|
|
958
|
-
// Open the DB port for every database from the shared SG. We
|
|
959
|
-
// use `connections.allowDefaultPortFrom` rather than hard-
|
|
960
|
-
// coding 5432/3306 so the same code path works for Postgres
|
|
961
|
-
// *and* MySQL projects (and any future engine `createDatabase`
|
|
962
|
-
// adds). One ingress rule per DB, regardless of how many
|
|
963
|
-
// Lambdas the stack creates.
|
|
964
|
-
for (const { instance } of dbInstances) {
|
|
965
|
-
instance.connections.allowDefaultPortFrom(lambdaSg, `${projectName}-${stage} Lambdas`);
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
// Wire the *primary* database's credentials into every Lambda's
|
|
969
|
-
// env so `@venturekit/data`'s `getPool()` (and the `query()` /
|
|
970
|
-
// `withTransaction()` entry points that wrap it) finds a complete
|
|
971
|
-
// config. The non-secret slots (host, port, name, ssl) are always
|
|
972
|
-
// wired the same way; the *username + password* delivery flips
|
|
973
|
-
// based on whether the VPC has a Secrets Manager interface
|
|
974
|
-
// endpoint:
|
|
975
|
-
//
|
|
976
|
-
// - **Default — VPC has SM endpoint, OR Lambda is not VPC-attached.**
|
|
977
|
-
// Expose `DB_SECRET_ARN`. Runtime fetches user/password via
|
|
978
|
-
// `applyDbSecretToEnv()` on cold start. Standard, supports
|
|
979
|
-
// rotation without a redeploy, secret value never lands in
|
|
980
|
-
// the CloudFormation template or Lambda env config.
|
|
981
|
-
//
|
|
982
|
-
// - **Inline mode — VPC-attached AND `enableVpcEndpoints: false`
|
|
983
|
-
// (today only the `nano` preset).**
|
|
984
|
-
// CloudFormation resolves the secret value at deploy time and
|
|
985
|
-
// injects `DB_USER` + `DB_PASSWORD` as plain Lambda env vars.
|
|
986
|
-
// `applyDbSecretToEnv()` short-circuits when both are set, so
|
|
987
|
-
// the SM round-trip is skipped entirely. This eliminates the
|
|
988
|
-
// hard runtime dependency on a single-AZ fck-nat instance
|
|
989
|
-
// reaching `secretsmanager.<region>.amazonaws.com`. Trade-offs:
|
|
990
|
-
// rotation requires a `vk deploy`, and the password is visible
|
|
991
|
-
// to anyone holding `lambda:GetFunctionConfiguration` IAM —
|
|
992
|
-
// acceptable for dev/preview, not for prod (which should run
|
|
993
|
-
// on a preset that turns endpoints on).
|
|
994
|
-
//
|
|
995
|
-
// "Primary" = the first database intent in `infrastructure.databases`.
|
|
996
|
-
// This matches the local-dev convention `vk` already uses to
|
|
997
|
-
// populate `DATABASE_URL` from docker-compose; multi-DB projects
|
|
998
|
-
// can read the additional secret ARNs from CFN outputs and call
|
|
999
|
-
// `applyDbSecretToEnv` themselves.
|
|
1000
|
-
if (dbInstances.length > 0) {
|
|
1001
|
-
const { instance: primaryDb, intent: primaryIntent } = dbInstances[0];
|
|
1002
|
-
if (primaryDb.secret) {
|
|
1003
|
-
this.ventureBaseEnv['DB_HOST'] = primaryDb.dbInstanceEndpointAddress;
|
|
1004
|
-
this.ventureBaseEnv['DB_PORT'] = primaryDb.dbInstanceEndpointPort;
|
|
1005
|
-
this.ventureBaseEnv['DB_NAME'] = primaryIntent.name || primaryIntent.id;
|
|
1006
|
-
// RDS enforces TLS on every modern engine; flipping this here
|
|
1007
|
-
// means consumers don't have to remember to set it themselves.
|
|
1008
|
-
this.ventureBaseEnv['DB_SSL'] = 'true';
|
|
1009
|
-
// Inline mode kicks in only when the Lambda will be VPC-attached
|
|
1010
|
-
// (a VPC exists) AND no SM interface endpoint is being created.
|
|
1011
|
-
// Without VPC attachment the Lambda reaches SM via the public
|
|
1012
|
-
// internet using its execution-role IAM, so the standard ARN
|
|
1013
|
-
// path stays optimal there.
|
|
1014
|
-
const inlineDbCredentials = vpc !== undefined && !vpcConfig.enableVpcEndpoints;
|
|
1015
|
-
if (inlineDbCredentials) {
|
|
1016
|
-
// `secretValueFromJson(...).unsafeUnwrap()` is the same
|
|
1017
|
-
// CloudFormation-resolved-at-deploy-time pattern already
|
|
1018
|
-
// used above for `VENTURE_INTERNAL_HMAC_SECRET`. The token
|
|
1019
|
-
// becomes a `Fn::Join`/`{{resolve:secretsmanager:...}}` in
|
|
1020
|
-
// the synthesized template; CFN substitutes the literal
|
|
1021
|
-
// value when creating/updating the Lambda's Environment
|
|
1022
|
-
// block, so no IAM call is needed at runtime.
|
|
1023
|
-
this.ventureBaseEnv['DB_USER'] = primaryDb.secret
|
|
1024
|
-
.secretValueFromJson('username').unsafeUnwrap();
|
|
1025
|
-
this.ventureBaseEnv['DB_PASSWORD'] = primaryDb.secret
|
|
1026
|
-
.secretValueFromJson('password').unsafeUnwrap();
|
|
1027
|
-
// Deliberately *not* setting DB_SECRET_ARN: with both
|
|
1028
|
-
// DB_USER and DB_PASSWORD pre-populated, `applyDbSecretToEnv()`
|
|
1029
|
-
// is a no-op — but if the ARN is set anyway, an operator
|
|
1030
|
-
// skimming the env vars might think rotation works without
|
|
1031
|
-
// a redeploy. Keeping it absent makes the contract explicit.
|
|
1032
|
-
}
|
|
1033
|
-
else {
|
|
1034
|
-
this.ventureBaseEnv['DB_SECRET_ARN'] = primaryDb.secret.secretArn;
|
|
1035
|
-
// Only grant when the runtime actually fetches. Inline mode
|
|
1036
|
-
// doesn't need it and the principle of least privilege
|
|
1037
|
-
// says don't hand out IAM you don't use.
|
|
1038
|
-
primaryDb.secret.grantRead(lambdaRole);
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
// Grant the shared Lambda role the baseline admin Cognito actions
|
|
1043
|
-
// every non-trivial auth flow needs. The public surface
|
|
1044
|
-
// (`SignUp`, `InitiateAuth`, `RespondToAuthChallenge`,
|
|
1045
|
-
// `ChangePassword`, …) is anonymous and works without IAM, but
|
|
1046
|
-
// self-service register flows that auto-confirm the new user, and
|
|
1047
|
-
// any admin/staff-invite console, hit the `Admin*` API which
|
|
1048
|
-
// does require IAM. Without this grant a `POST /auth/register`
|
|
1049
|
-
// route that calls `AdminConfirmSignUp` after `SignUp` fails at
|
|
1050
|
-
// runtime with `AccessDeniedException`.
|
|
1051
|
-
//
|
|
1052
|
-
// Grants are scoped to each declared pool's ARN (via
|
|
1053
|
-
// `userPool.grant`, which builds an inline policy on the role
|
|
1054
|
-
// pinned to `userPool.userPoolArn`) — never `Resource: *`.
|
|
1055
|
-
//
|
|
1056
|
-
// The action set covers the admin operations VentureKit-shaped
|
|
1057
|
-
// apps actually call from Lambda handlers: confirming/creating
|
|
1058
|
-
// users on registration or invite, flipping enabled status,
|
|
1059
|
-
// updating attributes (e.g. `email_verified`, `custom:role`),
|
|
1060
|
-
// reading user state, force-resetting passwords, and the
|
|
1061
|
-
// admin-flow auth pair used by some server-side login surfaces.
|
|
1062
|
-
// List-side reads (`ListUsers`, `AdminListGroupsForUser`) round
|
|
1063
|
-
// out the set so user-management consoles don't have to ship a
|
|
1064
|
-
// second grant.
|
|
1065
|
-
for (const pool of this.userPools) {
|
|
1066
|
-
pool.grant(lambdaRole, 'cognito-idp:AdminConfirmSignUp', 'cognito-idp:AdminCreateUser', 'cognito-idp:AdminDeleteUser', 'cognito-idp:AdminDisableUser', 'cognito-idp:AdminEnableUser', 'cognito-idp:AdminGetUser', 'cognito-idp:AdminInitiateAuth', 'cognito-idp:AdminListGroupsForUser', 'cognito-idp:AdminRespondToAuthChallenge', 'cognito-idp:AdminSetUserPassword', 'cognito-idp:AdminUpdateUserAttributes', 'cognito-idp:AdminUserGlobalSignOut', 'cognito-idp:ListUsers');
|
|
1067
|
-
}
|
|
1068
|
-
// Federated identity-provider client-credential secrets — created
|
|
1069
|
-
// by `createAuth` when an `AuthIntent` declares
|
|
1070
|
-
// `federated: ['google', 'facebook', …]`. Granting `GetSecretValue`
|
|
1071
|
-
// here (rather than at the secret's creation site) keeps the
|
|
1072
|
-
// grant tied to the same shared Lambda role used for the Cognito
|
|
1073
|
-
// admin actions above, so a single `signInAsFederatedUser` call
|
|
1074
|
-
// can both fetch the OAuth credentials and mint Cognito tokens.
|
|
1075
|
-
for (const secret of this.federatedAuthSecrets) {
|
|
1076
|
-
secret.grantRead(lambdaRole);
|
|
1077
|
-
}
|
|
1078
|
-
// Wire every storage bucket into the shared Lambda role + env.
|
|
1079
|
-
//
|
|
1080
|
-
// - IAM: `bucket.grantReadWrite(lambdaRole)` produces an inline
|
|
1081
|
-
// policy scoped to the bucket ARN (and `${arn}/*`), covering the
|
|
1082
|
-
// `s3:Get*` / `s3:Put*` / `s3:DeleteObject` / `s3:List*` actions
|
|
1083
|
-
// that `@venturekit/storage`'s presign + put/get/list paths need.
|
|
1084
|
-
// Never `Resource: *` — least privilege per declared intent.
|
|
1085
|
-
//
|
|
1086
|
-
// - Env: `VENTURE_STORAGE_BUCKET` points at the *first* bucket so
|
|
1087
|
-
// `createStorageClientFromEnv()` (the zero-config entry point
|
|
1088
|
-
// most handlers use) finds a complete config without an explicit
|
|
1089
|
-
// `{ bucket }` override. Per-intent `STORAGE_<ID>_BUCKET` vars
|
|
1090
|
-
// mirror the local-dev convention in `cli/src/utils/docker-compose.ts`
|
|
1091
|
-
// (`envVars[\`STORAGE_${id.toUpperCase()}_BUCKET\`] = ...`) so
|
|
1092
|
-
// multi-bucket projects can read each bucket name without an
|
|
1093
|
-
// extra round-trip to CloudFormation outputs. "Primary" =
|
|
1094
|
-
// the first storage intent in `infrastructure.storage`, matching
|
|
1095
|
-
// how the same CLI sets `VENTURE_STORAGE_BUCKET` to
|
|
1096
|
-
// `intents.storage[0].id` for `vk dev`.
|
|
1097
|
-
if (this.storageBuckets.length > 0) {
|
|
1098
|
-
const primary = this.storageBuckets[0];
|
|
1099
|
-
this.ventureBaseEnv['VENTURE_STORAGE_BUCKET'] = primary.bucket.bucketName;
|
|
1100
|
-
if (primary.distribution) {
|
|
1101
|
-
// Primary CDN domain — read by zero-config callers that don't
|
|
1102
|
-
// know the intent id (mirrors `VENTURE_STORAGE_BUCKET`). When
|
|
1103
|
-
// an alias is configured, prefer it so URLs end up at the
|
|
1104
|
-
// operator-chosen hostname (e.g. `cdn.example.com`) instead
|
|
1105
|
-
// of the `*.cloudfront.net` default.
|
|
1106
|
-
this.ventureBaseEnv['VENTURE_STORAGE_CDN_URL'] = primary.cdnAlias
|
|
1107
|
-
? `https://${primary.cdnAlias}`
|
|
1108
|
-
: `https://${primary.distribution.distributionDomainName}`;
|
|
1109
|
-
}
|
|
1110
|
-
for (const { bucket, intent, distribution, cdnAlias } of this.storageBuckets) {
|
|
1111
|
-
// Lambda env var keys must match `[a-zA-Z][a-zA-Z0-9_]+`, so
|
|
1112
|
-
// normalize any non-alphanumerics in the intent id (e.g.
|
|
1113
|
-
// `user-uploads` -> `USER_UPLOADS`). Without this, a hyphenated
|
|
1114
|
-
// id would cause CloudFormation to reject the function update.
|
|
1115
|
-
const idKey = String(intent.id).toUpperCase().replace(/[^A-Z0-9_]/g, '_');
|
|
1116
|
-
this.ventureBaseEnv[`STORAGE_${idKey}_BUCKET`] = bucket.bucketName;
|
|
1117
|
-
if (distribution) {
|
|
1118
|
-
// Per-intent CDN domain — handlers that resolve a stored
|
|
1119
|
-
// S3 key to a public URL (e.g. `publicUrlForKey()`) read
|
|
1120
|
-
// this to build `https://<dist>/<key>` without an explicit
|
|
1121
|
-
// override. Prefer the alternate domain (e.g.
|
|
1122
|
-
// `cdn.example.com`) when set; fall back to the auto-
|
|
1123
|
-
// generated `*.cloudfront.net` hostname otherwise.
|
|
1124
|
-
this.ventureBaseEnv[`STORAGE_${idKey}_CDN_URL`] = cdnAlias
|
|
1125
|
-
? `https://${cdnAlias}`
|
|
1126
|
-
: `https://${distribution.distributionDomainName}`;
|
|
1127
|
-
}
|
|
1128
|
-
bucket.grantReadWrite(lambdaRole);
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
// Wire every notify configuration into the shared Lambda role + env.
|
|
1132
|
-
//
|
|
1133
|
-
// - IAM: `ses:SendEmail` + `ses:SendRawEmail` scoped to the SES
|
|
1134
|
-
// identity ARN and the configuration set ARN — never `Resource: *`.
|
|
1135
|
-
// `secretsmanager:GetSecretValue` is granted on the WhatsApp
|
|
1136
|
-
// token secret when the channel is enabled.
|
|
1137
|
-
// - Env: the *first* notify config drives the unscoped vars
|
|
1138
|
-
// (`VENTURE_NOTIFY_FROM`, …) so handlers using the default
|
|
1139
|
-
// `notify` instance work without per-id config. Per-id vars
|
|
1140
|
-
// (`NOTIFY_<ID>_FROM`) mirror the storage convention.
|
|
1141
|
-
// - Auto-provisioning: dispatcher cron + bounce-handler Lambda
|
|
1142
|
-
// are created here too because they share `lambdaRole` and need
|
|
1143
|
-
// the same VPC/SG. The Lambdas import a stub from
|
|
1144
|
-
// `@venturekit/notify/runtime/dispatcher-stub.ts`; the consumer
|
|
1145
|
-
// provides their notify-client module path via
|
|
1146
|
-
// `VENTURE_NOTIFY_CLIENT_MODULE` env (default `'./lib/notify.js'`,
|
|
1147
|
-
// resolved against the bundled handler dir).
|
|
1148
|
-
if (this.notifyConfigs.length > 0) {
|
|
1149
|
-
const primary = this.notifyConfigs[0];
|
|
1150
|
-
this.ventureBaseEnv['VENTURE_NOTIFY_FROM'] = primary.intent.defaultFrom ?? '';
|
|
1151
|
-
this.ventureBaseEnv['VENTURE_NOTIFY_CONFIG_SET'] = primary.configurationSetName;
|
|
1152
|
-
this.ventureBaseEnv['VENTURE_NOTIFY_EVENTS_TOPIC_ARN'] = primary.eventsTopic.topicArn;
|
|
1153
|
-
if (primary.intent.whatsapp?.phoneNumberId) {
|
|
1154
|
-
this.ventureBaseEnv['VENTURE_NOTIFY_WHATSAPP_PHONE_ID'] = String(primary.intent.whatsapp.phoneNumberId);
|
|
1155
|
-
this.ventureBaseEnv['VENTURE_NOTIFY_WHATSAPP_API_VERSION'] =
|
|
1156
|
-
primary.intent.whatsapp.apiVersion ?? 'v21.0';
|
|
1157
|
-
}
|
|
1158
|
-
if (primary.whatsappTokenSecret) {
|
|
1159
|
-
this.ventureBaseEnv['VENTURE_NOTIFY_WHATSAPP_TOKEN_SECRET'] =
|
|
1160
|
-
primary.whatsappTokenSecret.secretArn;
|
|
1161
|
-
}
|
|
1162
|
-
for (const cfg of this.notifyConfigs) {
|
|
1163
|
-
const idKey = String(cfg.intent.id).toUpperCase().replace(/[^A-Z0-9_]/g, '_');
|
|
1164
|
-
this.ventureBaseEnv[`NOTIFY_${idKey}_FROM`] = cfg.intent.defaultFrom ?? '';
|
|
1165
|
-
this.ventureBaseEnv[`NOTIFY_${idKey}_CONFIG_SET`] = cfg.configurationSetName;
|
|
1166
|
-
this.ventureBaseEnv[`NOTIFY_${idKey}_EVENTS_TOPIC_ARN`] = cfg.eventsTopic.topicArn;
|
|
1167
|
-
// Grant SES sending permissions. `ses:SendEmail` /
|
|
1168
|
-
// `SendRawEmail` are required by SESv2's SendEmail API; the
|
|
1169
|
-
// resource-level scope is the identity ARN (`arn:aws:ses:…:identity/<from>`)
|
|
1170
|
-
// *plus* the configuration set ARN (Send is dual-resource in
|
|
1171
|
-
// SESv2 — both the identity AND the config set must allow).
|
|
1172
|
-
lambdaRole.addToPolicy(new iam.PolicyStatement({
|
|
1173
|
-
actions: ['ses:SendEmail', 'ses:SendRawEmail'],
|
|
1174
|
-
resources: [
|
|
1175
|
-
cfg.identityArn,
|
|
1176
|
-
`arn:${cdk.Aws.PARTITION}:ses:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:configuration-set/${cfg.configurationSetName}`,
|
|
1177
|
-
],
|
|
1178
|
-
}));
|
|
1179
|
-
if (cfg.whatsappTokenSecret) {
|
|
1180
|
-
cfg.whatsappTokenSecret.grantRead(lambdaRole);
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
// Auto-provision dispatcher + bounce-handler Lambdas now that
|
|
1184
|
-
// the role + VPC + SG exist. Wrapped in a single helper so the
|
|
1185
|
-
// constructor stays readable.
|
|
1186
|
-
this.provisionNotifyHandlers(projectName, stage, projectDir, lambdaRole, lambdaSg, vpc);
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
// --- HTTP Routes (file-system discovered) ---
|
|
1190
|
-
//
|
|
1191
|
-
// Each discovered file becomes one Lambda + HttpLambdaIntegration +
|
|
1192
|
-
// HttpRoute. Method-name aliases (list/create/update/remove) are
|
|
1193
|
-
// resolved upstream in `discoverRouteFiles`.
|
|
1194
|
-
if (discoveredRoutes.length > 0) {
|
|
1195
|
-
// De-duplicate: a misconfigured `src/routes/` (e.g. both
|
|
1196
|
-
// `users/get.ts` and `users/list.ts`) would otherwise fail
|
|
1197
|
-
// synth with a duplicate-route-key error from API Gateway.
|
|
1198
|
-
// Surface the conflict early with a clear message instead.
|
|
1199
|
-
const seenRouteKeys = new Set();
|
|
1200
|
-
for (const route of discoveredRoutes) {
|
|
1201
|
-
const routeKey = `${route.method} ${route.apiPath}`;
|
|
1202
|
-
if (seenRouteKeys.has(routeKey)) {
|
|
1203
|
-
throw new Error(`[venturekit] Duplicate HTTP route detected: ${routeKey} ` +
|
|
1204
|
-
`(at ${route.handlerFile}). Method aliases like 'get' + 'list' ` +
|
|
1205
|
-
`or 'post' + 'create' both resolve to the same HTTP method on ` +
|
|
1206
|
-
`the same path — pick one per directory.`);
|
|
1207
|
-
}
|
|
1208
|
-
seenRouteKeys.add(routeKey);
|
|
1209
|
-
}
|
|
1210
|
-
// CloudFormation enforces a hard 500-resources-per-stack limit.
|
|
1211
|
-
// Each route here translates to ~5 CFN resources (Function,
|
|
1212
|
-
// Permission, Integration, Route, log-retention custom resource),
|
|
1213
|
-
// so a stack with more than ~80 routes can't be deployed without
|
|
1214
|
-
// sharding. Below the threshold we keep the simpler in-stack
|
|
1215
|
-
// layout so existing test assertions (which inspect the parent
|
|
1216
|
-
// template only) still find route Lambdas there. At or above the
|
|
1217
|
-
// threshold we shard routes across nested stacks; CDK wires the
|
|
1218
|
-
// cross-stack references for `api`, `role`, `lambdaSg`, `vpc`
|
|
1219
|
-
// automatically via SSM Parameter passthrough.
|
|
1220
|
-
const NESTED_STACK_THRESHOLD = 80;
|
|
1221
|
-
const ROUTES_PER_NESTED_STACK = 50;
|
|
1222
|
-
// Fixed, immutable nested-stack slot count.
|
|
1223
|
-
//
|
|
1224
|
-
// The bucketing function `stableSlotFor(slug, numSlots)` is a
|
|
1225
|
-
// pure function of these two inputs. As long as `numSlots`
|
|
1226
|
-
// never changes, every previously-deployed route keeps its
|
|
1227
|
-
// slot — which means CloudFormation never has to migrate a
|
|
1228
|
-
// route's `ApiGatewayV2::Route` resource across nested stack
|
|
1229
|
-
// boundaries between deploys.
|
|
1230
|
-
//
|
|
1231
|
-
// That invariant is load-bearing. `ApiGatewayV2::Route` carries
|
|
1232
|
-
// a unique `(method, path)` key at the API Gateway level, not
|
|
1233
|
-
// just the CloudFormation level. If the same route key lives in
|
|
1234
|
-
// two nested stacks (old + new) at the same moment, the create
|
|
1235
|
-
// call fails with `Route with key <METHOD> <path> already exists
|
|
1236
|
-
// for this API` because CFN updates nested stacks in parallel
|
|
1237
|
-
// and has no cross-stack delete-before-create ordering.
|
|
1238
|
-
//
|
|
1239
|
-
// 16 slots × ~80 routes/slot (the CloudFormation per-stack 500-
|
|
1240
|
-
// resource limit at ~5 CFN resources per route) gives ~1280
|
|
1241
|
-
// routes of headroom — comfortably above anything we've seen
|
|
1242
|
-
// in practice. Raising this number later would force a
|
|
1243
|
-
// one-shot mass migration of every nested-stack route in every
|
|
1244
|
-
// already-deployed project; don't do it casually.
|
|
1245
|
-
const NUM_NESTED_STACK_SLOTS = 16;
|
|
1246
|
-
if (discoveredRoutes.length <= NESTED_STACK_THRESHOLD) {
|
|
1247
|
-
for (const route of discoveredRoutes) {
|
|
1248
|
-
this.createRouteFunction(route, projectName, stage, api, lambdaRole, lambdaSg, vpc);
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
else {
|
|
1252
|
-
// Stable hash-based bucketing into a fixed, never-changing slot
|
|
1253
|
-
// pool.
|
|
1254
|
-
//
|
|
1255
|
-
// The previous index-based scheme (`routes.slice(i, i+50)`)
|
|
1256
|
-
// sliced the discovered-route array into fixed 50-route windows.
|
|
1257
|
-
// Adding, removing, or renaming any single route shifted every
|
|
1258
|
-
// later route by one position, which moved a tail of routes
|
|
1259
|
-
// across a 50-route boundary into a different `RoutesN` nested
|
|
1260
|
-
// stack. Combined with the unique `(method, path)` key carried
|
|
1261
|
-
// by `ApiGatewayV2::Route`, this caused
|
|
1262
|
-
// `Route with key <METHOD> <path> already exists for this API`
|
|
1263
|
-
// deploy failures — CFN updates nested stacks in parallel, so
|
|
1264
|
-
// the new route resource tries to register at API Gateway
|
|
1265
|
-
// before the old one in the other nested stack is deleted.
|
|
1266
|
-
//
|
|
1267
|
-
// Hashing the slug into a fixed slot pool pins each route to a
|
|
1268
|
-
// slot that's determined the first time it's deployed and
|
|
1269
|
-
// never moves. Adding or removing siblings doesn't reshuffle
|
|
1270
|
-
// anything; changing slot count is the only event that would —
|
|
1271
|
-
// which is exactly why `NUM_NESTED_STACK_SLOTS` is a hard-coded
|
|
1272
|
-
// constant rather than something derived from the route count.
|
|
1273
|
-
const numSlots = NUM_NESTED_STACK_SLOTS;
|
|
1274
|
-
const buckets = Array.from({ length: numSlots }, () => []);
|
|
1275
|
-
for (const route of discoveredRoutes) {
|
|
1276
|
-
const slot = stableSlotFor(route.slug, numSlots);
|
|
1277
|
-
buckets[slot].push(route);
|
|
1278
|
-
}
|
|
1279
|
-
// Only materialise non-empty slots; an empty slot would synth an
|
|
1280
|
-
// empty nested CloudFormation stack, which CDK rejects. Slot
|
|
1281
|
-
// index (1-based, padded so the displayed name is stable as the
|
|
1282
|
-
// bucket count grows) becomes the construct ID, ensuring a
|
|
1283
|
-
// route's logical path stays the same as long as `numSlots`
|
|
1284
|
-
// doesn't change.
|
|
1285
|
-
buckets.forEach((batch, idx) => {
|
|
1286
|
-
if (batch.length === 0)
|
|
1287
|
-
return;
|
|
1288
|
-
new RoutesNestedStack(this, `Routes${idx + 1}`, {
|
|
1289
|
-
routes: batch,
|
|
1290
|
-
projectName,
|
|
1291
|
-
stage,
|
|
1292
|
-
api,
|
|
1293
|
-
role: lambdaRole,
|
|
1294
|
-
securityGroup: lambdaSg,
|
|
1295
|
-
vpc,
|
|
1296
|
-
envConfig: this.envConfig,
|
|
1297
|
-
ventureBaseEnv: this.ventureBaseEnv,
|
|
1298
|
-
projectDir: this.projectDir,
|
|
1299
|
-
skipBundling: this.skipBundling,
|
|
1300
|
-
});
|
|
1301
|
-
});
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
// --- Standalone Lambda Functions ---
|
|
1305
|
-
// Discover functions from src/functions/ and create Lambda functions.
|
|
1306
|
-
// Naming convention: {project}-{stage}-fn-{name}
|
|
1307
|
-
if (discoveredFunctions.length > 0) {
|
|
1308
|
-
const functionIntents = infrastructure?.functions ?? [];
|
|
1309
|
-
for (const fnFile of discoveredFunctions) {
|
|
1310
|
-
const intent = functionIntents.find((f) => f.id === fnFile.name);
|
|
1311
|
-
this.createStandaloneFunction(fnFile.name, fnFile.handlerFile, projectName, stage, projectDir, intent, lambdaRole, lambdaSg, vpc);
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
// --- Queue Consumer Lambda Functions ---
|
|
1315
|
-
// Discover handlers from src/queues/ and wire them to SQS queues.
|
|
1316
|
-
// Naming convention: {project}-{stage}-queue-{name}
|
|
1317
|
-
if (discoveredQueues.length > 0) {
|
|
1318
|
-
const queueIntents = infrastructure?.queues ?? [];
|
|
1319
|
-
for (const qFile of discoveredQueues) {
|
|
1320
|
-
const intent = queueIntents.find((q) => q.id === qFile.name);
|
|
1321
|
-
this.createQueueConsumer(qFile.name, qFile.handlerFile, projectName, stage, projectDir, intent, lambdaRole, lambdaSg, vpc);
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
// --- Cron/Scheduled Task Lambda Functions ---
|
|
1325
|
-
// Discover handlers from src/crons/ and wire them to EventBridge rules.
|
|
1326
|
-
// Naming convention: {project}-{stage}-cron-{name}
|
|
1327
|
-
if (discoveredCrons.length > 0) {
|
|
1328
|
-
const scheduleIntents = infrastructure?.schedules ?? [];
|
|
1329
|
-
for (const cFile of discoveredCrons) {
|
|
1330
|
-
const intent = scheduleIntents.find((s) => s.id === cFile.name);
|
|
1331
|
-
this.createCronHandler(cFile.name, cFile.handlerFile, projectName, stage, projectDir, intent, lambdaRole, lambdaSg, vpc);
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
/**
|
|
1336
|
-
* Well-Architected: Reliability — RDS with stable logical ID so preset
|
|
1337
|
-
* changes (e.g. nano→medium) perform an in-place instance class modify,
|
|
1338
|
-
* not a replacement. Data is preserved because the CloudFormation resource
|
|
1339
|
-
* identity stays the same.
|
|
1340
|
-
*
|
|
1341
|
-
* Well-Architected: Security — encrypted at rest, credentials in Secrets Manager
|
|
1342
|
-
* Well-Architected: Reliability — automated backups, optional multi-AZ
|
|
1343
|
-
* Well-Architected: Performance — instance type driven by intent.size
|
|
1344
|
-
* Well-Architected: Operational Excellence — auto minor version upgrades
|
|
1345
|
-
*/
|
|
1346
|
-
createDatabase(intent, projectName, stage, vpc, projectDir, vkCliVersion) {
|
|
1347
|
-
// Stable logical ID: 'db-{intent.id}' — never changes when preset changes
|
|
1348
|
-
const logicalId = `db-${intent.id}`;
|
|
1349
|
-
const instanceType = this.sizeToInstanceType(intent.size || 'small');
|
|
1350
|
-
const engine = intent.type === 'mysql'
|
|
1351
|
-
? rds.DatabaseInstanceEngine.mysql({ version: rds.MysqlEngineVersion.VER_8_0 })
|
|
1352
|
-
: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_16 });
|
|
1353
|
-
// Well-Architected: Security — place DB in isolated subnets when available
|
|
1354
|
-
const subnetType = vpc.isolatedSubnets?.length
|
|
1355
|
-
? ec2.SubnetType.PRIVATE_ISOLATED
|
|
1356
|
-
: ec2.SubnetType.PRIVATE_WITH_EGRESS;
|
|
1357
|
-
// Common props shared between fresh-create and snapshot-restore
|
|
1358
|
-
// paths. Pulled out so the two branches stay aligned on
|
|
1359
|
-
// dataSafety / VPC / observability config.
|
|
1360
|
-
const commonDbProps = {
|
|
1361
|
-
engine,
|
|
1362
|
-
instanceType,
|
|
1363
|
-
vpc,
|
|
1364
|
-
vpcSubnets: { subnetType },
|
|
1365
|
-
// Well-Architected: Reliability — use dataSafety for removal policy
|
|
1366
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
1367
|
-
deletionProtection: this.dataSafetyConfig.deletionProtection,
|
|
1368
|
-
multiAz: intent.highAvailability ?? false,
|
|
1369
|
-
// Well-Architected: Security — encryption at rest
|
|
1370
|
-
storageEncrypted: intent.encrypted ?? true,
|
|
1371
|
-
// Well-Architected: Reliability — automated backups
|
|
1372
|
-
backupRetention: intent.backups !== false
|
|
1373
|
-
? cdk.Duration.days(intent.backupRetentionDays ?? 7)
|
|
1374
|
-
: cdk.Duration.days(0),
|
|
1375
|
-
// Well-Architected: Operational Excellence — auto minor version upgrades
|
|
1376
|
-
autoMinorVersionUpgrade: true,
|
|
1377
|
-
// Well-Architected: Performance — performance insights for medium+ presets
|
|
1378
|
-
enablePerformanceInsights: intent.performanceInsights ?? this.envConfig.lambda.memoryMb >= 512,
|
|
1379
|
-
// Well-Architected: Reliability — enhanced monitoring for medium+ presets
|
|
1380
|
-
monitoringInterval: this.envConfig.lambda.memoryMb >= 512
|
|
1381
|
-
? cdk.Duration.seconds(60)
|
|
1382
|
-
: undefined,
|
|
1383
|
-
};
|
|
1384
|
-
// Branch: snapshot-restore vs fresh-create.
|
|
1385
|
-
//
|
|
1386
|
-
// RDS doesn't expose the original master password from a snapshot,
|
|
1387
|
-
// so `SnapshotCredentials.fromGeneratedSecret` rotates to a fresh
|
|
1388
|
-
// password at restore time and stores it in Secrets Manager —
|
|
1389
|
-
// identical surface to the fresh-create path's `Credentials.from
|
|
1390
|
-
// GeneratedSecret`. Application code reads `instance.secret` either
|
|
1391
|
-
// way, so the migration runner and Lambda env wiring downstream
|
|
1392
|
-
// see no difference.
|
|
1393
|
-
//
|
|
1394
|
-
// `databaseName` is intentionally omitted on the snapshot branch:
|
|
1395
|
-
// the database name is preserved from the snapshot, and passing
|
|
1396
|
-
// a value here is rejected by RDS as conflicting input.
|
|
1397
|
-
const instance = intent.restoreFromSnapshot
|
|
1398
|
-
? new rds.DatabaseInstanceFromSnapshot(this, logicalId, {
|
|
1399
|
-
...commonDbProps,
|
|
1400
|
-
snapshotIdentifier: intent.restoreFromSnapshot,
|
|
1401
|
-
credentials: rds.SnapshotCredentials.fromGeneratedSecret('postgres'),
|
|
1402
|
-
})
|
|
1403
|
-
: new rds.DatabaseInstance(this, logicalId, {
|
|
1404
|
-
...commonDbProps,
|
|
1405
|
-
databaseName: intent.name || intent.id,
|
|
1406
|
-
credentials: rds.Credentials.fromGeneratedSecret('postgres'),
|
|
1407
|
-
});
|
|
1408
|
-
// Well-Architected: Reliability — prevent accidental replacement
|
|
1409
|
-
// CFN UpdateReplacePolicy ensures data safety on resource property changes
|
|
1410
|
-
const cfnInstance = instance.node.defaultChild;
|
|
1411
|
-
if (this.dataSafetyConfig.removalPolicy === 'retain') {
|
|
1412
|
-
cfnInstance.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
1413
|
-
}
|
|
1414
|
-
// Well-Architected: Reliability — retain the master credentials secret
|
|
1415
|
-
// alongside the DB it unlocks. Without this, a stack delete schedules
|
|
1416
|
-
// the secret for permanent deletion (default 7-day window) while the
|
|
1417
|
-
// DB itself survives via RemovalPolicy.RETAIN. Operators recovering
|
|
1418
|
-
// an orphaned DB then face a useless instance whose master password
|
|
1419
|
-
// is gone — they have to `aws rds modify-db-instance
|
|
1420
|
-
// --master-user-password` to a new value, which invalidates every
|
|
1421
|
-
// application credential rotation that referenced the original
|
|
1422
|
-
// secret. Applying RETAIN here keeps the DB and its credentials in
|
|
1423
|
-
// sync, so `cdk import` (or `vk deploy --import-existing-resources`)
|
|
1424
|
-
// can reattach both cleanly. Mirrored UpdateReplacePolicy keeps the
|
|
1425
|
-
// secret safe across CFN-driven replacements too.
|
|
1426
|
-
//
|
|
1427
|
-
// ─── Subtle: don't use `instance.secret` ──────────────────────────
|
|
1428
|
-
// `instance.secret` returns a `SecretTargetAttachment` — CDK's L2
|
|
1429
|
-
// wrapper around `AWS::SecretsManager::SecretTargetAttachment`,
|
|
1430
|
-
// which is just metadata pointing the secret at the DB. Applying
|
|
1431
|
-
// RETAIN to *that* only retains the attachment metadata; the
|
|
1432
|
-
// underlying `AWS::SecretsManager::Secret` (the resource that
|
|
1433
|
-
// holds the generated password) still goes through CFN's default
|
|
1434
|
-
// delete-on-stack-delete flow. `Credentials.fromGeneratedSecret`
|
|
1435
|
-
// / `SnapshotCredentials.fromGeneratedSecret` create the actual
|
|
1436
|
-
// Secret as a sibling child of the DB construct under the
|
|
1437
|
-
// well-known id `'Secret'`, so we walk to it directly.
|
|
1438
|
-
if (this.dataSafetyConfig.removalPolicy === 'retain') {
|
|
1439
|
-
const dbSecret = instance.node.tryFindChild('Secret');
|
|
1440
|
-
if (dbSecret) {
|
|
1441
|
-
dbSecret.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
|
|
1442
|
-
const cfnSecret = dbSecret.node.defaultChild;
|
|
1443
|
-
if (cfnSecret) {
|
|
1444
|
-
cfnSecret.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
new cdk.CfnOutput(this, `${logicalId}-endpoint`, {
|
|
1449
|
-
value: instance.dbInstanceEndpointAddress,
|
|
1450
|
-
description: `Database endpoint for ${intent.id}`,
|
|
1451
|
-
});
|
|
1452
|
-
new cdk.CfnOutput(this, `${logicalId}-secret-arn`, {
|
|
1453
|
-
value: instance.secret?.secretArn || '',
|
|
1454
|
-
description: `Database secret ARN for ${intent.id}`,
|
|
1455
|
-
});
|
|
1456
|
-
// --- Auto-run migrations on every deploy ---
|
|
1457
|
-
// The in-VPC CodeBuild runner closes the long-standing gap where
|
|
1458
|
-
// public CI runners can't reach a private RDS. We wire it only for
|
|
1459
|
-
// Postgres (the only engine `@venturekit/data` has a runner for) and
|
|
1460
|
-
// only when the consumer has at least one `.sql` file in
|
|
1461
|
-
// `db/migrations/`. Failures roll back the stack via the custom
|
|
1462
|
-
// resource, making migrations atomic with `vk deploy`.
|
|
1463
|
-
if (intent.type === 'postgres' && instance.secret) {
|
|
1464
|
-
const dbDir = path.join(projectDir, 'db');
|
|
1465
|
-
if (hasMigrations(dbDir)) {
|
|
1466
|
-
// The runner's CodeBuild job lives inside this stack's VPC so it
|
|
1467
|
-
// can reach the private RDS, but its buildspec still needs public
|
|
1468
|
-
// egress to `npm install -g @venturekit/cli` and to ship logs +
|
|
1469
|
-
// build updates to the AWS control plane. When `natType: 'none'`
|
|
1470
|
-
// the private subnets have no default route at all — every
|
|
1471
|
-
// outbound call from the build times out and CFN reports a bare
|
|
1472
|
-
// "Migration build ... ended with status FAILED" on first
|
|
1473
|
-
// deploy. Skipping the runner here, with a loud warning pointing
|
|
1474
|
-
// at the operator's two options, keeps the deploy green and
|
|
1475
|
-
// surfaces the actual decision (enable a NAT provider, or
|
|
1476
|
-
// migrate manually via a tunnel). Every preset except `free`
|
|
1477
|
-
// now ships a NAT provider by default (instance for
|
|
1478
|
-
// nano/micro/medium, gateway for large), so this branch is only
|
|
1479
|
-
// reached when the operator explicitly opts out with
|
|
1480
|
-
// `natType: 'none'`.
|
|
1481
|
-
if (this.envConfig.vpc.natType === 'none') {
|
|
1482
|
-
console.warn(`[venturekit] Skipping auto-migrations for db "${intent.id}": ` +
|
|
1483
|
-
`natType is 'none', so the in-VPC migration runner can't ` +
|
|
1484
|
-
`reach npmjs.com to install @venturekit/cli. Fix: set ` +
|
|
1485
|
-
`'infrastructure.vpc.natType: "instance"' (or "gateway") in ` +
|
|
1486
|
-
`vk.config.ts. Until then, run \`vk migrate -e ${stage}\` ` +
|
|
1487
|
-
`manually once the DB is reachable (e.g. via SSM tunnel).`);
|
|
1488
|
-
}
|
|
1489
|
-
else {
|
|
1490
|
-
new MigrationRunner(this, `migration-runner-${intent.id}`, {
|
|
1491
|
-
projectName,
|
|
1492
|
-
stage,
|
|
1493
|
-
dbId: intent.id,
|
|
1494
|
-
vpc,
|
|
1495
|
-
dbInstance: instance,
|
|
1496
|
-
dbSecret: instance.secret,
|
|
1497
|
-
dbName: intent.name || intent.id,
|
|
1498
|
-
dbDir,
|
|
1499
|
-
// Merge in any `migrations/` dirs shipped by installed
|
|
1500
|
-
// `@venturekit/*` packages (resolved at the top of the
|
|
1501
|
-
// constructor via `discoverPackageMigrationDirs`). The
|
|
1502
|
-
// runner's `mergeDbDir` overlays them on top of the
|
|
1503
|
-
// project's own SQL files; collisions throw at synth
|
|
1504
|
-
// time (we never silently overwrite).
|
|
1505
|
-
extraMigrationsDirs: this.packageMigrationsDirs,
|
|
1506
|
-
vkCliVersion,
|
|
1507
|
-
logRetentionDays: this.envConfig.observability.logs.retentionDays,
|
|
1508
|
-
});
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
else if (intent.type === 'mysql') {
|
|
1513
|
-
// MySQL projects: no auto-migration runner yet because
|
|
1514
|
-
// `@venturekit/data` only ships a Postgres SQL runner. Operators
|
|
1515
|
-
// must still run migrations manually via a tunnel.
|
|
1516
|
-
console.warn(`[venturekit] MySQL auto-migrations are not yet supported (db: ${intent.id}). ` +
|
|
1517
|
-
`Run \`vk migrate\` manually after deploy until @venturekit/data adds a MySQL runner.`);
|
|
1518
|
-
}
|
|
1519
|
-
return instance;
|
|
1520
|
-
}
|
|
1521
|
-
/**
|
|
1522
|
-
* Well-Architected: Security — block public access by default, SSE encryption
|
|
1523
|
-
* Well-Architected: Reliability — versioning for backups, dataSafety removal policy
|
|
1524
|
-
* Well-Architected: Cost — lifecycle rules for logs/backups to reduce storage cost
|
|
1525
|
-
* Well-Architected: Sustainability — intelligent tiering for infrequent access
|
|
1526
|
-
*/
|
|
1527
|
-
createStorage(intent, projectName, stage) {
|
|
1528
|
-
// Stable logical ID: 'storage-{intent.id}'
|
|
1529
|
-
const logicalId = `storage-${intent.id}`;
|
|
1530
|
-
const bucket = new s3.Bucket(this, logicalId, {
|
|
1531
|
-
bucketName: `${projectName}-${intent.id}-${stage}`,
|
|
1532
|
-
versioned: intent.versioned ?? intent.purpose === 'backups',
|
|
1533
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
1534
|
-
autoDeleteObjects: this.dataSafetyConfig.removalPolicy === 'destroy',
|
|
1535
|
-
// Well-Architected: Security — encryption at rest
|
|
1536
|
-
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
1537
|
-
// Well-Architected: Security — bucket is always fully private.
|
|
1538
|
-
// When `intent.cdn === true` we still keep BlockPublicAccess.BLOCK_ALL
|
|
1539
|
-
// and front the bucket with a CloudFront distribution that uses an
|
|
1540
|
-
// Origin Access Control (OAC). CloudFront accesses S3 via the
|
|
1541
|
-
// service principal under a `aws:SourceArn` condition added by
|
|
1542
|
-
// `S3BucketOrigin.withOriginAccessControl()` to the bucket policy,
|
|
1543
|
-
// so anonymous direct-S3 GETs stay forbidden.
|
|
1544
|
-
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
1545
|
-
// Well-Architected: Security — enforce SSL
|
|
1546
|
-
enforceSSL: true,
|
|
1547
|
-
cors: intent.corsOrigins
|
|
1548
|
-
? [
|
|
1549
|
-
{
|
|
1550
|
-
allowedOrigins: intent.corsOrigins,
|
|
1551
|
-
allowedMethods: [
|
|
1552
|
-
s3.HttpMethods.GET,
|
|
1553
|
-
s3.HttpMethods.PUT,
|
|
1554
|
-
s3.HttpMethods.POST,
|
|
1555
|
-
s3.HttpMethods.DELETE,
|
|
1556
|
-
],
|
|
1557
|
-
allowedHeaders: ['*'],
|
|
1558
|
-
},
|
|
1559
|
-
]
|
|
1560
|
-
: undefined,
|
|
1561
|
-
// Well-Architected: Cost — lifecycle rules for logs and backups
|
|
1562
|
-
lifecycleRules: intent.purpose === 'logs'
|
|
1563
|
-
? [{ expiration: cdk.Duration.days(intent.retentionDays ?? 90), id: 'log-expiry' }]
|
|
1564
|
-
: intent.purpose === 'backups'
|
|
1565
|
-
? [
|
|
1566
|
-
{ transitions: [{ storageClass: s3.StorageClass.INFREQUENT_ACCESS, transitionAfter: cdk.Duration.days(30) }], id: 'backup-ia' },
|
|
1567
|
-
{ transitions: [{ storageClass: s3.StorageClass.GLACIER, transitionAfter: cdk.Duration.days(90) }], id: 'backup-glacier' },
|
|
1568
|
-
]
|
|
1569
|
-
: [],
|
|
1570
|
-
});
|
|
1571
|
-
// Well-Architected: Reliability — UpdateReplacePolicy for stateful resources
|
|
1572
|
-
const cfnBucket = bucket.node.defaultChild;
|
|
1573
|
-
if (this.dataSafetyConfig.removalPolicy === 'retain') {
|
|
1574
|
-
cfnBucket.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
1575
|
-
}
|
|
1576
|
-
new cdk.CfnOutput(this, `${logicalId}-bucket-name`, {
|
|
1577
|
-
value: bucket.bucketName,
|
|
1578
|
-
description: `S3 bucket name for ${intent.id}`,
|
|
1579
|
-
});
|
|
1580
|
-
new cdk.CfnOutput(this, `${logicalId}-bucket-arn`, {
|
|
1581
|
-
value: bucket.bucketArn,
|
|
1582
|
-
description: `S3 bucket ARN for ${intent.id}`,
|
|
1583
|
-
});
|
|
1584
|
-
// Optional CloudFront distribution in front of the (still-private)
|
|
1585
|
-
// bucket. Enabled by `intent.cdn === true`. The distribution uses an
|
|
1586
|
-
// Origin Access Control (OAC), which is the modern replacement for
|
|
1587
|
-
// OAI: `S3BucketOrigin.withOriginAccessControl(bucket)` auto-creates
|
|
1588
|
-
// the OAC resource and appends a bucket-policy statement granting
|
|
1589
|
-
// `s3:GetObject` to the CloudFront service principal under an
|
|
1590
|
-
// `aws:SourceArn` condition pinned to this distribution. Result:
|
|
1591
|
-
// anonymous viewers can GET image bytes through the CDN, but direct
|
|
1592
|
-
// S3 URLs continue to return 403.
|
|
1593
|
-
//
|
|
1594
|
-
// The distribution's domain name is exposed as a CfnOutput and is
|
|
1595
|
-
// injected into every Lambda as `STORAGE_<ID>_CDN_URL` (and as
|
|
1596
|
-
// `VENTURE_STORAGE_CDN_URL` for the primary intent) in the
|
|
1597
|
-
// role-wiring block below, so application code can build public
|
|
1598
|
-
// URLs without an extra Outputs lookup.
|
|
1599
|
-
let distribution;
|
|
1600
|
-
let cdnAlias;
|
|
1601
|
-
if (intent.cdn === true) {
|
|
1602
|
-
// Optional alternate domain (CNAME) + ACM cert. CloudFront only
|
|
1603
|
-
// accepts certs from `us-east-1`, so the ARN MUST come from there
|
|
1604
|
-
// regardless of which region the rest of the stack runs in. The
|
|
1605
|
-
// CLI helper for this is:
|
|
1606
|
-
//
|
|
1607
|
-
// vk cert request --domain cdn.example.com --region us-east-1
|
|
1608
|
-
//
|
|
1609
|
-
// followed by pasting the issued ARN into `cdnCertificateArn`.
|
|
1610
|
-
// An empty-string `cdnCertificateArn` is the same "pending"
|
|
1611
|
-
// sentinel used by `DomainIntent.certificateArn`: skip the alias
|
|
1612
|
-
// and let the distribution serve at its auto-generated
|
|
1613
|
-
// `*.cloudfront.net` hostname until the next deploy.
|
|
1614
|
-
const aliasReady = typeof intent.cdnDomain === 'string' &&
|
|
1615
|
-
intent.cdnDomain.length > 0 &&
|
|
1616
|
-
typeof intent.cdnCertificateArn === 'string' &&
|
|
1617
|
-
intent.cdnCertificateArn.length > 0;
|
|
1618
|
-
let aliasProps = {};
|
|
1619
|
-
if (aliasReady) {
|
|
1620
|
-
aliasProps = {
|
|
1621
|
-
domainNames: [intent.cdnDomain],
|
|
1622
|
-
certificate: acm.Certificate.fromCertificateArn(this, `${logicalId}-cdn-cert`, intent.cdnCertificateArn),
|
|
1623
|
-
};
|
|
1624
|
-
cdnAlias = intent.cdnDomain;
|
|
1625
|
-
}
|
|
1626
|
-
else if (typeof intent.cdnDomain === 'string' &&
|
|
1627
|
-
intent.cdnDomain.length > 0) {
|
|
1628
|
-
// Domain declared but cert ARN not filled in yet — surface a
|
|
1629
|
-
// hint so operators know exactly what to run, mirroring the
|
|
1630
|
-
// pending-domain CfnOutput in `--- Custom Domains ---`.
|
|
1631
|
-
new cdk.CfnOutput(this, `${logicalId}-cdn-alias-pending`, {
|
|
1632
|
-
value: `Run \`vk cert request --domain ${intent.cdnDomain} --region us-east-1\` to activate.`,
|
|
1633
|
-
description: `CDN alias ${intent.cdnDomain} is pending — no ACM cert ARN yet.`,
|
|
1634
|
-
});
|
|
1635
|
-
}
|
|
1636
|
-
distribution = new cloudfront.Distribution(this, `${logicalId}-cdn`, {
|
|
1637
|
-
comment: `${projectName}-${intent.id}-${stage}`,
|
|
1638
|
-
defaultBehavior: {
|
|
1639
|
-
origin: cloudfrontOrigins.S3BucketOrigin.withOriginAccessControl(bucket),
|
|
1640
|
-
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
1641
|
-
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
|
1642
|
-
cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
|
|
1643
|
-
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
|
1644
|
-
compress: true,
|
|
1645
|
-
},
|
|
1646
|
-
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
|
|
1647
|
-
enabled: true,
|
|
1648
|
-
...aliasProps,
|
|
1649
|
-
});
|
|
1650
|
-
// Well-Architected: Reliability — RETAIN the distribution under
|
|
1651
|
-
// strict dataSafety. Distributions are slow to create (~15-30 min
|
|
1652
|
-
// for the global edge rollout), have a stable `*.cloudfront.net`
|
|
1653
|
-
// domain that consumers may cache, and re-issuing the ACM cert
|
|
1654
|
-
// requires re-doing DNS validation. RETAIN means an accidental
|
|
1655
|
-
// stack delete leaves the distribution in place for `cdk import`.
|
|
1656
|
-
if (this.dataSafetyConfig.removalPolicy === 'retain') {
|
|
1657
|
-
const cfnDistribution = distribution.node.defaultChild;
|
|
1658
|
-
cfnDistribution.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
1659
|
-
cfnDistribution.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
1660
|
-
}
|
|
1661
|
-
new cdk.CfnOutput(this, `${logicalId}-cdn-domain`, {
|
|
1662
|
-
value: distribution.distributionDomainName,
|
|
1663
|
-
description: `CloudFront domain for ${intent.id}`,
|
|
1664
|
-
});
|
|
1665
|
-
if (cdnAlias) {
|
|
1666
|
-
// Reminder for operators: the alias only resolves once the
|
|
1667
|
-
// matching DNS record points at the distribution. Print the
|
|
1668
|
-
// exact target so this isn't lost in the CFN output noise.
|
|
1669
|
-
new cdk.CfnOutput(this, `${logicalId}-cdn-alias`, {
|
|
1670
|
-
value: `${cdnAlias} → ${distribution.distributionDomainName} (create a CNAME / Cloudflare proxy record)`,
|
|
1671
|
-
description: `CDN alias for ${intent.id}`,
|
|
1672
|
-
});
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
// Tracked for post-role wiring: the shared Lambda execution role is
|
|
1676
|
-
// created later in the constructor (only when the stack actually has
|
|
1677
|
-
// Lambdas), so we record the bucket here and grant + inject env vars
|
|
1678
|
-
// alongside the database / Cognito wiring once the role exists.
|
|
1679
|
-
this.storageBuckets.push({ bucket, intent, distribution, cdnAlias });
|
|
1680
|
-
}
|
|
1681
|
-
/**
|
|
1682
|
-
* Well-Architected: Security — Cognito with advanced security features,
|
|
1683
|
-
* stable logical ID for safe preset upgrades.
|
|
1684
|
-
* Well-Architected: Reliability — dataSafety-driven removal policy
|
|
1685
|
-
*/
|
|
1686
|
-
createAuth(intent, projectName, stage) {
|
|
1687
|
-
// Stable logical ID: 'auth-{intent.id}'
|
|
1688
|
-
const logicalId = `auth-${intent.id}`;
|
|
1689
|
-
const passwordPolicy = intent.passwordStrength === 'strong'
|
|
1690
|
-
? {
|
|
1691
|
-
minLength: 12,
|
|
1692
|
-
requireLowercase: true,
|
|
1693
|
-
requireUppercase: true,
|
|
1694
|
-
requireDigits: true,
|
|
1695
|
-
requireSymbols: true,
|
|
1696
|
-
}
|
|
1697
|
-
: {
|
|
1698
|
-
minLength: 8,
|
|
1699
|
-
requireLowercase: true,
|
|
1700
|
-
requireUppercase: true,
|
|
1701
|
-
requireDigits: true,
|
|
1702
|
-
requireSymbols: false,
|
|
1703
|
-
};
|
|
1704
|
-
// Custom user attributes — declared as mutable strings so the same
|
|
1705
|
-
// names show up as `custom:<name>` claims in the issued JWTs (and so
|
|
1706
|
-
// `vk dev`'s cognito-local provisioning, which mirrors this Schema,
|
|
1707
|
-
// matches production exactly). All app code that reads
|
|
1708
|
-
// `custom:tenantId` etc. relies on these being declared on the pool.
|
|
1709
|
-
const customAttrNames = Array.isArray(intent.customAttributes)
|
|
1710
|
-
? intent.customAttributes
|
|
1711
|
-
: [];
|
|
1712
|
-
const customAttributes = {};
|
|
1713
|
-
for (const name of customAttrNames) {
|
|
1714
|
-
customAttributes[name] = new cognito.StringAttribute({
|
|
1715
|
-
minLen: 1,
|
|
1716
|
-
maxLen: 2048,
|
|
1717
|
-
mutable: true,
|
|
1718
|
-
});
|
|
1719
|
-
}
|
|
1720
|
-
const userPool = new cognito.UserPool(this, logicalId, {
|
|
1721
|
-
userPoolName: `${projectName}-${intent.id}-${stage}`,
|
|
1722
|
-
// Security default: self-signup disabled unless the intent explicitly
|
|
1723
|
-
// opts in. Most production apps use invite flows; opt in with
|
|
1724
|
-
// `allowSignUp: true` on the auth intent if you run a consumer product.
|
|
1725
|
-
selfSignUpEnabled: intent.allowSignUp ?? false,
|
|
1726
|
-
// `phone` flips on Cognito's `phone_number` alias attribute so a
|
|
1727
|
-
// single user can authenticate with either email or phone without
|
|
1728
|
-
// a duplicate account. `signInWith: ['email', 'phone']` therefore
|
|
1729
|
-
// mirrors the §1 user model where buyers register with one and
|
|
1730
|
-
// log in with the other interchangeably.
|
|
1731
|
-
signInAliases: {
|
|
1732
|
-
email: intent.signInWith?.includes('email') ?? true,
|
|
1733
|
-
phone: intent.signInWith?.includes('phone') ?? false,
|
|
1734
|
-
username: intent.signInWith?.includes('username') ?? false,
|
|
1735
|
-
},
|
|
1736
|
-
autoVerify: {
|
|
1737
|
-
email: intent.signInWith?.includes('email') ?? true,
|
|
1738
|
-
phone: intent.signInWith?.includes('phone') ?? false,
|
|
1739
|
-
},
|
|
1740
|
-
passwordPolicy,
|
|
1741
|
-
mfa: intent.mfa === 'required'
|
|
1742
|
-
? cognito.Mfa.REQUIRED
|
|
1743
|
-
: intent.mfa === 'optional'
|
|
1744
|
-
? cognito.Mfa.OPTIONAL
|
|
1745
|
-
: cognito.Mfa.OFF,
|
|
1746
|
-
// Well-Architected: Reliability — dataSafety removal policy
|
|
1747
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
1748
|
-
// Well-Architected: Reliability — native deletion protection on top of
|
|
1749
|
-
// RETAIN. `DeletionPolicy=Retain` only blocks CFN-driven deletes; the
|
|
1750
|
-
// user pool can still be deleted via console / API / CLI. Cognito's
|
|
1751
|
-
// native deletionProtection flag blocks that path too. Both layers
|
|
1752
|
-
// must be explicitly disabled before the pool can be deleted, and
|
|
1753
|
-
// every flip is CloudTrail-logged. Losing a Cognito User Pool means
|
|
1754
|
-
// losing every user identity + password hash — the most catastrophic
|
|
1755
|
-
// recoverable state in a VentureKit deploy, since users must re-
|
|
1756
|
-
// register via password reset.
|
|
1757
|
-
deletionProtection: this.dataSafetyConfig.removalPolicy === 'retain',
|
|
1758
|
-
// Well-Architected: Security — advanced security mode for medium+ presets
|
|
1759
|
-
advancedSecurityMode: this.envConfig.lambda.memoryMb >= 512
|
|
1760
|
-
? cognito.AdvancedSecurityMode.ENFORCED
|
|
1761
|
-
: undefined,
|
|
1762
|
-
// Well-Architected: Security — account recovery via verified email
|
|
1763
|
-
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
|
|
1764
|
-
...(customAttrNames.length > 0 ? { customAttributes } : {}),
|
|
1765
|
-
});
|
|
1766
|
-
// Stash the pool so the shared Lambda role (created later in the
|
|
1767
|
-
// constructor, see `if (hasAnyLambda)`) can be granted admin
|
|
1768
|
-
// Cognito actions scoped to this pool's ARN. Without that grant,
|
|
1769
|
-
// any handler that calls `AdminConfirmSignUp`, `AdminCreateUser`
|
|
1770
|
-
// etc. fails at runtime with `AccessDeniedException` — the public
|
|
1771
|
-
// `SignUp` / `InitiateAuth` calls work without IAM, but the admin
|
|
1772
|
-
// surface that self-service register flows almost always need
|
|
1773
|
-
// does not.
|
|
1774
|
-
this.userPools.push(userPool);
|
|
1775
|
-
// Well-Architected: Reliability — prevent accidental replacement
|
|
1776
|
-
const cfnPool = userPool.node.defaultChild;
|
|
1777
|
-
if (this.dataSafetyConfig.removalPolicy === 'retain') {
|
|
1778
|
-
cfnPool.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
1779
|
-
}
|
|
1780
|
-
// Create clients
|
|
1781
|
-
const clients = intent.clients && intent.clients.length > 0
|
|
1782
|
-
? intent.clients
|
|
1783
|
-
: [{ name: 'default' }];
|
|
1784
|
-
// When the intent declares federated providers, the federated
|
|
1785
|
-
// sign-in helper in `@venturekit/auth/server` mints session tokens
|
|
1786
|
-
// through `AdminInitiateAuth` after creating/looking up the user
|
|
1787
|
-
// via `AdminCreateUser` / `AdminGetUser`. That flow needs
|
|
1788
|
-
// `ALLOW_ADMIN_USER_PASSWORD_AUTH` enabled on the app client —
|
|
1789
|
-
// password sign-in keeps using the public `USER_PASSWORD_AUTH`
|
|
1790
|
-
// path so we leave both turned on.
|
|
1791
|
-
const federatedProviders = Array.isArray(intent.federated)
|
|
1792
|
-
? intent.federated
|
|
1793
|
-
: [];
|
|
1794
|
-
const enableAdminAuth = federatedProviders.length > 0;
|
|
1795
|
-
let primaryClientId = '';
|
|
1796
|
-
for (const client of clients) {
|
|
1797
|
-
const appClient = userPool.addClient(client.name, {
|
|
1798
|
-
authFlows: {
|
|
1799
|
-
userPassword: true,
|
|
1800
|
-
userSrp: true,
|
|
1801
|
-
...(enableAdminAuth ? { adminUserPassword: true } : {}),
|
|
1802
|
-
},
|
|
1803
|
-
// Well-Architected: Security — prevent user existence errors
|
|
1804
|
-
preventUserExistenceErrors: true,
|
|
1805
|
-
});
|
|
1806
|
-
if (!primaryClientId) {
|
|
1807
|
-
primaryClientId = appClient.userPoolClientId;
|
|
1808
|
-
}
|
|
1809
|
-
}
|
|
1810
|
-
new cdk.CfnOutput(this, `${logicalId}-user-pool-id`, {
|
|
1811
|
-
value: userPool.userPoolId,
|
|
1812
|
-
description: `Cognito User Pool ID for ${intent.id}`,
|
|
1813
|
-
});
|
|
1814
|
-
new cdk.CfnOutput(this, `${logicalId}-client-id`, {
|
|
1815
|
-
value: primaryClientId,
|
|
1816
|
-
description: `Cognito Client ID for ${intent.id}`,
|
|
1817
|
-
});
|
|
1818
|
-
// Auto-inject the user-pool and app-client IDs into every Lambda's
|
|
1819
|
-
// environment so `@venturekit/auth/server`'s `loadAuthServerConfig()`
|
|
1820
|
-
// resolves without manual operator intervention. We use the FIRST
|
|
1821
|
-
// declared `auth` intent as the canonical pool — multi-pool projects
|
|
1822
|
-
// are rare, and consumers can always override with explicit
|
|
1823
|
-
// `envVars` entries on the intent.
|
|
1824
|
-
//
|
|
1825
|
-
// Lambdas already have `AWS_REGION` set automatically by Lambda, and
|
|
1826
|
-
// `loadAuthServerConfig()` falls back to it when `COGNITO_REGION`
|
|
1827
|
-
// isn't present, so we don't set the region here. (Setting
|
|
1828
|
-
// `AWS_REGION` ourselves is rejected by Lambda as a reserved key.)
|
|
1829
|
-
if (!('COGNITO_USER_POOL_ID' in this.ventureBaseEnv)) {
|
|
1830
|
-
this.ventureBaseEnv['COGNITO_USER_POOL_ID'] = userPool.userPoolId;
|
|
1831
|
-
this.ventureBaseEnv['COGNITO_APP_CLIENT_ID'] = primaryClientId;
|
|
1832
|
-
}
|
|
1833
|
-
// ── Federated identity providers ──────────────────────────────
|
|
1834
|
-
// For each declared third-party provider create a Secrets Manager
|
|
1835
|
-
// placeholder holding `{clientId, clientSecret}`. Operators paste
|
|
1836
|
-
// the real values from the Google Cloud / Meta for Developers /
|
|
1837
|
-
// Apple Developer console after the first deploy via
|
|
1838
|
-
// aws secretsmanager put-secret-value --secret-id <arn> \
|
|
1839
|
-
// --secret-string '{"clientId":"…","clientSecret":"…"}'
|
|
1840
|
-
//
|
|
1841
|
-
// The ARN is injected as `COGNITO_FEDERATED_<PROVIDER>_SECRET_ARN`
|
|
1842
|
-
// on every Lambda; `secretsmanager:GetSecretValue` is granted on
|
|
1843
|
-
// the secret in the shared-Lambda-role wiring later in the
|
|
1844
|
-
// constructor (next to the user-pool admin grants).
|
|
1845
|
-
//
|
|
1846
|
-
// VentureKit deliberately does NOT enable the Cognito Hosted UI
|
|
1847
|
-
// or wire these as Cognito Identity Providers — your app owns the
|
|
1848
|
-
// login screen. The SPA performs the OAuth dance with the IdP
|
|
1849
|
-
// directly and posts the resulting id_token / access_token to
|
|
1850
|
-
// your API; `signInAsFederatedUser` from `@venturekit/auth/server`
|
|
1851
|
-
// verifies it and mints Cognito session tokens via
|
|
1852
|
-
// `AdminInitiateAuth`.
|
|
1853
|
-
for (const provider of federatedProviders) {
|
|
1854
|
-
const secret = new secretsmanager.Secret(this, `${logicalId}-federated-${provider}`, {
|
|
1855
|
-
secretName: `venturekit/${projectName}/${stage}/auth/${intent.id}/${provider}`,
|
|
1856
|
-
description: `OAuth client credentials for ${provider} federated sign-in ` +
|
|
1857
|
-
`on auth pool '${intent.id}'. Populate after first deploy.`,
|
|
1858
|
-
secretObjectValue: {
|
|
1859
|
-
clientId: cdk.SecretValue.unsafePlainText('PLACEHOLDER'),
|
|
1860
|
-
clientSecret: cdk.SecretValue.unsafePlainText('PLACEHOLDER'),
|
|
1861
|
-
},
|
|
1862
|
-
// Well-Architected: Reliability — federated provider credentials
|
|
1863
|
-
// are operator-set values pasted from the third-party developer
|
|
1864
|
-
// console (Google Cloud, Meta for Developers, Apple Developer).
|
|
1865
|
-
// They can be re-generated by the operator but require manually
|
|
1866
|
-
// revisiting that console; we'd rather not force that on every
|
|
1867
|
-
// stack-replace event. Promote to RETAIN under strict dataSafety
|
|
1868
|
-
// so accidental stack deletes don't wipe them.
|
|
1869
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
1870
|
-
});
|
|
1871
|
-
this.federatedAuthSecrets.push(secret);
|
|
1872
|
-
const envVar = `COGNITO_FEDERATED_${provider.toUpperCase()}_SECRET_ARN`;
|
|
1873
|
-
this.ventureBaseEnv[envVar] = secret.secretArn;
|
|
1874
|
-
new cdk.CfnOutput(this, `${logicalId}-federated-${provider}-secret-arn`, {
|
|
1875
|
-
value: secret.secretArn,
|
|
1876
|
-
description: `Populate with the ${provider} OAuth client credentials: ` +
|
|
1877
|
-
`aws secretsmanager put-secret-value --secret-id <arn> ` +
|
|
1878
|
-
`--secret-string '{"clientId":"…","clientSecret":"…"}'`,
|
|
1879
|
-
});
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
|
-
/**
|
|
1883
|
-
* Provision SES + outbox infrastructure for one `NotifyIntent`.
|
|
1884
|
-
*
|
|
1885
|
-
* Resources created here:
|
|
1886
|
-
* - One SES email or domain identity (drives DKIM + verified-from).
|
|
1887
|
-
* - One SES configuration set with an event destination publishing
|
|
1888
|
-
* bounce / complaint / delivery / open / click events to an
|
|
1889
|
-
* SNS topic.
|
|
1890
|
-
* - The SNS topic itself (`{project}-{stage}-notify-{id}-events`).
|
|
1891
|
-
* - A Secrets Manager secret for the WhatsApp Cloud API token,
|
|
1892
|
-
* when `'whatsapp'` is in `channels`. The value is left as a
|
|
1893
|
-
* placeholder; the operator pastes the Meta token after first
|
|
1894
|
-
* deploy via the AWS console (the secret rotation knob would be
|
|
1895
|
-
* a Phase 2 enhancement once token rotation is part of the API).
|
|
1896
|
-
* - When `domain` AND `hostedZoneId` are both set, Route53 CNAME
|
|
1897
|
-
* records for the three SES DKIM tokens, plus an MX + TXT record
|
|
1898
|
-
* on the MAIL FROM subdomain. Without `hostedZoneId`, the
|
|
1899
|
-
* records are emitted as stack outputs and the operator
|
|
1900
|
-
* publishes them manually.
|
|
1901
|
-
*
|
|
1902
|
-
* Resources NOT created here: dispatcher cron Lambda, bounce-handler
|
|
1903
|
-
* Lambda, IAM grants on the shared role. Those need the role + VPC
|
|
1904
|
-
* + SG which only exist later in the constructor — see
|
|
1905
|
-
* `provisionNotifyHandlers` below.
|
|
1906
|
-
*/
|
|
1907
|
-
createNotify(intent, projectName, stage) {
|
|
1908
|
-
const logicalId = `notify-${intent.id}`;
|
|
1909
|
-
const channels = Array.isArray(intent.channels) ? intent.channels : [];
|
|
1910
|
-
// Stable, predictable resource names. Avoid hashing — operators
|
|
1911
|
-
// often grep CloudFormation outputs / IAM policies for the
|
|
1912
|
-
// intent id and silent hashes break that workflow.
|
|
1913
|
-
const configurationSetName = intent.configurationSetName ?? `${projectName}-${stage}-notify-${intent.id}`;
|
|
1914
|
-
// ── SNS events topic ────────────────────────────────────────────
|
|
1915
|
-
// Receives every SES event for this configuration set. Subscribed
|
|
1916
|
-
// to by the bounce-handler Lambda (provisioned later) and
|
|
1917
|
-
// optionally by external observability tools. Encryption at rest
|
|
1918
|
-
// with the AWS-managed key — events occasionally contain
|
|
1919
|
-
// recipient PII (bounce diagnostic emails) which we don't want
|
|
1920
|
-
// sitting unencrypted in case of an account compromise.
|
|
1921
|
-
const eventsTopic = new sns.Topic(this, `${logicalId}-events`, {
|
|
1922
|
-
topicName: `${projectName}-${stage}-notify-${intent.id}-events`,
|
|
1923
|
-
displayName: `VentureKit notify events for ${intent.id} (${stage})`,
|
|
1924
|
-
masterKey: undefined, // AWS-managed encryption is fine; KMS CMK
|
|
1925
|
-
// would add cost + key-policy maintenance
|
|
1926
|
-
// for a topic only consumed by our own
|
|
1927
|
-
// Lambda + the operator's SNS console.
|
|
1928
|
-
});
|
|
1929
|
-
// Well-Architected: Reliability — RETAIN the events topic under
|
|
1930
|
-
// strict dataSafety. SNS topics carry durable subscriptions (the
|
|
1931
|
-
// bounce-handler Lambda is one; operators may attach additional
|
|
1932
|
-
// subscribers via the console for observability tools or paging).
|
|
1933
|
-
// Deleting the topic silently drops every subscription; re-creating
|
|
1934
|
-
// the topic does NOT restore them. RETAIN means an accidental stack
|
|
1935
|
-
// delete leaves the topic + subscriptions in place for recovery.
|
|
1936
|
-
if (this.dataSafetyConfig.removalPolicy === 'retain') {
|
|
1937
|
-
const cfnEventsTopic = eventsTopic.node.defaultChild;
|
|
1938
|
-
cfnEventsTopic.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
1939
|
-
cfnEventsTopic.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
1940
|
-
}
|
|
1941
|
-
new cdk.CfnOutput(this, `${logicalId}-events-topic-arn`, {
|
|
1942
|
-
value: eventsTopic.topicArn,
|
|
1943
|
-
description: `SES events SNS topic for notify '${intent.id}'`,
|
|
1944
|
-
});
|
|
1945
|
-
// ── SES email identity ──────────────────────────────────────────
|
|
1946
|
-
// Two paths depending on what the intent declared:
|
|
1947
|
-
// - `domain` set → verify the whole domain. SES creates
|
|
1948
|
-
// DKIM tokens; we publish the matching
|
|
1949
|
-
// CNAMEs in Route53 when `hostedZoneId`
|
|
1950
|
-
// is set.
|
|
1951
|
-
// - `defaultFrom` only → verify a single email address. SES
|
|
1952
|
-
// sends a verification email to it;
|
|
1953
|
-
// sending is blocked until the
|
|
1954
|
-
// recipient clicks through. Useful for
|
|
1955
|
-
// sandbox / dev where the operator
|
|
1956
|
-
// doesn't own the domain DNS.
|
|
1957
|
-
let identityArn;
|
|
1958
|
-
if (intent.domain) {
|
|
1959
|
-
const domainIdentity = new ses.EmailIdentity(this, `${logicalId}-domain`, {
|
|
1960
|
-
identity: ses.Identity.domain(intent.domain),
|
|
1961
|
-
configurationSet: undefined, // attached to per-send command, not identity
|
|
1962
|
-
dkimSigning: true,
|
|
1963
|
-
feedbackForwarding: false, // we use the configuration set
|
|
1964
|
-
// event destination instead — no need
|
|
1965
|
-
// for SES to email the operator on
|
|
1966
|
-
// every bounce.
|
|
1967
|
-
});
|
|
1968
|
-
identityArn = `arn:${cdk.Aws.PARTITION}:ses:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:identity/${intent.domain}`;
|
|
1969
|
-
// CDK's `EmailIdentity` exposes the three DKIM token pairs as
|
|
1970
|
-
// discrete properties (`dkimDnsTokenName1/2/3` +
|
|
1971
|
-
// `dkimDnsTokenValue1/2/3`), not as arrays. We tuple them up
|
|
1972
|
-
// here so the loop below stays uniform.
|
|
1973
|
-
const dkimTokens = [
|
|
1974
|
-
{ name: domainIdentity.dkimDnsTokenName1, value: domainIdentity.dkimDnsTokenValue1 },
|
|
1975
|
-
{ name: domainIdentity.dkimDnsTokenName2, value: domainIdentity.dkimDnsTokenValue2 },
|
|
1976
|
-
{ name: domainIdentity.dkimDnsTokenName3, value: domainIdentity.dkimDnsTokenValue3 },
|
|
1977
|
-
];
|
|
1978
|
-
// DKIM token outputs — operator publishes these as CNAMEs in DNS
|
|
1979
|
-
// unless we own the zone (Route53 path below).
|
|
1980
|
-
dkimTokens.forEach((tok, i) => {
|
|
1981
|
-
new cdk.CfnOutput(this, `${logicalId}-dkim-${i + 1}-record`, {
|
|
1982
|
-
value: `${tok.name}.${intent.domain} CNAME ${tok.value}.dkim.amazonses.com`,
|
|
1983
|
-
description: `DKIM CNAME #${i + 1} for ${intent.domain} — publish in DNS`,
|
|
1984
|
-
});
|
|
1985
|
-
});
|
|
1986
|
-
if (intent.hostedZoneId) {
|
|
1987
|
-
const zone = route53.HostedZone.fromHostedZoneAttributes(this, `${logicalId}-zone`, {
|
|
1988
|
-
hostedZoneId: intent.hostedZoneId,
|
|
1989
|
-
zoneName: intent.domain,
|
|
1990
|
-
});
|
|
1991
|
-
// Auto-publish the three DKIM CNAMEs. Route53 charges $0.50
|
|
1992
|
-
// per hosted zone + $0.40 per million queries — these records
|
|
1993
|
-
// are queried only by SES so the cost is negligible.
|
|
1994
|
-
dkimTokens.forEach((tok, i) => {
|
|
1995
|
-
new route53.CnameRecord(this, `${logicalId}-dkim-record-${i + 1}`, {
|
|
1996
|
-
zone,
|
|
1997
|
-
// The token names from CDK are unqualified (e.g. `abc123`);
|
|
1998
|
-
// SES requires the full `<token>._domainkey.<domain>` form.
|
|
1999
|
-
// Route53 appends the zone automatically when `recordName`
|
|
2000
|
-
// is unqualified, so we just pass the SES-style label.
|
|
2001
|
-
recordName: `${tok.name}._domainkey`,
|
|
2002
|
-
domainName: `${tok.value}.dkim.amazonses.com`,
|
|
2003
|
-
ttl: cdk.Duration.minutes(5),
|
|
2004
|
-
});
|
|
2005
|
-
});
|
|
2006
|
-
// Custom MAIL FROM subdomain (`mail.<domain>`) improves DMARC
|
|
2007
|
-
// alignment by making the SMTP envelope-sender match the From
|
|
2008
|
-
// domain. SES requires an MX record on the subdomain pointing
|
|
2009
|
-
// at `feedback-smtp.<region>.amazonses.com` plus a TXT record
|
|
2010
|
-
// for SPF. We don't auto-enable in Phase 1 — turning it on
|
|
2011
|
-
// mid-life would temporarily break sending if the records
|
|
2012
|
-
// aren't published — but emit the recommendation as an output.
|
|
2013
|
-
new cdk.CfnOutput(this, `${logicalId}-mail-from-suggestion`, {
|
|
2014
|
-
value: `mail.${intent.domain}`,
|
|
2015
|
-
description: `Suggested MAIL FROM subdomain — enable in SES console for SPF alignment`,
|
|
2016
|
-
});
|
|
2017
|
-
}
|
|
2018
|
-
new cdk.CfnOutput(this, `${logicalId}-domain-identity`, {
|
|
2019
|
-
value: intent.domain,
|
|
2020
|
-
description: `SES domain identity for notify '${intent.id}'`,
|
|
2021
|
-
});
|
|
2022
|
-
}
|
|
2023
|
-
else if (intent.defaultFrom) {
|
|
2024
|
-
new ses.EmailIdentity(this, `${logicalId}-email`, {
|
|
2025
|
-
identity: ses.Identity.email(intent.defaultFrom),
|
|
2026
|
-
// No DKIM signing on single-address identities — SES doesn't
|
|
2027
|
-
// support DKIM without a verified domain.
|
|
2028
|
-
feedbackForwarding: false,
|
|
2029
|
-
});
|
|
2030
|
-
identityArn = `arn:${cdk.Aws.PARTITION}:ses:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:identity/${intent.defaultFrom}`;
|
|
2031
|
-
new cdk.CfnOutput(this, `${logicalId}-email-identity`, {
|
|
2032
|
-
value: intent.defaultFrom,
|
|
2033
|
-
description: `SES email identity for notify '${intent.id}'. ` +
|
|
2034
|
-
`Verify by clicking the SES email sent to this address on first deploy.`,
|
|
2035
|
-
});
|
|
2036
|
-
}
|
|
2037
|
-
else {
|
|
2038
|
-
throw new Error(`[venturekit] notify intent '${intent.id}' must declare either ` +
|
|
2039
|
-
`'defaultFrom' (single-address SES identity) or 'domain' (DomainIdentity).`);
|
|
2040
|
-
}
|
|
2041
|
-
// ── Configuration set + event destination ───────────────────────
|
|
2042
|
-
// The configuration set is attached at send-time (every
|
|
2043
|
-
// SendEmailCommand carries `ConfigurationSetName`). Its event
|
|
2044
|
-
// destination publishes selected event types to the SNS topic
|
|
2045
|
-
// above. We subscribe to bounce/complaint always (suppression
|
|
2046
|
-
// logic depends on them), plus delivery/open/click for
|
|
2047
|
-
// observability. Reject — SES rejecting a send for content
|
|
2048
|
-
// reasons — is implicit (the SendEmail call throws synchronously
|
|
2049
|
-
// and the dispatcher captures it without needing the event).
|
|
2050
|
-
const configSet = new ses.ConfigurationSet(this, `${logicalId}-config-set`, {
|
|
2051
|
-
configurationSetName,
|
|
2052
|
-
sendingEnabled: true,
|
|
2053
|
-
reputationMetrics: true, // surface deliverability metrics in
|
|
2054
|
-
// CloudWatch — free, helpful for
|
|
2055
|
-
// operators chasing soft-bounce rates.
|
|
2056
|
-
tlsPolicy: ses.ConfigurationSetTlsPolicy.REQUIRE,
|
|
2057
|
-
});
|
|
2058
|
-
new ses.ConfigurationSetEventDestination(this, `${logicalId}-event-dest`, {
|
|
2059
|
-
configurationSet: configSet,
|
|
2060
|
-
destination: ses.EventDestination.snsTopic(eventsTopic),
|
|
2061
|
-
events: [
|
|
2062
|
-
ses.EmailSendingEvent.SEND,
|
|
2063
|
-
ses.EmailSendingEvent.REJECT,
|
|
2064
|
-
ses.EmailSendingEvent.BOUNCE,
|
|
2065
|
-
ses.EmailSendingEvent.COMPLAINT,
|
|
2066
|
-
ses.EmailSendingEvent.DELIVERY,
|
|
2067
|
-
ses.EmailSendingEvent.OPEN,
|
|
2068
|
-
ses.EmailSendingEvent.CLICK,
|
|
2069
|
-
],
|
|
2070
|
-
enabled: true,
|
|
2071
|
-
});
|
|
2072
|
-
new cdk.CfnOutput(this, `${logicalId}-config-set-name`, {
|
|
2073
|
-
value: configurationSetName,
|
|
2074
|
-
description: `SES configuration set name for notify '${intent.id}'`,
|
|
2075
|
-
});
|
|
2076
|
-
// ── WhatsApp token secret ───────────────────────────────────────
|
|
2077
|
-
let whatsappTokenSecret;
|
|
2078
|
-
if (channels.includes('whatsapp')) {
|
|
2079
|
-
if (!intent.whatsapp?.phoneNumberId) {
|
|
2080
|
-
throw new Error(`[venturekit] notify intent '${intent.id}' has 'whatsapp' channel ` +
|
|
2081
|
-
`but no 'whatsapp.phoneNumberId'. Set it from Meta Business Suite ` +
|
|
2082
|
-
`→ WhatsApp → API Setup.`);
|
|
2083
|
-
}
|
|
2084
|
-
whatsappTokenSecret = new secretsmanager.Secret(this, `${logicalId}-whatsapp-token`, {
|
|
2085
|
-
secretName: `venturekit/${projectName}/${stage}/notify-${intent.id}-whatsapp-token`,
|
|
2086
|
-
description: `Meta WhatsApp Cloud API access token for notify '${intent.id}'. ` +
|
|
2087
|
-
`Set the value via the Secrets Manager console after first deploy.`,
|
|
2088
|
-
// The secret is created with a placeholder — the operator
|
|
2089
|
-
// pastes the real Meta token via the console. Auto-rotation
|
|
2090
|
-
// is not wired (Meta tokens are long-lived; rotation is a
|
|
2091
|
-
// Phase 2 concern).
|
|
2092
|
-
secretStringValue: cdk.SecretValue.unsafePlainText('PLACEHOLDER_SET_VALUE_AFTER_DEPLOY'),
|
|
2093
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
2094
|
-
});
|
|
2095
|
-
new cdk.CfnOutput(this, `${logicalId}-whatsapp-token-arn`, {
|
|
2096
|
-
value: whatsappTokenSecret.secretArn,
|
|
2097
|
-
description: `Set the value of this secret to your Meta WhatsApp access token: ` +
|
|
2098
|
-
`aws secretsmanager put-secret-value --secret-id <arn> --secret-string '<token>'`,
|
|
2099
|
-
});
|
|
2100
|
-
}
|
|
2101
|
-
this.notifyConfigs.push({
|
|
2102
|
-
intent,
|
|
2103
|
-
configurationSetName,
|
|
2104
|
-
eventsTopic,
|
|
2105
|
-
identityArn,
|
|
2106
|
-
whatsappTokenSecret,
|
|
2107
|
-
});
|
|
2108
|
-
}
|
|
2109
|
-
/**
|
|
2110
|
-
* Auto-provision the dispatcher cron Lambda + the bounce-handler
|
|
2111
|
-
* Lambda subscribed to the SES events SNS topic, for every notify
|
|
2112
|
-
* intent.
|
|
2113
|
-
*
|
|
2114
|
-
* Implementation notes:
|
|
2115
|
-
*
|
|
2116
|
-
* - The Lambdas import a stub shipped inside `@venturekit/notify`
|
|
2117
|
-
* (`runtime/dispatcher-stub.ts`, `runtime/bounce-handler-stub.ts`).
|
|
2118
|
-
* At synth time we copy the stub into the project's `.vk/` dir
|
|
2119
|
-
* alongside a generated `notify-client-import.ts` shim that
|
|
2120
|
-
* re-exports the consumer's `notify` instance from
|
|
2121
|
-
* `VENTURE_NOTIFY_CLIENT_MODULE` (default `src/lib/notify.ts`).
|
|
2122
|
-
* esbuild then bundles the whole closure into the Lambda — the
|
|
2123
|
-
* consumer's notify config + every provider + the @venturekit
|
|
2124
|
-
* runtime, all in one deploy artefact.
|
|
2125
|
-
*
|
|
2126
|
-
* - We re-use the shared Lambda role for these handlers. Permissions:
|
|
2127
|
-
* - SES + secrets grants are already attached above
|
|
2128
|
-
* (`provisionNotifyHandlers` is called from the same block).
|
|
2129
|
-
* - SNS topic subscription + invocation perms are added by CDK
|
|
2130
|
-
* when we wire `topic.addSubscription(LambdaSubscription)`.
|
|
2131
|
-
*
|
|
2132
|
-
* - VPC attachment: yes, when the project has a VPC. The dispatcher
|
|
2133
|
-
* needs DB access (Postgres outbox + preferences); the bounce
|
|
2134
|
-
* handler also writes `notification_event_log`.
|
|
2135
|
-
*/
|
|
2136
|
-
provisionNotifyHandlers(projectName, stage, projectDir, role, securityGroup, vpc) {
|
|
2137
|
-
// Resolve the path to the stubs inside the consumer's
|
|
2138
|
-
// `node_modules/@venturekit/notify`. We use Node's resolver via a
|
|
2139
|
-
// module-relative `require.resolve` pinned at the consumer's
|
|
2140
|
-
// `package.json`. If `@venturekit/notify` isn't a project
|
|
2141
|
-
// dependency we throw with a helpful message — declaring a
|
|
2142
|
-
// notify intent in vk.config.ts without installing the runtime
|
|
2143
|
-
// package is a frequent first-time-user mistake.
|
|
2144
|
-
const projectRequire = createRequire(path.join(projectDir, 'package.json'));
|
|
2145
|
-
let dispatcherStubSrc;
|
|
2146
|
-
let bounceStubSrc;
|
|
2147
|
-
try {
|
|
2148
|
-
dispatcherStubSrc = projectRequire.resolve('@venturekit/notify/runtime/dispatcher-stub');
|
|
2149
|
-
bounceStubSrc = projectRequire.resolve('@venturekit/notify/runtime/bounce-handler-stub');
|
|
2150
|
-
}
|
|
2151
|
-
catch (err) {
|
|
2152
|
-
throw new Error(`[venturekit] notify intent declared but '@venturekit/notify' is not ` +
|
|
2153
|
-
`installed in the project. Run \`pnpm add @venturekit/notify\` ` +
|
|
2154
|
-
`(or your equivalent) and redeploy. Underlying error: ${err.message}`);
|
|
2155
|
-
}
|
|
2156
|
-
// Materialize copies of the stubs in `.vk/` so esbuild can resolve
|
|
2157
|
-
// their `import { notify } from '<projectClientModule>'` against
|
|
2158
|
-
// the project's source tree. Writing to `.vk/` keeps the stubs out
|
|
2159
|
-
// of the consumer's `src/` (and out of `git status` if they
|
|
2160
|
-
// gitignore `.vk/`, which the init template already does).
|
|
2161
|
-
const vkDir = path.join(projectDir, '.vk');
|
|
2162
|
-
fs.mkdirSync(vkDir, { recursive: true });
|
|
2163
|
-
for (const cfg of this.notifyConfigs) {
|
|
2164
|
-
const id = String(cfg.intent.id);
|
|
2165
|
-
const dispatchRateMinutes = cfg.intent.dispatchRateMinutes && cfg.intent.dispatchRateMinutes >= 1
|
|
2166
|
-
? cfg.intent.dispatchRateMinutes
|
|
2167
|
-
: 1;
|
|
2168
|
-
const batchSize = cfg.intent.dispatchBatchSize && cfg.intent.dispatchBatchSize >= 1
|
|
2169
|
-
? cfg.intent.dispatchBatchSize
|
|
2170
|
-
: 50;
|
|
2171
|
-
// Per-intent stub copies — the consumer might declare multiple
|
|
2172
|
-
// notify intents (separate domains for marketing vs
|
|
2173
|
-
// transactional, say), each with its own client module. Naming
|
|
2174
|
-
// by id prevents collisions.
|
|
2175
|
-
const dispatchStubPath = path.join(vkDir, `notify-${id}-dispatcher.ts`);
|
|
2176
|
-
const bounceStubPath = path.join(vkDir, `notify-${id}-bounce.ts`);
|
|
2177
|
-
fs.copyFileSync(dispatcherStubSrc, dispatchStubPath);
|
|
2178
|
-
fs.copyFileSync(bounceStubSrc, bounceStubPath);
|
|
2179
|
-
// --- Dispatcher cron Lambda --------------------------------
|
|
2180
|
-
const dispatcherFnId = `notify-${id}-dispatcher`;
|
|
2181
|
-
const dispatcherDlq = new sqs.Queue(this, `${dispatcherFnId}-dlq`, {
|
|
2182
|
-
queueName: `${projectName}-${stage}-${dispatcherFnId}-dlq`,
|
|
2183
|
-
retentionPeriod: cdk.Duration.days(14),
|
|
2184
|
-
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
2185
|
-
// Well-Architected: Reliability — queues hold accepted-but-not-yet-
|
|
2186
|
-
// processed work. CDK's default removalPolicy for sqs.Queue is
|
|
2187
|
-
// DESTROY, which would silently delete the DLQ and all its failed-
|
|
2188
|
-
// message diagnostics on stack delete. Under dataSafety='strict' we
|
|
2189
|
-
// promote to RETAIN so the queue + messages survive any stack churn.
|
|
2190
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
2191
|
-
});
|
|
2192
|
-
const dispatcherFn = new lambda.Function(this, dispatcherFnId, {
|
|
2193
|
-
functionName: `${projectName}-${stage}-${dispatcherFnId}`,
|
|
2194
|
-
runtime: this.toLambdaRuntime(),
|
|
2195
|
-
architecture: this.toLambdaArchitecture(),
|
|
2196
|
-
handler: 'index.main',
|
|
2197
|
-
code: this.bundleHandlerCode(dispatchStubPath),
|
|
2198
|
-
memorySize: this.envConfig.lambda.memoryMb,
|
|
2199
|
-
timeout: cdk.Duration.seconds(60),
|
|
2200
|
-
description: `Notify dispatcher for '${id}' — drains the outbox.`,
|
|
2201
|
-
environment: {
|
|
2202
|
-
...this.ventureBaseEnv,
|
|
2203
|
-
VENTURE_NOTIFY_BATCH_SIZE: String(batchSize),
|
|
2204
|
-
},
|
|
2205
|
-
logRetention: this.toLogRetention(this.envConfig.observability.logs.retentionDays),
|
|
2206
|
-
tracing: this.envConfig.lambda.tracingEnabled
|
|
2207
|
-
? lambda.Tracing.ACTIVE
|
|
2208
|
-
: lambda.Tracing.DISABLED,
|
|
2209
|
-
deadLetterQueue: dispatcherDlq,
|
|
2210
|
-
deadLetterQueueEnabled: true,
|
|
2211
|
-
role,
|
|
2212
|
-
...(vpc && securityGroup
|
|
2213
|
-
? {
|
|
2214
|
-
vpc,
|
|
2215
|
-
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
2216
|
-
securityGroups: [securityGroup],
|
|
2217
|
-
}
|
|
2218
|
-
: {}),
|
|
2219
|
-
});
|
|
2220
|
-
new events.Rule(this, `${dispatcherFnId}-rule`, {
|
|
2221
|
-
ruleName: `${projectName}-${stage}-${dispatcherFnId}`,
|
|
2222
|
-
schedule: events.Schedule.rate(cdk.Duration.minutes(dispatchRateMinutes)),
|
|
2223
|
-
enabled: true,
|
|
2224
|
-
targets: [new targets.LambdaFunction(dispatcherFn)],
|
|
2225
|
-
});
|
|
2226
|
-
new cdk.CfnOutput(this, `${dispatcherFnId}-arn`, {
|
|
2227
|
-
value: dispatcherFn.functionArn,
|
|
2228
|
-
description: `Notify dispatcher Lambda ARN for '${id}'`,
|
|
2229
|
-
});
|
|
2230
|
-
// --- Bounce handler Lambda ---------------------------------
|
|
2231
|
-
const bounceFnId = `notify-${id}-bounce-handler`;
|
|
2232
|
-
const bounceDlq = new sqs.Queue(this, `${bounceFnId}-dlq`, {
|
|
2233
|
-
queueName: `${projectName}-${stage}-${bounceFnId}-dlq`,
|
|
2234
|
-
retentionPeriod: cdk.Duration.days(14),
|
|
2235
|
-
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
2236
|
-
// Same rationale as the dispatcher DLQ above: bounced/complained
|
|
2237
|
-
// notifications are the canonical record of what failed and why;
|
|
2238
|
-
// dropping them on stack delete loses the audit trail.
|
|
2239
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
2240
|
-
});
|
|
2241
|
-
const bounceFn = new lambda.Function(this, bounceFnId, {
|
|
2242
|
-
functionName: `${projectName}-${stage}-${bounceFnId}`,
|
|
2243
|
-
runtime: this.toLambdaRuntime(),
|
|
2244
|
-
architecture: this.toLambdaArchitecture(),
|
|
2245
|
-
handler: 'index.main',
|
|
2246
|
-
code: this.bundleHandlerCode(bounceStubPath),
|
|
2247
|
-
memorySize: this.envConfig.lambda.memoryMb,
|
|
2248
|
-
timeout: cdk.Duration.seconds(30),
|
|
2249
|
-
description: `Notify bounce/complaint handler for '${id}'.`,
|
|
2250
|
-
environment: { ...this.ventureBaseEnv },
|
|
2251
|
-
logRetention: this.toLogRetention(this.envConfig.observability.logs.retentionDays),
|
|
2252
|
-
tracing: this.envConfig.lambda.tracingEnabled
|
|
2253
|
-
? lambda.Tracing.ACTIVE
|
|
2254
|
-
: lambda.Tracing.DISABLED,
|
|
2255
|
-
deadLetterQueue: bounceDlq,
|
|
2256
|
-
deadLetterQueueEnabled: true,
|
|
2257
|
-
role,
|
|
2258
|
-
...(vpc && securityGroup
|
|
2259
|
-
? {
|
|
2260
|
-
vpc,
|
|
2261
|
-
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
2262
|
-
securityGroups: [securityGroup],
|
|
2263
|
-
}
|
|
2264
|
-
: {}),
|
|
2265
|
-
});
|
|
2266
|
-
cfg.eventsTopic.addSubscription(new snsSubscriptions.LambdaSubscription(bounceFn));
|
|
2267
|
-
new cdk.CfnOutput(this, `${bounceFnId}-arn`, {
|
|
2268
|
-
value: bounceFn.functionArn,
|
|
2269
|
-
description: `Notify bounce-handler Lambda ARN for '${id}'`,
|
|
2270
|
-
});
|
|
2271
|
-
}
|
|
2272
|
-
}
|
|
2273
|
-
/**
|
|
2274
|
-
* Well-Architected: Reliability — DLQ by default, stable logical ID
|
|
2275
|
-
* Well-Architected: Security — encryption at rest with SQS managed keys
|
|
2276
|
-
* Well-Architected: Performance — visibility timeout derived from handler timeout
|
|
2277
|
-
*/
|
|
2278
|
-
createQueue(intent, projectName, stage) {
|
|
2279
|
-
// Stable logical ID: 'queue-{intent.id}'
|
|
2280
|
-
const logicalId = `queue-${intent.id}`;
|
|
2281
|
-
// Well-Architected: Reliability — always create a DLQ for message safety
|
|
2282
|
-
const dlq = new sqs.Queue(this, `${logicalId}-dlq`, {
|
|
2283
|
-
queueName: `${projectName}-${intent.id}-dlq-${stage}${intent.type === 'fifo' ? '.fifo' : ''}`,
|
|
2284
|
-
fifo: intent.type === 'fifo',
|
|
2285
|
-
retentionPeriod: cdk.Duration.days(14),
|
|
2286
|
-
// Well-Architected: Security — encryption at rest
|
|
2287
|
-
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
2288
|
-
// Well-Architected: Reliability — DLQs are the durable record of every
|
|
2289
|
-
// message a consumer Lambda failed to process. Default CDK removalPolicy
|
|
2290
|
-
// for sqs.Queue is DESTROY; under dataSafety='strict' we promote to
|
|
2291
|
-
// RETAIN so investigation of past failures survives any redeploy or
|
|
2292
|
-
// stack-delete event.
|
|
2293
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
2294
|
-
});
|
|
2295
|
-
const queue = new sqs.Queue(this, logicalId, {
|
|
2296
|
-
queueName: `${projectName}-${intent.id}-${stage}${intent.type === 'fifo' ? '.fifo' : ''}`,
|
|
2297
|
-
fifo: intent.type === 'fifo',
|
|
2298
|
-
visibilityTimeout: intent.visibilityTimeoutSeconds
|
|
2299
|
-
? cdk.Duration.seconds(intent.visibilityTimeoutSeconds)
|
|
2300
|
-
: cdk.Duration.seconds((intent.timeout ?? 30) * 6),
|
|
2301
|
-
retentionPeriod: cdk.Duration.days(intent.retentionDays ?? 4),
|
|
2302
|
-
// Well-Architected: Security — encryption at rest
|
|
2303
|
-
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
2304
|
-
// Well-Architected: Reliability — DLQ captures failed messages
|
|
2305
|
-
deadLetterQueue: {
|
|
2306
|
-
queue: dlq,
|
|
2307
|
-
maxReceiveCount: intent.maxReceiveCount ?? 3,
|
|
2308
|
-
},
|
|
2309
|
-
// Well-Architected: Reliability — primary work queue holds accepted
|
|
2310
|
-
// jobs that have not yet been processed. CDK's default is DESTROY,
|
|
2311
|
-
// which would lose every in-flight message on any stack delete or
|
|
2312
|
-
// logical-ID rename. Under dataSafety='strict' we promote to RETAIN.
|
|
2313
|
-
// Combined with the upcoming multi-stack messaging-tier split, the
|
|
2314
|
-
// queue then survives even an app-stack tear-down — new ESMs in the
|
|
2315
|
-
// redeployed app stack resume consumption from where the old one
|
|
2316
|
-
// stopped.
|
|
2317
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
2318
|
-
});
|
|
2319
|
-
new cdk.CfnOutput(this, `${logicalId}-queue-url`, {
|
|
2320
|
-
value: queue.queueUrl,
|
|
2321
|
-
description: `SQS Queue URL for ${intent.id}`,
|
|
2322
|
-
});
|
|
2323
|
-
new cdk.CfnOutput(this, `${logicalId}-dlq-url`, {
|
|
2324
|
-
value: dlq.queueUrl,
|
|
2325
|
-
description: `SQS DLQ URL for ${intent.id}`,
|
|
2326
|
-
});
|
|
2327
|
-
}
|
|
2328
|
-
/**
|
|
2329
|
-
* Well-Architected: Security — VPC-only access, SG least-privilege
|
|
2330
|
-
* Well-Architected: Performance — cache node type driven by intent.size
|
|
2331
|
-
* Well-Architected: Reliability — encrypted in transit, snapshot retention for prod
|
|
2332
|
-
*/
|
|
2333
|
-
createCache(intent, projectName, stage, vpc) {
|
|
2334
|
-
// Stable logical ID: 'cache-{intent.id}'
|
|
2335
|
-
const logicalId = `cache-${intent.id}`;
|
|
2336
|
-
const securityGroup = new ec2.SecurityGroup(this, `${logicalId}-sg`, {
|
|
2337
|
-
vpc,
|
|
2338
|
-
description: `Security group for ${projectName}-${intent.id} Redis`,
|
|
2339
|
-
// Well-Architected: Security — restrict outbound to VPC only
|
|
2340
|
-
allowAllOutbound: false,
|
|
2341
|
-
});
|
|
2342
|
-
securityGroup.addIngressRule(ec2.Peer.ipv4(vpc.vpcCidrBlock), ec2.Port.tcp(6379), 'Allow Redis access from VPC');
|
|
2343
|
-
// Well-Architected: Security — use isolated subnets when available
|
|
2344
|
-
const subnetIds = vpc.isolatedSubnets?.length
|
|
2345
|
-
? vpc.isolatedSubnets.map((s) => s.subnetId)
|
|
2346
|
-
: vpc.privateSubnets.map((s) => s.subnetId);
|
|
2347
|
-
const subnetGroup = new elasticache.CfnSubnetGroup(this, `${logicalId}-subnet-group`, {
|
|
2348
|
-
description: `Subnet group for ${projectName}-${intent.id}`,
|
|
2349
|
-
subnetIds,
|
|
2350
|
-
});
|
|
2351
|
-
const cacheNodeType = this.sizeToCacheNodeType(intent.size || 'small');
|
|
2352
|
-
const redis = new elasticache.CfnCacheCluster(this, logicalId, {
|
|
2353
|
-
engine: 'redis',
|
|
2354
|
-
cacheNodeType,
|
|
2355
|
-
numCacheNodes: 1,
|
|
2356
|
-
vpcSecurityGroupIds: [securityGroup.securityGroupId],
|
|
2357
|
-
cacheSubnetGroupName: subnetGroup.ref,
|
|
2358
|
-
// Well-Architected: Security — encrypt in transit
|
|
2359
|
-
transitEncryptionEnabled: true,
|
|
2360
|
-
// Well-Architected: Reliability — snapshots for prod
|
|
2361
|
-
snapshotRetentionLimit: this.envConfig.dataSafety === 'strict' ? 7 : 0,
|
|
2362
|
-
// Well-Architected: Operational Excellence — auto minor version upgrade
|
|
2363
|
-
autoMinorVersionUpgrade: true,
|
|
2364
|
-
});
|
|
2365
|
-
new cdk.CfnOutput(this, `${logicalId}-endpoint`, {
|
|
2366
|
-
value: redis.attrRedisEndpointAddress,
|
|
2367
|
-
description: `Redis endpoint for ${intent.id}`,
|
|
2368
|
-
});
|
|
2369
|
-
}
|
|
2370
|
-
createMonitoring(intent, projectName, stage) {
|
|
2371
|
-
const resourceName = `${projectName}-${intent.id}`;
|
|
2372
|
-
// SNS topic for notifications
|
|
2373
|
-
let topic;
|
|
2374
|
-
if (intent.notificationArn) {
|
|
2375
|
-
topic = sns.Topic.fromTopicArn(this, `${resourceName}-topic`, intent.notificationArn);
|
|
2376
|
-
}
|
|
2377
|
-
// Create alarms
|
|
2378
|
-
if (intent.alarms) {
|
|
2379
|
-
for (const alarm of intent.alarms) {
|
|
2380
|
-
const comparisonMap = {
|
|
2381
|
-
gt: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
|
2382
|
-
gte: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
|
2383
|
-
lt: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
|
|
2384
|
-
lte: cloudwatch.ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD,
|
|
2385
|
-
};
|
|
2386
|
-
const metric = new cloudwatch.Metric({
|
|
2387
|
-
namespace: alarm.namespace || 'AWS/Lambda',
|
|
2388
|
-
metricName: alarm.metric,
|
|
2389
|
-
period: cdk.Duration.seconds(alarm.periodSeconds ?? 300),
|
|
2390
|
-
statistic: alarm.statistic || 'Average',
|
|
2391
|
-
dimensionsMap: alarm.dimensions,
|
|
2392
|
-
});
|
|
2393
|
-
const cwAlarm = new cloudwatch.Alarm(this, `${resourceName}-${alarm.name}`, {
|
|
2394
|
-
alarmName: `${projectName}-${stage}-${alarm.name}`,
|
|
2395
|
-
alarmDescription: alarm.description,
|
|
2396
|
-
metric,
|
|
2397
|
-
threshold: alarm.threshold,
|
|
2398
|
-
evaluationPeriods: alarm.evaluationPeriods ?? 1,
|
|
2399
|
-
comparisonOperator: comparisonMap[alarm.comparison] || cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
|
2400
|
-
});
|
|
2401
|
-
if (topic) {
|
|
2402
|
-
cwAlarm.addAlarmAction(new cloudwatchActions.SnsAction(topic));
|
|
2403
|
-
}
|
|
2404
|
-
new cdk.CfnOutput(this, `${resourceName}-${alarm.name}-arn`, {
|
|
2405
|
-
value: cwAlarm.alarmArn,
|
|
2406
|
-
description: `Alarm ARN for ${alarm.name}`,
|
|
2407
|
-
});
|
|
2408
|
-
}
|
|
2409
|
-
}
|
|
2410
|
-
// Create dashboard
|
|
2411
|
-
if (intent.dashboard) {
|
|
2412
|
-
const dashboard = new cloudwatch.Dashboard(this, `${resourceName}-dashboard`, {
|
|
2413
|
-
dashboardName: `${projectName}-${stage}-${intent.id}`,
|
|
2414
|
-
});
|
|
2415
|
-
new cdk.CfnOutput(this, `${resourceName}-dashboard-name`, {
|
|
2416
|
-
value: dashboard.dashboardName,
|
|
2417
|
-
description: `Dashboard name for ${intent.id}`,
|
|
2418
|
-
});
|
|
2419
|
-
}
|
|
2420
|
-
}
|
|
2421
|
-
createSecret(intent, projectName, stage) {
|
|
2422
|
-
const resourceName = `${projectName}-${intent.id}`;
|
|
2423
|
-
if (intent.store === 'secretsmanager') {
|
|
2424
|
-
const secret = new secretsmanager.Secret(this, resourceName, {
|
|
2425
|
-
secretName: `${projectName}/${stage}/${intent.id}`,
|
|
2426
|
-
description: intent.description || `Secret for ${intent.id}`,
|
|
2427
|
-
// Well-Architected: Reliability — operator-declared secrets hold
|
|
2428
|
-
// values the operator pasted in manually (API keys, third-party
|
|
2429
|
-
// integration tokens). They cannot be re-generated by VentureKit
|
|
2430
|
-
// and an accidental delete forces a manual rotation of every
|
|
2431
|
-
// downstream credential. RETAIN under strict dataSafety.
|
|
2432
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
2433
|
-
});
|
|
2434
|
-
new cdk.CfnOutput(this, `${resourceName}-secret-arn`, {
|
|
2435
|
-
value: secret.secretArn,
|
|
2436
|
-
description: `Secret ARN for ${intent.id}`,
|
|
2437
|
-
});
|
|
2438
|
-
}
|
|
2439
|
-
else {
|
|
2440
|
-
// SSM Parameter Store
|
|
2441
|
-
const param = new ssm.StringParameter(this, resourceName, {
|
|
2442
|
-
parameterName: `/${projectName}/${stage}/${intent.id}`,
|
|
2443
|
-
description: intent.description || `Parameter for ${intent.id}`,
|
|
2444
|
-
stringValue: 'PLACEHOLDER — set value after deploy',
|
|
2445
|
-
tier: ssm.ParameterTier.STANDARD,
|
|
2446
|
-
});
|
|
2447
|
-
// SSM parameters don't carry their own DeletionPolicy attribute
|
|
2448
|
-
// (the resource type doesn't support it in CFN), and AWS doesn't
|
|
2449
|
-
// expose a "deletion protection" flag for them either. Operators
|
|
2450
|
-
// relying on these for production secret-like config should
|
|
2451
|
-
// either move to Secrets Manager via `store: 'secretsmanager'`
|
|
2452
|
-
// (which we now RETAIN above) or accept that an accidental
|
|
2453
|
-
// stack delete loses the value.
|
|
2454
|
-
new cdk.CfnOutput(this, `${resourceName}-param-name`, {
|
|
2455
|
-
value: param.parameterName,
|
|
2456
|
-
description: `SSM Parameter name for ${intent.id}`,
|
|
2457
|
-
});
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
createDomain(intent, projectName, stage, api) {
|
|
2461
|
-
const resourceName = `${projectName}-${intent.id}`;
|
|
2462
|
-
// Certificate: use provided ARN or create a new one
|
|
2463
|
-
let certificate;
|
|
2464
|
-
if (intent.certificateArn) {
|
|
2465
|
-
certificate = acm.Certificate.fromCertificateArn(this, `${resourceName}-cert`, intent.certificateArn);
|
|
2466
|
-
}
|
|
2467
|
-
else if (intent.hostedZoneId) {
|
|
2468
|
-
// Auto-provision with DNS validation via Route53
|
|
2469
|
-
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, `${resourceName}-zone`, {
|
|
2470
|
-
hostedZoneId: intent.hostedZoneId,
|
|
2471
|
-
zoneName: intent.domain.split('.').slice(-2).join('.'),
|
|
2472
|
-
});
|
|
2473
|
-
certificate = new acm.Certificate(this, `${resourceName}-cert`, {
|
|
2474
|
-
domainName: intent.domain,
|
|
2475
|
-
validation: acm.CertificateValidation.fromDns(hostedZone),
|
|
2476
|
-
});
|
|
2477
|
-
}
|
|
2478
|
-
else {
|
|
2479
|
-
// Certificate without DNS validation (manual)
|
|
2480
|
-
certificate = new acm.Certificate(this, `${resourceName}-cert`, {
|
|
2481
|
-
domainName: intent.domain,
|
|
2482
|
-
});
|
|
2483
|
-
}
|
|
2484
|
-
// Custom domain name for API Gateway
|
|
2485
|
-
const domainName = new apigatewayv2.DomainName(this, `${resourceName}-domain`, {
|
|
2486
|
-
domainName: intent.domain,
|
|
2487
|
-
certificate,
|
|
2488
|
-
});
|
|
2489
|
-
// Well-Architected: Reliability — RETAIN both the domain name and the
|
|
2490
|
-
// mapping under strict dataSafety. The custom domain is the *public*
|
|
2491
|
-
// contract with the world; deleting it forces DNS reconfiguration on
|
|
2492
|
-
// every client/integration. RETAIN means an accidental stack delete
|
|
2493
|
-
// leaves the domain claimed to this account and ready for `cdk
|
|
2494
|
-
// import`. The ApiMapping rides along — without it the domain points
|
|
2495
|
-
// nowhere, but mappings recreate cleanly so RETAIN is mostly a
|
|
2496
|
-
// convenience for the recovery path.
|
|
2497
|
-
if (this.dataSafetyConfig.removalPolicy === 'retain') {
|
|
2498
|
-
const cfnDomain = domainName.node.defaultChild;
|
|
2499
|
-
cfnDomain.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
2500
|
-
cfnDomain.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
2501
|
-
}
|
|
2502
|
-
// API mapping
|
|
2503
|
-
const apiMapping = new apigatewayv2.ApiMapping(this, `${resourceName}-mapping`, {
|
|
2504
|
-
api,
|
|
2505
|
-
domainName,
|
|
2506
|
-
stage: api.defaultStage,
|
|
2507
|
-
});
|
|
2508
|
-
if (this.dataSafetyConfig.removalPolicy === 'retain') {
|
|
2509
|
-
const cfnMapping = apiMapping.node.defaultChild;
|
|
2510
|
-
cfnMapping.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
2511
|
-
cfnMapping.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
2512
|
-
}
|
|
2513
|
-
// Route53 A record if hosted zone provided
|
|
2514
|
-
if (intent.hostedZoneId) {
|
|
2515
|
-
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, `${resourceName}-zone-record`, {
|
|
2516
|
-
hostedZoneId: intent.hostedZoneId,
|
|
2517
|
-
zoneName: intent.domain.split('.').slice(-2).join('.'),
|
|
2518
|
-
});
|
|
2519
|
-
new route53.ARecord(this, `${resourceName}-a-record`, {
|
|
2520
|
-
zone: hostedZone,
|
|
2521
|
-
recordName: intent.domain,
|
|
2522
|
-
target: route53.RecordTarget.fromAlias(new route53Targets.ApiGatewayv2DomainProperties(domainName.regionalDomainName, domainName.regionalHostedZoneId)),
|
|
2523
|
-
});
|
|
2524
|
-
}
|
|
2525
|
-
new cdk.CfnOutput(this, `${resourceName}-domain-name`, {
|
|
2526
|
-
value: intent.domain,
|
|
2527
|
-
description: `Custom domain for ${intent.id}`,
|
|
2528
|
-
});
|
|
2529
|
-
new cdk.CfnOutput(this, `${resourceName}-regional-domain`, {
|
|
2530
|
-
value: domainName.regionalDomainName,
|
|
2531
|
-
description: `Regional domain name for ${intent.id} (use for DNS CNAME if not using Route53)`,
|
|
2532
|
-
});
|
|
2533
|
-
}
|
|
2534
|
-
/**
|
|
2535
|
-
* Free-tier alternative to RDS: create a DynamoDB table.
|
|
2536
|
-
* DynamoDB offers 25 GB + 25 RCU/WCU in the always-free tier.
|
|
2537
|
-
*
|
|
2538
|
-
* Well-Architected: Security — encryption at rest (default)
|
|
2539
|
-
* Well-Architected: Reliability — PITR for strict dataSafety
|
|
2540
|
-
* Well-Architected: Cost — PAY_PER_REQUEST for unpredictable workloads
|
|
2541
|
-
*/
|
|
2542
|
-
createDynamoDBTable(intent, projectName, stage) {
|
|
2543
|
-
// Stable logical ID: 'dynamo-{intent.id}'
|
|
2544
|
-
const logicalId = `dynamo-${intent.id}`;
|
|
2545
|
-
const table = new dynamodb.Table(this, logicalId, {
|
|
2546
|
-
tableName: `${projectName}-${intent.id}-${stage}`,
|
|
2547
|
-
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
|
|
2548
|
-
sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
|
|
2549
|
-
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
2550
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
2551
|
-
// Well-Architected: Reliability — PITR for strict data safety
|
|
2552
|
-
pointInTimeRecovery: this.envConfig.dataSafety === 'strict',
|
|
2553
|
-
// Well-Architected: Security — encryption at rest (AWS managed key)
|
|
2554
|
-
encryption: dynamodb.TableEncryption.AWS_MANAGED,
|
|
2555
|
-
timeToLiveAttribute: 'ttl',
|
|
2556
|
-
// Well-Architected: Operational Excellence — contributor insights for medium+
|
|
2557
|
-
contributorInsightsEnabled: this.envConfig.lambda.memoryMb >= 512,
|
|
2558
|
-
// Well-Architected: Reliability — native deletion protection on top of
|
|
2559
|
-
// RETAIN. DeletionPolicy=Retain only blocks CFN-driven deletes; turning
|
|
2560
|
-
// on deletionProtectionEnabled also blocks the AWS console / API / CLI
|
|
2561
|
-
// "delete table" path. Together they make accidental data loss require
|
|
2562
|
-
// two explicit operator actions (disable protection AND override the
|
|
2563
|
-
// deletion policy), each loggable in CloudTrail.
|
|
2564
|
-
deletionProtection: this.dataSafetyConfig.removalPolicy === 'retain',
|
|
2565
|
-
});
|
|
2566
|
-
// Well-Architected: Reliability — prevent accidental replacement
|
|
2567
|
-
const cfnTable = table.node.defaultChild;
|
|
2568
|
-
if (this.dataSafetyConfig.removalPolicy === 'retain') {
|
|
2569
|
-
cfnTable.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
2570
|
-
}
|
|
2571
|
-
// GSI for common access patterns
|
|
2572
|
-
table.addGlobalSecondaryIndex({
|
|
2573
|
-
indexName: 'gsi1',
|
|
2574
|
-
partitionKey: { name: 'gsi1pk', type: dynamodb.AttributeType.STRING },
|
|
2575
|
-
sortKey: { name: 'gsi1sk', type: dynamodb.AttributeType.STRING },
|
|
2576
|
-
projectionType: dynamodb.ProjectionType.ALL,
|
|
2577
|
-
});
|
|
2578
|
-
new cdk.CfnOutput(this, `${logicalId}-table-name`, {
|
|
2579
|
-
value: table.tableName,
|
|
2580
|
-
description: `DynamoDB table name for ${intent.id} (free-tier)`,
|
|
2581
|
-
});
|
|
2582
|
-
new cdk.CfnOutput(this, `${logicalId}-table-arn`, {
|
|
2583
|
-
value: table.tableArn,
|
|
2584
|
-
description: `DynamoDB table ARN for ${intent.id} (free-tier)`,
|
|
2585
|
-
});
|
|
2586
|
-
}
|
|
2587
|
-
/**
|
|
2588
|
-
* Free-tier rate limit table using DynamoDB (always-free tier).
|
|
2589
|
-
* Used by createDefaultRateLimiter() from @venturekit/runtime.
|
|
2590
|
-
*/
|
|
2591
|
-
createRateLimitTable(projectName, stage) {
|
|
2592
|
-
const logicalId = 'RateLimitTable';
|
|
2593
|
-
// Per-stack table name so multiple environments (preview/prod) and
|
|
2594
|
-
// multiple projects in the same AWS account don't collide on the
|
|
2595
|
-
// single global `venturekit-rate-limits` name. The runtime store
|
|
2596
|
-
// reads `VENTURE_RATE_LIMIT_TABLE` (see @venturekit/runtime
|
|
2597
|
-
// rate-limit-store), so we plumb the resolved name through the
|
|
2598
|
-
// base Lambda env to keep `createDefaultRateLimiter()` working
|
|
2599
|
-
// without per-app configuration.
|
|
2600
|
-
const tableName = `${projectName}-rate-limits-${stage}`;
|
|
2601
|
-
const table = new dynamodb.Table(this, logicalId, {
|
|
2602
|
-
tableName,
|
|
2603
|
-
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
|
|
2604
|
-
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
2605
|
-
// Well-Architected: Reliability — rate-limit counters are per-user
|
|
2606
|
-
// state. Hardcoding DESTROY would erase every user's current rate-
|
|
2607
|
-
// limit window on any stack churn, surfacing as a simultaneous
|
|
2608
|
-
// "all users get a fresh quota" event — minor but observable under
|
|
2609
|
-
// load and not what dataSafety='strict' implies. Track dataSafety
|
|
2610
|
-
// the same way every other stateful resource does.
|
|
2611
|
-
removalPolicy: this.toRemovalPolicy(),
|
|
2612
|
-
timeToLiveAttribute: 'ttl',
|
|
2613
|
-
encryption: dynamodb.TableEncryption.AWS_MANAGED,
|
|
2614
|
-
// Native AWS deletion protection alongside RETAIN. See the
|
|
2615
|
-
// user-declared DDB path above for the two-layer rationale.
|
|
2616
|
-
deletionProtection: this.dataSafetyConfig.removalPolicy === 'retain',
|
|
2617
|
-
});
|
|
2618
|
-
// Well-Architected: Reliability — UpdateReplacePolicy=Retain so that a
|
|
2619
|
-
// property change requiring REPLACE (e.g. changing the partition-key
|
|
2620
|
-
// schema, which CFN executes as delete-then-create) preserves the old
|
|
2621
|
-
// table rather than dropping every active rate-limit window. Matches
|
|
2622
|
-
// the user-declared DDB path.
|
|
2623
|
-
const cfnRateLimitTable = table.node.defaultChild;
|
|
2624
|
-
if (this.dataSafetyConfig.removalPolicy === 'retain') {
|
|
2625
|
-
cfnRateLimitTable.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
|
|
2626
|
-
}
|
|
2627
|
-
this.ventureBaseEnv['VENTURE_RATE_LIMIT_TABLE'] = table.tableName;
|
|
2628
|
-
new cdk.CfnOutput(this, `${logicalId}-table-name`, {
|
|
2629
|
-
value: table.tableName,
|
|
2630
|
-
description: 'DynamoDB rate limit table (free-tier)',
|
|
2631
|
-
});
|
|
2632
|
-
}
|
|
2633
|
-
// ─── Helper methods ────────────────────────────────────────────────
|
|
2634
|
-
/**
|
|
2635
|
-
* Map ResourceSize to RDS instance type.
|
|
2636
|
-
* Uses Graviton (T4G) instances for cost efficiency.
|
|
2637
|
-
* Changing size triggers an in-place modify, NOT a replacement.
|
|
2638
|
-
*/
|
|
2639
|
-
sizeToInstanceType(size) {
|
|
2640
|
-
switch (size) {
|
|
2641
|
-
case 'xlarge':
|
|
2642
|
-
return ec2.InstanceType.of(ec2.InstanceClass.R6G, ec2.InstanceSize.LARGE);
|
|
2643
|
-
case 'large':
|
|
2644
|
-
return ec2.InstanceType.of(ec2.InstanceClass.R6G, ec2.InstanceSize.MEDIUM);
|
|
2645
|
-
case 'medium':
|
|
2646
|
-
return ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.SMALL);
|
|
2647
|
-
default: // 'small'
|
|
2648
|
-
return ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO);
|
|
2649
|
-
}
|
|
2650
|
-
}
|
|
2651
|
-
/**
|
|
2652
|
-
* Map ResourceSize to ElastiCache node type.
|
|
2653
|
-
* Uses Graviton (T4G) instances for cost efficiency.
|
|
2654
|
-
* Changing size triggers an in-place modify, NOT a replacement.
|
|
2655
|
-
*/
|
|
2656
|
-
sizeToCacheNodeType(size) {
|
|
2657
|
-
switch (size) {
|
|
2658
|
-
case 'xlarge':
|
|
2659
|
-
return 'cache.r6g.large';
|
|
2660
|
-
case 'large':
|
|
2661
|
-
return 'cache.r6g.medium';
|
|
2662
|
-
case 'medium':
|
|
2663
|
-
return 'cache.t4g.small';
|
|
2664
|
-
default: // 'small'
|
|
2665
|
-
return 'cache.t4g.micro';
|
|
2666
|
-
}
|
|
2667
|
-
}
|
|
2668
|
-
/**
|
|
2669
|
-
* Convert dataSafety config to CDK RemovalPolicy.
|
|
2670
|
-
* Well-Architected: Reliability — prevent accidental data loss.
|
|
2671
|
-
*/
|
|
2672
|
-
toRemovalPolicy() {
|
|
2673
|
-
return this.dataSafetyConfig.removalPolicy === 'retain'
|
|
2674
|
-
? cdk.RemovalPolicy.RETAIN
|
|
2675
|
-
: cdk.RemovalPolicy.DESTROY;
|
|
2676
|
-
}
|
|
2677
|
-
/**
|
|
2678
|
-
* Convert days to CDK logs.RetentionDays enum.
|
|
2679
|
-
* Well-Architected: Cost — don't over-retain logs.
|
|
2680
|
-
*/
|
|
2681
|
-
toLogRetention(days) {
|
|
2682
|
-
if (days <= 1)
|
|
2683
|
-
return logs.RetentionDays.ONE_DAY;
|
|
2684
|
-
if (days <= 3)
|
|
2685
|
-
return logs.RetentionDays.THREE_DAYS;
|
|
2686
|
-
if (days <= 5)
|
|
2687
|
-
return logs.RetentionDays.FIVE_DAYS;
|
|
2688
|
-
if (days <= 7)
|
|
2689
|
-
return logs.RetentionDays.ONE_WEEK;
|
|
2690
|
-
if (days <= 14)
|
|
2691
|
-
return logs.RetentionDays.TWO_WEEKS;
|
|
2692
|
-
if (days <= 30)
|
|
2693
|
-
return logs.RetentionDays.ONE_MONTH;
|
|
2694
|
-
if (days <= 60)
|
|
2695
|
-
return logs.RetentionDays.TWO_MONTHS;
|
|
2696
|
-
if (days <= 90)
|
|
2697
|
-
return logs.RetentionDays.THREE_MONTHS;
|
|
2698
|
-
if (days <= 120)
|
|
2699
|
-
return logs.RetentionDays.FOUR_MONTHS;
|
|
2700
|
-
if (days <= 150)
|
|
2701
|
-
return logs.RetentionDays.FIVE_MONTHS;
|
|
2702
|
-
if (days <= 180)
|
|
2703
|
-
return logs.RetentionDays.SIX_MONTHS;
|
|
2704
|
-
if (days <= 365)
|
|
2705
|
-
return logs.RetentionDays.ONE_YEAR;
|
|
2706
|
-
return logs.RetentionDays.INFINITE;
|
|
2707
|
-
}
|
|
2708
|
-
/**
|
|
2709
|
-
* Resolve Lambda runtime from preset config.
|
|
2710
|
-
*/
|
|
2711
|
-
toLambdaRuntime() {
|
|
2712
|
-
switch (this.envConfig.lambda.runtime) {
|
|
2713
|
-
case 'nodejs22.x':
|
|
2714
|
-
return lambda.Runtime.NODEJS_22_X;
|
|
2715
|
-
case 'nodejs18.x':
|
|
2716
|
-
return lambda.Runtime.NODEJS_18_X;
|
|
2717
|
-
default:
|
|
2718
|
-
return lambda.Runtime.NODEJS_20_X;
|
|
2719
|
-
}
|
|
2720
|
-
}
|
|
2721
|
-
/**
|
|
2722
|
-
* Resolve Lambda architecture from preset config.
|
|
2723
|
-
*/
|
|
2724
|
-
toLambdaArchitecture() {
|
|
2725
|
-
return this.envConfig.lambda.architecture === 'x86_64'
|
|
2726
|
-
? lambda.Architecture.X86_64
|
|
2727
|
-
: lambda.Architecture.ARM_64;
|
|
2728
|
-
}
|
|
2729
|
-
/**
|
|
2730
|
-
* Recursively discover function files under the functions directory.
|
|
2731
|
-
* Returns an array of { name, handlerFile } where name is the
|
|
2732
|
-
* dash-delimited path (e.g. `order/create-order.ts` → `order-create-order`).
|
|
2733
|
-
*/
|
|
2734
|
-
discoverFunctionFiles(dir, baseDir) {
|
|
2735
|
-
const root = baseDir ?? dir;
|
|
2736
|
-
const results = [];
|
|
2737
|
-
if (!fs.existsSync(dir))
|
|
2738
|
-
return results;
|
|
2739
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2740
|
-
for (const entry of entries) {
|
|
2741
|
-
const fullPath = path.join(dir, entry.name);
|
|
2742
|
-
if (entry.isDirectory()) {
|
|
2743
|
-
results.push(...this.discoverFunctionFiles(fullPath, root));
|
|
2744
|
-
}
|
|
2745
|
-
else if (/\.(ts|js)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
|
|
2746
|
-
const relativePath = path.relative(root, fullPath);
|
|
2747
|
-
const name = relativePath
|
|
2748
|
-
.replace(/\.(ts|js)$/, '')
|
|
2749
|
-
.replace(/[\\/]/g, '-');
|
|
2750
|
-
results.push({ name, handlerFile: fullPath });
|
|
2751
|
-
}
|
|
2752
|
-
}
|
|
2753
|
-
return results;
|
|
2754
|
-
}
|
|
2755
|
-
// ─── Lambda function factories ─────────────────────────────────────
|
|
2756
|
-
/**
|
|
2757
|
-
* Build the `lambda.Code` for a handler file.
|
|
2758
|
-
*
|
|
2759
|
-
* Production path: bundle the handler in-process via esbuild's
|
|
2760
|
-
* synchronous API — see `bundleHandlerCodeWithEsbuild` for the
|
|
2761
|
-
* rationale (no Docker, native Node module resolution, sub-second
|
|
2762
|
-
* per handler).
|
|
2763
|
-
*
|
|
2764
|
-
* Test path (`_skipBundling: true`): return an inline stub so unit
|
|
2765
|
-
* tests can synth Lambdas without invoking esbuild.
|
|
2766
|
-
*/
|
|
2767
|
-
bundleHandlerCode(handlerFile) {
|
|
2768
|
-
if (this.skipBundling) {
|
|
2769
|
-
return inlineStubLambdaCode();
|
|
2770
|
-
}
|
|
2771
|
-
return bundleHandlerCodeWithEsbuild(handlerFile, this.projectDir);
|
|
2772
|
-
}
|
|
2773
|
-
/**
|
|
2774
|
-
* Create one Lambda + HTTP API integration + HttpRoute for a discovered
|
|
2775
|
-
* route file.
|
|
2776
|
-
*
|
|
2777
|
-
* Function name: `{project}-{stage}-route-{slug}`. Lambda function names
|
|
2778
|
-
* are capped at 64 chars; we truncate + append a 6-char content hash if
|
|
2779
|
-
* the full name overflows so deeply nested routes
|
|
2780
|
-
* (e.g. `promoter/groups/[id]/images/[imageId]/delete.ts`) still fit.
|
|
2781
|
-
*
|
|
2782
|
-
* Well-Architected: Performance — memory/timeout from preset config
|
|
2783
|
-
* Well-Architected: Sustainability — ARM64 Graviton by default
|
|
2784
|
-
* Well-Architected: Reliability — VPC-attached when DBs exist so routes
|
|
2785
|
-
* can reach RDS without timing out at the API Gateway 30 s deadline
|
|
2786
|
-
* Well-Architected: Cost — shared role + SG across all route Lambdas
|
|
2787
|
-
* keeps CFN resource count linear in the number of routes
|
|
2788
|
-
*/
|
|
2789
|
-
createRouteFunction(route, projectName, stage, api, role, securityGroup, vpc) {
|
|
2790
|
-
const lambdaConfig = this.envConfig.lambda;
|
|
2791
|
-
// Cap function name at 64 chars (Lambda hard limit). Append a content
|
|
2792
|
-
// hash on overflow so two deeply nested routes that share a prefix
|
|
2793
|
-
// don't collide.
|
|
2794
|
-
const rawName = `${projectName}-${stage}-route-${route.slug}`;
|
|
2795
|
-
const functionName = rawName.length <= 64
|
|
2796
|
-
? rawName
|
|
2797
|
-
: `${rawName.slice(0, 56)}-${this.shortHash(rawName)}`;
|
|
2798
|
-
const resourceId = `route-${route.slug}`;
|
|
2799
|
-
// Construct ID may collide if two slugs sanitize to the same value,
|
|
2800
|
-
// but `discoverRouteFiles` already enforces filesystem-uniqueness, so
|
|
2801
|
-
// any collision implies a bug in the slug builder — not a runtime
|
|
2802
|
-
// concern.
|
|
2803
|
-
const fn = new lambda.Function(this, resourceId, {
|
|
2804
|
-
functionName,
|
|
2805
|
-
runtime: this.toLambdaRuntime(),
|
|
2806
|
-
architecture: this.toLambdaArchitecture(),
|
|
2807
|
-
handler: 'index.main',
|
|
2808
|
-
code: this.bundleHandlerCode(route.handlerFile),
|
|
2809
|
-
memorySize: lambdaConfig.memoryMb,
|
|
2810
|
-
timeout: cdk.Duration.seconds(lambdaConfig.timeoutSec),
|
|
2811
|
-
description: `${route.method} ${route.apiPath}`,
|
|
2812
|
-
environment: {
|
|
2813
|
-
...this.ventureBaseEnv,
|
|
2814
|
-
...lambdaConfig.environmentVariables,
|
|
2815
|
-
},
|
|
2816
|
-
logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
|
|
2817
|
-
tracing: lambdaConfig.tracingEnabled
|
|
2818
|
-
? lambda.Tracing.ACTIVE
|
|
2819
|
-
: lambda.Tracing.DISABLED,
|
|
2820
|
-
reservedConcurrentExecutions: lambdaConfig.reservedConcurrency,
|
|
2821
|
-
role,
|
|
2822
|
-
// Attach to the VPC iff one exists. Route Lambdas without a VPC
|
|
2823
|
-
// can't reach RDS, but the free preset and DB-less projects don't
|
|
2824
|
-
// need one — paying ENI cold-start tax there would be a
|
|
2825
|
-
// regression.
|
|
2826
|
-
...(vpc && securityGroup
|
|
2827
|
-
? {
|
|
2828
|
-
vpc,
|
|
2829
|
-
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
2830
|
-
securityGroups: [securityGroup],
|
|
2831
|
-
}
|
|
2832
|
-
: {}),
|
|
2833
|
-
});
|
|
2834
|
-
// Route the matching API Gateway HTTP method + path at the Lambda.
|
|
2835
|
-
// `HttpRouteKey.with` builds the `${METHOD} ${path}` string that API
|
|
2836
|
-
// Gateway uses internally; passing path + method separately keeps us
|
|
2837
|
-
// honest if AWS ever changes the format.
|
|
2838
|
-
new apigatewayv2.HttpRoute(this, `${resourceId}-route`, {
|
|
2839
|
-
httpApi: api,
|
|
2840
|
-
routeKey: apigatewayv2.HttpRouteKey.with(route.apiPath, apigatewayv2.HttpMethod[route.method]),
|
|
2841
|
-
integration: new apigatewayv2Integrations.HttpLambdaIntegration(`${resourceId}-integration`, fn),
|
|
2842
|
-
});
|
|
2843
|
-
}
|
|
2844
|
-
/**
|
|
2845
|
-
* Tiny non-cryptographic 6-char hex hash. Used only to disambiguate
|
|
2846
|
-
* Lambda function names that would otherwise overflow the 64-char
|
|
2847
|
-
* limit; collision risk is acceptable because the truncated prefix
|
|
2848
|
-
* already encodes most of the route path.
|
|
2849
|
-
*/
|
|
2850
|
-
shortHash(input) {
|
|
2851
|
-
let h = 5381;
|
|
2852
|
-
for (let i = 0; i < input.length; i++) {
|
|
2853
|
-
h = ((h << 5) + h + input.charCodeAt(i)) | 0;
|
|
2854
|
-
}
|
|
2855
|
-
return (h >>> 0).toString(16).padStart(6, '0').slice(0, 6);
|
|
2856
|
-
}
|
|
2857
|
-
/**
|
|
2858
|
-
* Create a standalone Lambda function.
|
|
2859
|
-
* Naming convention: {project}-{stage}-fn-{name}
|
|
2860
|
-
*
|
|
2861
|
-
* Well-Architected: Performance — memory/timeout from preset config
|
|
2862
|
-
* Well-Architected: Sustainability — ARM64 Graviton by default
|
|
2863
|
-
* Well-Architected: Operational Excellence — structured env vars, log retention from preset
|
|
2864
|
-
* Well-Architected: Security — X-Ray tracing from preset config
|
|
2865
|
-
*/
|
|
2866
|
-
createStandaloneFunction(name, handlerFile, projectName, stage, projectDir, intent, role, securityGroup, vpc) {
|
|
2867
|
-
const functionName = `${projectName}-${stage}-fn-${name}`;
|
|
2868
|
-
const resourceId = `fn-${name}`;
|
|
2869
|
-
const lambdaConfig = this.envConfig.lambda;
|
|
2870
|
-
// Auto-DLQ: every async failure goes somewhere you can inspect later.
|
|
2871
|
-
// Lambda retries async invocations twice, then drops the event silently
|
|
2872
|
-
// unless a DLQ is wired. We wire one by default (SQS, 14-day retention,
|
|
2873
|
-
// SQS-managed encryption, same stable logical ID so preset changes don't
|
|
2874
|
-
// replace it). Consumers can inspect failed events via `vk logs dlq` or
|
|
2875
|
-
// directly in the SQS console.
|
|
2876
|
-
const dlq = new sqs.Queue(this, `${resourceId}-dlq`, {
|
|
2877
|
-
queueName: `${projectName}-${stage}-fn-${name}-dlq`,
|
|
2878
|
-
retentionPeriod: cdk.Duration.days(14),
|
|
2879
|
-
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
2880
|
-
});
|
|
2881
|
-
const fn = new lambda.Function(this, resourceId, {
|
|
2882
|
-
functionName,
|
|
2883
|
-
runtime: this.toLambdaRuntime(),
|
|
2884
|
-
architecture: this.toLambdaArchitecture(),
|
|
2885
|
-
handler: 'index.main',
|
|
2886
|
-
code: this.bundleHandlerCode(handlerFile),
|
|
2887
|
-
// Well-Architected: Performance — memory and timeout from intent or preset
|
|
2888
|
-
memorySize: intent?.memorySize ?? lambdaConfig.memoryMb,
|
|
2889
|
-
timeout: cdk.Duration.seconds(intent?.timeout ?? lambdaConfig.timeoutSec),
|
|
2890
|
-
description: intent?.description ?? `Standalone function: ${name}`,
|
|
2891
|
-
environment: {
|
|
2892
|
-
...this.ventureBaseEnv,
|
|
2893
|
-
...lambdaConfig.environmentVariables,
|
|
2894
|
-
},
|
|
2895
|
-
// Well-Architected: Operational Excellence — log retention from preset
|
|
2896
|
-
logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
|
|
2897
|
-
// Well-Architected: Security — X-Ray tracing from preset
|
|
2898
|
-
tracing: lambdaConfig.tracingEnabled
|
|
2899
|
-
? lambda.Tracing.ACTIVE
|
|
2900
|
-
: lambda.Tracing.DISABLED,
|
|
2901
|
-
// Well-Architected: Performance — reserved concurrency from preset
|
|
2902
|
-
reservedConcurrentExecutions: lambdaConfig.reservedConcurrency,
|
|
2903
|
-
// Well-Architected: Reliability — async failure destination
|
|
2904
|
-
deadLetterQueue: dlq,
|
|
2905
|
-
deadLetterQueueEnabled: true,
|
|
2906
|
-
role,
|
|
2907
|
-
// Well-Architected: Reliability — VPC-attach when a VPC exists so
|
|
2908
|
-
// standalone workers (sagas, processors, dispatchers) can reach
|
|
2909
|
-
// RDS through the shared SG without operators wiring it up.
|
|
2910
|
-
...(vpc && securityGroup
|
|
2911
|
-
? {
|
|
2912
|
-
vpc,
|
|
2913
|
-
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
2914
|
-
securityGroups: [securityGroup],
|
|
2915
|
-
}
|
|
2916
|
-
: {}),
|
|
2917
|
-
});
|
|
2918
|
-
new cdk.CfnOutput(this, `${resourceId}-name`, {
|
|
2919
|
-
value: fn.functionName,
|
|
2920
|
-
description: `Lambda function name for ${name}`,
|
|
2921
|
-
});
|
|
2922
|
-
new cdk.CfnOutput(this, `${resourceId}-arn`, {
|
|
2923
|
-
value: fn.functionArn,
|
|
2924
|
-
description: `Lambda function ARN for ${name}`,
|
|
2925
|
-
});
|
|
2926
|
-
new cdk.CfnOutput(this, `${resourceId}-dlq-url`, {
|
|
2927
|
-
value: dlq.queueUrl,
|
|
2928
|
-
description: `Dead-letter queue for ${name} (async invocation failures land here)`,
|
|
2929
|
-
});
|
|
2930
|
-
}
|
|
2931
|
-
/**
|
|
2932
|
-
* Create a queue consumer Lambda function wired to an SQS queue.
|
|
2933
|
-
* Naming convention: {project}-{stage}-queue-{name}
|
|
2934
|
-
*
|
|
2935
|
-
* Well-Architected: Reliability — DLQ on consumer queue, encrypted
|
|
2936
|
-
* Well-Architected: Performance — batch size and visibility timeout
|
|
2937
|
-
*/
|
|
2938
|
-
createQueueConsumer(name, handlerFile, projectName, stage, projectDir, intent, role, securityGroup, vpc) {
|
|
2939
|
-
const functionName = `${projectName}-${stage}-queue-${name}`;
|
|
2940
|
-
const resourceId = `queue-consumer-${name}`;
|
|
2941
|
-
const lambdaConfig = this.envConfig.lambda;
|
|
2942
|
-
// Create the SQS queue with DLQ
|
|
2943
|
-
const dlq = new sqs.Queue(this, `${resourceId}-dlq`, {
|
|
2944
|
-
queueName: `${projectName}-${stage}-${name}-dlq`,
|
|
2945
|
-
retentionPeriod: cdk.Duration.days(14),
|
|
2946
|
-
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
2947
|
-
});
|
|
2948
|
-
const queue = new sqs.Queue(this, `${resourceId}-sqs`, {
|
|
2949
|
-
queueName: `${projectName}-${stage}-${name}`,
|
|
2950
|
-
visibilityTimeout: cdk.Duration.seconds(intent?.visibilityTimeoutSeconds ?? (intent?.timeout ?? 30) * 6),
|
|
2951
|
-
retentionPeriod: cdk.Duration.days(intent?.retentionDays ?? 4),
|
|
2952
|
-
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
2953
|
-
deadLetterQueue: {
|
|
2954
|
-
queue: dlq,
|
|
2955
|
-
maxReceiveCount: 3,
|
|
2956
|
-
},
|
|
2957
|
-
});
|
|
2958
|
-
const fn = new lambda.Function(this, resourceId, {
|
|
2959
|
-
functionName,
|
|
2960
|
-
runtime: this.toLambdaRuntime(),
|
|
2961
|
-
architecture: this.toLambdaArchitecture(),
|
|
2962
|
-
handler: 'index.main',
|
|
2963
|
-
code: this.bundleHandlerCode(handlerFile),
|
|
2964
|
-
memorySize: intent?.memorySize ?? lambdaConfig.memoryMb,
|
|
2965
|
-
timeout: cdk.Duration.seconds(intent?.timeout ?? lambdaConfig.timeoutSec),
|
|
2966
|
-
description: intent?.description ?? `Queue consumer: ${name}`,
|
|
2967
|
-
environment: {
|
|
2968
|
-
...this.ventureBaseEnv,
|
|
2969
|
-
...lambdaConfig.environmentVariables,
|
|
2970
|
-
},
|
|
2971
|
-
logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
|
|
2972
|
-
tracing: lambdaConfig.tracingEnabled
|
|
2973
|
-
? lambda.Tracing.ACTIVE
|
|
2974
|
-
: lambda.Tracing.DISABLED,
|
|
2975
|
-
role,
|
|
2976
|
-
// Well-Architected: Reliability — VPC-attach so consumers can
|
|
2977
|
-
// reach RDS. SQS itself is reachable from public Internet, but a
|
|
2978
|
-
// queue handler that needs to UPDATE its own DB on success would
|
|
2979
|
-
// otherwise hang forever once a database is provisioned.
|
|
2980
|
-
...(vpc && securityGroup
|
|
2981
|
-
? {
|
|
2982
|
-
vpc,
|
|
2983
|
-
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
2984
|
-
securityGroups: [securityGroup],
|
|
2985
|
-
}
|
|
2986
|
-
: {}),
|
|
2987
|
-
});
|
|
2988
|
-
// Wire Lambda to SQS queue
|
|
2989
|
-
fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
|
|
2990
|
-
batchSize: intent?.batchSize ?? 10,
|
|
2991
|
-
}));
|
|
2992
|
-
new cdk.CfnOutput(this, `${resourceId}-fn-name`, {
|
|
2993
|
-
value: fn.functionName,
|
|
2994
|
-
description: `Queue consumer Lambda for ${name}`,
|
|
2995
|
-
});
|
|
2996
|
-
new cdk.CfnOutput(this, `${resourceId}-queue-url`, {
|
|
2997
|
-
value: queue.queueUrl,
|
|
2998
|
-
description: `SQS queue URL for ${name}`,
|
|
2999
|
-
});
|
|
3000
|
-
}
|
|
3001
|
-
/**
|
|
3002
|
-
* Create a cron/scheduled task Lambda function wired to an EventBridge rule.
|
|
3003
|
-
* Naming convention: {project}-{stage}-cron-{name}
|
|
3004
|
-
*
|
|
3005
|
-
* Well-Architected: Performance — memory/timeout from preset
|
|
3006
|
-
* Well-Architected: Operational Excellence — log retention, tracing from preset
|
|
3007
|
-
*/
|
|
3008
|
-
createCronHandler(name, handlerFile, projectName, stage, projectDir, intent, role, securityGroup, vpc) {
|
|
3009
|
-
const functionName = `${projectName}-${stage}-cron-${name}`;
|
|
3010
|
-
const resourceId = `cron-${name}`;
|
|
3011
|
-
const lambdaConfig = this.envConfig.lambda;
|
|
3012
|
-
// Auto-DLQ for cron handlers. EventBridge delivers events async; without
|
|
3013
|
-
// a DLQ, a broken cron silently stops working after Lambda's 2-retry
|
|
3014
|
-
// budget and you only notice when someone asks "did the nightly job run?"
|
|
3015
|
-
const dlq = new sqs.Queue(this, `${resourceId}-dlq`, {
|
|
3016
|
-
queueName: `${projectName}-${stage}-cron-${name}-dlq`,
|
|
3017
|
-
retentionPeriod: cdk.Duration.days(14),
|
|
3018
|
-
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
3019
|
-
});
|
|
3020
|
-
const fn = new lambda.Function(this, resourceId, {
|
|
3021
|
-
functionName,
|
|
3022
|
-
runtime: this.toLambdaRuntime(),
|
|
3023
|
-
architecture: this.toLambdaArchitecture(),
|
|
3024
|
-
handler: 'index.main',
|
|
3025
|
-
code: this.bundleHandlerCode(handlerFile),
|
|
3026
|
-
memorySize: intent?.memorySize ?? lambdaConfig.memoryMb,
|
|
3027
|
-
timeout: cdk.Duration.seconds(intent?.timeout ?? 60),
|
|
3028
|
-
description: intent?.description ?? `Cron handler: ${name}`,
|
|
3029
|
-
environment: {
|
|
3030
|
-
...this.ventureBaseEnv,
|
|
3031
|
-
...lambdaConfig.environmentVariables,
|
|
3032
|
-
},
|
|
3033
|
-
logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
|
|
3034
|
-
tracing: lambdaConfig.tracingEnabled
|
|
3035
|
-
? lambda.Tracing.ACTIVE
|
|
3036
|
-
: lambda.Tracing.DISABLED,
|
|
3037
|
-
// Well-Architected: Reliability — EventBridge failures land in the DLQ
|
|
3038
|
-
deadLetterQueue: dlq,
|
|
3039
|
-
deadLetterQueueEnabled: true,
|
|
3040
|
-
role,
|
|
3041
|
-
// Well-Architected: Reliability — VPC-attach so nightly jobs that
|
|
3042
|
-
// poll RDS for stale state (cleanup, retries, billing rollups)
|
|
3043
|
-
// don't spin up only to discover they can't reach the database.
|
|
3044
|
-
...(vpc && securityGroup
|
|
3045
|
-
? {
|
|
3046
|
-
vpc,
|
|
3047
|
-
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
3048
|
-
securityGroups: [securityGroup],
|
|
3049
|
-
}
|
|
3050
|
-
: {}),
|
|
3051
|
-
});
|
|
3052
|
-
// Wire EventBridge rule if schedule expression is provided
|
|
3053
|
-
if (intent?.schedule) {
|
|
3054
|
-
const scheduleExpr = intent.schedule;
|
|
3055
|
-
const isEnabled = intent.enabled ?? true;
|
|
3056
|
-
let schedule;
|
|
3057
|
-
if ('rate' in scheduleExpr) {
|
|
3058
|
-
schedule = events.Schedule.expression(`rate(${scheduleExpr.rate})`);
|
|
3059
|
-
}
|
|
3060
|
-
else {
|
|
3061
|
-
schedule = events.Schedule.expression(`cron(${scheduleExpr.cron})`);
|
|
3062
|
-
}
|
|
3063
|
-
new events.Rule(this, `${resourceId}-rule`, {
|
|
3064
|
-
ruleName: `${projectName}-${stage}-cron-${name}`,
|
|
3065
|
-
schedule,
|
|
3066
|
-
enabled: isEnabled,
|
|
3067
|
-
targets: [new targets.LambdaFunction(fn)],
|
|
3068
|
-
});
|
|
3069
|
-
}
|
|
3070
|
-
new cdk.CfnOutput(this, `${resourceId}-fn-name`, {
|
|
3071
|
-
value: fn.functionName,
|
|
3072
|
-
description: `Cron handler Lambda for ${name}`,
|
|
3073
|
-
});
|
|
3074
|
-
new cdk.CfnOutput(this, `${resourceId}-fn-arn`, {
|
|
3075
|
-
value: fn.functionArn,
|
|
3076
|
-
description: `Cron handler Lambda ARN for ${name}`,
|
|
3077
|
-
});
|
|
3078
|
-
new cdk.CfnOutput(this, `${resourceId}-dlq-url`, {
|
|
3079
|
-
value: dlq.queueUrl,
|
|
3080
|
-
description: `Dead-letter queue for ${name} (async invocation failures land here)`,
|
|
3081
|
-
});
|
|
3082
|
-
}
|
|
3083
|
-
}
|
|
3084
|
-
//# sourceMappingURL=stack.js.map
|