@venturekit/infra 0.0.0-dev.20260312012510 → 0.0.0-dev.20260329102450

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/stack.js CHANGED
@@ -1,4 +1,3 @@
1
- "use strict";
2
1
  /**
3
2
  * VentureStack — CDK Stack for VentureKit applications
4
3
  *
@@ -7,72 +6,122 @@
7
6
  *
8
7
  * Consumers never see this file — it's invoked by the generated CDK app in .vk/cdk/.
9
8
  */
10
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
- if (k2 === undefined) k2 = k;
12
- var desc = Object.getOwnPropertyDescriptor(m, k);
13
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
- desc = { enumerable: true, get: function() { return m[k]; } };
15
- }
16
- Object.defineProperty(o, k2, desc);
17
- }) : (function(o, m, k, k2) {
18
- if (k2 === undefined) k2 = k;
19
- o[k2] = m[k];
20
- }));
21
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
- Object.defineProperty(o, "default", { enumerable: true, value: v });
23
- }) : function(o, v) {
24
- o["default"] = v;
25
- });
26
- var __importStar = (this && this.__importStar) || (function () {
27
- var ownKeys = function(o) {
28
- ownKeys = Object.getOwnPropertyNames || function (o) {
29
- var ar = [];
30
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
- return ar;
32
- };
33
- return ownKeys(o);
34
- };
35
- return function (mod) {
36
- if (mod && mod.__esModule) return mod;
37
- var result = {};
38
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
- __setModuleDefault(result, mod);
40
- return result;
41
- };
42
- })();
43
- Object.defineProperty(exports, "__esModule", { value: true });
44
- exports.VentureStack = void 0;
45
- const cdk = __importStar(require("aws-cdk-lib"));
46
- const ec2 = __importStar(require("aws-cdk-lib/aws-ec2"));
47
- const rds = __importStar(require("aws-cdk-lib/aws-rds"));
48
- const s3 = __importStar(require("aws-cdk-lib/aws-s3"));
49
- const sqs = __importStar(require("aws-cdk-lib/aws-sqs"));
50
- const cognito = __importStar(require("aws-cdk-lib/aws-cognito"));
51
- const elasticache = __importStar(require("aws-cdk-lib/aws-elasticache"));
52
- const apigatewayv2 = __importStar(require("aws-cdk-lib/aws-apigatewayv2"));
53
- class VentureStack extends cdk.Stack {
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 sqs from 'aws-cdk-lib/aws-sqs';
14
+ import * as cognito from 'aws-cdk-lib/aws-cognito';
15
+ import * as elasticache from 'aws-cdk-lib/aws-elasticache';
16
+ import * as events from 'aws-cdk-lib/aws-events';
17
+ import * as targets from 'aws-cdk-lib/aws-events-targets';
18
+ import * as lambda from 'aws-cdk-lib/aws-lambda';
19
+ import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
20
+ import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
21
+ import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
22
+ import * as logs from 'aws-cdk-lib/aws-logs';
23
+ import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
24
+ import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
25
+ import * as sns from 'aws-cdk-lib/aws-sns';
26
+ import * as ssm from 'aws-cdk-lib/aws-ssm';
27
+ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
28
+ import * as acm from 'aws-cdk-lib/aws-certificatemanager';
29
+ import * as route53 from 'aws-cdk-lib/aws-route53';
30
+ import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';
31
+ import { resolveEnvConfig, DATA_SAFETY_CONFIG } from '@venturekit/core';
32
+ import * as path from 'path';
33
+ import * as fs from 'fs';
34
+ export class VentureStack extends cdk.Stack {
54
35
  outputs = {};
36
+ /** Resolved environment configuration driving all resource sizing */
37
+ envConfig;
38
+ /** Data safety configuration for removal policies */
39
+ dataSafetyConfig;
40
+ /** Whether this is a free-tier deployment */
41
+ isFreeTier;
42
+ /** Standard tags applied to all taggable resources */
43
+ standardTags;
55
44
  constructor(scope, id, props) {
56
45
  super(scope, id, props);
57
- const { projectName, stage, routesDir, infrastructure, projectDir } = props;
58
- // Shared VPC (created if databases or caches are declared)
46
+ const { projectName, stage, routesDir, functionsDir, queuesDir, cronsDir, infrastructure, projectDir, preset } = props;
47
+ // --- Resolve environment configuration ---
48
+ // Priority: explicit envConfig > resolve from preset > fallback to 'micro'
49
+ const env = (stage === 'prod' ? 'prod' : stage === 'stage' ? 'stage' : 'dev');
50
+ this.envConfig = props.envConfig ?? resolveEnvConfig(env, { preset: preset ?? 'micro' });
51
+ this.isFreeTier = preset === 'free' || this.envConfig.dataSafety === 'relaxed' && preset === 'free';
52
+ this.dataSafetyConfig = DATA_SAFETY_CONFIG[this.envConfig.dataSafety];
53
+ // --- Well-Architected: Operational Excellence — consistent tagging ---
54
+ this.standardTags = {
55
+ Project: projectName,
56
+ Stage: stage,
57
+ ManagedBy: 'venturekit',
58
+ Preset: preset ?? 'micro',
59
+ };
60
+ Object.entries(this.standardTags).forEach(([key, value]) => {
61
+ cdk.Tags.of(this).add(key, value);
62
+ });
63
+ const lambdaConfig = this.envConfig.lambda;
64
+ const vpcConfig = this.envConfig.vpc;
65
+ const apiConfig = this.envConfig.api;
66
+ const obsConfig = this.envConfig.observability;
67
+ // --- Shared VPC ---
68
+ // Well-Architected: Security — network isolation for databases and caches
69
+ // Free tier: skip VPC entirely (NAT gateways are not free)
70
+ // Stable logical ID 'Vpc' ensures preset changes don't replace the VPC
59
71
  let vpc;
60
- const needsVpc = (infrastructure?.databases && infrastructure.databases.length > 0) ||
61
- (infrastructure?.caches && infrastructure.caches.length > 0);
72
+ const needsVpc = !this.isFreeTier && (lambdaConfig.vpcEnabled ||
73
+ (infrastructure?.databases && infrastructure.databases.length > 0) ||
74
+ (infrastructure?.caches && infrastructure.caches.length > 0));
62
75
  if (needsVpc) {
63
- vpc = new ec2.Vpc(this, `${projectName}-vpc`, {
64
- maxAzs: 2,
65
- natGateways: stage === 'prod' ? 1 : 0,
66
- subnetConfiguration: [
67
- { name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
68
- { name: 'private', subnetType: ec2.SubnetType.PRIVATE_ISOLATED, cidrMask: 24 },
69
- ],
76
+ const subnetConfig = [
77
+ { name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: vpcConfig.publicSubnets.cidrMask },
78
+ { name: 'private', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: vpcConfig.privateSubnets.cidrMask },
79
+ ];
80
+ if (vpcConfig.isolatedSubnets) {
81
+ subnetConfig.push({
82
+ name: 'isolated',
83
+ subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
84
+ cidrMask: vpcConfig.isolatedSubnets.cidrMask,
85
+ });
86
+ }
87
+ vpc = new ec2.Vpc(this, 'Vpc', {
88
+ maxAzs: vpcConfig.maxAzs,
89
+ natGateways: vpcConfig.natGateways,
90
+ ipAddresses: ec2.IpAddresses.cidr(vpcConfig.cidr),
91
+ subnetConfiguration: subnetConfig,
92
+ // Well-Architected: Security — restrict default SG
93
+ restrictDefaultSecurityGroup: true,
70
94
  });
95
+ // Well-Architected: Security — VPC flow logs for network monitoring
96
+ if (vpcConfig.flowLogsEnabled) {
97
+ vpc.addFlowLog('FlowLog', {
98
+ destination: ec2.FlowLogDestination.toCloudWatchLogs(new logs.LogGroup(this, 'VpcFlowLogGroup', {
99
+ logGroupName: `/venturekit/${projectName}/${stage}/vpc-flow-logs`,
100
+ retention: this.toLogRetention(vpcConfig.flowLogsRetentionDays),
101
+ removalPolicy: this.toRemovalPolicy(),
102
+ })),
103
+ trafficType: ec2.FlowLogTrafficType.ALL,
104
+ });
105
+ }
106
+ // Well-Architected: Performance — VPC endpoints reduce latency and cost
107
+ if (vpcConfig.enableVpcEndpoints) {
108
+ vpc.addGatewayEndpoint('S3Endpoint', {
109
+ service: ec2.GatewayVpcEndpointAwsService.S3,
110
+ });
111
+ vpc.addGatewayEndpoint('DynamoEndpoint', {
112
+ service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
113
+ });
114
+ }
71
115
  }
72
116
  // --- Databases ---
73
117
  if (infrastructure?.databases) {
74
118
  for (const db of infrastructure.databases) {
75
- this.createDatabase(db, projectName, stage, vpc);
119
+ if (this.isFreeTier) {
120
+ this.createDynamoDBTable(db, projectName, stage);
121
+ }
122
+ else {
123
+ this.createDatabase(db, projectName, stage, vpc);
124
+ }
76
125
  }
77
126
  }
78
127
  // --- Storage ---
@@ -94,17 +143,41 @@ class VentureStack extends cdk.Stack {
94
143
  }
95
144
  }
96
145
  // --- Caches ---
97
- if (infrastructure?.caches) {
146
+ // Free tier: skip ElastiCache (not free). Use DynamoDB-based caching
147
+ // via createDefaultRateLimiter() from @venturekit/runtime instead.
148
+ if (infrastructure?.caches && !this.isFreeTier) {
98
149
  for (const cache of infrastructure.caches) {
99
150
  this.createCache(cache, projectName, stage, vpc);
100
151
  }
101
152
  }
153
+ // Free tier: create a DynamoDB table for rate limiting (always-free tier)
154
+ if (this.isFreeTier) {
155
+ this.createRateLimitTable(projectName, stage);
156
+ }
157
+ // --- Monitoring ---
158
+ if (infrastructure?.monitoring) {
159
+ for (const mon of infrastructure.monitoring) {
160
+ this.createMonitoring(mon, projectName, stage);
161
+ }
162
+ }
163
+ // --- Secrets ---
164
+ if (infrastructure?.secrets) {
165
+ for (const secret of infrastructure.secrets) {
166
+ this.createSecret(secret, projectName, stage);
167
+ }
168
+ }
102
169
  // --- API Gateway + Lambda routes ---
103
- // Routes are discovered at deploy time from the filesystem
104
- // For now, create the API Gateway shell; route wiring happens in a future iteration
105
- const api = new apigatewayv2.HttpApi(this, `${projectName}-api`, {
170
+ // Well-Architected: Performance — throttling protects downstream services
171
+ // Well-Architected: Security — CORS preflight with explicit origins in prod
172
+ const api = new apigatewayv2.HttpApi(this, 'Api', {
106
173
  apiName: `${projectName}-${stage}`,
107
- corsPreflight: {
174
+ corsPreflight: apiConfig.cors ? {
175
+ allowOrigins: apiConfig.cors.allowOrigins,
176
+ allowMethods: apiConfig.cors.allowMethods.map((m) => apigatewayv2.CorsHttpMethod[m.toUpperCase()] ?? apigatewayv2.CorsHttpMethod.ANY),
177
+ allowHeaders: apiConfig.cors.allowHeaders,
178
+ allowCredentials: apiConfig.cors.allowCredentials,
179
+ maxAge: cdk.Duration.seconds(apiConfig.cors.maxAgeSec),
180
+ } : {
108
181
  allowOrigins: ['*'],
109
182
  allowMethods: [
110
183
  apigatewayv2.CorsHttpMethod.GET,
@@ -116,49 +189,178 @@ class VentureStack extends cdk.Stack {
116
189
  ],
117
190
  allowHeaders: ['*'],
118
191
  },
192
+ disableExecuteApiEndpoint: false,
193
+ });
194
+ // Well-Architected: Operational Excellence — access logging for API
195
+ const apiLogGroup = new logs.LogGroup(this, 'ApiAccessLog', {
196
+ logGroupName: `/venturekit/${projectName}/${stage}/api-access`,
197
+ retention: this.toLogRetention(obsConfig.logs.retentionDays),
198
+ removalPolicy: this.toRemovalPolicy(),
119
199
  });
200
+ const defaultStage = api.defaultStage?.node.defaultChild;
201
+ if (defaultStage) {
202
+ defaultStage.accessLogSettings = {
203
+ destinationArn: apiLogGroup.logGroupArn,
204
+ format: JSON.stringify({
205
+ requestId: '$context.requestId',
206
+ ip: '$context.identity.sourceIp',
207
+ method: '$context.httpMethod',
208
+ path: '$context.path',
209
+ status: '$context.status',
210
+ latency: '$context.responseLatency',
211
+ integrationLatency: '$context.integrationLatency',
212
+ }),
213
+ };
214
+ defaultStage.defaultRouteSettings = {
215
+ detailedMetricsEnabled: apiConfig.detailedMetricsEnabled,
216
+ throttlingRateLimit: apiConfig.throttleRateLimit,
217
+ throttlingBurstLimit: apiConfig.throttleBurstLimit,
218
+ };
219
+ }
120
220
  new cdk.CfnOutput(this, 'ApiUrl', {
121
221
  value: api.apiEndpoint,
122
222
  description: 'API Gateway URL',
123
223
  });
224
+ // --- Custom Domains ---
225
+ if (infrastructure?.domains) {
226
+ for (const domain of infrastructure.domains) {
227
+ this.createDomain(domain, projectName, stage, api);
228
+ }
229
+ }
230
+ // --- Standalone Lambda Functions ---
231
+ // Discover functions from src/functions/ and create Lambda functions.
232
+ // Naming convention: {project}-{stage}-fn-{name}
233
+ const resolvedFunctionsDir = functionsDir
234
+ ? path.resolve(projectDir, functionsDir)
235
+ : path.resolve(projectDir, 'src/functions');
236
+ if (fs.existsSync(resolvedFunctionsDir)) {
237
+ const discoveredFunctions = this.discoverFunctionFiles(resolvedFunctionsDir);
238
+ const functionIntents = infrastructure?.functions ?? [];
239
+ for (const fnFile of discoveredFunctions) {
240
+ const intent = functionIntents.find((f) => f.id === fnFile.name);
241
+ this.createStandaloneFunction(fnFile.name, fnFile.handlerFile, projectName, stage, projectDir, intent);
242
+ }
243
+ }
244
+ // --- Queue Consumer Lambda Functions ---
245
+ // Discover handlers from src/queues/ and wire them to SQS queues.
246
+ // Naming convention: {project}-{stage}-queue-{name}
247
+ const resolvedQueuesDir = queuesDir
248
+ ? path.resolve(projectDir, queuesDir)
249
+ : path.resolve(projectDir, 'src/queues');
250
+ if (fs.existsSync(resolvedQueuesDir)) {
251
+ const discoveredQueues = this.discoverFunctionFiles(resolvedQueuesDir);
252
+ const queueIntents = infrastructure?.queues ?? [];
253
+ for (const qFile of discoveredQueues) {
254
+ const intent = queueIntents.find((q) => q.id === qFile.name);
255
+ this.createQueueConsumer(qFile.name, qFile.handlerFile, projectName, stage, projectDir, intent);
256
+ }
257
+ }
258
+ // --- Cron/Scheduled Task Lambda Functions ---
259
+ // Discover handlers from src/crons/ and wire them to EventBridge rules.
260
+ // Naming convention: {project}-{stage}-cron-{name}
261
+ const resolvedCronsDir = cronsDir
262
+ ? path.resolve(projectDir, cronsDir)
263
+ : path.resolve(projectDir, 'src/crons');
264
+ if (fs.existsSync(resolvedCronsDir)) {
265
+ const discoveredCrons = this.discoverFunctionFiles(resolvedCronsDir);
266
+ const scheduleIntents = infrastructure?.schedules ?? [];
267
+ for (const cFile of discoveredCrons) {
268
+ const intent = scheduleIntents.find((s) => s.id === cFile.name);
269
+ this.createCronHandler(cFile.name, cFile.handlerFile, projectName, stage, projectDir, intent);
270
+ }
271
+ }
124
272
  }
273
+ /**
274
+ * Well-Architected: Reliability — RDS with stable logical ID so preset
275
+ * changes (e.g. nano→medium) perform an in-place instance class modify,
276
+ * not a replacement. Data is preserved because the CloudFormation resource
277
+ * identity stays the same.
278
+ *
279
+ * Well-Architected: Security — encrypted at rest, credentials in Secrets Manager
280
+ * Well-Architected: Reliability — automated backups, optional multi-AZ
281
+ * Well-Architected: Performance — instance type driven by intent.size
282
+ * Well-Architected: Operational Excellence — auto minor version upgrades
283
+ */
125
284
  createDatabase(intent, projectName, stage, vpc) {
126
- const resourceName = `${projectName}-${intent.id}`;
285
+ // Stable logical ID: 'db-{intent.id}' — never changes when preset changes
286
+ const logicalId = `db-${intent.id}`;
127
287
  const instanceType = this.sizeToInstanceType(intent.size || 'small');
128
- const instance = new rds.DatabaseInstance(this, resourceName, {
129
- engine: rds.DatabaseInstanceEngine.postgres({
130
- version: rds.PostgresEngineVersion.VER_16,
131
- }),
288
+ const engine = intent.type === 'mysql'
289
+ ? rds.DatabaseInstanceEngine.mysql({ version: rds.MysqlEngineVersion.VER_8_0 })
290
+ : rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_16 });
291
+ // Well-Architected: Security — place DB in isolated subnets when available
292
+ const subnetType = vpc.isolatedSubnets?.length
293
+ ? ec2.SubnetType.PRIVATE_ISOLATED
294
+ : ec2.SubnetType.PRIVATE_WITH_EGRESS;
295
+ const instance = new rds.DatabaseInstance(this, logicalId, {
296
+ engine,
132
297
  instanceType,
133
298
  vpc,
134
- vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
299
+ vpcSubnets: { subnetType },
135
300
  databaseName: intent.name || intent.id,
136
301
  credentials: rds.Credentials.fromGeneratedSecret('postgres'),
137
- removalPolicy: stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
138
- deletionProtection: stage === 'prod',
302
+ // Well-Architected: Reliability — use dataSafety for removal policy
303
+ removalPolicy: this.toRemovalPolicy(),
304
+ deletionProtection: this.dataSafetyConfig.deletionProtection,
139
305
  multiAz: intent.highAvailability ?? false,
306
+ // Well-Architected: Security — encryption at rest
140
307
  storageEncrypted: intent.encrypted ?? true,
308
+ // Well-Architected: Reliability — automated backups
141
309
  backupRetention: intent.backups !== false
142
310
  ? cdk.Duration.days(intent.backupRetentionDays ?? 7)
143
311
  : cdk.Duration.days(0),
312
+ // Well-Architected: Operational Excellence — auto minor version upgrades
313
+ autoMinorVersionUpgrade: true,
314
+ // Well-Architected: Performance — performance insights for medium+ presets
315
+ enablePerformanceInsights: intent.performanceInsights ?? this.envConfig.lambda.memoryMb >= 512,
316
+ // Well-Architected: Reliability — enhanced monitoring for medium+ presets
317
+ monitoringInterval: this.envConfig.lambda.memoryMb >= 512
318
+ ? cdk.Duration.seconds(60)
319
+ : undefined,
144
320
  });
145
- new cdk.CfnOutput(this, `${resourceName}-endpoint`, {
321
+ // Well-Architected: Reliability — prevent accidental replacement
322
+ // CFN UpdateReplacePolicy ensures data safety on resource property changes
323
+ const cfnInstance = instance.node.defaultChild;
324
+ if (this.dataSafetyConfig.removalPolicy === 'retain') {
325
+ cfnInstance.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
326
+ }
327
+ new cdk.CfnOutput(this, `${logicalId}-endpoint`, {
146
328
  value: instance.dbInstanceEndpointAddress,
147
329
  description: `Database endpoint for ${intent.id}`,
148
330
  });
149
- new cdk.CfnOutput(this, `${resourceName}-secret-arn`, {
331
+ new cdk.CfnOutput(this, `${logicalId}-secret-arn`, {
150
332
  value: instance.secret?.secretArn || '',
151
333
  description: `Database secret ARN for ${intent.id}`,
152
334
  });
153
335
  }
336
+ /**
337
+ * Well-Architected: Security — block public access by default, SSE encryption
338
+ * Well-Architected: Reliability — versioning for backups, dataSafety removal policy
339
+ * Well-Architected: Cost — lifecycle rules for logs/backups to reduce storage cost
340
+ * Well-Architected: Sustainability — intelligent tiering for infrequent access
341
+ */
154
342
  createStorage(intent, projectName, stage) {
155
- const resourceName = `${projectName}-${intent.id}`;
156
- const bucket = new s3.Bucket(this, resourceName, {
157
- bucketName: `${resourceName}-${stage}`,
343
+ // Stable logical ID: 'storage-{intent.id}'
344
+ const logicalId = `storage-${intent.id}`;
345
+ const isPublicAssets = intent.purpose === 'assets' && intent.cdn;
346
+ const bucket = new s3.Bucket(this, logicalId, {
347
+ bucketName: `${projectName}-${intent.id}-${stage}`,
158
348
  versioned: intent.versioned ?? intent.purpose === 'backups',
159
- removalPolicy: stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
160
- autoDeleteObjects: stage !== 'prod',
349
+ removalPolicy: this.toRemovalPolicy(),
350
+ autoDeleteObjects: this.dataSafetyConfig.removalPolicy === 'destroy',
351
+ // Well-Architected: Security — encryption at rest
161
352
  encryption: s3.BucketEncryption.S3_MANAGED,
353
+ // Well-Architected: Security — block public access unless explicit CDN assets
354
+ blockPublicAccess: isPublicAssets
355
+ ? new s3.BlockPublicAccess({
356
+ blockPublicAcls: true,
357
+ ignorePublicAcls: true,
358
+ blockPublicPolicy: false,
359
+ restrictPublicBuckets: false,
360
+ })
361
+ : s3.BlockPublicAccess.BLOCK_ALL,
362
+ // Well-Architected: Security — enforce SSL
363
+ enforceSSL: true,
162
364
  cors: intent.corsOrigins
163
365
  ? [
164
366
  {
@@ -173,18 +375,38 @@ class VentureStack extends cdk.Stack {
173
375
  },
174
376
  ]
175
377
  : undefined,
378
+ // Well-Architected: Cost — lifecycle rules for logs and backups
379
+ lifecycleRules: intent.purpose === 'logs'
380
+ ? [{ expiration: cdk.Duration.days(intent.retentionDays ?? 90), id: 'log-expiry' }]
381
+ : intent.purpose === 'backups'
382
+ ? [
383
+ { transitions: [{ storageClass: s3.StorageClass.INFREQUENT_ACCESS, transitionAfter: cdk.Duration.days(30) }], id: 'backup-ia' },
384
+ { transitions: [{ storageClass: s3.StorageClass.GLACIER, transitionAfter: cdk.Duration.days(90) }], id: 'backup-glacier' },
385
+ ]
386
+ : [],
176
387
  });
177
- new cdk.CfnOutput(this, `${resourceName}-bucket-name`, {
388
+ // Well-Architected: Reliability — UpdateReplacePolicy for stateful resources
389
+ const cfnBucket = bucket.node.defaultChild;
390
+ if (this.dataSafetyConfig.removalPolicy === 'retain') {
391
+ cfnBucket.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
392
+ }
393
+ new cdk.CfnOutput(this, `${logicalId}-bucket-name`, {
178
394
  value: bucket.bucketName,
179
395
  description: `S3 bucket name for ${intent.id}`,
180
396
  });
181
- new cdk.CfnOutput(this, `${resourceName}-bucket-arn`, {
397
+ new cdk.CfnOutput(this, `${logicalId}-bucket-arn`, {
182
398
  value: bucket.bucketArn,
183
399
  description: `S3 bucket ARN for ${intent.id}`,
184
400
  });
185
401
  }
402
+ /**
403
+ * Well-Architected: Security — Cognito with advanced security features,
404
+ * stable logical ID for safe preset upgrades.
405
+ * Well-Architected: Reliability — dataSafety-driven removal policy
406
+ */
186
407
  createAuth(intent, projectName, stage) {
187
- const resourceName = `${projectName}-${intent.id}`;
408
+ // Stable logical ID: 'auth-{intent.id}'
409
+ const logicalId = `auth-${intent.id}`;
188
410
  const passwordPolicy = intent.passwordStrength === 'strong'
189
411
  ? {
190
412
  minLength: 12,
@@ -200,9 +422,9 @@ class VentureStack extends cdk.Stack {
200
422
  requireDigits: true,
201
423
  requireSymbols: false,
202
424
  };
203
- const userPool = new cognito.UserPool(this, resourceName, {
204
- userPoolName: `${resourceName}-${stage}`,
205
- selfSignUpEnabled: true,
425
+ const userPool = new cognito.UserPool(this, logicalId, {
426
+ userPoolName: `${projectName}-${intent.id}-${stage}`,
427
+ selfSignUpEnabled: intent.allowSignUp ?? true,
206
428
  signInAliases: {
207
429
  email: intent.signInWith?.includes('email') ?? true,
208
430
  username: intent.signInWith?.includes('username') ?? false,
@@ -213,8 +435,20 @@ class VentureStack extends cdk.Stack {
213
435
  : intent.mfa === 'optional'
214
436
  ? cognito.Mfa.OPTIONAL
215
437
  : cognito.Mfa.OFF,
216
- removalPolicy: stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
438
+ // Well-Architected: Reliability — dataSafety removal policy
439
+ removalPolicy: this.toRemovalPolicy(),
440
+ // Well-Architected: Security — advanced security mode for medium+ presets
441
+ advancedSecurityMode: this.envConfig.lambda.memoryMb >= 512
442
+ ? cognito.AdvancedSecurityMode.ENFORCED
443
+ : undefined,
444
+ // Well-Architected: Security — account recovery via verified email
445
+ accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
217
446
  });
447
+ // Well-Architected: Reliability — prevent accidental replacement
448
+ const cfnPool = userPool.node.defaultChild;
449
+ if (this.dataSafetyConfig.removalPolicy === 'retain') {
450
+ cfnPool.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
451
+ }
218
452
  // Create clients
219
453
  const clients = intent.clients && intent.clients.length > 0
220
454
  ? intent.clients
@@ -223,79 +457,619 @@ class VentureStack extends cdk.Stack {
223
457
  for (const client of clients) {
224
458
  const appClient = userPool.addClient(client.name, {
225
459
  authFlows: { userPassword: true, userSrp: true },
460
+ // Well-Architected: Security — prevent user existence errors
461
+ preventUserExistenceErrors: true,
226
462
  });
227
463
  if (!primaryClientId) {
228
464
  primaryClientId = appClient.userPoolClientId;
229
465
  }
230
466
  }
231
- new cdk.CfnOutput(this, `${resourceName}-user-pool-id`, {
467
+ new cdk.CfnOutput(this, `${logicalId}-user-pool-id`, {
232
468
  value: userPool.userPoolId,
233
469
  description: `Cognito User Pool ID for ${intent.id}`,
234
470
  });
235
- new cdk.CfnOutput(this, `${resourceName}-client-id`, {
471
+ new cdk.CfnOutput(this, `${logicalId}-client-id`, {
236
472
  value: primaryClientId,
237
473
  description: `Cognito Client ID for ${intent.id}`,
238
474
  });
239
475
  }
476
+ /**
477
+ * Well-Architected: Reliability — DLQ by default, stable logical ID
478
+ * Well-Architected: Security — encryption at rest with SQS managed keys
479
+ * Well-Architected: Performance — visibility timeout derived from handler timeout
480
+ */
240
481
  createQueue(intent, projectName, stage) {
241
- const resourceName = `${projectName}-${intent.id}`;
242
- let dlq;
243
- if (intent.deadLetterQueue) {
244
- dlq = new sqs.Queue(this, `${resourceName}-dlq`, {
245
- queueName: `${resourceName}-dlq-${stage}`,
246
- fifo: intent.type === 'fifo',
247
- retentionPeriod: cdk.Duration.days(14),
248
- });
249
- }
250
- const queue = new sqs.Queue(this, resourceName, {
251
- queueName: `${resourceName}-${stage}${intent.type === 'fifo' ? '.fifo' : ''}`,
482
+ // Stable logical ID: 'queue-{intent.id}'
483
+ const logicalId = `queue-${intent.id}`;
484
+ // Well-Architected: Reliability — always create a DLQ for message safety
485
+ const dlq = new sqs.Queue(this, `${logicalId}-dlq`, {
486
+ queueName: `${projectName}-${intent.id}-dlq-${stage}${intent.type === 'fifo' ? '.fifo' : ''}`,
487
+ fifo: intent.type === 'fifo',
488
+ retentionPeriod: cdk.Duration.days(14),
489
+ // Well-Architected: Security — encryption at rest
490
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
491
+ });
492
+ const queue = new sqs.Queue(this, logicalId, {
493
+ queueName: `${projectName}-${intent.id}-${stage}${intent.type === 'fifo' ? '.fifo' : ''}`,
252
494
  fifo: intent.type === 'fifo',
253
495
  visibilityTimeout: intent.visibilityTimeoutSeconds
254
496
  ? cdk.Duration.seconds(intent.visibilityTimeoutSeconds)
255
- : undefined,
256
- deadLetterQueue: dlq
257
- ? { queue: dlq, maxReceiveCount: intent.maxReceiveCount ?? 3 }
258
- : undefined,
497
+ : cdk.Duration.seconds((intent.timeout ?? 30) * 6),
498
+ retentionPeriod: cdk.Duration.days(intent.retentionDays ?? 4),
499
+ // Well-Architected: Security — encryption at rest
500
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
501
+ // Well-Architected: Reliability — DLQ captures failed messages
502
+ deadLetterQueue: {
503
+ queue: dlq,
504
+ maxReceiveCount: intent.maxReceiveCount ?? 3,
505
+ },
259
506
  });
260
- new cdk.CfnOutput(this, `${resourceName}-queue-url`, {
507
+ new cdk.CfnOutput(this, `${logicalId}-queue-url`, {
261
508
  value: queue.queueUrl,
262
509
  description: `SQS Queue URL for ${intent.id}`,
263
510
  });
511
+ new cdk.CfnOutput(this, `${logicalId}-dlq-url`, {
512
+ value: dlq.queueUrl,
513
+ description: `SQS DLQ URL for ${intent.id}`,
514
+ });
264
515
  }
516
+ /**
517
+ * Well-Architected: Security — VPC-only access, SG least-privilege
518
+ * Well-Architected: Performance — cache node type driven by intent.size
519
+ * Well-Architected: Reliability — encrypted in transit, snapshot retention for prod
520
+ */
265
521
  createCache(intent, projectName, stage, vpc) {
266
- const resourceName = `${projectName}-${intent.id}`;
267
- const securityGroup = new ec2.SecurityGroup(this, `${resourceName}-sg`, {
522
+ // Stable logical ID: 'cache-{intent.id}'
523
+ const logicalId = `cache-${intent.id}`;
524
+ const securityGroup = new ec2.SecurityGroup(this, `${logicalId}-sg`, {
268
525
  vpc,
269
- description: `Security group for ${resourceName} Redis`,
270
- allowAllOutbound: true,
526
+ description: `Security group for ${projectName}-${intent.id} Redis`,
527
+ // Well-Architected: Security — restrict outbound to VPC only
528
+ allowAllOutbound: false,
271
529
  });
272
530
  securityGroup.addIngressRule(ec2.Peer.ipv4(vpc.vpcCidrBlock), ec2.Port.tcp(6379), 'Allow Redis access from VPC');
273
- const subnetGroup = new elasticache.CfnSubnetGroup(this, `${resourceName}-subnet-group`, {
274
- description: `Subnet group for ${resourceName}`,
275
- subnetIds: vpc.isolatedSubnets.map((s) => s.subnetId),
531
+ // Well-Architected: Security — use isolated subnets when available
532
+ const subnetIds = vpc.isolatedSubnets?.length
533
+ ? vpc.isolatedSubnets.map((s) => s.subnetId)
534
+ : vpc.privateSubnets.map((s) => s.subnetId);
535
+ const subnetGroup = new elasticache.CfnSubnetGroup(this, `${logicalId}-subnet-group`, {
536
+ description: `Subnet group for ${projectName}-${intent.id}`,
537
+ subnetIds,
276
538
  });
277
- const redis = new elasticache.CfnCacheCluster(this, resourceName, {
539
+ const cacheNodeType = this.sizeToCacheNodeType(intent.size || 'small');
540
+ const redis = new elasticache.CfnCacheCluster(this, logicalId, {
278
541
  engine: 'redis',
279
- cacheNodeType: 'cache.t3.micro',
542
+ cacheNodeType,
280
543
  numCacheNodes: 1,
281
544
  vpcSecurityGroupIds: [securityGroup.securityGroupId],
282
545
  cacheSubnetGroupName: subnetGroup.ref,
546
+ // Well-Architected: Security — encrypt in transit
547
+ transitEncryptionEnabled: true,
548
+ // Well-Architected: Reliability — snapshots for prod
549
+ snapshotRetentionLimit: this.envConfig.dataSafety === 'strict' ? 7 : 0,
550
+ // Well-Architected: Operational Excellence — auto minor version upgrade
551
+ autoMinorVersionUpgrade: true,
283
552
  });
284
- new cdk.CfnOutput(this, `${resourceName}-endpoint`, {
553
+ new cdk.CfnOutput(this, `${logicalId}-endpoint`, {
285
554
  value: redis.attrRedisEndpointAddress,
286
555
  description: `Redis endpoint for ${intent.id}`,
287
556
  });
288
557
  }
558
+ createMonitoring(intent, projectName, stage) {
559
+ const resourceName = `${projectName}-${intent.id}`;
560
+ // SNS topic for notifications
561
+ let topic;
562
+ if (intent.notificationArn) {
563
+ topic = sns.Topic.fromTopicArn(this, `${resourceName}-topic`, intent.notificationArn);
564
+ }
565
+ // Create alarms
566
+ if (intent.alarms) {
567
+ for (const alarm of intent.alarms) {
568
+ const comparisonMap = {
569
+ gt: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
570
+ gte: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
571
+ lt: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
572
+ lte: cloudwatch.ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD,
573
+ };
574
+ const metric = new cloudwatch.Metric({
575
+ namespace: alarm.namespace || 'AWS/Lambda',
576
+ metricName: alarm.metric,
577
+ period: cdk.Duration.seconds(alarm.periodSeconds ?? 300),
578
+ statistic: alarm.statistic || 'Average',
579
+ dimensionsMap: alarm.dimensions,
580
+ });
581
+ const cwAlarm = new cloudwatch.Alarm(this, `${resourceName}-${alarm.name}`, {
582
+ alarmName: `${projectName}-${stage}-${alarm.name}`,
583
+ alarmDescription: alarm.description,
584
+ metric,
585
+ threshold: alarm.threshold,
586
+ evaluationPeriods: alarm.evaluationPeriods ?? 1,
587
+ comparisonOperator: comparisonMap[alarm.comparison] || cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
588
+ });
589
+ if (topic) {
590
+ cwAlarm.addAlarmAction(new cloudwatchActions.SnsAction(topic));
591
+ }
592
+ new cdk.CfnOutput(this, `${resourceName}-${alarm.name}-arn`, {
593
+ value: cwAlarm.alarmArn,
594
+ description: `Alarm ARN for ${alarm.name}`,
595
+ });
596
+ }
597
+ }
598
+ // Create dashboard
599
+ if (intent.dashboard) {
600
+ const dashboard = new cloudwatch.Dashboard(this, `${resourceName}-dashboard`, {
601
+ dashboardName: `${projectName}-${stage}-${intent.id}`,
602
+ });
603
+ new cdk.CfnOutput(this, `${resourceName}-dashboard-name`, {
604
+ value: dashboard.dashboardName,
605
+ description: `Dashboard name for ${intent.id}`,
606
+ });
607
+ }
608
+ }
609
+ createSecret(intent, projectName, stage) {
610
+ const resourceName = `${projectName}-${intent.id}`;
611
+ if (intent.store === 'secretsmanager') {
612
+ const secret = new secretsmanager.Secret(this, resourceName, {
613
+ secretName: `${projectName}/${stage}/${intent.id}`,
614
+ description: intent.description || `Secret for ${intent.id}`,
615
+ });
616
+ new cdk.CfnOutput(this, `${resourceName}-secret-arn`, {
617
+ value: secret.secretArn,
618
+ description: `Secret ARN for ${intent.id}`,
619
+ });
620
+ }
621
+ else {
622
+ // SSM Parameter Store
623
+ const param = new ssm.StringParameter(this, resourceName, {
624
+ parameterName: `/${projectName}/${stage}/${intent.id}`,
625
+ description: intent.description || `Parameter for ${intent.id}`,
626
+ stringValue: 'PLACEHOLDER — set value after deploy',
627
+ tier: ssm.ParameterTier.STANDARD,
628
+ });
629
+ new cdk.CfnOutput(this, `${resourceName}-param-name`, {
630
+ value: param.parameterName,
631
+ description: `SSM Parameter name for ${intent.id}`,
632
+ });
633
+ }
634
+ }
635
+ createDomain(intent, projectName, stage, api) {
636
+ const resourceName = `${projectName}-${intent.id}`;
637
+ // Certificate: use provided ARN or create a new one
638
+ let certificate;
639
+ if (intent.certificateArn) {
640
+ certificate = acm.Certificate.fromCertificateArn(this, `${resourceName}-cert`, intent.certificateArn);
641
+ }
642
+ else if (intent.hostedZoneId) {
643
+ // Auto-provision with DNS validation via Route53
644
+ const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, `${resourceName}-zone`, {
645
+ hostedZoneId: intent.hostedZoneId,
646
+ zoneName: intent.domain.split('.').slice(-2).join('.'),
647
+ });
648
+ certificate = new acm.Certificate(this, `${resourceName}-cert`, {
649
+ domainName: intent.domain,
650
+ validation: acm.CertificateValidation.fromDns(hostedZone),
651
+ });
652
+ }
653
+ else {
654
+ // Certificate without DNS validation (manual)
655
+ certificate = new acm.Certificate(this, `${resourceName}-cert`, {
656
+ domainName: intent.domain,
657
+ });
658
+ }
659
+ // Custom domain name for API Gateway
660
+ const domainName = new apigatewayv2.DomainName(this, `${resourceName}-domain`, {
661
+ domainName: intent.domain,
662
+ certificate,
663
+ });
664
+ // API mapping
665
+ new apigatewayv2.ApiMapping(this, `${resourceName}-mapping`, {
666
+ api,
667
+ domainName,
668
+ stage: api.defaultStage,
669
+ });
670
+ // Route53 A record if hosted zone provided
671
+ if (intent.hostedZoneId) {
672
+ const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, `${resourceName}-zone-record`, {
673
+ hostedZoneId: intent.hostedZoneId,
674
+ zoneName: intent.domain.split('.').slice(-2).join('.'),
675
+ });
676
+ new route53.ARecord(this, `${resourceName}-a-record`, {
677
+ zone: hostedZone,
678
+ recordName: intent.domain,
679
+ target: route53.RecordTarget.fromAlias(new route53Targets.ApiGatewayv2DomainProperties(domainName.regionalDomainName, domainName.regionalHostedZoneId)),
680
+ });
681
+ }
682
+ new cdk.CfnOutput(this, `${resourceName}-domain-name`, {
683
+ value: intent.domain,
684
+ description: `Custom domain for ${intent.id}`,
685
+ });
686
+ new cdk.CfnOutput(this, `${resourceName}-regional-domain`, {
687
+ value: domainName.regionalDomainName,
688
+ description: `Regional domain name for ${intent.id} (use for DNS CNAME if not using Route53)`,
689
+ });
690
+ }
691
+ /**
692
+ * Free-tier alternative to RDS: create a DynamoDB table.
693
+ * DynamoDB offers 25 GB + 25 RCU/WCU in the always-free tier.
694
+ *
695
+ * Well-Architected: Security — encryption at rest (default)
696
+ * Well-Architected: Reliability — PITR for strict dataSafety
697
+ * Well-Architected: Cost — PAY_PER_REQUEST for unpredictable workloads
698
+ */
699
+ createDynamoDBTable(intent, projectName, stage) {
700
+ // Stable logical ID: 'dynamo-{intent.id}'
701
+ const logicalId = `dynamo-${intent.id}`;
702
+ const table = new dynamodb.Table(this, logicalId, {
703
+ tableName: `${projectName}-${intent.id}-${stage}`,
704
+ partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
705
+ sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
706
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
707
+ removalPolicy: this.toRemovalPolicy(),
708
+ // Well-Architected: Reliability — PITR for strict data safety
709
+ pointInTimeRecovery: this.envConfig.dataSafety === 'strict',
710
+ // Well-Architected: Security — encryption at rest (AWS managed key)
711
+ encryption: dynamodb.TableEncryption.AWS_MANAGED,
712
+ timeToLiveAttribute: 'ttl',
713
+ // Well-Architected: Operational Excellence — contributor insights for medium+
714
+ contributorInsightsEnabled: this.envConfig.lambda.memoryMb >= 512,
715
+ });
716
+ // Well-Architected: Reliability — prevent accidental replacement
717
+ const cfnTable = table.node.defaultChild;
718
+ if (this.dataSafetyConfig.removalPolicy === 'retain') {
719
+ cfnTable.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
720
+ }
721
+ // GSI for common access patterns
722
+ table.addGlobalSecondaryIndex({
723
+ indexName: 'gsi1',
724
+ partitionKey: { name: 'gsi1pk', type: dynamodb.AttributeType.STRING },
725
+ sortKey: { name: 'gsi1sk', type: dynamodb.AttributeType.STRING },
726
+ projectionType: dynamodb.ProjectionType.ALL,
727
+ });
728
+ new cdk.CfnOutput(this, `${logicalId}-table-name`, {
729
+ value: table.tableName,
730
+ description: `DynamoDB table name for ${intent.id} (free-tier)`,
731
+ });
732
+ new cdk.CfnOutput(this, `${logicalId}-table-arn`, {
733
+ value: table.tableArn,
734
+ description: `DynamoDB table ARN for ${intent.id} (free-tier)`,
735
+ });
736
+ }
737
+ /**
738
+ * Free-tier rate limit table using DynamoDB (always-free tier).
739
+ * Used by createDefaultRateLimiter() from @venturekit/runtime.
740
+ */
741
+ createRateLimitTable(projectName, stage) {
742
+ const logicalId = 'RateLimitTable';
743
+ const table = new dynamodb.Table(this, logicalId, {
744
+ tableName: `venturekit-rate-limits`,
745
+ partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
746
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
747
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
748
+ timeToLiveAttribute: 'ttl',
749
+ encryption: dynamodb.TableEncryption.AWS_MANAGED,
750
+ });
751
+ new cdk.CfnOutput(this, `${logicalId}-table-name`, {
752
+ value: table.tableName,
753
+ description: 'DynamoDB rate limit table (free-tier)',
754
+ });
755
+ }
756
+ // ─── Helper methods ────────────────────────────────────────────────
757
+ /**
758
+ * Map ResourceSize to RDS instance type.
759
+ * Uses Graviton (T4G) instances for cost efficiency.
760
+ * Changing size triggers an in-place modify, NOT a replacement.
761
+ */
289
762
  sizeToInstanceType(size) {
290
763
  switch (size) {
764
+ case 'xlarge':
765
+ return ec2.InstanceType.of(ec2.InstanceClass.R6G, ec2.InstanceSize.LARGE);
766
+ case 'large':
767
+ return ec2.InstanceType.of(ec2.InstanceClass.R6G, ec2.InstanceSize.MEDIUM);
291
768
  case 'medium':
292
769
  return ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.SMALL);
293
- case 'large':
294
- return ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MEDIUM);
295
770
  default: // 'small'
296
771
  return ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO);
297
772
  }
298
773
  }
774
+ /**
775
+ * Map ResourceSize to ElastiCache node type.
776
+ * Uses Graviton (T4G) instances for cost efficiency.
777
+ * Changing size triggers an in-place modify, NOT a replacement.
778
+ */
779
+ sizeToCacheNodeType(size) {
780
+ switch (size) {
781
+ case 'xlarge':
782
+ return 'cache.r6g.large';
783
+ case 'large':
784
+ return 'cache.r6g.medium';
785
+ case 'medium':
786
+ return 'cache.t4g.small';
787
+ default: // 'small'
788
+ return 'cache.t4g.micro';
789
+ }
790
+ }
791
+ /**
792
+ * Convert dataSafety config to CDK RemovalPolicy.
793
+ * Well-Architected: Reliability — prevent accidental data loss.
794
+ */
795
+ toRemovalPolicy() {
796
+ return this.dataSafetyConfig.removalPolicy === 'retain'
797
+ ? cdk.RemovalPolicy.RETAIN
798
+ : cdk.RemovalPolicy.DESTROY;
799
+ }
800
+ /**
801
+ * Convert days to CDK logs.RetentionDays enum.
802
+ * Well-Architected: Cost — don't over-retain logs.
803
+ */
804
+ toLogRetention(days) {
805
+ if (days <= 1)
806
+ return logs.RetentionDays.ONE_DAY;
807
+ if (days <= 3)
808
+ return logs.RetentionDays.THREE_DAYS;
809
+ if (days <= 5)
810
+ return logs.RetentionDays.FIVE_DAYS;
811
+ if (days <= 7)
812
+ return logs.RetentionDays.ONE_WEEK;
813
+ if (days <= 14)
814
+ return logs.RetentionDays.TWO_WEEKS;
815
+ if (days <= 30)
816
+ return logs.RetentionDays.ONE_MONTH;
817
+ if (days <= 60)
818
+ return logs.RetentionDays.TWO_MONTHS;
819
+ if (days <= 90)
820
+ return logs.RetentionDays.THREE_MONTHS;
821
+ if (days <= 120)
822
+ return logs.RetentionDays.FOUR_MONTHS;
823
+ if (days <= 150)
824
+ return logs.RetentionDays.FIVE_MONTHS;
825
+ if (days <= 180)
826
+ return logs.RetentionDays.SIX_MONTHS;
827
+ if (days <= 365)
828
+ return logs.RetentionDays.ONE_YEAR;
829
+ return logs.RetentionDays.INFINITE;
830
+ }
831
+ /**
832
+ * Resolve Lambda runtime from preset config.
833
+ */
834
+ toLambdaRuntime() {
835
+ switch (this.envConfig.lambda.runtime) {
836
+ case 'nodejs22.x':
837
+ return lambda.Runtime.NODEJS_22_X;
838
+ case 'nodejs18.x':
839
+ return lambda.Runtime.NODEJS_18_X;
840
+ default:
841
+ return lambda.Runtime.NODEJS_20_X;
842
+ }
843
+ }
844
+ /**
845
+ * Resolve Lambda architecture from preset config.
846
+ */
847
+ toLambdaArchitecture() {
848
+ return this.envConfig.lambda.architecture === 'x86_64'
849
+ ? lambda.Architecture.X86_64
850
+ : lambda.Architecture.ARM_64;
851
+ }
852
+ /**
853
+ * Recursively discover function files under the functions directory.
854
+ * Returns an array of { name, handlerFile } where name is the
855
+ * dash-delimited path (e.g. `order/create-order.ts` → `order-create-order`).
856
+ */
857
+ discoverFunctionFiles(dir, baseDir) {
858
+ const root = baseDir ?? dir;
859
+ const results = [];
860
+ if (!fs.existsSync(dir))
861
+ return results;
862
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
863
+ for (const entry of entries) {
864
+ const fullPath = path.join(dir, entry.name);
865
+ if (entry.isDirectory()) {
866
+ results.push(...this.discoverFunctionFiles(fullPath, root));
867
+ }
868
+ else if (/\.(ts|js)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
869
+ const relativePath = path.relative(root, fullPath);
870
+ const name = relativePath
871
+ .replace(/\.(ts|js)$/, '')
872
+ .replace(/[\\/]/g, '-');
873
+ results.push({ name, handlerFile: fullPath });
874
+ }
875
+ }
876
+ return results;
877
+ }
878
+ // ─── Lambda function factories ─────────────────────────────────────
879
+ /**
880
+ * Create a standalone Lambda function.
881
+ * Naming convention: {project}-{stage}-fn-{name}
882
+ *
883
+ * Well-Architected: Performance — memory/timeout from preset config
884
+ * Well-Architected: Sustainability — ARM64 Graviton by default
885
+ * Well-Architected: Operational Excellence — structured env vars, log retention from preset
886
+ * Well-Architected: Security — X-Ray tracing from preset config
887
+ */
888
+ createStandaloneFunction(name, handlerFile, projectName, stage, projectDir, intent) {
889
+ const functionName = `${projectName}-${stage}-fn-${name}`;
890
+ const resourceId = `fn-${name}`;
891
+ const lambdaConfig = this.envConfig.lambda;
892
+ const fn = new lambda.Function(this, resourceId, {
893
+ functionName,
894
+ runtime: this.toLambdaRuntime(),
895
+ architecture: this.toLambdaArchitecture(),
896
+ handler: 'index.main',
897
+ code: lambda.Code.fromAsset(path.dirname(handlerFile), {
898
+ bundling: {
899
+ image: lambda.Runtime.NODEJS_20_X.bundlingImage,
900
+ command: [
901
+ 'bash', '-c', [
902
+ 'npm install -g esbuild',
903
+ `esbuild ${path.basename(handlerFile)} --bundle --platform=node --target=node20 --outfile=/asset-output/index.js --external:@aws-sdk/*`,
904
+ ].join(' && '),
905
+ ],
906
+ },
907
+ }),
908
+ // Well-Architected: Performance — memory and timeout from intent or preset
909
+ memorySize: intent?.memorySize ?? lambdaConfig.memoryMb,
910
+ timeout: cdk.Duration.seconds(intent?.timeout ?? lambdaConfig.timeoutSec),
911
+ description: intent?.description ?? `Standalone function: ${name}`,
912
+ environment: {
913
+ VENTURE_PROJECT_NAME: projectName,
914
+ VENTURE_STAGE: stage,
915
+ NODE_OPTIONS: '--enable-source-maps',
916
+ ...lambdaConfig.environmentVariables,
917
+ },
918
+ // Well-Architected: Operational Excellence — log retention from preset
919
+ logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
920
+ // Well-Architected: Security — X-Ray tracing from preset
921
+ tracing: lambdaConfig.tracingEnabled
922
+ ? lambda.Tracing.ACTIVE
923
+ : lambda.Tracing.DISABLED,
924
+ // Well-Architected: Performance — reserved concurrency from preset
925
+ reservedConcurrentExecutions: lambdaConfig.reservedConcurrency,
926
+ });
927
+ new cdk.CfnOutput(this, `${resourceId}-name`, {
928
+ value: fn.functionName,
929
+ description: `Lambda function name for ${name}`,
930
+ });
931
+ new cdk.CfnOutput(this, `${resourceId}-arn`, {
932
+ value: fn.functionArn,
933
+ description: `Lambda function ARN for ${name}`,
934
+ });
935
+ }
936
+ /**
937
+ * Create a queue consumer Lambda function wired to an SQS queue.
938
+ * Naming convention: {project}-{stage}-queue-{name}
939
+ *
940
+ * Well-Architected: Reliability — DLQ on consumer queue, encrypted
941
+ * Well-Architected: Performance — batch size and visibility timeout
942
+ */
943
+ createQueueConsumer(name, handlerFile, projectName, stage, projectDir, intent) {
944
+ const functionName = `${projectName}-${stage}-queue-${name}`;
945
+ const resourceId = `queue-consumer-${name}`;
946
+ const lambdaConfig = this.envConfig.lambda;
947
+ // Create the SQS queue with DLQ
948
+ const dlq = new sqs.Queue(this, `${resourceId}-dlq`, {
949
+ queueName: `${projectName}-${stage}-${name}-dlq`,
950
+ retentionPeriod: cdk.Duration.days(14),
951
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
952
+ });
953
+ const queue = new sqs.Queue(this, `${resourceId}-sqs`, {
954
+ queueName: `${projectName}-${stage}-${name}`,
955
+ visibilityTimeout: cdk.Duration.seconds(intent?.visibilityTimeoutSeconds ?? (intent?.timeout ?? 30) * 6),
956
+ retentionPeriod: cdk.Duration.days(intent?.retentionDays ?? 4),
957
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
958
+ deadLetterQueue: {
959
+ queue: dlq,
960
+ maxReceiveCount: 3,
961
+ },
962
+ });
963
+ const fn = new lambda.Function(this, resourceId, {
964
+ functionName,
965
+ runtime: this.toLambdaRuntime(),
966
+ architecture: this.toLambdaArchitecture(),
967
+ handler: 'index.main',
968
+ code: lambda.Code.fromAsset(path.dirname(handlerFile), {
969
+ bundling: {
970
+ image: lambda.Runtime.NODEJS_20_X.bundlingImage,
971
+ command: [
972
+ 'bash', '-c', [
973
+ 'npm install -g esbuild',
974
+ `esbuild ${path.basename(handlerFile)} --bundle --platform=node --target=node20 --outfile=/asset-output/index.js --external:@aws-sdk/*`,
975
+ ].join(' && '),
976
+ ],
977
+ },
978
+ }),
979
+ memorySize: intent?.memorySize ?? lambdaConfig.memoryMb,
980
+ timeout: cdk.Duration.seconds(intent?.timeout ?? lambdaConfig.timeoutSec),
981
+ description: intent?.description ?? `Queue consumer: ${name}`,
982
+ environment: {
983
+ VENTURE_PROJECT_NAME: projectName,
984
+ VENTURE_STAGE: stage,
985
+ NODE_OPTIONS: '--enable-source-maps',
986
+ ...lambdaConfig.environmentVariables,
987
+ },
988
+ logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
989
+ tracing: lambdaConfig.tracingEnabled
990
+ ? lambda.Tracing.ACTIVE
991
+ : lambda.Tracing.DISABLED,
992
+ });
993
+ // Wire Lambda to SQS queue
994
+ fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
995
+ batchSize: intent?.batchSize ?? 10,
996
+ }));
997
+ new cdk.CfnOutput(this, `${resourceId}-fn-name`, {
998
+ value: fn.functionName,
999
+ description: `Queue consumer Lambda for ${name}`,
1000
+ });
1001
+ new cdk.CfnOutput(this, `${resourceId}-queue-url`, {
1002
+ value: queue.queueUrl,
1003
+ description: `SQS queue URL for ${name}`,
1004
+ });
1005
+ }
1006
+ /**
1007
+ * Create a cron/scheduled task Lambda function wired to an EventBridge rule.
1008
+ * Naming convention: {project}-{stage}-cron-{name}
1009
+ *
1010
+ * Well-Architected: Performance — memory/timeout from preset
1011
+ * Well-Architected: Operational Excellence — log retention, tracing from preset
1012
+ */
1013
+ createCronHandler(name, handlerFile, projectName, stage, projectDir, intent) {
1014
+ const functionName = `${projectName}-${stage}-cron-${name}`;
1015
+ const resourceId = `cron-${name}`;
1016
+ const lambdaConfig = this.envConfig.lambda;
1017
+ const fn = new lambda.Function(this, resourceId, {
1018
+ functionName,
1019
+ runtime: this.toLambdaRuntime(),
1020
+ architecture: this.toLambdaArchitecture(),
1021
+ handler: 'index.main',
1022
+ code: lambda.Code.fromAsset(path.dirname(handlerFile), {
1023
+ bundling: {
1024
+ image: lambda.Runtime.NODEJS_20_X.bundlingImage,
1025
+ command: [
1026
+ 'bash', '-c', [
1027
+ 'npm install -g esbuild',
1028
+ `esbuild ${path.basename(handlerFile)} --bundle --platform=node --target=node20 --outfile=/asset-output/index.js --external:@aws-sdk/*`,
1029
+ ].join(' && '),
1030
+ ],
1031
+ },
1032
+ }),
1033
+ memorySize: intent?.memorySize ?? lambdaConfig.memoryMb,
1034
+ timeout: cdk.Duration.seconds(intent?.timeout ?? 60),
1035
+ description: intent?.description ?? `Cron handler: ${name}`,
1036
+ environment: {
1037
+ VENTURE_PROJECT_NAME: projectName,
1038
+ VENTURE_STAGE: stage,
1039
+ NODE_OPTIONS: '--enable-source-maps',
1040
+ ...lambdaConfig.environmentVariables,
1041
+ },
1042
+ logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
1043
+ tracing: lambdaConfig.tracingEnabled
1044
+ ? lambda.Tracing.ACTIVE
1045
+ : lambda.Tracing.DISABLED,
1046
+ });
1047
+ // Wire EventBridge rule if schedule expression is provided
1048
+ if (intent?.schedule) {
1049
+ const scheduleExpr = intent.schedule;
1050
+ const isEnabled = intent.enabled ?? true;
1051
+ let schedule;
1052
+ if ('rate' in scheduleExpr) {
1053
+ schedule = events.Schedule.expression(`rate(${scheduleExpr.rate})`);
1054
+ }
1055
+ else {
1056
+ schedule = events.Schedule.expression(`cron(${scheduleExpr.cron})`);
1057
+ }
1058
+ new events.Rule(this, `${resourceId}-rule`, {
1059
+ ruleName: `${projectName}-${stage}-cron-${name}`,
1060
+ schedule,
1061
+ enabled: isEnabled,
1062
+ targets: [new targets.LambdaFunction(fn)],
1063
+ });
1064
+ }
1065
+ new cdk.CfnOutput(this, `${resourceId}-fn-name`, {
1066
+ value: fn.functionName,
1067
+ description: `Cron handler Lambda for ${name}`,
1068
+ });
1069
+ new cdk.CfnOutput(this, `${resourceId}-fn-arn`, {
1070
+ value: fn.functionArn,
1071
+ description: `Cron handler Lambda ARN for ${name}`,
1072
+ });
1073
+ }
299
1074
  }
300
- exports.VentureStack = VentureStack;
301
1075
  //# sourceMappingURL=stack.js.map