@aws/nx-plugin 0.111.0 → 0.112.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +1 -0
  2. package/generators.json +6 -0
  3. package/package.json +1 -1
  4. package/src/preset/__snapshots__/generator.spec.ts.snap +2 -0
  5. package/src/ts/rdb/__snapshots__/generator.spec.ts.snap +4822 -0
  6. package/src/ts/rdb/files/Dockerfile.template +15 -0
  7. package/src/ts/rdb/files/prisma/models/example.prisma.template +5 -0
  8. package/src/ts/rdb/files/prisma/schema.prisma.template +9 -0
  9. package/src/ts/rdb/files/prisma.config.ts.template +24 -0
  10. package/src/ts/rdb/files/scripts/docker-pull.ts.template +15 -0
  11. package/src/ts/rdb/files/scripts/docker-start.ts.template +81 -0
  12. package/src/ts/rdb/files/scripts/wait-for-db.ts.template +55 -0
  13. package/src/ts/rdb/files/src/constants.ts.template +8 -0
  14. package/src/ts/rdb/files/src/create-db-user-handler.ts.template +128 -0
  15. package/src/ts/rdb/files/src/index.ts.template +2 -0
  16. package/src/ts/rdb/files/src/migration-handler.ts.template +54 -0
  17. package/src/ts/rdb/files/src/prisma.ts.template +104 -0
  18. package/src/ts/rdb/files/src/utils.ts.template +145 -0
  19. package/src/ts/rdb/generator.d.ts +10 -0
  20. package/src/ts/rdb/generator.js +206 -0
  21. package/src/ts/rdb/generator.js.map +1 -0
  22. package/src/ts/rdb/schema.d.ts +17 -0
  23. package/src/ts/rdb/schema.json +73 -0
  24. package/src/utils/rdb-constructs/files/cdk/app/dbs/__nameKebabCase__.ts.template +43 -0
  25. package/src/utils/rdb-constructs/files/cdk/core/rdb/aurora.ts.template +380 -0
  26. package/src/utils/rdb-constructs/files/terraform/app/dbs/__nameKebabCase__/__nameKebabCase__.tf.template +732 -0
  27. package/src/utils/rdb-constructs/files/terraform/core/rdb/aurora/aurora.tf.template +742 -0
  28. package/src/utils/rdb-constructs/rdb-constructs.d.ts +23 -0
  29. package/src/utils/rdb-constructs/rdb-constructs.js +59 -0
  30. package/src/utils/rdb-constructs/rdb-constructs.js.map +1 -0
  31. package/src/utils/versions.d.ts +12 -1
  32. package/src/utils/versions.js +11 -0
  33. package/src/utils/versions.js.map +1 -1
@@ -0,0 +1,4822 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL > packages/db/prisma/models/example.prisma 1`] = `
4
+ "model ExampleTable {
5
+ id Int @id @default(autoincrement())
6
+ column1 String
7
+ column2 String
8
+ }
9
+ "
10
+ `;
11
+
12
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL > packages/db/prisma/schema.prisma 1`] = `
13
+ "generator client {
14
+ provider = "prisma-client"
15
+ output = "../generated/prisma"
16
+ }
17
+
18
+ datasource db {
19
+ provider = "mysql"
20
+ }
21
+
22
+ "
23
+ `;
24
+
25
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL > packages/db/scripts/docker-pull.ts 1`] = `
26
+ "import Docker from 'dockerode';
27
+ import { promisify } from 'util';
28
+
29
+ const docker = new Docker();
30
+ const image = process.argv[2];
31
+
32
+ try {
33
+ await docker.getImage(image).inspect();
34
+ } catch (e) {
35
+ if ((e as { statusCode?: number }).statusCode !== 404) throw e;
36
+ const stream = (await promisify(docker.pull.bind(docker))(
37
+ image,
38
+ )) as NodeJS.ReadableStream;
39
+ await promisify(docker.modem.followProgress.bind(docker.modem))(stream);
40
+ }
41
+ "
42
+ `;
43
+
44
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL > packages/db/scripts/docker-start.ts 1`] = `
45
+ "import Docker from 'dockerode';
46
+
47
+ const [containerName, image, hostPort, dbName, dbPassword] =
48
+ process.argv.slice(2);
49
+
50
+ const docker = new Docker();
51
+
52
+ let container: Docker.Container;
53
+
54
+ try {
55
+ const existing = docker.getContainer(containerName);
56
+ const info = await existing.inspect();
57
+ container = existing;
58
+ if (!info.State.Running) {
59
+ await container.start();
60
+ }
61
+ } catch (e) {
62
+ if ((e as { statusCode?: number }).statusCode !== 404) throw e;
63
+ container = await docker.createContainer({
64
+ name: containerName,
65
+ Image: image,
66
+ Env: [\`MYSQL_DATABASE=\${dbName}\`, \`MYSQL_ROOT_PASSWORD=\${dbPassword}\`],
67
+ ExposedPorts: { '3306/tcp': {} },
68
+ HostConfig: {
69
+ AutoRemove: true,
70
+ PortBindings: { '3306/tcp': [{ HostPort: hostPort }] },
71
+ Binds: [\`\${containerName}-data:/var/lib/mysql\`],
72
+ },
73
+ });
74
+ await container.start();
75
+ }
76
+
77
+ const stream = await container.attach({
78
+ stream: true,
79
+ stdout: true,
80
+ stderr: true,
81
+ });
82
+ container.modem.demuxStream(stream, process.stdout, process.stderr);
83
+
84
+ let exiting = false;
85
+
86
+ async function cleanup() {
87
+ if (exiting) return;
88
+ exiting = true;
89
+ try {
90
+ await container.stop();
91
+ } catch (e) {
92
+ if ((e as { statusCode?: number }).statusCode !== 404) console.error(e);
93
+ }
94
+ process.exit(0);
95
+ }
96
+
97
+ process.on('SIGTERM', () => void cleanup());
98
+ process.on('SIGINT', () => void cleanup());
99
+ process.on('SIGHUP', () => void cleanup());
100
+
101
+ const { StatusCode } = await container.wait();
102
+ if (!exiting) process.exit(StatusCode);
103
+ "
104
+ `;
105
+
106
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL > packages/db/scripts/wait-for-db.ts 1`] = `
107
+ "import { createConnection, Connection } from 'mariadb';
108
+
109
+ const noop = () => undefined;
110
+
111
+ const [portArg, dbArg, userArg, passwordArg] = process.argv.slice(2);
112
+ const timeoutAt = Date.now() + 60000;
113
+
114
+ while (Date.now() < timeoutAt) {
115
+ let conn: Connection | undefined;
116
+ try {
117
+ conn = await createConnection({
118
+ host: 'localhost',
119
+ port: parseInt(portArg),
120
+ user: userArg,
121
+ password: passwordArg,
122
+ database: dbArg,
123
+ connectTimeout: 500,
124
+ allowPublicKeyRetrieval: true,
125
+ });
126
+ await conn.end();
127
+ console.log('Database is ready.');
128
+ process.exit(0);
129
+ } catch {
130
+ console.log('Database is not ready.');
131
+ await conn?.end().catch(noop);
132
+ }
133
+ await new Promise((r) => setTimeout(r, 200));
134
+ }
135
+
136
+ throw new Error(\`Timed out waiting for mysql on port \${portArg}\`);
137
+ "
138
+ `;
139
+
140
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL > packages/db/src/constants.ts 1`] = `
141
+ "export const DB_PACKAGE_NAME = 'Db';
142
+
143
+ // Local development connection details (used when SERVE_LOCAL=true, see serve-local Nx target)
144
+ export const LOCAL_DB_PORT = 3306;
145
+ export const LOCAL_DB_HOST = 'localhost';
146
+ export const LOCAL_DB_NAME = 'database_name';
147
+ export const LOCAL_DB_USER = 'root';
148
+ export const LOCAL_DB_PASSWORD = 'password';
149
+ "
150
+ `;
151
+
152
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL > packages/db/src/create-db-user-handler.ts 1`] = `
153
+ "import type { CloudFormationCustomResourceEvent } from 'aws-lambda';
154
+ import { randomBytes } from 'node:crypto';
155
+ import { createPool, type PoolConnection } from 'mariadb';
156
+ import { getDatabaseSecret, withConnectionRetry } from './utils.js';
157
+
158
+ type OnEventResult = {
159
+ PhysicalResourceId: string;
160
+ Data?: Record<string, unknown>;
161
+ };
162
+
163
+ const physicalResourceIdPrefix = 'db-user:';
164
+
165
+ const resolveDbUser = (physicalResourceId?: string): string =>
166
+ physicalResourceId?.startsWith(physicalResourceIdPrefix)
167
+ ? physicalResourceId.slice(physicalResourceIdPrefix.length)
168
+ : \`db_\${randomBytes(8).toString('hex')}\`;
169
+
170
+ const ensureDatabaseUser = async (dbUser: string): Promise<void> => {
171
+ const { dbname, username, password, host, port } = await getDatabaseSecret();
172
+ const pool = createPool({
173
+ host,
174
+ port,
175
+ database: dbname,
176
+ user: username,
177
+ password,
178
+ ssl: {
179
+ rejectUnauthorized: true,
180
+ },
181
+ connectionLimit: 1,
182
+ multipleStatements: true,
183
+ connectTimeout: 10_000,
184
+ });
185
+
186
+ const quotedDbName = pool.escapeId(dbname);
187
+ const quotedUser = pool.escape(dbUser);
188
+ const quotedHost = pool.escape('%');
189
+
190
+ let connection: PoolConnection | undefined;
191
+
192
+ try {
193
+ connection = await pool.getConnection();
194
+ await connection.query(
195
+ [
196
+ \`CREATE USER IF NOT EXISTS \${quotedUser}@\${quotedHost} IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS'\`,
197
+ \`ALTER USER \${quotedUser}@\${quotedHost} IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS' REQUIRE SSL\`,
198
+ \`GRANT ALL PRIVILEGES ON \${quotedDbName}.* TO \${quotedUser}@\${quotedHost}\`,
199
+ ].join(';\\n'),
200
+ );
201
+ } finally {
202
+ await connection?.release();
203
+ await pool.end();
204
+ }
205
+ };
206
+
207
+ export const handler = async (
208
+ event: CloudFormationCustomResourceEvent,
209
+ ): Promise<OnEventResult> => {
210
+ const dbUser = resolveDbUser(
211
+ 'PhysicalResourceId' in event ? event.PhysicalResourceId : undefined,
212
+ );
213
+
214
+ if (event.RequestType !== 'Delete') {
215
+ await withConnectionRetry(() => ensureDatabaseUser(dbUser));
216
+ }
217
+
218
+ return {
219
+ PhysicalResourceId: \`\${physicalResourceIdPrefix}\${dbUser}\`,
220
+ Data: {
221
+ dbUser,
222
+ },
223
+ };
224
+ };
225
+ "
226
+ `;
227
+
228
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL > packages/db/src/index.ts 1`] = `
229
+ "export { DB_PACKAGE_NAME } from './constants.js';
230
+ export { getPrisma } from './prisma.js';
231
+ "
232
+ `;
233
+
234
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL > packages/db/src/migration-handler.ts 1`] = `
235
+ "import { execFile } from 'node:child_process';
236
+ import { promisify } from 'node:util';
237
+ import { getDatabaseSecret, withConnectionRetry } from './utils.js';
238
+
239
+ const buildDatabaseUrl = async (): Promise<string> => {
240
+ const { host, port, dbname, username, password } = await getDatabaseSecret();
241
+
242
+ return (
243
+ \`mysql://\${encodeURIComponent(username)}\` +
244
+ \`:\${encodeURIComponent(password)}\` +
245
+ \`@\${host}:\${port}/\${dbname}\` +
246
+ \`?sslaccept=strict\`
247
+ );
248
+ };
249
+
250
+ export const handler = async () => {
251
+ await withConnectionRetry(async () => {
252
+ const databaseUrl = await buildDatabaseUrl();
253
+ await promisify(execFile)('npx', ['prisma', 'migrate', 'deploy'], {
254
+ cwd: __dirname,
255
+ env: {
256
+ ...process.env,
257
+ DATABASE_URL: databaseUrl,
258
+ },
259
+ });
260
+ });
261
+ };
262
+ "
263
+ `;
264
+
265
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL > packages/db/src/prisma.ts 1`] = `
266
+ "import { PrismaMariaDb } from '@prisma/adapter-mariadb';
267
+ import { Signer } from '@aws-sdk/rds-signer';
268
+ import { PrismaClient } from '../generated/prisma/client.js';
269
+ import {
270
+ DB_PACKAGE_NAME,
271
+ LOCAL_DB_HOST,
272
+ LOCAL_DB_NAME,
273
+ LOCAL_DB_PASSWORD,
274
+ LOCAL_DB_PORT,
275
+ LOCAL_DB_USER,
276
+ } from './constants.js';
277
+ import { getDatabaseConfig } from './utils.js';
278
+
279
+ export const getPrisma = async (): Promise<PrismaClient> => {
280
+ if (process.env.SERVE_LOCAL === 'true') {
281
+ const adapter = new PrismaMariaDb({
282
+ host: LOCAL_DB_HOST,
283
+ port: LOCAL_DB_PORT,
284
+ database: LOCAL_DB_NAME,
285
+ user: LOCAL_DB_USER,
286
+ password: LOCAL_DB_PASSWORD,
287
+ allowPublicKeyRetrieval: true,
288
+ });
289
+ return new PrismaClient({ adapter });
290
+ }
291
+
292
+ const { hostname, port, database, dbUser, region } =
293
+ await getDatabaseConfig(DB_PACKAGE_NAME);
294
+ const iamAuthToken = await new Signer({
295
+ hostname,
296
+ port,
297
+ region,
298
+ username: dbUser,
299
+ }).getAuthToken();
300
+
301
+ const adapter = new PrismaMariaDb({
302
+ host: hostname,
303
+ port,
304
+ database,
305
+ user: dbUser,
306
+ password: iamAuthToken,
307
+ ssl: {
308
+ rejectUnauthorized: true,
309
+ },
310
+ });
311
+
312
+ return new PrismaClient({ adapter });
313
+ };
314
+ "
315
+ `;
316
+
317
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL > packages/db/src/utils.ts 1`] = `
318
+ "import { getAppConfig } from '@aws-lambda-powertools/parameters/appconfig';
319
+ import {
320
+ GetSecretValueCommand,
321
+ SecretsManagerClient,
322
+ } from '@aws-sdk/client-secrets-manager';
323
+
324
+ export type DatabaseConfig = {
325
+ hostname: string;
326
+ port: number;
327
+ database: string;
328
+ adminUser: string;
329
+ dbUser: string;
330
+ region: string;
331
+ };
332
+
333
+ export type DatabaseSecret = {
334
+ dbname: string;
335
+ username: string;
336
+ password: string;
337
+ host: string;
338
+ port: number;
339
+ };
340
+
341
+ const databaseConfigPromises: Record<string, Promise<DatabaseConfig>> = {};
342
+
343
+ const getSecretValue = async (secretArn: string): Promise<string> => {
344
+ const client = new SecretsManagerClient();
345
+ const data = await client.send(
346
+ new GetSecretValueCommand({
347
+ SecretId: secretArn,
348
+ }),
349
+ );
350
+
351
+ if (!data.SecretString) {
352
+ throw new Error('Database secret does not contain SecretString.');
353
+ }
354
+
355
+ return data.SecretString;
356
+ };
357
+
358
+ export const getDatabaseSecret = async (): Promise<DatabaseSecret> => {
359
+ const secretArn = process.env.DATABASE_SECRET_ARN;
360
+
361
+ if (!secretArn) {
362
+ throw new Error(
363
+ 'Missing required environment variable DATABASE_SECRET_ARN.',
364
+ );
365
+ }
366
+
367
+ return JSON.parse(await getSecretValue(secretArn)) as DatabaseSecret;
368
+ };
369
+
370
+ const loadDatabaseConfig = async (
371
+ runtimeConfigKey: string,
372
+ ): Promise<DatabaseConfig> => {
373
+ const appId = process.env.RUNTIME_CONFIG_APP_ID;
374
+
375
+ if (!appId) {
376
+ throw new Error(
377
+ 'Missing required environment variable RUNTIME_CONFIG_APP_ID.',
378
+ );
379
+ }
380
+
381
+ const config = await getAppConfig<{
382
+ [key: string]: DatabaseConfig | undefined;
383
+ }>('database', {
384
+ application: appId,
385
+ environment: 'default',
386
+ transform: 'json',
387
+ });
388
+
389
+ const databaseConfig = config?.[runtimeConfigKey];
390
+
391
+ if (!databaseConfig) {
392
+ throw new Error(\`RuntimeConfig is missing database.\${runtimeConfigKey}.\`);
393
+ }
394
+
395
+ return databaseConfig;
396
+ };
397
+
398
+ export const getDatabaseConfig = (
399
+ runtimeConfigKey: string,
400
+ ): Promise<DatabaseConfig> => {
401
+ databaseConfigPromises[runtimeConfigKey] ??=
402
+ loadDatabaseConfig(runtimeConfigKey);
403
+ return databaseConfigPromises[runtimeConfigKey];
404
+ };
405
+
406
+ // Aurora's writer endpoint is briefly unreachable from within the VPC right
407
+ // after the cluster reports ready, and IAM policy attachments can take tens
408
+ // of seconds to propagate before RDS accepts an IAM auth token. Both surface
409
+ // as errors that resolve on retry.
410
+ const transientErrorPatterns = [
411
+ 'ETIMEDOUT',
412
+ 'ECONNREFUSED',
413
+ 'ENOTFOUND',
414
+ 'P1000', // Prisma: authentication failed
415
+ 'ER_ACCESS_DENIED_ERROR', // MySQL: access denied
416
+ ];
417
+
418
+ const asString = (value: unknown): string => {
419
+ if (value == null) return '';
420
+ if (typeof value === 'string') return value;
421
+ if (Buffer.isBuffer(value)) return value.toString('utf-8');
422
+ return String(value);
423
+ };
424
+
425
+ export const isTransientConnectionError = (error: unknown): boolean => {
426
+ const err = error as {
427
+ message?: unknown;
428
+ code?: unknown;
429
+ stderr?: unknown;
430
+ stdout?: unknown;
431
+ };
432
+ const haystack = [
433
+ error instanceof Error ? error.message : asString(err?.message),
434
+ asString(err?.code),
435
+ asString(err?.stderr),
436
+ asString(err?.stdout),
437
+ ].join('\\n');
438
+ return transientErrorPatterns.some((p) => haystack.includes(p));
439
+ };
440
+
441
+ export const withConnectionRetry = async <T>(
442
+ fn: () => Promise<T>,
443
+ {
444
+ maxAttempts = 6,
445
+ delayMs = 10_000,
446
+ }: { maxAttempts?: number; delayMs?: number } = {},
447
+ ): Promise<T> => {
448
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
449
+ try {
450
+ return await fn();
451
+ } catch (error) {
452
+ if (attempt === maxAttempts || !isTransientConnectionError(error)) {
453
+ throw error;
454
+ }
455
+ const wait = delayMs * attempt;
456
+ console.log(
457
+ \`Transient connection error (attempt \${attempt}/\${maxAttempts}), retrying in \${wait}ms: \${
458
+ error instanceof Error ? error.message : String(error)
459
+ }\`,
460
+ );
461
+ await new Promise((resolve) => setTimeout(resolve, wait));
462
+ }
463
+ }
464
+ throw new Error('unreachable');
465
+ };
466
+ "
467
+ `;
468
+
469
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL 1`] = `
470
+ "import { CfnOutput, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib';
471
+ import { CustomResource } from 'aws-cdk-lib';
472
+ import { IConnectable, IVpc, Port, SubnetType } from 'aws-cdk-lib/aws-ec2';
473
+ import { IGrantable, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
474
+ import { Key } from 'aws-cdk-lib/aws-kms';
475
+ import { Platform } from 'aws-cdk-lib/aws-ecr-assets';
476
+ import {
477
+ Architecture,
478
+ Code,
479
+ DockerImageCode,
480
+ DockerImageFunction,
481
+ Function,
482
+ Runtime,
483
+ Tracing,
484
+ } from 'aws-cdk-lib/aws-lambda';
485
+ import {
486
+ AuroraMysqlEngineVersion,
487
+ AuroraPostgresEngineVersion,
488
+ ClusterInstance,
489
+ Credentials,
490
+ DatabaseCluster,
491
+ DatabaseClusterEngine,
492
+ DatabaseClusterProps,
493
+ DatabaseProxy,
494
+ DefaultAuthScheme,
495
+ IClusterEngine,
496
+ } from 'aws-cdk-lib/aws-rds';
497
+ import { Provider } from 'aws-cdk-lib/custom-resources';
498
+ import { Trigger } from 'aws-cdk-lib/triggers';
499
+ import { Construct } from 'constructs';
500
+ import { RuntimeConfig } from '../runtime-config.js';
501
+
502
+ export type AuroraDatabaseEngineVersion =
503
+ | AuroraMysqlEngineVersion
504
+ | AuroraPostgresEngineVersion;
505
+
506
+ export interface AuroraDatabaseEngine {
507
+ /**
508
+ * Discriminant identifying the engine type.
509
+ */
510
+ readonly type: 'mysql' | 'postgres';
511
+
512
+ /**
513
+ * Default Aurora engine version used when one is not explicitly provided.
514
+ */
515
+ readonly defaultVersion: AuroraDatabaseEngineVersion;
516
+
517
+ /**
518
+ * Builds the CDK cluster engine for the provided engine version.
519
+ */
520
+ clusterEngine(version: AuroraDatabaseEngineVersion): IClusterEngine;
521
+ }
522
+
523
+ export class AuroraDatabaseEngines {
524
+ public static mysql({
525
+ defaultVersion = AuroraMysqlEngineVersion.VER_3_12_0,
526
+ }: {
527
+ defaultVersion?: AuroraMysqlEngineVersion;
528
+ }): AuroraDatabaseEngine {
529
+ return {
530
+ type: 'mysql',
531
+ defaultVersion,
532
+ clusterEngine: (version) =>
533
+ DatabaseClusterEngine.auroraMysql({
534
+ version: version as AuroraMysqlEngineVersion,
535
+ }),
536
+ };
537
+ }
538
+
539
+ public static postgres({
540
+ defaultVersion = AuroraPostgresEngineVersion.VER_17_7,
541
+ }: {
542
+ defaultVersion?: AuroraPostgresEngineVersion;
543
+ }): AuroraDatabaseEngine {
544
+ return {
545
+ type: 'postgres',
546
+ defaultVersion,
547
+ clusterEngine: (version) =>
548
+ DatabaseClusterEngine.auroraPostgres({
549
+ version: version as AuroraPostgresEngineVersion,
550
+ }),
551
+ };
552
+ }
553
+ }
554
+
555
+ type _AuroraDatabaseProps = Omit<
556
+ DatabaseClusterProps,
557
+ | 'credentials'
558
+ | 'defaultDatabaseName'
559
+ | 'engine'
560
+ | 'iamAuthentication'
561
+ | 'instanceProps'
562
+ | 'writer'
563
+ >;
564
+
565
+ export interface AuroraDatabaseProps extends _AuroraDatabaseProps {
566
+ /**
567
+ * VPC where the Aurora cluster will be deployed.
568
+ */
569
+ readonly vpc: IVpc;
570
+
571
+ /**
572
+ * Aurora engine preset used to build the cluster.
573
+ */
574
+ readonly engine: AuroraDatabaseEngine;
575
+
576
+ /**
577
+ * The engine version to deploy.
578
+ *
579
+ * @default - engine.defaultVersion
580
+ */
581
+ readonly engineVersion?: AuroraDatabaseEngineVersion;
582
+
583
+ /**
584
+ * Admin username used when generating credentials.
585
+ */
586
+ readonly adminUser: string;
587
+
588
+ /**
589
+ * The initial database created in the cluster.
590
+ */
591
+ readonly databaseName: string;
592
+
593
+ /**
594
+ * RuntimeConfig key used under the \`database\` namespace.
595
+ */
596
+ readonly runtimeConfigKey: string;
597
+
598
+ /**
599
+ * Migration bundle used to create a migration handler Lambda.
600
+ */
601
+ readonly migrationBundleDir: string;
602
+
603
+ /**
604
+ * Bundle used to create or reconcile the application database user.
605
+ */
606
+ readonly createDbUserBundleDir: string;
607
+
608
+ /**
609
+ * Writer instance for the Aurora cluster.
610
+ */
611
+ readonly writer?: DatabaseClusterProps['writer'];
612
+
613
+ /**
614
+ * Whether to provision an RDS Proxy in front of the Aurora cluster.
615
+ *
616
+ * @default true
617
+ */
618
+ readonly enableRdsProxy?: boolean;
619
+
620
+ /**
621
+ * Whether to enable automatic credential rotation for the admin secret.
622
+ *
623
+ * @default true
624
+ */
625
+ readonly enableCredentialRotation?: boolean;
626
+
627
+ /**
628
+ * Whether to enable deletion protection on the Aurora cluster.
629
+ *
630
+ * @default true
631
+ */
632
+ readonly deletionProtection?: boolean;
633
+
634
+ /**
635
+ * Removal policy applied to the Aurora cluster.
636
+ *
637
+ * @default RemovalPolicy.RETAIN
638
+ */
639
+ readonly removalPolicy?: RemovalPolicy;
640
+
641
+ /**
642
+ * Whether to enable automatic key rotation on the KMS key used to encrypt the Aurora cluster and its credentials secret.
643
+ *
644
+ * @default true
645
+ */
646
+ readonly enableKeyRotation?: boolean;
647
+ }
648
+
649
+ /**
650
+ * Reusable Aurora database construct that supports different Aurora engines
651
+ * through typed engine presets.
652
+ */
653
+ export abstract class AuroraDatabase extends Construct {
654
+ public readonly cluster: DatabaseCluster;
655
+ public readonly proxy?: DatabaseProxy;
656
+ private readonly adminUser: string;
657
+ private readonly dbUser: string;
658
+
659
+ constructor(
660
+ scope: Construct,
661
+ id: string,
662
+ {
663
+ vpc,
664
+ adminUser,
665
+ databaseName,
666
+ runtimeConfigKey,
667
+ migrationBundleDir,
668
+ createDbUserBundleDir,
669
+ vpcSubnets,
670
+ writer,
671
+ enableRdsProxy = true,
672
+ enableCredentialRotation = true,
673
+ deletionProtection = true,
674
+ removalPolicy = RemovalPolicy.RETAIN,
675
+ enableKeyRotation = true,
676
+ engine,
677
+ engineVersion,
678
+ ...clusterProps
679
+ }: AuroraDatabaseProps,
680
+ ) {
681
+ super(scope, id);
682
+
683
+ this.adminUser = adminUser;
684
+
685
+ const key = new Key(this, 'EncryptionKey', { enableKeyRotation });
686
+ this.cluster = new DatabaseCluster(this, 'DatabaseCluster', {
687
+ ...clusterProps,
688
+ vpc,
689
+ vpcSubnets,
690
+ engine: engine.clusterEngine(engineVersion ?? engine.defaultVersion),
691
+ writer: writer ?? ClusterInstance.serverlessV2('writer'),
692
+ credentials: Credentials.fromGeneratedSecret(adminUser, {
693
+ encryptionKey: key,
694
+ }),
695
+ iamAuthentication: true,
696
+ monitoringInterval: Duration.seconds(5),
697
+ defaultDatabaseName: databaseName,
698
+ storageEncrypted: true,
699
+ storageEncryptionKey: key,
700
+ deletionProtection,
701
+ removalPolicy,
702
+ });
703
+
704
+ if (enableCredentialRotation) {
705
+ this.cluster.addRotationSingleUser({
706
+ automaticallyAfter: Duration.days(30),
707
+ vpcSubnets: {
708
+ subnetType: SubnetType.PRIVATE_WITH_EGRESS,
709
+ },
710
+ });
711
+ }
712
+
713
+ const proxyRole = enableRdsProxy
714
+ ? new Role(this, 'DatabaseProxyRole', {
715
+ assumedBy: new ServicePrincipal('rds.amazonaws.com'),
716
+ })
717
+ : undefined;
718
+
719
+ if (enableRdsProxy) {
720
+ this.proxy = this.cluster.addProxy('DatabaseProxy', {
721
+ vpc,
722
+ vpcSubnets,
723
+ defaultAuthScheme: DefaultAuthScheme.IAM_AUTH,
724
+ role: proxyRole,
725
+ });
726
+ }
727
+
728
+ const databaseHostname =
729
+ this.proxy?.endpoint ?? this.cluster.clusterEndpoint.hostname;
730
+ const databasePort = this.cluster.clusterEndpoint.port;
731
+
732
+ const createDbUserHandler = new Function(this, 'CreateDbUserHandler', {
733
+ code: Code.fromAsset(createDbUserBundleDir),
734
+ handler: 'index.handler',
735
+ runtime: Runtime.NODEJS_LATEST,
736
+ timeout: Duration.minutes(5),
737
+ tracing: Tracing.ACTIVE,
738
+ vpc,
739
+ vpcSubnets: {
740
+ subnetType: SubnetType.PRIVATE_WITH_EGRESS,
741
+ },
742
+ environment: {
743
+ DATABASE_SECRET_ARN: this.cluster.secret!.secretArn,
744
+ NODE_EXTRA_CA_CERTS: '/var/runtime/ca-cert.pem',
745
+ },
746
+ architecture: Architecture.ARM_64,
747
+ });
748
+
749
+ this.cluster.connections.allowDefaultPortFrom(
750
+ createDbUserHandler,
751
+ 'Allow the create-db-user handler to connect to the database',
752
+ );
753
+ this.grantSecretRead(createDbUserHandler);
754
+
755
+ const createDbUserProvider = new Provider(this, 'CreateDbUserProvider', {
756
+ onEventHandler: createDbUserHandler,
757
+ });
758
+
759
+ const createDbUserResource = new CustomResource(
760
+ this,
761
+ 'CreateDbUserResource',
762
+ {
763
+ serviceToken: createDbUserProvider.serviceToken,
764
+ properties: {
765
+ clusterIdentifier: this.cluster.clusterIdentifier,
766
+ },
767
+ },
768
+ );
769
+ createDbUserResource.node.addDependency(this.cluster);
770
+ this.dbUser = createDbUserResource.getAttString('dbUser');
771
+ if (this.proxy && proxyRole) {
772
+ this.cluster.grantConnect(proxyRole, this.dbUser);
773
+ }
774
+
775
+ const rc = RuntimeConfig.ensure(this);
776
+ rc.set('database', runtimeConfigKey, {
777
+ hostname: databaseHostname,
778
+ port: databasePort,
779
+ database: databaseName,
780
+ adminUser: this.adminUser,
781
+ dbUser: this.dbUser,
782
+ region: Stack.of(this).region,
783
+ });
784
+
785
+ const migrationHandler = new DockerImageFunction(this, 'MigrationHandler', {
786
+ code: DockerImageCode.fromImageAsset(migrationBundleDir, {
787
+ platform: Platform.LINUX_ARM64,
788
+ }),
789
+ memorySize: 1024,
790
+ timeout: Duration.minutes(5),
791
+ tracing: Tracing.ACTIVE,
792
+ vpc,
793
+ environment:
794
+ engine.type === 'mysql'
795
+ ? {
796
+ DATABASE_SECRET_ARN: this.cluster.secret!.secretArn,
797
+ }
798
+ : {
799
+ HOSTNAME: databaseHostname,
800
+ DATABASE: databaseName,
801
+ PORT: databasePort.toString(),
802
+ DBUSER: this.dbUser,
803
+ },
804
+ architecture: Architecture.ARM_64,
805
+ });
806
+
807
+ if (engine.type === 'mysql') {
808
+ this.cluster.connections.allowDefaultPortFrom(migrationHandler);
809
+ this.grantSecretRead(migrationHandler);
810
+ } else {
811
+ this.allowDefaultPortFrom(migrationHandler);
812
+ this.grantConnect(migrationHandler);
813
+ }
814
+
815
+ const trigger = new Trigger(this, 'MigrationTrigger', {
816
+ handler: migrationHandler,
817
+ });
818
+ trigger.node.addDependency(createDbUserResource);
819
+
820
+ new CfnOutput(this, 'ClusterEndpoint', {
821
+ value: this.cluster.clusterEndpoint.hostname,
822
+ });
823
+
824
+ if (this.proxy?.endpoint) {
825
+ new CfnOutput(this, 'ProxyEndpoint', {
826
+ value: this.proxy?.endpoint,
827
+ });
828
+ }
829
+ }
830
+
831
+ public allowDefaultPortFrom(other: IConnectable, description?: string): void {
832
+ if (this.proxy) {
833
+ this.proxy.connections.allowFrom(
834
+ other,
835
+ Port.tcp(this.cluster.clusterEndpoint.port),
836
+ description,
837
+ );
838
+ } else {
839
+ this.cluster.connections.allowDefaultPortFrom(other, description);
840
+ }
841
+ }
842
+
843
+ public grantSecretRead(grantee: IGrantable) {
844
+ return this.cluster.secret!.grantRead(grantee);
845
+ }
846
+
847
+ public grantConnect(grantee: IGrantable) {
848
+ return this.proxy
849
+ ? this.proxy.grantConnect(grantee, this.dbUser)
850
+ : this.cluster.grantConnect(grantee, this.dbUser);
851
+ }
852
+ }
853
+ "
854
+ `;
855
+
856
+ exports[`ts#rdb generator > should add mysql prisma dependencies when engine is MySQL 2`] = `
857
+ "import { defineConfig } from 'prisma/config';
858
+
859
+ const getDatabaseUrl = async () => {
860
+ if (process.env.SERVE_LOCAL === 'true') {
861
+ const {
862
+ LOCAL_DB_HOST,
863
+ LOCAL_DB_NAME,
864
+ LOCAL_DB_PASSWORD,
865
+ LOCAL_DB_PORT,
866
+ LOCAL_DB_USER,
867
+ } = await import('./src/constants.js');
868
+ return \`mysql://\${LOCAL_DB_USER}:\${LOCAL_DB_PASSWORD}@\${LOCAL_DB_HOST}:\${LOCAL_DB_PORT}/\${LOCAL_DB_NAME}\`;
869
+ }
870
+ return process.env.DATABASE_URL;
871
+ };
872
+
873
+ export default defineConfig({
874
+ schema: 'prisma/',
875
+ migrations: {
876
+ path: 'prisma/migrations',
877
+ },
878
+ datasource: {
879
+ url: await getDatabaseUrl(),
880
+ },
881
+ });
882
+ "
883
+ `;
884
+
885
+ exports[`ts#rdb generator > should generate terraform modules when iacProvider is Terraform 1`] = `
886
+ "# Core Aurora module
887
+ # This module creates an Aurora cluster and a generated admin secret.
888
+
889
+ terraform {
890
+ required_version = ">= 1.0"
891
+
892
+ required_providers {
893
+ aws = {
894
+ source = "hashicorp/aws"
895
+ version = "~> 6.33"
896
+ }
897
+ null = {
898
+ source = "hashicorp/null"
899
+ version = ">= 3.0"
900
+ }
901
+ random = {
902
+ source = "hashicorp/random"
903
+ version = ">= 3.0"
904
+ }
905
+ }
906
+ }
907
+
908
+ data "aws_caller_identity" "current" {}
909
+ data "aws_partition" "current" {}
910
+ data "aws_region" "current" {}
911
+
912
+ data "aws_iam_policy" "enhanced_monitoring" {
913
+ arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole"
914
+ }
915
+
916
+ variable "name" {
917
+ description = "Base name applied to Aurora resources."
918
+ type = string
919
+ }
920
+
921
+ variable "vpc_id" {
922
+ description = "VPC where the Aurora cluster will be deployed."
923
+ type = string
924
+ }
925
+
926
+ variable "subnet_ids" {
927
+ description = "Subnet IDs for the Aurora DB subnet group and RDS Proxy. These subnets do not need outbound egress — private isolated subnets are fine."
928
+ type = list(string)
929
+ }
930
+
931
+ variable "lambda_subnet_ids" {
932
+ description = "Subnet IDs for the credential-rotation Lambda. Must have outbound egress to AWS service endpoints (e.g. Secrets Manager) — use private subnets with a NAT gateway."
933
+ type = list(string)
934
+ }
935
+
936
+ variable "engine" {
937
+ description = "Aurora engine to use."
938
+ type = string
939
+ default = "aurora-postgresql"
940
+
941
+ validation {
942
+ condition = contains(["aurora-postgresql", "aurora-mysql"], var.engine)
943
+ error_message = "engine must be aurora-postgresql or aurora-mysql."
944
+ }
945
+ }
946
+
947
+ variable "engine_version" {
948
+ description = "Aurora engine version."
949
+ type = string
950
+ default = null
951
+ }
952
+
953
+ variable "database_name" {
954
+ description = "Initial database created in the cluster."
955
+ type = string
956
+ }
957
+
958
+ variable "admin_user" {
959
+ description = "Admin username stored in the generated Secrets Manager secret."
960
+ type = string
961
+ }
962
+
963
+ variable "port" {
964
+ description = "Database port for the selected Aurora engine."
965
+ type = number
966
+ default = null
967
+ }
968
+
969
+ variable "serverless_min_capacity" {
970
+ description = "Minimum Aurora Serverless v2 ACUs."
971
+ type = number
972
+ default = 0.5
973
+ }
974
+
975
+ variable "serverless_max_capacity" {
976
+ description = "Maximum Aurora Serverless v2 ACUs."
977
+ type = number
978
+ default = 4
979
+ }
980
+
981
+ variable "instance_count" {
982
+ description = "Number of Aurora instances to create."
983
+ type = number
984
+ default = 1
985
+ }
986
+
987
+ variable "deletion_protection" {
988
+ description = "Whether deletion protection is enabled."
989
+ type = bool
990
+ default = true
991
+ }
992
+
993
+ variable "skip_final_snapshot" {
994
+ description = "Whether to skip the final snapshot on deletion."
995
+ type = bool
996
+ default = false
997
+ }
998
+
999
+ variable "enable_rds_proxy" {
1000
+ description = "Whether to provision an RDS Proxy in front of the Aurora cluster."
1001
+ type = bool
1002
+ default = true
1003
+ }
1004
+
1005
+ variable "enable_credential_rotation" {
1006
+ description = "Whether to enable automatic credential rotation for the admin secret."
1007
+ type = bool
1008
+ default = true
1009
+ }
1010
+
1011
+ variable "enable_cloudwatch_logs" {
1012
+ description = "Whether to export Aurora engine logs to CloudWatch. PostgreSQL also enables verbose statement logging."
1013
+ type = bool
1014
+ default = false
1015
+ }
1016
+
1017
+ variable "enable_performance_insights" {
1018
+ description = "Whether to enable Performance Insights on Aurora cluster instances."
1019
+ type = bool
1020
+ default = true
1021
+ }
1022
+
1023
+ variable "performance_insights_retention_period" {
1024
+ description = "Retention period, in days, for Performance Insights data when enabled."
1025
+ type = number
1026
+ default = 7
1027
+ }
1028
+
1029
+ variable "enable_backup" {
1030
+ description = "Whether to provision an AWS Backup plan for the Aurora cluster."
1031
+ type = bool
1032
+ default = false
1033
+ }
1034
+
1035
+ variable "enable_key_rotation" {
1036
+ description = "Whether to enable automatic key rotation on the KMS key used to encrypt the Aurora cluster and its credentials secret."
1037
+ type = bool
1038
+ default = true
1039
+ }
1040
+
1041
+ variable "tags" {
1042
+ description = "Tags to apply to all resources."
1043
+ type = map(string)
1044
+ default = {}
1045
+ }
1046
+
1047
+ locals {
1048
+ default_port = var.engine == "aurora-mysql" ? 3306 : 5432
1049
+ default_engine_version = var.engine == "aurora-mysql" ? "8.0.mysql_aurora.3.12.0" : "17.7"
1050
+ parameter_group_family = var.engine == "aurora-mysql" ? "aurora-mysql8.0" : "aurora-postgresql\${split(".", coalesce(var.engine_version, local.default_engine_version))[0]}"
1051
+ }
1052
+
1053
+ resource "aws_rds_cluster_parameter_group" "database" {
1054
+ count = var.enable_cloudwatch_logs ? 1 : 0
1055
+
1056
+ name_prefix = "\${var.name}-"
1057
+ family = local.parameter_group_family
1058
+ description = "Parameter group for \${var.name} Aurora cluster"
1059
+
1060
+ dynamic "parameter" {
1061
+ for_each = var.engine == "aurora-postgresql" ? [1] : []
1062
+ content {
1063
+ name = "log_statement"
1064
+ value = "all"
1065
+ }
1066
+ }
1067
+
1068
+ tags = var.tags
1069
+ }
1070
+
1071
+ resource "aws_kms_key" "database" {
1072
+ description = "KMS key for Aurora cluster \${var.name}"
1073
+ enable_key_rotation = var.enable_key_rotation
1074
+
1075
+ policy = jsonencode({
1076
+ Version = "2012-10-17"
1077
+ Statement = [
1078
+ {
1079
+ Sid = "EnableRootAccess"
1080
+ Effect = "Allow"
1081
+ Principal = {
1082
+ AWS = "arn:\${data.aws_partition.current.partition}:iam::\${data.aws_caller_identity.current.account_id}:root"
1083
+ }
1084
+ Action = "kms:*"
1085
+ Resource = "*"
1086
+ },
1087
+ {
1088
+ Sid = "AllowRDSService"
1089
+ Effect = "Allow"
1090
+ Principal = {
1091
+ Service = "rds.amazonaws.com"
1092
+ }
1093
+ Action = [
1094
+ "kms:Encrypt",
1095
+ "kms:Decrypt",
1096
+ "kms:ReEncrypt*",
1097
+ "kms:GenerateDataKey*",
1098
+ "kms:DescribeKey",
1099
+ "kms:CreateGrant"
1100
+ ]
1101
+ Resource = "*"
1102
+ }
1103
+ ]
1104
+ })
1105
+
1106
+ tags = merge(var.tags, {
1107
+ Name = "\${var.name}-aurora"
1108
+ })
1109
+ }
1110
+
1111
+ resource "aws_iam_role" "enhanced_monitoring" {
1112
+ name_prefix = "\${var.name}-aurora-monitoring-"
1113
+
1114
+ assume_role_policy = jsonencode({
1115
+ Version = "2012-10-17"
1116
+ Statement = [
1117
+ {
1118
+ Effect = "Allow"
1119
+ Principal = {
1120
+ Service = "monitoring.rds.amazonaws.com"
1121
+ }
1122
+ Action = "sts:AssumeRole"
1123
+ }
1124
+ ]
1125
+ })
1126
+
1127
+ tags = merge(var.tags, {
1128
+ Name = "\${var.name}-aurora-monitoring"
1129
+ })
1130
+ }
1131
+
1132
+ resource "aws_iam_role_policy_attachment" "enhanced_monitoring" {
1133
+ role = aws_iam_role.enhanced_monitoring.name
1134
+ policy_arn = data.aws_iam_policy.enhanced_monitoring.arn
1135
+ }
1136
+
1137
+ resource "aws_security_group" "database" {
1138
+ name_prefix = "\${var.name}-aurora-"
1139
+ description = "Security group for Aurora cluster \${var.name}"
1140
+ vpc_id = var.vpc_id
1141
+
1142
+ tags = merge(var.tags, {
1143
+ Name = "\${var.name}-aurora"
1144
+ })
1145
+ }
1146
+
1147
+ resource "aws_db_subnet_group" "database" {
1148
+ name = "\${var.name}-aurora"
1149
+ subnet_ids = var.subnet_ids
1150
+
1151
+ tags = merge(var.tags, {
1152
+ Name = "\${var.name}-aurora"
1153
+ })
1154
+ }
1155
+
1156
+ resource "random_password" "master_password" {
1157
+ length = 32
1158
+ special = true
1159
+ override_special = "!#$%&*()-_=+[]{}<>:?"
1160
+ }
1161
+
1162
+ resource "aws_secretsmanager_secret" "credentials" {
1163
+ #checkov:skip=CKV2_AWS_57:Rotation is configured via aws_cloudformation_stack.credentials_rotation using the AWS::SecretsManager-2024-09-16 transform; Checkov cannot resolve rotation schedules created by CloudFormation transforms
1164
+ name_prefix = "\${var.name}-aurora-credentials-"
1165
+ kms_key_id = aws_kms_key.database.arn
1166
+
1167
+ tags = merge(var.tags, {
1168
+ Name = "\${var.name}-aurora-credentials"
1169
+ })
1170
+ }
1171
+
1172
+ resource "aws_secretsmanager_secret_version" "credentials" {
1173
+ secret_id = aws_secretsmanager_secret.credentials.id
1174
+ secret_string = jsonencode({
1175
+ engine = var.engine == "aurora-mysql" ? "mysql" : "postgres"
1176
+ username = var.admin_user
1177
+ password = random_password.master_password.result
1178
+ host = aws_rds_cluster.database.endpoint
1179
+ port = aws_rds_cluster.database.port
1180
+ dbname = var.database_name
1181
+ dbClusterIdentifier = aws_rds_cluster.database.cluster_identifier
1182
+ })
1183
+ }
1184
+
1185
+ resource "aws_rds_cluster" "database" {
1186
+ #checkov:skip=CKV2_AWS_27:Query logging can be enabled with enable_cloudwatch_logs; this module defaults to CDK-equivalent behavior unless logging is requested
1187
+ #checkov:skip=CKV_AWS_139:Deletion protection is enabled but checkov is unable to detect it correctly
1188
+ cluster_identifier = "\${var.name}-aurora"
1189
+ engine = var.engine
1190
+ engine_version = coalesce(var.engine_version, local.default_engine_version)
1191
+ database_name = var.database_name
1192
+ master_username = var.admin_user
1193
+ master_password = random_password.master_password.result
1194
+ db_subnet_group_name = aws_db_subnet_group.database.name
1195
+ db_cluster_parameter_group_name = var.enable_cloudwatch_logs ? aws_rds_cluster_parameter_group.database[0].name : null
1196
+ vpc_security_group_ids = [aws_security_group.database.id]
1197
+ port = coalesce(var.port, local.default_port)
1198
+ storage_encrypted = true
1199
+ kms_key_id = aws_kms_key.database.arn
1200
+ deletion_protection = var.deletion_protection
1201
+ skip_final_snapshot = var.skip_final_snapshot
1202
+ final_snapshot_identifier = var.skip_final_snapshot ? null : "\${var.name}-aurora-final-snapshot"
1203
+ iam_database_authentication_enabled = true
1204
+ monitoring_interval = 5
1205
+ monitoring_role_arn = aws_iam_role.enhanced_monitoring.arn
1206
+ copy_tags_to_snapshot = true
1207
+ enabled_cloudwatch_logs_exports = var.enable_cloudwatch_logs ? (var.engine == "aurora-mysql" ? ["audit", "error", "general", "slowquery"] : ["postgresql"]) : null
1208
+
1209
+ serverlessv2_scaling_configuration {
1210
+ min_capacity = var.serverless_min_capacity
1211
+ max_capacity = var.serverless_max_capacity
1212
+ }
1213
+
1214
+ tags = merge(var.tags, {
1215
+ Name = "\${var.name}-aurora"
1216
+ })
1217
+ }
1218
+
1219
+ resource "aws_rds_cluster_instance" "database" {
1220
+ count = var.instance_count
1221
+
1222
+ identifier = "\${var.name}-aurora-\${count.index + 1}"
1223
+ cluster_identifier = aws_rds_cluster.database.id
1224
+ instance_class = "db.serverless"
1225
+ engine = aws_rds_cluster.database.engine
1226
+ engine_version = aws_rds_cluster.database.engine_version
1227
+ db_subnet_group_name = aws_db_subnet_group.database.name
1228
+
1229
+ auto_minor_version_upgrade = true
1230
+ monitoring_interval = 5
1231
+ monitoring_role_arn = aws_iam_role.enhanced_monitoring.arn
1232
+ performance_insights_enabled = var.enable_performance_insights
1233
+ performance_insights_kms_key_id = var.enable_performance_insights ? aws_kms_key.database.arn : null
1234
+ performance_insights_retention_period = var.enable_performance_insights ? var.performance_insights_retention_period : null
1235
+
1236
+ tags = merge(var.tags, {
1237
+ Name = "\${var.name}-aurora-\${count.index + 1}"
1238
+ })
1239
+ }
1240
+
1241
+ resource "aws_security_group" "rotation" {
1242
+ #checkov:skip=CKV2_AWS_5:Security group is attached to the hosted rotation Lambda created by AWS::SecretsManager::RotationSchedule; Checkov cannot resolve resources created by the CloudFormation transform
1243
+ count = var.enable_credential_rotation ? 1 : 0
1244
+ name_prefix = "\${var.name}-aurora-rotation-"
1245
+ description = "Security group for Aurora secret rotation \${var.name}"
1246
+ vpc_id = var.vpc_id
1247
+
1248
+ tags = merge(var.tags, {
1249
+ Name = "\${var.name}-aurora-rotation"
1250
+ })
1251
+ }
1252
+
1253
+ resource "aws_vpc_security_group_egress_rule" "rotation_to_database" {
1254
+ count = var.enable_credential_rotation ? 1 : 0
1255
+ security_group_id = aws_security_group.rotation[0].id
1256
+ referenced_security_group_id = aws_security_group.database.id
1257
+ from_port = coalesce(var.port, local.default_port)
1258
+ to_port = coalesce(var.port, local.default_port)
1259
+ ip_protocol = "tcp"
1260
+ description = "Allow outbound database traffic from secret rotation Lambda"
1261
+ }
1262
+
1263
+ resource "aws_vpc_security_group_ingress_rule" "rotation_to_database" {
1264
+ count = var.enable_credential_rotation ? 1 : 0
1265
+ security_group_id = aws_security_group.database.id
1266
+ referenced_security_group_id = aws_security_group.rotation[0].id
1267
+ from_port = coalesce(var.port, local.default_port)
1268
+ to_port = coalesce(var.port, local.default_port)
1269
+ ip_protocol = "tcp"
1270
+ description = "Allow inbound database traffic from secret rotation Lambda"
1271
+ }
1272
+
1273
+ resource "aws_cloudformation_stack" "credentials_rotation" {
1274
+ #checkov:skip=CKV_AWS_124:SNS event notifications are not required for the credentials rotation stack
1275
+ count = var.enable_credential_rotation ? 1 : 0
1276
+ name = "\${var.name}-aurora-credentials-rotation"
1277
+
1278
+ capabilities = ["CAPABILITY_IAM", "CAPABILITY_AUTO_EXPAND"]
1279
+
1280
+ template_body = jsonencode({
1281
+ AWSTemplateFormatVersion = "2010-09-09"
1282
+ Transform = "AWS::SecretsManager-2024-09-16"
1283
+ Resources = {
1284
+ CredentialsRotationSchedule = {
1285
+ Type = "AWS::SecretsManager::RotationSchedule"
1286
+ Properties = {
1287
+ SecretId = aws_secretsmanager_secret.credentials.arn
1288
+ HostedRotationLambda = {
1289
+ RotationType = var.engine == "aurora-mysql" ? "MySQLSingleUser" : "PostgreSQLSingleUser"
1290
+ RotationLambdaName = "\${var.name}-aurora-credentials-rotation"
1291
+ VpcSecurityGroupIds = aws_security_group.rotation[0].id
1292
+ VpcSubnetIds = join(",", var.lambda_subnet_ids)
1293
+ }
1294
+ RotationRules = {
1295
+ AutomaticallyAfterDays = 30
1296
+ }
1297
+ }
1298
+ }
1299
+ }
1300
+ })
1301
+
1302
+ depends_on = [
1303
+ aws_secretsmanager_secret_version.credentials,
1304
+ aws_rds_cluster_instance.database,
1305
+ aws_vpc_security_group_egress_rule.rotation_to_database,
1306
+ aws_vpc_security_group_ingress_rule.rotation_to_database,
1307
+ ]
1308
+ }
1309
+
1310
+ resource "aws_iam_role" "proxy" {
1311
+ count = var.enable_rds_proxy ? 1 : 0
1312
+
1313
+ name_prefix = "\${var.name}-aurora-proxy-"
1314
+
1315
+ assume_role_policy = jsonencode({
1316
+ Version = "2012-10-17"
1317
+ Statement = [
1318
+ {
1319
+ Effect = "Allow"
1320
+ Principal = {
1321
+ Service = "rds.amazonaws.com"
1322
+ }
1323
+ Action = "sts:AssumeRole"
1324
+ }
1325
+ ]
1326
+ })
1327
+
1328
+ tags = merge(var.tags, {
1329
+ Name = "\${var.name}-aurora-proxy"
1330
+ })
1331
+ }
1332
+
1333
+ resource "aws_iam_role_policy" "proxy_secret_access" {
1334
+ count = var.enable_rds_proxy ? 1 : 0
1335
+ role = aws_iam_role.proxy[0].name
1336
+
1337
+ policy = jsonencode({
1338
+ Version = "2012-10-17"
1339
+ Statement = [
1340
+ {
1341
+ Effect = "Allow"
1342
+ Action = ["secretsmanager:GetSecretValue"]
1343
+ Resource = [aws_secretsmanager_secret.credentials.arn]
1344
+ },
1345
+ {
1346
+ Effect = "Allow"
1347
+ Action = ["kms:Decrypt"]
1348
+ Resource = [aws_kms_key.database.arn]
1349
+ }
1350
+ ]
1351
+ })
1352
+ }
1353
+
1354
+ resource "aws_security_group" "proxy" {
1355
+ #checkov:skip=CKV2_AWS_5:Security group is attached to aws_db_proxy.aurora; Checkov cannot resolve count-conditional cross-resource references
1356
+ count = var.enable_rds_proxy ? 1 : 0
1357
+
1358
+ name_prefix = "\${var.name}-aurora-proxy-"
1359
+ description = "Security group for Aurora proxy \${var.name}"
1360
+ vpc_id = var.vpc_id
1361
+
1362
+ tags = merge(var.tags, {
1363
+ Name = "\${var.name}-aurora-proxy"
1364
+ })
1365
+ }
1366
+
1367
+ resource "aws_db_proxy" "aurora" {
1368
+ count = var.enable_rds_proxy ? 1 : 0
1369
+
1370
+ name = "\${var.name}-proxy"
1371
+ default_auth_scheme = "IAM_AUTH"
1372
+ engine_family = var.engine == "aurora-mysql" ? "MYSQL" : "POSTGRESQL"
1373
+ role_arn = aws_iam_role.proxy[0].arn
1374
+ vpc_subnet_ids = var.subnet_ids
1375
+ vpc_security_group_ids = [aws_security_group.proxy[0].id]
1376
+ require_tls = true
1377
+
1378
+ auth {
1379
+ auth_scheme = "SECRETS"
1380
+ iam_auth = "REQUIRED"
1381
+ secret_arn = aws_secretsmanager_secret.credentials.arn
1382
+ }
1383
+
1384
+ depends_on = [
1385
+ aws_iam_role_policy.proxy_secret_access,
1386
+ aws_secretsmanager_secret_version.credentials,
1387
+ ]
1388
+ }
1389
+
1390
+ resource "aws_db_proxy_default_target_group" "default" {
1391
+ count = var.enable_rds_proxy ? 1 : 0
1392
+
1393
+ db_proxy_name = aws_db_proxy.aurora[0].name
1394
+ }
1395
+
1396
+ resource "aws_db_proxy_target" "default" {
1397
+ count = var.enable_rds_proxy ? 1 : 0
1398
+
1399
+ db_proxy_name = aws_db_proxy.aurora[0].name
1400
+ target_group_name = aws_db_proxy_default_target_group.default[0].name
1401
+ db_cluster_identifier = aws_rds_cluster.database.cluster_identifier
1402
+ }
1403
+
1404
+ resource "null_resource" "proxy_target_ready" {
1405
+ count = var.enable_rds_proxy ? 1 : 0
1406
+
1407
+ triggers = {
1408
+ db_proxy_name = aws_db_proxy.aurora[0].name
1409
+ target_group_name = aws_db_proxy_default_target_group.default[0].name
1410
+ db_cluster_identifier = aws_rds_cluster.database.cluster_identifier
1411
+ }
1412
+
1413
+ provisioner "local-exec" {
1414
+ command = <<-EOT
1415
+ start_time=$(date +%s)
1416
+ deadline=$((start_time + 600))
1417
+ while true; do
1418
+ target_state=$(aws rds describe-db-proxy-targets \\
1419
+ --region \${data.aws_region.current.region} \\
1420
+ --db-proxy-name \${self.triggers.db_proxy_name} \\
1421
+ --target-group-name \${self.triggers.target_group_name} \\
1422
+ --query "Targets[?Type=='RDS_INSTANCE'] | [0].TargetHealth.State" \\
1423
+ --output text)
1424
+
1425
+ if [ "$target_state" = "AVAILABLE" ]; then
1426
+ exit 0
1427
+ fi
1428
+
1429
+ current_time=$(date +%s)
1430
+ if [ "$current_time" -ge "$deadline" ]; then
1431
+ echo "RDS Proxy target \${self.triggers.db_cluster_identifier} did not become AVAILABLE within 10 minutes. Last state: $target_state"
1432
+ exit 1
1433
+ fi
1434
+
1435
+ sleep 10
1436
+ done
1437
+ EOT
1438
+ }
1439
+
1440
+ depends_on = [
1441
+ aws_db_proxy_target.default,
1442
+ aws_rds_cluster_instance.database,
1443
+ aws_vpc_security_group_ingress_rule.proxy_to_database,
1444
+ aws_vpc_security_group_egress_rule.proxy_to_database
1445
+ ]
1446
+ }
1447
+
1448
+ resource "null_resource" "cluster_ready" {
1449
+ count = var.enable_rds_proxy ? 0 : 1
1450
+
1451
+ triggers = {
1452
+ cluster_identifier = aws_rds_cluster.database.cluster_identifier
1453
+ instance_ids = join(",", aws_rds_cluster_instance.database[*].id)
1454
+ }
1455
+
1456
+ depends_on = [aws_rds_cluster_instance.database]
1457
+ }
1458
+
1459
+ resource "aws_vpc_security_group_ingress_rule" "proxy_to_database" {
1460
+ count = var.enable_rds_proxy ? 1 : 0
1461
+
1462
+ security_group_id = aws_security_group.database.id
1463
+ referenced_security_group_id = aws_security_group.proxy[0].id
1464
+ from_port = coalesce(var.port, local.default_port)
1465
+ to_port = coalesce(var.port, local.default_port)
1466
+ ip_protocol = "tcp"
1467
+ description = "Allow inbound database traffic from RDS Proxy"
1468
+ }
1469
+
1470
+ resource "aws_vpc_security_group_egress_rule" "proxy_to_database" {
1471
+ count = var.enable_rds_proxy ? 1 : 0
1472
+
1473
+ security_group_id = aws_security_group.proxy[0].id
1474
+ referenced_security_group_id = aws_security_group.database.id
1475
+ from_port = coalesce(var.port, local.default_port)
1476
+ to_port = coalesce(var.port, local.default_port)
1477
+ ip_protocol = "tcp"
1478
+ description = "Allow outbound database traffic from RDS Proxy to Aurora cluster"
1479
+ }
1480
+
1481
+ data "aws_iam_policy" "backup" {
1482
+ count = var.enable_backup ? 1 : 0
1483
+
1484
+ arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup"
1485
+ }
1486
+
1487
+ resource "aws_iam_role" "backup" {
1488
+ count = var.enable_backup ? 1 : 0
1489
+
1490
+ name_prefix = "\${var.name}-backup-"
1491
+
1492
+ assume_role_policy = jsonencode({
1493
+ Version = "2012-10-17"
1494
+ Statement = [
1495
+ {
1496
+ Effect = "Allow"
1497
+ Principal = { Service = "backup.amazonaws.com" }
1498
+ Action = "sts:AssumeRole"
1499
+ }
1500
+ ]
1501
+ })
1502
+
1503
+ tags = var.tags
1504
+ }
1505
+
1506
+ resource "aws_iam_role_policy_attachment" "backup" {
1507
+ count = var.enable_backup ? 1 : 0
1508
+
1509
+ role = aws_iam_role.backup[0].name
1510
+ policy_arn = data.aws_iam_policy.backup[0].arn
1511
+ }
1512
+
1513
+ resource "aws_backup_vault" "database" {
1514
+ count = var.enable_backup ? 1 : 0
1515
+
1516
+ name = "\${var.name}-aurora-backup"
1517
+ kms_key_arn = aws_kms_key.database.arn
1518
+ tags = var.tags
1519
+ }
1520
+
1521
+ resource "aws_backup_plan" "database" {
1522
+ count = var.enable_backup ? 1 : 0
1523
+
1524
+ name = "\${var.name}-aurora-backup"
1525
+
1526
+ rule {
1527
+ rule_name = "daily-backup"
1528
+ target_vault_name = aws_backup_vault.database[0].name
1529
+ schedule = "cron(0 5 ? * * *)"
1530
+
1531
+ lifecycle {
1532
+ delete_after = 35
1533
+ }
1534
+ }
1535
+
1536
+ tags = var.tags
1537
+ }
1538
+
1539
+ resource "aws_backup_selection" "database" {
1540
+ count = var.enable_backup ? 1 : 0
1541
+
1542
+ name = "\${var.name}-aurora"
1543
+ plan_id = aws_backup_plan.database[0].id
1544
+ iam_role_arn = aws_iam_role.backup[0].arn
1545
+
1546
+ resources = [aws_rds_cluster.database.arn]
1547
+ }
1548
+
1549
+ output "cluster_arn" {
1550
+ description = "ARN of the Aurora cluster."
1551
+ value = aws_rds_cluster.database.arn
1552
+ }
1553
+
1554
+ output "cluster_resource_id" {
1555
+ description = "Resource ID of the Aurora cluster used for IAM database authentication."
1556
+ value = aws_rds_cluster.database.cluster_resource_id
1557
+ }
1558
+
1559
+ output "proxy_resource_id" {
1560
+ description = "Resource identifier of the RDS Proxy (prx-XXXX), used for IAM rds-db:connect policies when connecting through the proxy. Null if RDS Proxy is disabled."
1561
+ value = var.enable_rds_proxy ? split(":", aws_db_proxy.aurora[0].arn)[6] : null
1562
+ }
1563
+
1564
+ output "cluster_endpoint" {
1565
+ description = "Writer endpoint of the Aurora cluster or proxy."
1566
+ value = var.enable_rds_proxy ? aws_db_proxy.aurora[0].endpoint : aws_rds_cluster.database.endpoint
1567
+ }
1568
+
1569
+ output "reader_endpoint" {
1570
+ description = "Reader endpoint of the Aurora cluster."
1571
+ value = aws_rds_cluster.database.reader_endpoint
1572
+ }
1573
+
1574
+ output "cluster_port" {
1575
+ description = "Port exposed by the Aurora cluster."
1576
+ value = aws_rds_cluster.database.port
1577
+ }
1578
+
1579
+ output "security_group_id" {
1580
+ description = "Security group protecting the Aurora proxy or cluster."
1581
+ value = var.enable_rds_proxy ? aws_security_group.proxy[0].id : aws_security_group.database.id
1582
+ }
1583
+
1584
+ output "database_security_group_id" {
1585
+ description = "Security group protecting the Aurora cluster directly."
1586
+ value = aws_security_group.database.id
1587
+ }
1588
+
1589
+ output "proxy_role_name" {
1590
+ description = "Name of the IAM role used by the RDS Proxy, or null if proxy is disabled."
1591
+ value = var.enable_rds_proxy ? aws_iam_role.proxy[0].name : null
1592
+ }
1593
+
1594
+ output "secret_arn" {
1595
+ description = "ARN of the generated admin credentials secret."
1596
+ value = aws_secretsmanager_secret.credentials.arn
1597
+ }
1598
+
1599
+ output "kms_key_arn" {
1600
+ description = "ARN of the KMS key protecting Aurora and the generated secret."
1601
+ value = aws_kms_key.database.arn
1602
+ }
1603
+
1604
+ output "database_host" {
1605
+ description = "Hostname of the Aurora cluster writer endpoint or proxy endpoint."
1606
+ value = var.enable_rds_proxy ? aws_db_proxy.aurora[0].endpoint : aws_rds_cluster.database.endpoint
1607
+ }
1608
+
1609
+ output "database_ready" {
1610
+ description = "Dependency marker that is resolved after the selected database connection path is ready."
1611
+ value = true
1612
+
1613
+ depends_on = [
1614
+ null_resource.proxy_target_ready,
1615
+ null_resource.cluster_ready
1616
+ ]
1617
+ }
1618
+
1619
+ output "database_name" {
1620
+ description = "Initial database created in the cluster."
1621
+ value = var.database_name
1622
+ }
1623
+
1624
+ output "admin_user" {
1625
+ description = "Admin username stored in the generated secret."
1626
+ value = jsondecode(aws_secretsmanager_secret_version.credentials.secret_string).username
1627
+ }
1628
+ "
1629
+ `;
1630
+
1631
+ exports[`ts#rdb generator > should generate terraform modules when iacProvider is Terraform 2`] = `
1632
+ "terraform {
1633
+ required_version = ">= 1.0"
1634
+
1635
+ required_providers {
1636
+ archive = {
1637
+ source = "hashicorp/archive"
1638
+ version = "~> 2.5"
1639
+ }
1640
+ aws = {
1641
+ source = "hashicorp/aws"
1642
+ version = "~> 6.33"
1643
+ }
1644
+ null = {
1645
+ source = "hashicorp/null"
1646
+ version = ">= 3.0"
1647
+ }
1648
+ random = {
1649
+ source = "hashicorp/random"
1650
+ version = ">= 3.0"
1651
+ }
1652
+ }
1653
+ }
1654
+
1655
+ variable "vpc_id" {
1656
+ description = "VPC where the Aurora cluster will be deployed."
1657
+ type = string
1658
+ }
1659
+
1660
+ variable "database_subnet_ids" {
1661
+ description = "Subnet IDs for the Aurora cluster and RDS Proxy. Private isolated subnets are fine — no outbound egress required."
1662
+ type = list(string)
1663
+ }
1664
+
1665
+ variable "lambda_subnet_ids" {
1666
+ description = "Subnet IDs for the create-db-user and migration Lambda functions. Must have outbound egress to AWS service endpoints (e.g. Secrets Manager) — use private subnets with a NAT gateway."
1667
+ type = list(string)
1668
+ }
1669
+
1670
+ variable "engine_version" {
1671
+ description = "Aurora engine version."
1672
+ type = string
1673
+ default = null
1674
+ }
1675
+
1676
+ variable "port" {
1677
+ description = "Database port for the selected Aurora engine."
1678
+ type = number
1679
+ default = null
1680
+ }
1681
+
1682
+ variable "serverless_min_capacity" {
1683
+ description = "Minimum Aurora Serverless v2 ACUs."
1684
+ type = number
1685
+ default = 0.5
1686
+ }
1687
+
1688
+ variable "serverless_max_capacity" {
1689
+ description = "Maximum Aurora Serverless v2 ACUs."
1690
+ type = number
1691
+ default = 4
1692
+ }
1693
+
1694
+ variable "instance_count" {
1695
+ description = "Number of Aurora instances to create."
1696
+ type = number
1697
+ default = 1
1698
+ }
1699
+
1700
+ variable "deletion_protection" {
1701
+ description = "Whether deletion protection is enabled."
1702
+ type = bool
1703
+ default = true
1704
+ }
1705
+
1706
+ variable "skip_final_snapshot" {
1707
+ description = "Whether to skip the final snapshot on deletion."
1708
+ type = bool
1709
+ default = false
1710
+ }
1711
+
1712
+ variable "enable_rds_proxy" {
1713
+ description = "Whether to provision an RDS Proxy in front of the Aurora cluster."
1714
+ type = bool
1715
+ default = true
1716
+ }
1717
+
1718
+ variable "enable_credential_rotation" {
1719
+ description = "Whether to enable automatic credential rotation for the admin secret."
1720
+ type = bool
1721
+ default = true
1722
+ }
1723
+
1724
+ variable "enable_cloudwatch_logs" {
1725
+ description = "Whether to export Aurora engine logs to CloudWatch. PostgreSQL also enables verbose statement logging."
1726
+ type = bool
1727
+ default = false
1728
+ }
1729
+
1730
+ variable "enable_performance_insights" {
1731
+ description = "Whether to enable Performance Insights on Aurora cluster instances."
1732
+ type = bool
1733
+ default = true
1734
+ }
1735
+
1736
+ variable "performance_insights_retention_period" {
1737
+ description = "Retention period, in days, for Performance Insights data when enabled."
1738
+ type = number
1739
+ default = 7
1740
+ }
1741
+
1742
+ variable "enable_backup" {
1743
+ description = "Whether to provision an AWS Backup plan for the Aurora cluster."
1744
+ type = bool
1745
+ default = false
1746
+ }
1747
+
1748
+ variable "tags" {
1749
+ description = "Tags to apply to all resources."
1750
+ type = map(string)
1751
+ default = {}
1752
+ }
1753
+
1754
+ variable "docker_image_tag" {
1755
+ description = "Docker image tag for the migration handler. Defaults to the tag built by the Nx docker target."
1756
+ type = string
1757
+ default = "proj-db-migration:latest"
1758
+ }
1759
+
1760
+ variable "asset_bucket_name" {
1761
+ description = "Name of the shared asset S3 bucket used to stage the Lambda deployment zip. Instantiate the \`core/asset-bucket\` module once per deployment and pass its \`bucket_name\` output here."
1762
+ type = string
1763
+ }
1764
+
1765
+ data "aws_region" "current" {}
1766
+ data "aws_caller_identity" "current" {}
1767
+
1768
+ resource "random_string" "suffix" {
1769
+ length = 8
1770
+ special = false
1771
+ upper = false
1772
+ }
1773
+
1774
+ locals {
1775
+ create_db_user_bundle_path = "\${path.module}/../../../../../../../dist/packages/db/bundle/create-db-user"
1776
+ migration_function_name = "db-migration-\${random_string.suffix.result}"
1777
+ create_db_user_function_name = "db-create-db-user-\${random_string.suffix.result}"
1778
+ database_runtime_user = "db_\${random_string.suffix.result}"
1779
+ }
1780
+
1781
+ module "aurora" {
1782
+ source = "../../../core/rdb/aurora"
1783
+ #checkov:skip=CKV_AWS_139:Deletion protection is enabled but checkov is unable to detect it correctly
1784
+ name = "db"
1785
+ vpc_id = var.vpc_id
1786
+ subnet_ids = var.database_subnet_ids
1787
+ lambda_subnet_ids = var.lambda_subnet_ids
1788
+ engine = "aurora-postgresql"
1789
+ engine_version = var.engine_version
1790
+ database_name = "database_name"
1791
+ admin_user = "databaseUser"
1792
+ port = var.port
1793
+ serverless_min_capacity = var.serverless_min_capacity
1794
+ serverless_max_capacity = var.serverless_max_capacity
1795
+ instance_count = var.instance_count
1796
+ deletion_protection = var.deletion_protection
1797
+ skip_final_snapshot = var.skip_final_snapshot
1798
+ enable_rds_proxy = var.enable_rds_proxy
1799
+ enable_credential_rotation = var.enable_credential_rotation
1800
+ enable_cloudwatch_logs = var.enable_cloudwatch_logs
1801
+ enable_performance_insights = var.enable_performance_insights
1802
+ performance_insights_retention_period = var.performance_insights_retention_period
1803
+ enable_backup = var.enable_backup
1804
+ tags = var.tags
1805
+ }
1806
+
1807
+ resource "aws_iam_role_policy" "proxy_db_user_connect" {
1808
+ count = var.enable_rds_proxy ? 1 : 0
1809
+
1810
+ role = module.aurora.proxy_role_name
1811
+
1812
+ policy = jsonencode({
1813
+ Version = "2012-10-17"
1814
+ Statement = [
1815
+ {
1816
+ Effect = "Allow"
1817
+ Action = ["rds-db:connect"]
1818
+ Resource = [
1819
+ "arn:aws:rds-db:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:dbuser:\${module.aurora.cluster_resource_id}/\${local.database_runtime_user}"
1820
+ ]
1821
+ }
1822
+ ]
1823
+ })
1824
+ }
1825
+
1826
+ data "archive_file" "create_db_user_zip" {
1827
+ type = "zip"
1828
+ source_dir = local.create_db_user_bundle_path
1829
+ output_path = "\${path.module}/../../../../../../../dist/packages/common/terraform/dbs/db/create-db-user.zip"
1830
+ }
1831
+
1832
+ resource "aws_s3_object" "create_db_user_zip" {
1833
+ bucket = var.asset_bucket_name
1834
+ key = "dbs/db/\${data.archive_file.create_db_user_zip.output_sha256}.zip"
1835
+ source = data.archive_file.create_db_user_zip.output_path
1836
+ source_hash = data.archive_file.create_db_user_zip.output_base64sha256
1837
+ etag = data.archive_file.create_db_user_zip.output_md5
1838
+ }
1839
+
1840
+ module "add_rdb_to_runtime_config" {
1841
+ source = "../../../core/runtime-config/entry"
1842
+
1843
+ namespace = "database"
1844
+ key = "Db"
1845
+ value = {
1846
+ hostname = module.aurora.cluster_endpoint
1847
+ port = module.aurora.cluster_port
1848
+ database = module.aurora.database_name
1849
+ adminUser = module.aurora.admin_user
1850
+ dbUser = local.database_runtime_user
1851
+ region = data.aws_region.current.region
1852
+ }
1853
+ }
1854
+
1855
+ resource "aws_ecr_repository" "migration_handler" {
1856
+ #checkov:skip=CKV_AWS_136:AES256 encryption is sufficient for ECR repositories
1857
+ #checkov:skip=CKV_AWS_51:Mutable tags are intentional; the migration handler is always pushed as latest and redeployment is gated on docker_digest changes in null_resource.docker_publish
1858
+ name = "db-migration-\${random_string.suffix.result}"
1859
+ image_tag_mutability = "MUTABLE"
1860
+ force_delete = true
1861
+
1862
+ image_scanning_configuration {
1863
+ scan_on_push = true
1864
+ }
1865
+
1866
+ tags = var.tags
1867
+ }
1868
+
1869
+ resource "aws_ecr_repository_policy" "migration_handler" {
1870
+ repository = aws_ecr_repository.migration_handler.name
1871
+
1872
+ policy = jsonencode({
1873
+ Version = "2012-10-17"
1874
+ Statement = [
1875
+ {
1876
+ Sid = "AllowPushPull"
1877
+ Effect = "Allow"
1878
+ Principal = {
1879
+ AWS = "arn:aws:iam::\${data.aws_caller_identity.current.account_id}:root"
1880
+ }
1881
+ Action = [
1882
+ "ecr:BatchCheckLayerAvailability",
1883
+ "ecr:CompleteLayerUpload",
1884
+ "ecr:GetDownloadUrlForLayer",
1885
+ "ecr:InitiateLayerUpload",
1886
+ "ecr:PutImage",
1887
+ "ecr:UploadLayerPart"
1888
+ ]
1889
+ },
1890
+ {
1891
+ Sid = "AllowLambdaPull"
1892
+ Effect = "Allow"
1893
+ Principal = {
1894
+ Service = "lambda.amazonaws.com"
1895
+ }
1896
+ Action = [
1897
+ "ecr:BatchCheckLayerAvailability",
1898
+ "ecr:BatchGetImage",
1899
+ "ecr:GetDownloadUrlForLayer"
1900
+ ]
1901
+ }
1902
+ ]
1903
+ })
1904
+ }
1905
+
1906
+ data "external" "docker_digest" {
1907
+ program = ["sh", "-c", "echo '{\\"digest\\":\\"'$(docker inspect \${var.docker_image_tag} --format '{{.Id}}')'\\"}' "]
1908
+ }
1909
+
1910
+ resource "null_resource" "docker_publish" {
1911
+ triggers = {
1912
+ docker_digest = data.external.docker_digest.result.digest
1913
+ repository_url = aws_ecr_repository.migration_handler.repository_url
1914
+ docker_image_tag = var.docker_image_tag
1915
+ }
1916
+
1917
+ provisioner "local-exec" {
1918
+ command = <<-EOT
1919
+ aws ecr get-login-password --region \${data.aws_region.current.region} | docker login --username AWS --password-stdin \${self.triggers.repository_url}
1920
+ docker tag \${self.triggers.docker_image_tag} \${self.triggers.repository_url}:latest
1921
+ docker push \${self.triggers.repository_url}:latest
1922
+ EOT
1923
+ }
1924
+
1925
+ depends_on = [aws_ecr_repository_policy.migration_handler]
1926
+ }
1927
+
1928
+ resource "aws_iam_role" "migration_handler" {
1929
+ name = "DbMigrationHandlerRole-\${random_string.suffix.result}"
1930
+
1931
+ assume_role_policy = jsonencode({
1932
+ Version = "2012-10-17"
1933
+ Statement = [
1934
+ {
1935
+ Effect = "Allow"
1936
+ Principal = {
1937
+ Service = "lambda.amazonaws.com"
1938
+ }
1939
+ Action = "sts:AssumeRole"
1940
+ }
1941
+ ]
1942
+ })
1943
+
1944
+ tags = var.tags
1945
+ }
1946
+
1947
+ resource "aws_iam_role_policy_attachment" "migration_handler_basic_execution" {
1948
+ role = aws_iam_role.migration_handler.name
1949
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
1950
+ }
1951
+
1952
+ resource "aws_iam_role_policy_attachment" "migration_handler_vpc_access" {
1953
+ role = aws_iam_role.migration_handler.name
1954
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
1955
+ }
1956
+
1957
+ resource "aws_iam_role_policy_attachment" "migration_handler_xray" {
1958
+ role = aws_iam_role.migration_handler.name
1959
+ policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
1960
+ }
1961
+
1962
+ resource "aws_iam_role_policy" "migration_handler_access" {
1963
+ role = aws_iam_role.migration_handler.name
1964
+
1965
+ policy = jsonencode({
1966
+ Version = "2012-10-17"
1967
+ Statement = [
1968
+ {
1969
+ Effect = "Allow"
1970
+ Action = [
1971
+ "rds-db:connect"
1972
+ ]
1973
+ Resource = [
1974
+ "arn:aws:rds-db:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:dbuser:\${var.enable_rds_proxy ? module.aurora.proxy_resource_id : module.aurora.cluster_resource_id}/\${local.database_runtime_user}"
1975
+ ]
1976
+ }
1977
+ ]
1978
+ })
1979
+ }
1980
+
1981
+ resource "aws_iam_role" "create_db_user" {
1982
+ name = "DbCreateDbUserRole-\${random_string.suffix.result}"
1983
+
1984
+ assume_role_policy = jsonencode({
1985
+ Version = "2012-10-17"
1986
+ Statement = [
1987
+ {
1988
+ Effect = "Allow"
1989
+ Principal = {
1990
+ Service = "lambda.amazonaws.com"
1991
+ }
1992
+ Action = "sts:AssumeRole"
1993
+ }
1994
+ ]
1995
+ })
1996
+
1997
+ tags = var.tags
1998
+ }
1999
+
2000
+ resource "aws_iam_role_policy_attachment" "create_db_user_basic_execution" {
2001
+ role = aws_iam_role.create_db_user.name
2002
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
2003
+ }
2004
+
2005
+ resource "aws_iam_role_policy_attachment" "create_db_user_vpc_access" {
2006
+ role = aws_iam_role.create_db_user.name
2007
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
2008
+ }
2009
+
2010
+ resource "aws_iam_role_policy_attachment" "create_db_user_xray" {
2011
+ role = aws_iam_role.create_db_user.name
2012
+ policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
2013
+ }
2014
+
2015
+ resource "aws_iam_role_policy" "create_db_user_secret_access" {
2016
+ role = aws_iam_role.create_db_user.name
2017
+
2018
+ policy = jsonencode({
2019
+ Version = "2012-10-17"
2020
+ Statement = [
2021
+ {
2022
+ Effect = "Allow"
2023
+ Action = [
2024
+ "secretsmanager:GetSecretValue"
2025
+ ]
2026
+ Resource = [module.aurora.secret_arn]
2027
+ },
2028
+ {
2029
+ Effect = "Allow"
2030
+ Action = [
2031
+ "kms:Decrypt"
2032
+ ]
2033
+ Resource = [module.aurora.kms_key_arn]
2034
+ }
2035
+ ]
2036
+ })
2037
+ }
2038
+
2039
+ resource "aws_cloudwatch_log_group" "migration_handler" {
2040
+ #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
2041
+ #checkov:skip=CKV_AWS_338:Log retention set to forever
2042
+ #checkov:skip=CKV_AWS_66:Log retention set to forever
2043
+ name = "/aws/lambda/\${local.migration_function_name}"
2044
+ tags = var.tags
2045
+ }
2046
+
2047
+ resource "aws_cloudwatch_log_group" "create_db_user" {
2048
+ #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
2049
+ #checkov:skip=CKV_AWS_338:Log retention set to forever
2050
+ #checkov:skip=CKV_AWS_66:Log retention set to forever
2051
+ name = "/aws/lambda/\${local.create_db_user_function_name}"
2052
+ tags = var.tags
2053
+ }
2054
+
2055
+ resource "aws_security_group" "migration_handler" {
2056
+ name_prefix = "db-migration-"
2057
+ description = "Security group for the migration Lambda function"
2058
+ vpc_id = var.vpc_id
2059
+ tags = var.tags
2060
+ }
2061
+
2062
+ resource "aws_vpc_security_group_egress_rule" "migration_handler_to_database" {
2063
+ security_group_id = aws_security_group.migration_handler.id
2064
+ referenced_security_group_id = module.aurora.security_group_id
2065
+ from_port = module.aurora.cluster_port
2066
+ to_port = module.aurora.cluster_port
2067
+ ip_protocol = "tcp"
2068
+ description = "Allow outbound traffic to Aurora on database port"
2069
+ }
2070
+
2071
+ resource "aws_vpc_security_group_egress_rule" "migration_handler_https" {
2072
+ security_group_id = aws_security_group.migration_handler.id
2073
+ cidr_ipv4 = "0.0.0.0/0"
2074
+ from_port = 443
2075
+ to_port = 443
2076
+ ip_protocol = "tcp"
2077
+ description = "Allow outbound HTTPS to AWS service endpoints"
2078
+ }
2079
+
2080
+ resource "aws_security_group" "create_db_user" {
2081
+ name_prefix = "db-create-db-user-"
2082
+ description = "Security group for the create-db-user Lambda function"
2083
+ vpc_id = var.vpc_id
2084
+ tags = var.tags
2085
+ }
2086
+
2087
+ resource "aws_vpc_security_group_egress_rule" "create_db_user_to_database" {
2088
+ security_group_id = aws_security_group.create_db_user.id
2089
+ referenced_security_group_id = module.aurora.database_security_group_id
2090
+ from_port = module.aurora.cluster_port
2091
+ to_port = module.aurora.cluster_port
2092
+ ip_protocol = "tcp"
2093
+ description = "Allow outbound traffic to Aurora on database port"
2094
+ }
2095
+
2096
+ resource "aws_vpc_security_group_egress_rule" "create_db_user_https" {
2097
+ security_group_id = aws_security_group.create_db_user.id
2098
+ cidr_ipv4 = "0.0.0.0/0"
2099
+ from_port = 443
2100
+ to_port = 443
2101
+ ip_protocol = "tcp"
2102
+ description = "Allow outbound HTTPS to AWS service endpoints"
2103
+ }
2104
+
2105
+ resource "aws_vpc_security_group_ingress_rule" "migration_handler_to_database" {
2106
+ security_group_id = module.aurora.security_group_id
2107
+ referenced_security_group_id = aws_security_group.migration_handler.id
2108
+ from_port = module.aurora.cluster_port
2109
+ to_port = module.aurora.cluster_port
2110
+ ip_protocol = "tcp"
2111
+ description = "Allow inbound traffic from migration Lambda on database port"
2112
+ }
2113
+
2114
+ resource "aws_vpc_security_group_ingress_rule" "create_db_user_to_database" {
2115
+ security_group_id = module.aurora.database_security_group_id
2116
+ referenced_security_group_id = aws_security_group.create_db_user.id
2117
+ from_port = module.aurora.cluster_port
2118
+ to_port = module.aurora.cluster_port
2119
+ ip_protocol = "tcp"
2120
+ description = "Allow inbound traffic from create-db-user Lambda on database port"
2121
+ }
2122
+
2123
+ resource "aws_lambda_function" "create_db_user" {
2124
+ #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for the create-db-user handler
2125
+ #checkov:skip=CKV_AWS_115:Concurrent execution limit not required for the create-db-user handler
2126
+ #checkov:skip=CKV_AWS_173:Lambda environment variables encrypted by managed key
2127
+ #checkov:skip=CKV_AWS_272:Code signing not configured as deployment packages are built and deployed within the same pipeline
2128
+ s3_bucket = aws_s3_object.create_db_user_zip.bucket
2129
+ s3_key = aws_s3_object.create_db_user_zip.key
2130
+ s3_object_version = aws_s3_object.create_db_user_zip.version_id
2131
+ function_name = local.create_db_user_function_name
2132
+ role = aws_iam_role.create_db_user.arn
2133
+ handler = "index.handler"
2134
+ source_code_hash = data.archive_file.create_db_user_zip.output_base64sha256
2135
+ runtime = "nodejs24.x"
2136
+ timeout = 300
2137
+ architectures = ["arm64"]
2138
+
2139
+ tracing_config {
2140
+ mode = "Active"
2141
+ }
2142
+
2143
+ vpc_config {
2144
+ subnet_ids = var.lambda_subnet_ids
2145
+ security_group_ids = [aws_security_group.create_db_user.id]
2146
+ }
2147
+
2148
+ environment {
2149
+ variables = {
2150
+ DATABASE_SECRET_ARN = module.aurora.secret_arn
2151
+ NODE_EXTRA_CA_CERTS = "/var/runtime/ca-cert.pem"
2152
+ }
2153
+ }
2154
+
2155
+ tags = var.tags
2156
+
2157
+ depends_on = [
2158
+ aws_s3_object.create_db_user_zip,
2159
+ aws_iam_role_policy_attachment.create_db_user_basic_execution,
2160
+ aws_iam_role_policy_attachment.create_db_user_vpc_access,
2161
+ aws_iam_role_policy_attachment.create_db_user_xray,
2162
+ aws_iam_role_policy.create_db_user_secret_access,
2163
+ aws_cloudwatch_log_group.create_db_user,
2164
+ aws_vpc_security_group_ingress_rule.create_db_user_to_database
2165
+ ]
2166
+ }
2167
+
2168
+ resource "null_resource" "create_db_user_trigger" {
2169
+ triggers = {
2170
+ cluster_arn = module.aurora.cluster_arn
2171
+ function_name = aws_lambda_function.create_db_user.function_name
2172
+ db_user = local.database_runtime_user
2173
+ bundle_hash = data.archive_file.create_db_user_zip.output_base64sha256
2174
+ }
2175
+
2176
+ provisioner "local-exec" {
2177
+ command = <<-EOT
2178
+ output_file=$(mktemp)
2179
+ response=$(aws lambda invoke \\
2180
+ --region \${data.aws_region.current.region} \\
2181
+ --function-name \${self.triggers.function_name} \\
2182
+ --cli-binary-format raw-in-base64-out \\
2183
+ --payload '{"RequestType":"Create","PhysicalResourceId":"db-user:\${self.triggers.db_user}"}' \\
2184
+ "$output_file")
2185
+ cat "$output_file"
2186
+ echo "$response"
2187
+ if echo "$response" | grep -q '"FunctionError"'; then
2188
+ rm -f "$output_file"
2189
+ exit 1
2190
+ fi
2191
+ rm -f "$output_file"
2192
+ EOT
2193
+ }
2194
+
2195
+ depends_on = [aws_lambda_function.create_db_user, module.aurora]
2196
+ }
2197
+
2198
+ resource "aws_lambda_function" "migration_handler" {
2199
+ #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for the migration handler
2200
+ #checkov:skip=CKV_AWS_115:Concurrent execution limit not required for the migration handler
2201
+ #checkov:skip=CKV_AWS_173:Lambda environment variables encrypted by managed key
2202
+ #checkov:skip=CKV_AWS_272:Code signing does not apply to container image Lambda functions
2203
+ package_type = "Image"
2204
+ function_name = local.migration_function_name
2205
+ role = aws_iam_role.migration_handler.arn
2206
+ image_uri = "\${aws_ecr_repository.migration_handler.repository_url}:latest"
2207
+ memory_size = 1024
2208
+ timeout = 300
2209
+ architectures = ["arm64"]
2210
+
2211
+ tracing_config {
2212
+ mode = "Active"
2213
+ }
2214
+
2215
+ vpc_config {
2216
+ subnet_ids = var.lambda_subnet_ids
2217
+ security_group_ids = [aws_security_group.migration_handler.id]
2218
+ }
2219
+
2220
+ environment {
2221
+ variables = {
2222
+ HOSTNAME = module.aurora.cluster_endpoint
2223
+ DATABASE = module.aurora.database_name
2224
+ PORT = tostring(module.aurora.cluster_port)
2225
+ DBUSER = local.database_runtime_user
2226
+ }
2227
+ }
2228
+
2229
+ tags = var.tags
2230
+
2231
+ depends_on = [
2232
+ null_resource.docker_publish,
2233
+ aws_iam_role_policy_attachment.migration_handler_basic_execution,
2234
+ aws_iam_role_policy_attachment.migration_handler_vpc_access,
2235
+ aws_iam_role_policy_attachment.migration_handler_xray,
2236
+ aws_iam_role_policy.migration_handler_access,
2237
+ aws_cloudwatch_log_group.migration_handler,
2238
+ aws_vpc_security_group_ingress_rule.migration_handler_to_database
2239
+ ]
2240
+ }
2241
+
2242
+ resource "null_resource" "migration_trigger" {
2243
+ triggers = {
2244
+ docker_digest = data.external.docker_digest.result.digest
2245
+ cluster_arn = module.aurora.cluster_arn
2246
+ database_ready = module.aurora.database_ready
2247
+ function_name = aws_lambda_function.migration_handler.function_name
2248
+ db_user = local.database_runtime_user
2249
+ }
2250
+
2251
+ provisioner "local-exec" {
2252
+ command = <<-EOT
2253
+ output_file=$(mktemp)
2254
+ response=$(aws lambda invoke \\
2255
+ --region \${data.aws_region.current.region} \\
2256
+ --function-name \${self.triggers.function_name} \\
2257
+ --cli-binary-format raw-in-base64-out \\
2258
+ "$output_file")
2259
+ cat "$output_file"
2260
+ echo "$response"
2261
+ if echo "$response" | grep -q '"FunctionError"'; then
2262
+ rm -f "$output_file"
2263
+ exit 1
2264
+ fi
2265
+ rm -f "$output_file"
2266
+ EOT
2267
+ }
2268
+
2269
+ depends_on = [
2270
+ aws_lambda_function.migration_handler,
2271
+ module.aurora,
2272
+ null_resource.create_db_user_trigger,
2273
+ aws_iam_role_policy.proxy_db_user_connect
2274
+ ]
2275
+ }
2276
+
2277
+ output "security_group_id" {
2278
+ description = "Security group ID to allow inbound connections to the database (proxy SG if RDS Proxy is enabled, otherwise DB SG)."
2279
+ value = module.aurora.security_group_id
2280
+ }
2281
+
2282
+ output "database_security_group_id" {
2283
+ description = "Security group ID of the Aurora cluster directly, used for direct DB access rules (e.g. when RDS Proxy is disabled)."
2284
+ value = module.aurora.database_security_group_id
2285
+ }
2286
+
2287
+ output "cluster_resource_id" {
2288
+ description = "Resource ID of the Aurora cluster, used for IAM rds-db:connect policies."
2289
+ value = module.aurora.cluster_resource_id
2290
+ }
2291
+
2292
+ output "proxy_resource_id" {
2293
+ description = "Resource identifier of the RDS Proxy (prx-XXXX), used for IAM rds-db:connect policies when connecting through the proxy. Null if RDS Proxy is disabled."
2294
+ value = module.aurora.proxy_resource_id
2295
+ }
2296
+
2297
+ output "kms_key_arn" {
2298
+ description = "ARN of the KMS key protecting Aurora and the admin credentials secret, used to grant decrypt access alongside secret_arn."
2299
+ value = module.aurora.kms_key_arn
2300
+ }
2301
+
2302
+ output "cluster_arn" {
2303
+ description = "ARN of the Aurora cluster."
2304
+ value = module.aurora.cluster_arn
2305
+ }
2306
+
2307
+ output "cluster_endpoint" {
2308
+ description = "Writer endpoint of the Aurora cluster."
2309
+ value = module.aurora.cluster_endpoint
2310
+ }
2311
+
2312
+ output "reader_endpoint" {
2313
+ description = "Reader endpoint of the Aurora cluster."
2314
+ value = module.aurora.reader_endpoint
2315
+ }
2316
+
2317
+ output "cluster_port" {
2318
+ description = "Port exposed by the Aurora cluster."
2319
+ value = module.aurora.cluster_port
2320
+ }
2321
+
2322
+ output "secret_arn" {
2323
+ description = "ARN of the generated admin credentials secret."
2324
+ value = module.aurora.secret_arn
2325
+ }
2326
+
2327
+ output "database_runtime_user" {
2328
+ description = "Application database user created for IAM-authenticated access."
2329
+ value = local.database_runtime_user
2330
+ }
2331
+
2332
+ "
2333
+ `;
2334
+
2335
+ exports[`ts#rdb generator > should generate terraform modules with MySQL engine 1`] = `
2336
+ "# Core Aurora module
2337
+ # This module creates an Aurora cluster and a generated admin secret.
2338
+
2339
+ terraform {
2340
+ required_version = ">= 1.0"
2341
+
2342
+ required_providers {
2343
+ aws = {
2344
+ source = "hashicorp/aws"
2345
+ version = "~> 6.33"
2346
+ }
2347
+ null = {
2348
+ source = "hashicorp/null"
2349
+ version = ">= 3.0"
2350
+ }
2351
+ random = {
2352
+ source = "hashicorp/random"
2353
+ version = ">= 3.0"
2354
+ }
2355
+ }
2356
+ }
2357
+
2358
+ data "aws_caller_identity" "current" {}
2359
+ data "aws_partition" "current" {}
2360
+ data "aws_region" "current" {}
2361
+
2362
+ data "aws_iam_policy" "enhanced_monitoring" {
2363
+ arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole"
2364
+ }
2365
+
2366
+ variable "name" {
2367
+ description = "Base name applied to Aurora resources."
2368
+ type = string
2369
+ }
2370
+
2371
+ variable "vpc_id" {
2372
+ description = "VPC where the Aurora cluster will be deployed."
2373
+ type = string
2374
+ }
2375
+
2376
+ variable "subnet_ids" {
2377
+ description = "Subnet IDs for the Aurora DB subnet group and RDS Proxy. These subnets do not need outbound egress — private isolated subnets are fine."
2378
+ type = list(string)
2379
+ }
2380
+
2381
+ variable "lambda_subnet_ids" {
2382
+ description = "Subnet IDs for the credential-rotation Lambda. Must have outbound egress to AWS service endpoints (e.g. Secrets Manager) — use private subnets with a NAT gateway."
2383
+ type = list(string)
2384
+ }
2385
+
2386
+ variable "engine" {
2387
+ description = "Aurora engine to use."
2388
+ type = string
2389
+ default = "aurora-postgresql"
2390
+
2391
+ validation {
2392
+ condition = contains(["aurora-postgresql", "aurora-mysql"], var.engine)
2393
+ error_message = "engine must be aurora-postgresql or aurora-mysql."
2394
+ }
2395
+ }
2396
+
2397
+ variable "engine_version" {
2398
+ description = "Aurora engine version."
2399
+ type = string
2400
+ default = null
2401
+ }
2402
+
2403
+ variable "database_name" {
2404
+ description = "Initial database created in the cluster."
2405
+ type = string
2406
+ }
2407
+
2408
+ variable "admin_user" {
2409
+ description = "Admin username stored in the generated Secrets Manager secret."
2410
+ type = string
2411
+ }
2412
+
2413
+ variable "port" {
2414
+ description = "Database port for the selected Aurora engine."
2415
+ type = number
2416
+ default = null
2417
+ }
2418
+
2419
+ variable "serverless_min_capacity" {
2420
+ description = "Minimum Aurora Serverless v2 ACUs."
2421
+ type = number
2422
+ default = 0.5
2423
+ }
2424
+
2425
+ variable "serverless_max_capacity" {
2426
+ description = "Maximum Aurora Serverless v2 ACUs."
2427
+ type = number
2428
+ default = 4
2429
+ }
2430
+
2431
+ variable "instance_count" {
2432
+ description = "Number of Aurora instances to create."
2433
+ type = number
2434
+ default = 1
2435
+ }
2436
+
2437
+ variable "deletion_protection" {
2438
+ description = "Whether deletion protection is enabled."
2439
+ type = bool
2440
+ default = true
2441
+ }
2442
+
2443
+ variable "skip_final_snapshot" {
2444
+ description = "Whether to skip the final snapshot on deletion."
2445
+ type = bool
2446
+ default = false
2447
+ }
2448
+
2449
+ variable "enable_rds_proxy" {
2450
+ description = "Whether to provision an RDS Proxy in front of the Aurora cluster."
2451
+ type = bool
2452
+ default = true
2453
+ }
2454
+
2455
+ variable "enable_credential_rotation" {
2456
+ description = "Whether to enable automatic credential rotation for the admin secret."
2457
+ type = bool
2458
+ default = true
2459
+ }
2460
+
2461
+ variable "enable_cloudwatch_logs" {
2462
+ description = "Whether to export Aurora engine logs to CloudWatch. PostgreSQL also enables verbose statement logging."
2463
+ type = bool
2464
+ default = false
2465
+ }
2466
+
2467
+ variable "enable_performance_insights" {
2468
+ description = "Whether to enable Performance Insights on Aurora cluster instances."
2469
+ type = bool
2470
+ default = true
2471
+ }
2472
+
2473
+ variable "performance_insights_retention_period" {
2474
+ description = "Retention period, in days, for Performance Insights data when enabled."
2475
+ type = number
2476
+ default = 7
2477
+ }
2478
+
2479
+ variable "enable_backup" {
2480
+ description = "Whether to provision an AWS Backup plan for the Aurora cluster."
2481
+ type = bool
2482
+ default = false
2483
+ }
2484
+
2485
+ variable "enable_key_rotation" {
2486
+ description = "Whether to enable automatic key rotation on the KMS key used to encrypt the Aurora cluster and its credentials secret."
2487
+ type = bool
2488
+ default = true
2489
+ }
2490
+
2491
+ variable "tags" {
2492
+ description = "Tags to apply to all resources."
2493
+ type = map(string)
2494
+ default = {}
2495
+ }
2496
+
2497
+ locals {
2498
+ default_port = var.engine == "aurora-mysql" ? 3306 : 5432
2499
+ default_engine_version = var.engine == "aurora-mysql" ? "8.0.mysql_aurora.3.12.0" : "17.7"
2500
+ parameter_group_family = var.engine == "aurora-mysql" ? "aurora-mysql8.0" : "aurora-postgresql\${split(".", coalesce(var.engine_version, local.default_engine_version))[0]}"
2501
+ }
2502
+
2503
+ resource "aws_rds_cluster_parameter_group" "database" {
2504
+ count = var.enable_cloudwatch_logs ? 1 : 0
2505
+
2506
+ name_prefix = "\${var.name}-"
2507
+ family = local.parameter_group_family
2508
+ description = "Parameter group for \${var.name} Aurora cluster"
2509
+
2510
+ dynamic "parameter" {
2511
+ for_each = var.engine == "aurora-postgresql" ? [1] : []
2512
+ content {
2513
+ name = "log_statement"
2514
+ value = "all"
2515
+ }
2516
+ }
2517
+
2518
+ tags = var.tags
2519
+ }
2520
+
2521
+ resource "aws_kms_key" "database" {
2522
+ description = "KMS key for Aurora cluster \${var.name}"
2523
+ enable_key_rotation = var.enable_key_rotation
2524
+
2525
+ policy = jsonencode({
2526
+ Version = "2012-10-17"
2527
+ Statement = [
2528
+ {
2529
+ Sid = "EnableRootAccess"
2530
+ Effect = "Allow"
2531
+ Principal = {
2532
+ AWS = "arn:\${data.aws_partition.current.partition}:iam::\${data.aws_caller_identity.current.account_id}:root"
2533
+ }
2534
+ Action = "kms:*"
2535
+ Resource = "*"
2536
+ },
2537
+ {
2538
+ Sid = "AllowRDSService"
2539
+ Effect = "Allow"
2540
+ Principal = {
2541
+ Service = "rds.amazonaws.com"
2542
+ }
2543
+ Action = [
2544
+ "kms:Encrypt",
2545
+ "kms:Decrypt",
2546
+ "kms:ReEncrypt*",
2547
+ "kms:GenerateDataKey*",
2548
+ "kms:DescribeKey",
2549
+ "kms:CreateGrant"
2550
+ ]
2551
+ Resource = "*"
2552
+ }
2553
+ ]
2554
+ })
2555
+
2556
+ tags = merge(var.tags, {
2557
+ Name = "\${var.name}-aurora"
2558
+ })
2559
+ }
2560
+
2561
+ resource "aws_iam_role" "enhanced_monitoring" {
2562
+ name_prefix = "\${var.name}-aurora-monitoring-"
2563
+
2564
+ assume_role_policy = jsonencode({
2565
+ Version = "2012-10-17"
2566
+ Statement = [
2567
+ {
2568
+ Effect = "Allow"
2569
+ Principal = {
2570
+ Service = "monitoring.rds.amazonaws.com"
2571
+ }
2572
+ Action = "sts:AssumeRole"
2573
+ }
2574
+ ]
2575
+ })
2576
+
2577
+ tags = merge(var.tags, {
2578
+ Name = "\${var.name}-aurora-monitoring"
2579
+ })
2580
+ }
2581
+
2582
+ resource "aws_iam_role_policy_attachment" "enhanced_monitoring" {
2583
+ role = aws_iam_role.enhanced_monitoring.name
2584
+ policy_arn = data.aws_iam_policy.enhanced_monitoring.arn
2585
+ }
2586
+
2587
+ resource "aws_security_group" "database" {
2588
+ name_prefix = "\${var.name}-aurora-"
2589
+ description = "Security group for Aurora cluster \${var.name}"
2590
+ vpc_id = var.vpc_id
2591
+
2592
+ tags = merge(var.tags, {
2593
+ Name = "\${var.name}-aurora"
2594
+ })
2595
+ }
2596
+
2597
+ resource "aws_db_subnet_group" "database" {
2598
+ name = "\${var.name}-aurora"
2599
+ subnet_ids = var.subnet_ids
2600
+
2601
+ tags = merge(var.tags, {
2602
+ Name = "\${var.name}-aurora"
2603
+ })
2604
+ }
2605
+
2606
+ resource "random_password" "master_password" {
2607
+ length = 32
2608
+ special = true
2609
+ override_special = "!#$%&*()-_=+[]{}<>:?"
2610
+ }
2611
+
2612
+ resource "aws_secretsmanager_secret" "credentials" {
2613
+ #checkov:skip=CKV2_AWS_57:Rotation is configured via aws_cloudformation_stack.credentials_rotation using the AWS::SecretsManager-2024-09-16 transform; Checkov cannot resolve rotation schedules created by CloudFormation transforms
2614
+ name_prefix = "\${var.name}-aurora-credentials-"
2615
+ kms_key_id = aws_kms_key.database.arn
2616
+
2617
+ tags = merge(var.tags, {
2618
+ Name = "\${var.name}-aurora-credentials"
2619
+ })
2620
+ }
2621
+
2622
+ resource "aws_secretsmanager_secret_version" "credentials" {
2623
+ secret_id = aws_secretsmanager_secret.credentials.id
2624
+ secret_string = jsonencode({
2625
+ engine = var.engine == "aurora-mysql" ? "mysql" : "postgres"
2626
+ username = var.admin_user
2627
+ password = random_password.master_password.result
2628
+ host = aws_rds_cluster.database.endpoint
2629
+ port = aws_rds_cluster.database.port
2630
+ dbname = var.database_name
2631
+ dbClusterIdentifier = aws_rds_cluster.database.cluster_identifier
2632
+ })
2633
+ }
2634
+
2635
+ resource "aws_rds_cluster" "database" {
2636
+ #checkov:skip=CKV2_AWS_27:Query logging can be enabled with enable_cloudwatch_logs; this module defaults to CDK-equivalent behavior unless logging is requested
2637
+ #checkov:skip=CKV_AWS_139:Deletion protection is enabled but checkov is unable to detect it correctly
2638
+ cluster_identifier = "\${var.name}-aurora"
2639
+ engine = var.engine
2640
+ engine_version = coalesce(var.engine_version, local.default_engine_version)
2641
+ database_name = var.database_name
2642
+ master_username = var.admin_user
2643
+ master_password = random_password.master_password.result
2644
+ db_subnet_group_name = aws_db_subnet_group.database.name
2645
+ db_cluster_parameter_group_name = var.enable_cloudwatch_logs ? aws_rds_cluster_parameter_group.database[0].name : null
2646
+ vpc_security_group_ids = [aws_security_group.database.id]
2647
+ port = coalesce(var.port, local.default_port)
2648
+ storage_encrypted = true
2649
+ kms_key_id = aws_kms_key.database.arn
2650
+ deletion_protection = var.deletion_protection
2651
+ skip_final_snapshot = var.skip_final_snapshot
2652
+ final_snapshot_identifier = var.skip_final_snapshot ? null : "\${var.name}-aurora-final-snapshot"
2653
+ iam_database_authentication_enabled = true
2654
+ monitoring_interval = 5
2655
+ monitoring_role_arn = aws_iam_role.enhanced_monitoring.arn
2656
+ copy_tags_to_snapshot = true
2657
+ enabled_cloudwatch_logs_exports = var.enable_cloudwatch_logs ? (var.engine == "aurora-mysql" ? ["audit", "error", "general", "slowquery"] : ["postgresql"]) : null
2658
+
2659
+ serverlessv2_scaling_configuration {
2660
+ min_capacity = var.serverless_min_capacity
2661
+ max_capacity = var.serverless_max_capacity
2662
+ }
2663
+
2664
+ tags = merge(var.tags, {
2665
+ Name = "\${var.name}-aurora"
2666
+ })
2667
+ }
2668
+
2669
+ resource "aws_rds_cluster_instance" "database" {
2670
+ count = var.instance_count
2671
+
2672
+ identifier = "\${var.name}-aurora-\${count.index + 1}"
2673
+ cluster_identifier = aws_rds_cluster.database.id
2674
+ instance_class = "db.serverless"
2675
+ engine = aws_rds_cluster.database.engine
2676
+ engine_version = aws_rds_cluster.database.engine_version
2677
+ db_subnet_group_name = aws_db_subnet_group.database.name
2678
+
2679
+ auto_minor_version_upgrade = true
2680
+ monitoring_interval = 5
2681
+ monitoring_role_arn = aws_iam_role.enhanced_monitoring.arn
2682
+ performance_insights_enabled = var.enable_performance_insights
2683
+ performance_insights_kms_key_id = var.enable_performance_insights ? aws_kms_key.database.arn : null
2684
+ performance_insights_retention_period = var.enable_performance_insights ? var.performance_insights_retention_period : null
2685
+
2686
+ tags = merge(var.tags, {
2687
+ Name = "\${var.name}-aurora-\${count.index + 1}"
2688
+ })
2689
+ }
2690
+
2691
+ resource "aws_security_group" "rotation" {
2692
+ #checkov:skip=CKV2_AWS_5:Security group is attached to the hosted rotation Lambda created by AWS::SecretsManager::RotationSchedule; Checkov cannot resolve resources created by the CloudFormation transform
2693
+ count = var.enable_credential_rotation ? 1 : 0
2694
+ name_prefix = "\${var.name}-aurora-rotation-"
2695
+ description = "Security group for Aurora secret rotation \${var.name}"
2696
+ vpc_id = var.vpc_id
2697
+
2698
+ tags = merge(var.tags, {
2699
+ Name = "\${var.name}-aurora-rotation"
2700
+ })
2701
+ }
2702
+
2703
+ resource "aws_vpc_security_group_egress_rule" "rotation_to_database" {
2704
+ count = var.enable_credential_rotation ? 1 : 0
2705
+ security_group_id = aws_security_group.rotation[0].id
2706
+ referenced_security_group_id = aws_security_group.database.id
2707
+ from_port = coalesce(var.port, local.default_port)
2708
+ to_port = coalesce(var.port, local.default_port)
2709
+ ip_protocol = "tcp"
2710
+ description = "Allow outbound database traffic from secret rotation Lambda"
2711
+ }
2712
+
2713
+ resource "aws_vpc_security_group_ingress_rule" "rotation_to_database" {
2714
+ count = var.enable_credential_rotation ? 1 : 0
2715
+ security_group_id = aws_security_group.database.id
2716
+ referenced_security_group_id = aws_security_group.rotation[0].id
2717
+ from_port = coalesce(var.port, local.default_port)
2718
+ to_port = coalesce(var.port, local.default_port)
2719
+ ip_protocol = "tcp"
2720
+ description = "Allow inbound database traffic from secret rotation Lambda"
2721
+ }
2722
+
2723
+ resource "aws_cloudformation_stack" "credentials_rotation" {
2724
+ #checkov:skip=CKV_AWS_124:SNS event notifications are not required for the credentials rotation stack
2725
+ count = var.enable_credential_rotation ? 1 : 0
2726
+ name = "\${var.name}-aurora-credentials-rotation"
2727
+
2728
+ capabilities = ["CAPABILITY_IAM", "CAPABILITY_AUTO_EXPAND"]
2729
+
2730
+ template_body = jsonencode({
2731
+ AWSTemplateFormatVersion = "2010-09-09"
2732
+ Transform = "AWS::SecretsManager-2024-09-16"
2733
+ Resources = {
2734
+ CredentialsRotationSchedule = {
2735
+ Type = "AWS::SecretsManager::RotationSchedule"
2736
+ Properties = {
2737
+ SecretId = aws_secretsmanager_secret.credentials.arn
2738
+ HostedRotationLambda = {
2739
+ RotationType = var.engine == "aurora-mysql" ? "MySQLSingleUser" : "PostgreSQLSingleUser"
2740
+ RotationLambdaName = "\${var.name}-aurora-credentials-rotation"
2741
+ VpcSecurityGroupIds = aws_security_group.rotation[0].id
2742
+ VpcSubnetIds = join(",", var.lambda_subnet_ids)
2743
+ }
2744
+ RotationRules = {
2745
+ AutomaticallyAfterDays = 30
2746
+ }
2747
+ }
2748
+ }
2749
+ }
2750
+ })
2751
+
2752
+ depends_on = [
2753
+ aws_secretsmanager_secret_version.credentials,
2754
+ aws_rds_cluster_instance.database,
2755
+ aws_vpc_security_group_egress_rule.rotation_to_database,
2756
+ aws_vpc_security_group_ingress_rule.rotation_to_database,
2757
+ ]
2758
+ }
2759
+
2760
+ resource "aws_iam_role" "proxy" {
2761
+ count = var.enable_rds_proxy ? 1 : 0
2762
+
2763
+ name_prefix = "\${var.name}-aurora-proxy-"
2764
+
2765
+ assume_role_policy = jsonencode({
2766
+ Version = "2012-10-17"
2767
+ Statement = [
2768
+ {
2769
+ Effect = "Allow"
2770
+ Principal = {
2771
+ Service = "rds.amazonaws.com"
2772
+ }
2773
+ Action = "sts:AssumeRole"
2774
+ }
2775
+ ]
2776
+ })
2777
+
2778
+ tags = merge(var.tags, {
2779
+ Name = "\${var.name}-aurora-proxy"
2780
+ })
2781
+ }
2782
+
2783
+ resource "aws_iam_role_policy" "proxy_secret_access" {
2784
+ count = var.enable_rds_proxy ? 1 : 0
2785
+ role = aws_iam_role.proxy[0].name
2786
+
2787
+ policy = jsonencode({
2788
+ Version = "2012-10-17"
2789
+ Statement = [
2790
+ {
2791
+ Effect = "Allow"
2792
+ Action = ["secretsmanager:GetSecretValue"]
2793
+ Resource = [aws_secretsmanager_secret.credentials.arn]
2794
+ },
2795
+ {
2796
+ Effect = "Allow"
2797
+ Action = ["kms:Decrypt"]
2798
+ Resource = [aws_kms_key.database.arn]
2799
+ }
2800
+ ]
2801
+ })
2802
+ }
2803
+
2804
+ resource "aws_security_group" "proxy" {
2805
+ #checkov:skip=CKV2_AWS_5:Security group is attached to aws_db_proxy.aurora; Checkov cannot resolve count-conditional cross-resource references
2806
+ count = var.enable_rds_proxy ? 1 : 0
2807
+
2808
+ name_prefix = "\${var.name}-aurora-proxy-"
2809
+ description = "Security group for Aurora proxy \${var.name}"
2810
+ vpc_id = var.vpc_id
2811
+
2812
+ tags = merge(var.tags, {
2813
+ Name = "\${var.name}-aurora-proxy"
2814
+ })
2815
+ }
2816
+
2817
+ resource "aws_db_proxy" "aurora" {
2818
+ count = var.enable_rds_proxy ? 1 : 0
2819
+
2820
+ name = "\${var.name}-proxy"
2821
+ default_auth_scheme = "IAM_AUTH"
2822
+ engine_family = var.engine == "aurora-mysql" ? "MYSQL" : "POSTGRESQL"
2823
+ role_arn = aws_iam_role.proxy[0].arn
2824
+ vpc_subnet_ids = var.subnet_ids
2825
+ vpc_security_group_ids = [aws_security_group.proxy[0].id]
2826
+ require_tls = true
2827
+
2828
+ auth {
2829
+ auth_scheme = "SECRETS"
2830
+ iam_auth = "REQUIRED"
2831
+ secret_arn = aws_secretsmanager_secret.credentials.arn
2832
+ }
2833
+
2834
+ depends_on = [
2835
+ aws_iam_role_policy.proxy_secret_access,
2836
+ aws_secretsmanager_secret_version.credentials,
2837
+ ]
2838
+ }
2839
+
2840
+ resource "aws_db_proxy_default_target_group" "default" {
2841
+ count = var.enable_rds_proxy ? 1 : 0
2842
+
2843
+ db_proxy_name = aws_db_proxy.aurora[0].name
2844
+ }
2845
+
2846
+ resource "aws_db_proxy_target" "default" {
2847
+ count = var.enable_rds_proxy ? 1 : 0
2848
+
2849
+ db_proxy_name = aws_db_proxy.aurora[0].name
2850
+ target_group_name = aws_db_proxy_default_target_group.default[0].name
2851
+ db_cluster_identifier = aws_rds_cluster.database.cluster_identifier
2852
+ }
2853
+
2854
+ resource "null_resource" "proxy_target_ready" {
2855
+ count = var.enable_rds_proxy ? 1 : 0
2856
+
2857
+ triggers = {
2858
+ db_proxy_name = aws_db_proxy.aurora[0].name
2859
+ target_group_name = aws_db_proxy_default_target_group.default[0].name
2860
+ db_cluster_identifier = aws_rds_cluster.database.cluster_identifier
2861
+ }
2862
+
2863
+ provisioner "local-exec" {
2864
+ command = <<-EOT
2865
+ start_time=$(date +%s)
2866
+ deadline=$((start_time + 600))
2867
+ while true; do
2868
+ target_state=$(aws rds describe-db-proxy-targets \\
2869
+ --region \${data.aws_region.current.region} \\
2870
+ --db-proxy-name \${self.triggers.db_proxy_name} \\
2871
+ --target-group-name \${self.triggers.target_group_name} \\
2872
+ --query "Targets[?Type=='RDS_INSTANCE'] | [0].TargetHealth.State" \\
2873
+ --output text)
2874
+
2875
+ if [ "$target_state" = "AVAILABLE" ]; then
2876
+ exit 0
2877
+ fi
2878
+
2879
+ current_time=$(date +%s)
2880
+ if [ "$current_time" -ge "$deadline" ]; then
2881
+ echo "RDS Proxy target \${self.triggers.db_cluster_identifier} did not become AVAILABLE within 10 minutes. Last state: $target_state"
2882
+ exit 1
2883
+ fi
2884
+
2885
+ sleep 10
2886
+ done
2887
+ EOT
2888
+ }
2889
+
2890
+ depends_on = [
2891
+ aws_db_proxy_target.default,
2892
+ aws_rds_cluster_instance.database,
2893
+ aws_vpc_security_group_ingress_rule.proxy_to_database,
2894
+ aws_vpc_security_group_egress_rule.proxy_to_database
2895
+ ]
2896
+ }
2897
+
2898
+ resource "null_resource" "cluster_ready" {
2899
+ count = var.enable_rds_proxy ? 0 : 1
2900
+
2901
+ triggers = {
2902
+ cluster_identifier = aws_rds_cluster.database.cluster_identifier
2903
+ instance_ids = join(",", aws_rds_cluster_instance.database[*].id)
2904
+ }
2905
+
2906
+ depends_on = [aws_rds_cluster_instance.database]
2907
+ }
2908
+
2909
+ resource "aws_vpc_security_group_ingress_rule" "proxy_to_database" {
2910
+ count = var.enable_rds_proxy ? 1 : 0
2911
+
2912
+ security_group_id = aws_security_group.database.id
2913
+ referenced_security_group_id = aws_security_group.proxy[0].id
2914
+ from_port = coalesce(var.port, local.default_port)
2915
+ to_port = coalesce(var.port, local.default_port)
2916
+ ip_protocol = "tcp"
2917
+ description = "Allow inbound database traffic from RDS Proxy"
2918
+ }
2919
+
2920
+ resource "aws_vpc_security_group_egress_rule" "proxy_to_database" {
2921
+ count = var.enable_rds_proxy ? 1 : 0
2922
+
2923
+ security_group_id = aws_security_group.proxy[0].id
2924
+ referenced_security_group_id = aws_security_group.database.id
2925
+ from_port = coalesce(var.port, local.default_port)
2926
+ to_port = coalesce(var.port, local.default_port)
2927
+ ip_protocol = "tcp"
2928
+ description = "Allow outbound database traffic from RDS Proxy to Aurora cluster"
2929
+ }
2930
+
2931
+ data "aws_iam_policy" "backup" {
2932
+ count = var.enable_backup ? 1 : 0
2933
+
2934
+ arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup"
2935
+ }
2936
+
2937
+ resource "aws_iam_role" "backup" {
2938
+ count = var.enable_backup ? 1 : 0
2939
+
2940
+ name_prefix = "\${var.name}-backup-"
2941
+
2942
+ assume_role_policy = jsonencode({
2943
+ Version = "2012-10-17"
2944
+ Statement = [
2945
+ {
2946
+ Effect = "Allow"
2947
+ Principal = { Service = "backup.amazonaws.com" }
2948
+ Action = "sts:AssumeRole"
2949
+ }
2950
+ ]
2951
+ })
2952
+
2953
+ tags = var.tags
2954
+ }
2955
+
2956
+ resource "aws_iam_role_policy_attachment" "backup" {
2957
+ count = var.enable_backup ? 1 : 0
2958
+
2959
+ role = aws_iam_role.backup[0].name
2960
+ policy_arn = data.aws_iam_policy.backup[0].arn
2961
+ }
2962
+
2963
+ resource "aws_backup_vault" "database" {
2964
+ count = var.enable_backup ? 1 : 0
2965
+
2966
+ name = "\${var.name}-aurora-backup"
2967
+ kms_key_arn = aws_kms_key.database.arn
2968
+ tags = var.tags
2969
+ }
2970
+
2971
+ resource "aws_backup_plan" "database" {
2972
+ count = var.enable_backup ? 1 : 0
2973
+
2974
+ name = "\${var.name}-aurora-backup"
2975
+
2976
+ rule {
2977
+ rule_name = "daily-backup"
2978
+ target_vault_name = aws_backup_vault.database[0].name
2979
+ schedule = "cron(0 5 ? * * *)"
2980
+
2981
+ lifecycle {
2982
+ delete_after = 35
2983
+ }
2984
+ }
2985
+
2986
+ tags = var.tags
2987
+ }
2988
+
2989
+ resource "aws_backup_selection" "database" {
2990
+ count = var.enable_backup ? 1 : 0
2991
+
2992
+ name = "\${var.name}-aurora"
2993
+ plan_id = aws_backup_plan.database[0].id
2994
+ iam_role_arn = aws_iam_role.backup[0].arn
2995
+
2996
+ resources = [aws_rds_cluster.database.arn]
2997
+ }
2998
+
2999
+ output "cluster_arn" {
3000
+ description = "ARN of the Aurora cluster."
3001
+ value = aws_rds_cluster.database.arn
3002
+ }
3003
+
3004
+ output "cluster_resource_id" {
3005
+ description = "Resource ID of the Aurora cluster used for IAM database authentication."
3006
+ value = aws_rds_cluster.database.cluster_resource_id
3007
+ }
3008
+
3009
+ output "proxy_resource_id" {
3010
+ description = "Resource identifier of the RDS Proxy (prx-XXXX), used for IAM rds-db:connect policies when connecting through the proxy. Null if RDS Proxy is disabled."
3011
+ value = var.enable_rds_proxy ? split(":", aws_db_proxy.aurora[0].arn)[6] : null
3012
+ }
3013
+
3014
+ output "cluster_endpoint" {
3015
+ description = "Writer endpoint of the Aurora cluster or proxy."
3016
+ value = var.enable_rds_proxy ? aws_db_proxy.aurora[0].endpoint : aws_rds_cluster.database.endpoint
3017
+ }
3018
+
3019
+ output "reader_endpoint" {
3020
+ description = "Reader endpoint of the Aurora cluster."
3021
+ value = aws_rds_cluster.database.reader_endpoint
3022
+ }
3023
+
3024
+ output "cluster_port" {
3025
+ description = "Port exposed by the Aurora cluster."
3026
+ value = aws_rds_cluster.database.port
3027
+ }
3028
+
3029
+ output "security_group_id" {
3030
+ description = "Security group protecting the Aurora proxy or cluster."
3031
+ value = var.enable_rds_proxy ? aws_security_group.proxy[0].id : aws_security_group.database.id
3032
+ }
3033
+
3034
+ output "database_security_group_id" {
3035
+ description = "Security group protecting the Aurora cluster directly."
3036
+ value = aws_security_group.database.id
3037
+ }
3038
+
3039
+ output "proxy_role_name" {
3040
+ description = "Name of the IAM role used by the RDS Proxy, or null if proxy is disabled."
3041
+ value = var.enable_rds_proxy ? aws_iam_role.proxy[0].name : null
3042
+ }
3043
+
3044
+ output "secret_arn" {
3045
+ description = "ARN of the generated admin credentials secret."
3046
+ value = aws_secretsmanager_secret.credentials.arn
3047
+ }
3048
+
3049
+ output "kms_key_arn" {
3050
+ description = "ARN of the KMS key protecting Aurora and the generated secret."
3051
+ value = aws_kms_key.database.arn
3052
+ }
3053
+
3054
+ output "database_host" {
3055
+ description = "Hostname of the Aurora cluster writer endpoint or proxy endpoint."
3056
+ value = var.enable_rds_proxy ? aws_db_proxy.aurora[0].endpoint : aws_rds_cluster.database.endpoint
3057
+ }
3058
+
3059
+ output "database_ready" {
3060
+ description = "Dependency marker that is resolved after the selected database connection path is ready."
3061
+ value = true
3062
+
3063
+ depends_on = [
3064
+ null_resource.proxy_target_ready,
3065
+ null_resource.cluster_ready
3066
+ ]
3067
+ }
3068
+
3069
+ output "database_name" {
3070
+ description = "Initial database created in the cluster."
3071
+ value = var.database_name
3072
+ }
3073
+
3074
+ output "admin_user" {
3075
+ description = "Admin username stored in the generated secret."
3076
+ value = jsondecode(aws_secretsmanager_secret_version.credentials.secret_string).username
3077
+ }
3078
+ "
3079
+ `;
3080
+
3081
+ exports[`ts#rdb generator > should generate terraform modules with MySQL engine 2`] = `
3082
+ "terraform {
3083
+ required_version = ">= 1.0"
3084
+
3085
+ required_providers {
3086
+ archive = {
3087
+ source = "hashicorp/archive"
3088
+ version = "~> 2.5"
3089
+ }
3090
+ aws = {
3091
+ source = "hashicorp/aws"
3092
+ version = "~> 6.33"
3093
+ }
3094
+ null = {
3095
+ source = "hashicorp/null"
3096
+ version = ">= 3.0"
3097
+ }
3098
+ random = {
3099
+ source = "hashicorp/random"
3100
+ version = ">= 3.0"
3101
+ }
3102
+ }
3103
+ }
3104
+
3105
+ variable "vpc_id" {
3106
+ description = "VPC where the Aurora cluster will be deployed."
3107
+ type = string
3108
+ }
3109
+
3110
+ variable "database_subnet_ids" {
3111
+ description = "Subnet IDs for the Aurora cluster and RDS Proxy. Private isolated subnets are fine — no outbound egress required."
3112
+ type = list(string)
3113
+ }
3114
+
3115
+ variable "lambda_subnet_ids" {
3116
+ description = "Subnet IDs for the create-db-user and migration Lambda functions. Must have outbound egress to AWS service endpoints (e.g. Secrets Manager) — use private subnets with a NAT gateway."
3117
+ type = list(string)
3118
+ }
3119
+
3120
+ variable "engine_version" {
3121
+ description = "Aurora engine version."
3122
+ type = string
3123
+ default = null
3124
+ }
3125
+
3126
+ variable "port" {
3127
+ description = "Database port for the selected Aurora engine."
3128
+ type = number
3129
+ default = null
3130
+ }
3131
+
3132
+ variable "serverless_min_capacity" {
3133
+ description = "Minimum Aurora Serverless v2 ACUs."
3134
+ type = number
3135
+ default = 0.5
3136
+ }
3137
+
3138
+ variable "serverless_max_capacity" {
3139
+ description = "Maximum Aurora Serverless v2 ACUs."
3140
+ type = number
3141
+ default = 4
3142
+ }
3143
+
3144
+ variable "instance_count" {
3145
+ description = "Number of Aurora instances to create."
3146
+ type = number
3147
+ default = 1
3148
+ }
3149
+
3150
+ variable "deletion_protection" {
3151
+ description = "Whether deletion protection is enabled."
3152
+ type = bool
3153
+ default = true
3154
+ }
3155
+
3156
+ variable "skip_final_snapshot" {
3157
+ description = "Whether to skip the final snapshot on deletion."
3158
+ type = bool
3159
+ default = false
3160
+ }
3161
+
3162
+ variable "enable_rds_proxy" {
3163
+ description = "Whether to provision an RDS Proxy in front of the Aurora cluster."
3164
+ type = bool
3165
+ default = true
3166
+ }
3167
+
3168
+ variable "enable_credential_rotation" {
3169
+ description = "Whether to enable automatic credential rotation for the admin secret."
3170
+ type = bool
3171
+ default = true
3172
+ }
3173
+
3174
+ variable "enable_cloudwatch_logs" {
3175
+ description = "Whether to export Aurora engine logs to CloudWatch. PostgreSQL also enables verbose statement logging."
3176
+ type = bool
3177
+ default = false
3178
+ }
3179
+
3180
+ variable "enable_performance_insights" {
3181
+ description = "Whether to enable Performance Insights on Aurora cluster instances."
3182
+ type = bool
3183
+ default = true
3184
+ }
3185
+
3186
+ variable "performance_insights_retention_period" {
3187
+ description = "Retention period, in days, for Performance Insights data when enabled."
3188
+ type = number
3189
+ default = 7
3190
+ }
3191
+
3192
+ variable "enable_backup" {
3193
+ description = "Whether to provision an AWS Backup plan for the Aurora cluster."
3194
+ type = bool
3195
+ default = false
3196
+ }
3197
+
3198
+ variable "tags" {
3199
+ description = "Tags to apply to all resources."
3200
+ type = map(string)
3201
+ default = {}
3202
+ }
3203
+
3204
+ variable "docker_image_tag" {
3205
+ description = "Docker image tag for the migration handler. Defaults to the tag built by the Nx docker target."
3206
+ type = string
3207
+ default = "proj-db-migration:latest"
3208
+ }
3209
+
3210
+ variable "asset_bucket_name" {
3211
+ description = "Name of the shared asset S3 bucket used to stage the Lambda deployment zip. Instantiate the \`core/asset-bucket\` module once per deployment and pass its \`bucket_name\` output here."
3212
+ type = string
3213
+ }
3214
+
3215
+ data "aws_region" "current" {}
3216
+ data "aws_caller_identity" "current" {}
3217
+
3218
+ resource "random_string" "suffix" {
3219
+ length = 8
3220
+ special = false
3221
+ upper = false
3222
+ }
3223
+
3224
+ locals {
3225
+ create_db_user_bundle_path = "\${path.module}/../../../../../../../dist/packages/db/bundle/create-db-user"
3226
+ migration_function_name = "db-migration-\${random_string.suffix.result}"
3227
+ create_db_user_function_name = "db-create-db-user-\${random_string.suffix.result}"
3228
+ database_runtime_user = "db_\${random_string.suffix.result}"
3229
+ }
3230
+
3231
+ module "aurora" {
3232
+ source = "../../../core/rdb/aurora"
3233
+ #checkov:skip=CKV_AWS_139:Deletion protection is enabled but checkov is unable to detect it correctly
3234
+ name = "db"
3235
+ vpc_id = var.vpc_id
3236
+ subnet_ids = var.database_subnet_ids
3237
+ lambda_subnet_ids = var.lambda_subnet_ids
3238
+ engine = "aurora-mysql"
3239
+ engine_version = var.engine_version
3240
+ database_name = "database_name"
3241
+ admin_user = "databaseUser"
3242
+ port = var.port
3243
+ serverless_min_capacity = var.serverless_min_capacity
3244
+ serverless_max_capacity = var.serverless_max_capacity
3245
+ instance_count = var.instance_count
3246
+ deletion_protection = var.deletion_protection
3247
+ skip_final_snapshot = var.skip_final_snapshot
3248
+ enable_rds_proxy = var.enable_rds_proxy
3249
+ enable_credential_rotation = var.enable_credential_rotation
3250
+ enable_cloudwatch_logs = var.enable_cloudwatch_logs
3251
+ enable_performance_insights = var.enable_performance_insights
3252
+ performance_insights_retention_period = var.performance_insights_retention_period
3253
+ enable_backup = var.enable_backup
3254
+ tags = var.tags
3255
+ }
3256
+
3257
+ resource "aws_iam_role_policy" "proxy_db_user_connect" {
3258
+ count = var.enable_rds_proxy ? 1 : 0
3259
+
3260
+ role = module.aurora.proxy_role_name
3261
+
3262
+ policy = jsonencode({
3263
+ Version = "2012-10-17"
3264
+ Statement = [
3265
+ {
3266
+ Effect = "Allow"
3267
+ Action = ["rds-db:connect"]
3268
+ Resource = [
3269
+ "arn:aws:rds-db:\${data.aws_region.current.region}:\${data.aws_caller_identity.current.account_id}:dbuser:\${module.aurora.cluster_resource_id}/\${local.database_runtime_user}"
3270
+ ]
3271
+ }
3272
+ ]
3273
+ })
3274
+ }
3275
+
3276
+ data "archive_file" "create_db_user_zip" {
3277
+ type = "zip"
3278
+ source_dir = local.create_db_user_bundle_path
3279
+ output_path = "\${path.module}/../../../../../../../dist/packages/common/terraform/dbs/db/create-db-user.zip"
3280
+ }
3281
+
3282
+ resource "aws_s3_object" "create_db_user_zip" {
3283
+ bucket = var.asset_bucket_name
3284
+ key = "dbs/db/\${data.archive_file.create_db_user_zip.output_sha256}.zip"
3285
+ source = data.archive_file.create_db_user_zip.output_path
3286
+ source_hash = data.archive_file.create_db_user_zip.output_base64sha256
3287
+ etag = data.archive_file.create_db_user_zip.output_md5
3288
+ }
3289
+
3290
+ module "add_rdb_to_runtime_config" {
3291
+ source = "../../../core/runtime-config/entry"
3292
+
3293
+ namespace = "database"
3294
+ key = "Db"
3295
+ value = {
3296
+ hostname = module.aurora.cluster_endpoint
3297
+ port = module.aurora.cluster_port
3298
+ database = module.aurora.database_name
3299
+ adminUser = module.aurora.admin_user
3300
+ dbUser = local.database_runtime_user
3301
+ region = data.aws_region.current.region
3302
+ }
3303
+ }
3304
+
3305
+ resource "aws_ecr_repository" "migration_handler" {
3306
+ #checkov:skip=CKV_AWS_136:AES256 encryption is sufficient for ECR repositories
3307
+ #checkov:skip=CKV_AWS_51:Mutable tags are intentional; the migration handler is always pushed as latest and redeployment is gated on docker_digest changes in null_resource.docker_publish
3308
+ name = "db-migration-\${random_string.suffix.result}"
3309
+ image_tag_mutability = "MUTABLE"
3310
+ force_delete = true
3311
+
3312
+ image_scanning_configuration {
3313
+ scan_on_push = true
3314
+ }
3315
+
3316
+ tags = var.tags
3317
+ }
3318
+
3319
+ resource "aws_ecr_repository_policy" "migration_handler" {
3320
+ repository = aws_ecr_repository.migration_handler.name
3321
+
3322
+ policy = jsonencode({
3323
+ Version = "2012-10-17"
3324
+ Statement = [
3325
+ {
3326
+ Sid = "AllowPushPull"
3327
+ Effect = "Allow"
3328
+ Principal = {
3329
+ AWS = "arn:aws:iam::\${data.aws_caller_identity.current.account_id}:root"
3330
+ }
3331
+ Action = [
3332
+ "ecr:BatchCheckLayerAvailability",
3333
+ "ecr:CompleteLayerUpload",
3334
+ "ecr:GetDownloadUrlForLayer",
3335
+ "ecr:InitiateLayerUpload",
3336
+ "ecr:PutImage",
3337
+ "ecr:UploadLayerPart"
3338
+ ]
3339
+ },
3340
+ {
3341
+ Sid = "AllowLambdaPull"
3342
+ Effect = "Allow"
3343
+ Principal = {
3344
+ Service = "lambda.amazonaws.com"
3345
+ }
3346
+ Action = [
3347
+ "ecr:BatchCheckLayerAvailability",
3348
+ "ecr:BatchGetImage",
3349
+ "ecr:GetDownloadUrlForLayer"
3350
+ ]
3351
+ }
3352
+ ]
3353
+ })
3354
+ }
3355
+
3356
+ data "external" "docker_digest" {
3357
+ program = ["sh", "-c", "echo '{\\"digest\\":\\"'$(docker inspect \${var.docker_image_tag} --format '{{.Id}}')'\\"}' "]
3358
+ }
3359
+
3360
+ resource "null_resource" "docker_publish" {
3361
+ triggers = {
3362
+ docker_digest = data.external.docker_digest.result.digest
3363
+ repository_url = aws_ecr_repository.migration_handler.repository_url
3364
+ docker_image_tag = var.docker_image_tag
3365
+ }
3366
+
3367
+ provisioner "local-exec" {
3368
+ command = <<-EOT
3369
+ aws ecr get-login-password --region \${data.aws_region.current.region} | docker login --username AWS --password-stdin \${self.triggers.repository_url}
3370
+ docker tag \${self.triggers.docker_image_tag} \${self.triggers.repository_url}:latest
3371
+ docker push \${self.triggers.repository_url}:latest
3372
+ EOT
3373
+ }
3374
+
3375
+ depends_on = [aws_ecr_repository_policy.migration_handler]
3376
+ }
3377
+
3378
+ resource "aws_iam_role" "migration_handler" {
3379
+ name = "DbMigrationHandlerRole-\${random_string.suffix.result}"
3380
+
3381
+ assume_role_policy = jsonencode({
3382
+ Version = "2012-10-17"
3383
+ Statement = [
3384
+ {
3385
+ Effect = "Allow"
3386
+ Principal = {
3387
+ Service = "lambda.amazonaws.com"
3388
+ }
3389
+ Action = "sts:AssumeRole"
3390
+ }
3391
+ ]
3392
+ })
3393
+
3394
+ tags = var.tags
3395
+ }
3396
+
3397
+ resource "aws_iam_role_policy_attachment" "migration_handler_basic_execution" {
3398
+ role = aws_iam_role.migration_handler.name
3399
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
3400
+ }
3401
+
3402
+ resource "aws_iam_role_policy_attachment" "migration_handler_vpc_access" {
3403
+ role = aws_iam_role.migration_handler.name
3404
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
3405
+ }
3406
+
3407
+ resource "aws_iam_role_policy_attachment" "migration_handler_xray" {
3408
+ role = aws_iam_role.migration_handler.name
3409
+ policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
3410
+ }
3411
+
3412
+ resource "aws_iam_role_policy" "migration_handler_access" {
3413
+ role = aws_iam_role.migration_handler.name
3414
+
3415
+ policy = jsonencode({
3416
+ Version = "2012-10-17"
3417
+ Statement = [
3418
+ {
3419
+ Effect = "Allow"
3420
+ Action = [
3421
+ "secretsmanager:GetSecretValue"
3422
+ ]
3423
+ Resource = [module.aurora.secret_arn]
3424
+ },
3425
+ {
3426
+ Effect = "Allow"
3427
+ Action = [
3428
+ "kms:Decrypt"
3429
+ ]
3430
+ Resource = [module.aurora.kms_key_arn]
3431
+ }
3432
+ ]
3433
+ })
3434
+ }
3435
+
3436
+ resource "aws_iam_role" "create_db_user" {
3437
+ name = "DbCreateDbUserRole-\${random_string.suffix.result}"
3438
+
3439
+ assume_role_policy = jsonencode({
3440
+ Version = "2012-10-17"
3441
+ Statement = [
3442
+ {
3443
+ Effect = "Allow"
3444
+ Principal = {
3445
+ Service = "lambda.amazonaws.com"
3446
+ }
3447
+ Action = "sts:AssumeRole"
3448
+ }
3449
+ ]
3450
+ })
3451
+
3452
+ tags = var.tags
3453
+ }
3454
+
3455
+ resource "aws_iam_role_policy_attachment" "create_db_user_basic_execution" {
3456
+ role = aws_iam_role.create_db_user.name
3457
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
3458
+ }
3459
+
3460
+ resource "aws_iam_role_policy_attachment" "create_db_user_vpc_access" {
3461
+ role = aws_iam_role.create_db_user.name
3462
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
3463
+ }
3464
+
3465
+ resource "aws_iam_role_policy_attachment" "create_db_user_xray" {
3466
+ role = aws_iam_role.create_db_user.name
3467
+ policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
3468
+ }
3469
+
3470
+ resource "aws_iam_role_policy" "create_db_user_secret_access" {
3471
+ role = aws_iam_role.create_db_user.name
3472
+
3473
+ policy = jsonencode({
3474
+ Version = "2012-10-17"
3475
+ Statement = [
3476
+ {
3477
+ Effect = "Allow"
3478
+ Action = [
3479
+ "secretsmanager:GetSecretValue"
3480
+ ]
3481
+ Resource = [module.aurora.secret_arn]
3482
+ },
3483
+ {
3484
+ Effect = "Allow"
3485
+ Action = [
3486
+ "kms:Decrypt"
3487
+ ]
3488
+ Resource = [module.aurora.kms_key_arn]
3489
+ }
3490
+ ]
3491
+ })
3492
+ }
3493
+
3494
+ resource "aws_cloudwatch_log_group" "migration_handler" {
3495
+ #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
3496
+ #checkov:skip=CKV_AWS_338:Log retention set to forever
3497
+ #checkov:skip=CKV_AWS_66:Log retention set to forever
3498
+ name = "/aws/lambda/\${local.migration_function_name}"
3499
+ tags = var.tags
3500
+ }
3501
+
3502
+ resource "aws_cloudwatch_log_group" "create_db_user" {
3503
+ #checkov:skip=CKV_AWS_158:Using default CloudWatch log encryption
3504
+ #checkov:skip=CKV_AWS_338:Log retention set to forever
3505
+ #checkov:skip=CKV_AWS_66:Log retention set to forever
3506
+ name = "/aws/lambda/\${local.create_db_user_function_name}"
3507
+ tags = var.tags
3508
+ }
3509
+
3510
+ resource "aws_security_group" "migration_handler" {
3511
+ name_prefix = "db-migration-"
3512
+ description = "Security group for the migration Lambda function"
3513
+ vpc_id = var.vpc_id
3514
+ tags = var.tags
3515
+ }
3516
+
3517
+ resource "aws_vpc_security_group_egress_rule" "migration_handler_to_database" {
3518
+ security_group_id = aws_security_group.migration_handler.id
3519
+ referenced_security_group_id = module.aurora.database_security_group_id
3520
+ from_port = module.aurora.cluster_port
3521
+ to_port = module.aurora.cluster_port
3522
+ ip_protocol = "tcp"
3523
+ description = "Allow outbound traffic to Aurora on database port"
3524
+ }
3525
+
3526
+ resource "aws_vpc_security_group_egress_rule" "migration_handler_https" {
3527
+ security_group_id = aws_security_group.migration_handler.id
3528
+ cidr_ipv4 = "0.0.0.0/0"
3529
+ from_port = 443
3530
+ to_port = 443
3531
+ ip_protocol = "tcp"
3532
+ description = "Allow outbound HTTPS to AWS service endpoints"
3533
+ }
3534
+
3535
+ resource "aws_security_group" "create_db_user" {
3536
+ name_prefix = "db-create-db-user-"
3537
+ description = "Security group for the create-db-user Lambda function"
3538
+ vpc_id = var.vpc_id
3539
+ tags = var.tags
3540
+ }
3541
+
3542
+ resource "aws_vpc_security_group_egress_rule" "create_db_user_to_database" {
3543
+ security_group_id = aws_security_group.create_db_user.id
3544
+ referenced_security_group_id = module.aurora.database_security_group_id
3545
+ from_port = module.aurora.cluster_port
3546
+ to_port = module.aurora.cluster_port
3547
+ ip_protocol = "tcp"
3548
+ description = "Allow outbound traffic to Aurora on database port"
3549
+ }
3550
+
3551
+ resource "aws_vpc_security_group_egress_rule" "create_db_user_https" {
3552
+ security_group_id = aws_security_group.create_db_user.id
3553
+ cidr_ipv4 = "0.0.0.0/0"
3554
+ from_port = 443
3555
+ to_port = 443
3556
+ ip_protocol = "tcp"
3557
+ description = "Allow outbound HTTPS to AWS service endpoints"
3558
+ }
3559
+
3560
+ resource "aws_vpc_security_group_ingress_rule" "migration_handler_to_database" {
3561
+ security_group_id = module.aurora.database_security_group_id
3562
+ referenced_security_group_id = aws_security_group.migration_handler.id
3563
+ from_port = module.aurora.cluster_port
3564
+ to_port = module.aurora.cluster_port
3565
+ ip_protocol = "tcp"
3566
+ description = "Allow inbound traffic from migration Lambda on database port"
3567
+ }
3568
+
3569
+ resource "aws_vpc_security_group_ingress_rule" "create_db_user_to_database" {
3570
+ security_group_id = module.aurora.database_security_group_id
3571
+ referenced_security_group_id = aws_security_group.create_db_user.id
3572
+ from_port = module.aurora.cluster_port
3573
+ to_port = module.aurora.cluster_port
3574
+ ip_protocol = "tcp"
3575
+ description = "Allow inbound traffic from create-db-user Lambda on database port"
3576
+ }
3577
+
3578
+ resource "aws_lambda_function" "create_db_user" {
3579
+ #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for the create-db-user handler
3580
+ #checkov:skip=CKV_AWS_115:Concurrent execution limit not required for the create-db-user handler
3581
+ #checkov:skip=CKV_AWS_173:Lambda environment variables encrypted by managed key
3582
+ #checkov:skip=CKV_AWS_272:Code signing not configured as deployment packages are built and deployed within the same pipeline
3583
+ s3_bucket = aws_s3_object.create_db_user_zip.bucket
3584
+ s3_key = aws_s3_object.create_db_user_zip.key
3585
+ s3_object_version = aws_s3_object.create_db_user_zip.version_id
3586
+ function_name = local.create_db_user_function_name
3587
+ role = aws_iam_role.create_db_user.arn
3588
+ handler = "index.handler"
3589
+ source_code_hash = data.archive_file.create_db_user_zip.output_base64sha256
3590
+ runtime = "nodejs24.x"
3591
+ timeout = 300
3592
+ architectures = ["arm64"]
3593
+
3594
+ tracing_config {
3595
+ mode = "Active"
3596
+ }
3597
+
3598
+ vpc_config {
3599
+ subnet_ids = var.lambda_subnet_ids
3600
+ security_group_ids = [aws_security_group.create_db_user.id]
3601
+ }
3602
+
3603
+ environment {
3604
+ variables = {
3605
+ DATABASE_SECRET_ARN = module.aurora.secret_arn
3606
+ NODE_EXTRA_CA_CERTS = "/var/runtime/ca-cert.pem"
3607
+ }
3608
+ }
3609
+
3610
+ tags = var.tags
3611
+
3612
+ depends_on = [
3613
+ aws_s3_object.create_db_user_zip,
3614
+ aws_iam_role_policy_attachment.create_db_user_basic_execution,
3615
+ aws_iam_role_policy_attachment.create_db_user_vpc_access,
3616
+ aws_iam_role_policy_attachment.create_db_user_xray,
3617
+ aws_iam_role_policy.create_db_user_secret_access,
3618
+ aws_cloudwatch_log_group.create_db_user,
3619
+ aws_vpc_security_group_ingress_rule.create_db_user_to_database
3620
+ ]
3621
+ }
3622
+
3623
+ resource "null_resource" "create_db_user_trigger" {
3624
+ triggers = {
3625
+ cluster_arn = module.aurora.cluster_arn
3626
+ function_name = aws_lambda_function.create_db_user.function_name
3627
+ db_user = local.database_runtime_user
3628
+ bundle_hash = data.archive_file.create_db_user_zip.output_base64sha256
3629
+ }
3630
+
3631
+ provisioner "local-exec" {
3632
+ command = <<-EOT
3633
+ output_file=$(mktemp)
3634
+ response=$(aws lambda invoke \\
3635
+ --region \${data.aws_region.current.region} \\
3636
+ --function-name \${self.triggers.function_name} \\
3637
+ --cli-binary-format raw-in-base64-out \\
3638
+ --payload '{"RequestType":"Create","PhysicalResourceId":"db-user:\${self.triggers.db_user}"}' \\
3639
+ "$output_file")
3640
+ cat "$output_file"
3641
+ echo "$response"
3642
+ if echo "$response" | grep -q '"FunctionError"'; then
3643
+ rm -f "$output_file"
3644
+ exit 1
3645
+ fi
3646
+ rm -f "$output_file"
3647
+ EOT
3648
+ }
3649
+
3650
+ depends_on = [aws_lambda_function.create_db_user, module.aurora]
3651
+ }
3652
+
3653
+ resource "aws_lambda_function" "migration_handler" {
3654
+ #checkov:skip=CKV_AWS_116:Dead Letter Queue not required for the migration handler
3655
+ #checkov:skip=CKV_AWS_115:Concurrent execution limit not required for the migration handler
3656
+ #checkov:skip=CKV_AWS_173:Lambda environment variables encrypted by managed key
3657
+ #checkov:skip=CKV_AWS_272:Code signing does not apply to container image Lambda functions
3658
+ package_type = "Image"
3659
+ function_name = local.migration_function_name
3660
+ role = aws_iam_role.migration_handler.arn
3661
+ image_uri = "\${aws_ecr_repository.migration_handler.repository_url}:latest"
3662
+ memory_size = 1024
3663
+ timeout = 300
3664
+ architectures = ["arm64"]
3665
+
3666
+ tracing_config {
3667
+ mode = "Active"
3668
+ }
3669
+
3670
+ vpc_config {
3671
+ subnet_ids = var.lambda_subnet_ids
3672
+ security_group_ids = [aws_security_group.migration_handler.id]
3673
+ }
3674
+
3675
+ environment {
3676
+ variables = {
3677
+ DATABASE_SECRET_ARN = module.aurora.secret_arn
3678
+ }
3679
+ }
3680
+
3681
+ tags = var.tags
3682
+
3683
+ depends_on = [
3684
+ null_resource.docker_publish,
3685
+ aws_iam_role_policy_attachment.migration_handler_basic_execution,
3686
+ aws_iam_role_policy_attachment.migration_handler_vpc_access,
3687
+ aws_iam_role_policy_attachment.migration_handler_xray,
3688
+ aws_iam_role_policy.migration_handler_access,
3689
+ aws_cloudwatch_log_group.migration_handler,
3690
+ aws_vpc_security_group_ingress_rule.migration_handler_to_database
3691
+ ]
3692
+ }
3693
+
3694
+ resource "null_resource" "migration_trigger" {
3695
+ triggers = {
3696
+ docker_digest = data.external.docker_digest.result.digest
3697
+ cluster_arn = module.aurora.cluster_arn
3698
+ database_ready = module.aurora.database_ready
3699
+ function_name = aws_lambda_function.migration_handler.function_name
3700
+ }
3701
+
3702
+ provisioner "local-exec" {
3703
+ command = <<-EOT
3704
+ output_file=$(mktemp)
3705
+ response=$(aws lambda invoke \\
3706
+ --region \${data.aws_region.current.region} \\
3707
+ --function-name \${self.triggers.function_name} \\
3708
+ --cli-binary-format raw-in-base64-out \\
3709
+ "$output_file")
3710
+ cat "$output_file"
3711
+ echo "$response"
3712
+ if echo "$response" | grep -q '"FunctionError"'; then
3713
+ rm -f "$output_file"
3714
+ exit 1
3715
+ fi
3716
+ rm -f "$output_file"
3717
+ EOT
3718
+ }
3719
+
3720
+ depends_on = [
3721
+ aws_lambda_function.migration_handler,
3722
+ module.aurora,
3723
+ null_resource.create_db_user_trigger,
3724
+ aws_iam_role_policy.proxy_db_user_connect
3725
+ ]
3726
+ }
3727
+
3728
+ output "security_group_id" {
3729
+ description = "Security group ID to allow inbound connections to the database (proxy SG if RDS Proxy is enabled, otherwise DB SG)."
3730
+ value = module.aurora.security_group_id
3731
+ }
3732
+
3733
+ output "database_security_group_id" {
3734
+ description = "Security group ID of the Aurora cluster directly, used for direct DB access rules (e.g. when RDS Proxy is disabled)."
3735
+ value = module.aurora.database_security_group_id
3736
+ }
3737
+
3738
+ output "cluster_resource_id" {
3739
+ description = "Resource ID of the Aurora cluster, used for IAM rds-db:connect policies."
3740
+ value = module.aurora.cluster_resource_id
3741
+ }
3742
+
3743
+ output "proxy_resource_id" {
3744
+ description = "Resource identifier of the RDS Proxy (prx-XXXX), used for IAM rds-db:connect policies when connecting through the proxy. Null if RDS Proxy is disabled."
3745
+ value = module.aurora.proxy_resource_id
3746
+ }
3747
+
3748
+ output "kms_key_arn" {
3749
+ description = "ARN of the KMS key protecting Aurora and the admin credentials secret, used to grant decrypt access alongside secret_arn."
3750
+ value = module.aurora.kms_key_arn
3751
+ }
3752
+
3753
+ output "cluster_arn" {
3754
+ description = "ARN of the Aurora cluster."
3755
+ value = module.aurora.cluster_arn
3756
+ }
3757
+
3758
+ output "cluster_endpoint" {
3759
+ description = "Writer endpoint of the Aurora cluster."
3760
+ value = module.aurora.cluster_endpoint
3761
+ }
3762
+
3763
+ output "reader_endpoint" {
3764
+ description = "Reader endpoint of the Aurora cluster."
3765
+ value = module.aurora.reader_endpoint
3766
+ }
3767
+
3768
+ output "cluster_port" {
3769
+ description = "Port exposed by the Aurora cluster."
3770
+ value = module.aurora.cluster_port
3771
+ }
3772
+
3773
+ output "secret_arn" {
3774
+ description = "ARN of the generated admin credentials secret."
3775
+ value = module.aurora.secret_arn
3776
+ }
3777
+
3778
+ output "database_runtime_user" {
3779
+ description = "Application database user created for IAM-authenticated access."
3780
+ value = local.database_runtime_user
3781
+ }
3782
+
3783
+ "
3784
+ `;
3785
+
3786
+ exports[`ts#rdb generator > should generate the aurora shared construct > packages/db/prisma/models/example.prisma 1`] = `
3787
+ "model ExampleTable {
3788
+ id Int @id @default(autoincrement())
3789
+ column1 String
3790
+ column2 String
3791
+ }
3792
+ "
3793
+ `;
3794
+
3795
+ exports[`ts#rdb generator > should generate the aurora shared construct > packages/db/prisma/schema.prisma 1`] = `
3796
+ "generator client {
3797
+ provider = "prisma-client"
3798
+ output = "../generated/prisma"
3799
+ }
3800
+
3801
+ datasource db {
3802
+ provider = "postgresql"
3803
+ }
3804
+
3805
+ "
3806
+ `;
3807
+
3808
+ exports[`ts#rdb generator > should generate the aurora shared construct > packages/db/scripts/docker-pull.ts 1`] = `
3809
+ "import Docker from 'dockerode';
3810
+ import { promisify } from 'util';
3811
+
3812
+ const docker = new Docker();
3813
+ const image = process.argv[2];
3814
+
3815
+ try {
3816
+ await docker.getImage(image).inspect();
3817
+ } catch (e) {
3818
+ if ((e as { statusCode?: number }).statusCode !== 404) throw e;
3819
+ const stream = (await promisify(docker.pull.bind(docker))(
3820
+ image,
3821
+ )) as NodeJS.ReadableStream;
3822
+ await promisify(docker.modem.followProgress.bind(docker.modem))(stream);
3823
+ }
3824
+ "
3825
+ `;
3826
+
3827
+ exports[`ts#rdb generator > should generate the aurora shared construct > packages/db/scripts/docker-start.ts 1`] = `
3828
+ "import Docker from 'dockerode';
3829
+
3830
+ const [containerName, image, hostPort, dbName, dbUser, dbPassword] =
3831
+ process.argv.slice(2);
3832
+
3833
+ const docker = new Docker();
3834
+
3835
+ let container: Docker.Container;
3836
+
3837
+ try {
3838
+ const existing = docker.getContainer(containerName);
3839
+ const info = await existing.inspect();
3840
+ container = existing;
3841
+ if (!info.State.Running) {
3842
+ await container.start();
3843
+ }
3844
+ } catch (e) {
3845
+ if ((e as { statusCode?: number }).statusCode !== 404) throw e;
3846
+ container = await docker.createContainer({
3847
+ name: containerName,
3848
+ Image: image,
3849
+ Env: [
3850
+ \`POSTGRES_DB=\${dbName}\`,
3851
+ \`POSTGRES_USER=\${dbUser}\`,
3852
+ \`POSTGRES_PASSWORD=\${dbPassword}\`,
3853
+ ],
3854
+ ExposedPorts: { '5432/tcp': {} },
3855
+ HostConfig: {
3856
+ AutoRemove: true,
3857
+ PortBindings: { '5432/tcp': [{ HostPort: hostPort }] },
3858
+ Binds: [\`\${containerName}-data:/var/lib/postgresql\`],
3859
+ },
3860
+ });
3861
+ await container.start();
3862
+ }
3863
+
3864
+ const stream = await container.attach({
3865
+ stream: true,
3866
+ stdout: true,
3867
+ stderr: true,
3868
+ });
3869
+ container.modem.demuxStream(stream, process.stdout, process.stderr);
3870
+
3871
+ let exiting = false;
3872
+
3873
+ async function cleanup() {
3874
+ if (exiting) return;
3875
+ exiting = true;
3876
+ try {
3877
+ await container.stop();
3878
+ } catch (e) {
3879
+ if ((e as { statusCode?: number }).statusCode !== 404) console.error(e);
3880
+ }
3881
+ process.exit(0);
3882
+ }
3883
+
3884
+ process.on('SIGTERM', () => void cleanup());
3885
+ process.on('SIGINT', () => void cleanup());
3886
+ process.on('SIGHUP', () => void cleanup());
3887
+
3888
+ const { StatusCode } = await container.wait();
3889
+ if (!exiting) process.exit(StatusCode);
3890
+ "
3891
+ `;
3892
+
3893
+ exports[`ts#rdb generator > should generate the aurora shared construct > packages/db/scripts/wait-for-db.ts 1`] = `
3894
+ "import { Client } from 'pg';
3895
+
3896
+ const noop = () => undefined;
3897
+
3898
+ const [portArg, dbArg, userArg, passwordArg] = process.argv.slice(2);
3899
+ const timeoutAt = Date.now() + 60000;
3900
+
3901
+ while (Date.now() < timeoutAt) {
3902
+ const client = new Client({
3903
+ host: 'localhost',
3904
+ port: parseInt(portArg),
3905
+ user: userArg,
3906
+ password: passwordArg,
3907
+ database: dbArg,
3908
+ connectionTimeoutMillis: 500,
3909
+ });
3910
+ client.on('error', noop);
3911
+ try {
3912
+ await client.connect();
3913
+ await client.end();
3914
+ console.log('Database is ready.');
3915
+ process.exit(0);
3916
+ } catch {
3917
+ console.log('Database is not ready.');
3918
+ await client.end().catch(noop);
3919
+ }
3920
+ await new Promise((r) => setTimeout(r, 200));
3921
+ }
3922
+
3923
+ throw new Error(\`Timed out waiting for postgres on port \${portArg}\`);
3924
+ "
3925
+ `;
3926
+
3927
+ exports[`ts#rdb generator > should generate the aurora shared construct > packages/db/src/constants.ts 1`] = `
3928
+ "export const DB_PACKAGE_NAME = 'Db';
3929
+
3930
+ // Local development connection details (used when SERVE_LOCAL=true, see serve-local Nx target)
3931
+ export const LOCAL_DB_PORT = 5432;
3932
+ export const LOCAL_DB_HOST = 'localhost';
3933
+ export const LOCAL_DB_NAME = 'database_name';
3934
+ export const LOCAL_DB_USER = 'dbadmin';
3935
+ export const LOCAL_DB_PASSWORD = 'password';
3936
+ "
3937
+ `;
3938
+
3939
+ exports[`ts#rdb generator > should generate the aurora shared construct > packages/db/src/create-db-user-handler.ts 1`] = `
3940
+ "import type { CloudFormationCustomResourceEvent } from 'aws-lambda';
3941
+ import { randomBytes } from 'node:crypto';
3942
+ import { Client, escapeIdentifier } from 'pg';
3943
+ import { getDatabaseSecret, withConnectionRetry } from './utils.js';
3944
+
3945
+ type OnEventResult = {
3946
+ PhysicalResourceId: string;
3947
+ Data?: Record<string, unknown>;
3948
+ };
3949
+
3950
+ const physicalResourceIdPrefix = 'db-user:';
3951
+
3952
+ const resolveDbUser = (physicalResourceId?: string): string =>
3953
+ physicalResourceId?.startsWith(physicalResourceIdPrefix)
3954
+ ? physicalResourceId.slice(physicalResourceIdPrefix.length)
3955
+ : \`db_\${randomBytes(8).toString('hex')}\`;
3956
+
3957
+ const ensureDatabaseUser = async (dbUser: string): Promise<void> => {
3958
+ const { dbname, username, password, host, port } = await getDatabaseSecret();
3959
+ const quotedDbUser = escapeIdentifier(dbUser);
3960
+ const quotedDbName = escapeIdentifier(dbname);
3961
+ const client = new Client({
3962
+ host,
3963
+ port,
3964
+ database: dbname,
3965
+ user: username,
3966
+ password,
3967
+ ssl: {
3968
+ rejectUnauthorized: true,
3969
+ },
3970
+ connectionTimeoutMillis: 10_000,
3971
+ });
3972
+
3973
+ await client.connect();
3974
+
3975
+ try {
3976
+ await client.query('BEGIN');
3977
+
3978
+ const roleExists = await client.query(
3979
+ 'SELECT 1 FROM pg_roles WHERE rolname = $1',
3980
+ [dbUser],
3981
+ );
3982
+ if (roleExists.rowCount === 0) {
3983
+ await client.query(\`CREATE ROLE \${quotedDbUser} WITH LOGIN;\`);
3984
+ }
3985
+
3986
+ await client.query(
3987
+ \`ALTER ROLE \${quotedDbUser} WITH LOGIN;
3988
+ GRANT ALL PRIVILEGES ON DATABASE \${quotedDbName} TO \${quotedDbUser};
3989
+ GRANT USAGE, CREATE ON SCHEMA public TO \${quotedDbUser};
3990
+ GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO \${quotedDbUser};
3991
+ GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO \${quotedDbUser};
3992
+ GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO \${quotedDbUser};
3993
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO \${quotedDbUser};
3994
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO \${quotedDbUser};
3995
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON FUNCTIONS TO \${quotedDbUser};
3996
+ GRANT rds_iam TO \${quotedDbUser};\`,
3997
+ );
3998
+
3999
+ await client.query('COMMIT');
4000
+ } catch (error) {
4001
+ await client.query('ROLLBACK');
4002
+ throw error;
4003
+ } finally {
4004
+ await client.end();
4005
+ }
4006
+ };
4007
+
4008
+ export const handler = async (
4009
+ event: CloudFormationCustomResourceEvent,
4010
+ ): Promise<OnEventResult> => {
4011
+ const dbUser = resolveDbUser(
4012
+ 'PhysicalResourceId' in event ? event.PhysicalResourceId : undefined,
4013
+ );
4014
+
4015
+ if (event.RequestType !== 'Delete') {
4016
+ await withConnectionRetry(() => ensureDatabaseUser(dbUser));
4017
+ }
4018
+
4019
+ return {
4020
+ PhysicalResourceId: \`\${physicalResourceIdPrefix}\${dbUser}\`,
4021
+ Data: {
4022
+ dbUser,
4023
+ },
4024
+ };
4025
+ };
4026
+ "
4027
+ `;
4028
+
4029
+ exports[`ts#rdb generator > should generate the aurora shared construct > packages/db/src/index.ts 1`] = `
4030
+ "export { DB_PACKAGE_NAME } from './constants.js';
4031
+ export { getPrisma } from './prisma.js';
4032
+ "
4033
+ `;
4034
+
4035
+ exports[`ts#rdb generator > should generate the aurora shared construct > packages/db/src/migration-handler.ts 1`] = `
4036
+ "import { execFile } from 'node:child_process';
4037
+ import { promisify } from 'node:util';
4038
+ import { Signer } from '@aws-sdk/rds-signer';
4039
+ import { withConnectionRetry } from './utils.js';
4040
+
4041
+ const buildDatabaseUrl = async (): Promise<string> => {
4042
+ const hostname = process.env.HOSTNAME!;
4043
+ const port = process.env.PORT!;
4044
+ const database = process.env.DATABASE!;
4045
+ const dbUser = process.env.DBUSER!;
4046
+ const region = process.env.AWS_REGION!;
4047
+
4048
+ const iamAuthToken = await new Signer({
4049
+ hostname,
4050
+ port: Number(port),
4051
+ region,
4052
+ username: dbUser,
4053
+ }).getAuthToken();
4054
+
4055
+ return (
4056
+ \`postgresql://\${encodeURIComponent(dbUser)}\` +
4057
+ \`:\${encodeURIComponent(iamAuthToken)}\` +
4058
+ \`@\${hostname}:\${port}/\${database}\` +
4059
+ \`?sslaccept=strict\` // \`sslaccept\` is the correct parameter here for postgresql. The Prisma CLI Rust engine does not use the standard libpq \`sslmode\` option.
4060
+ );
4061
+ };
4062
+
4063
+ export const handler = async () => {
4064
+ await withConnectionRetry(async () => {
4065
+ const databaseUrl = await buildDatabaseUrl();
4066
+ await promisify(execFile)('npx', ['prisma', 'migrate', 'deploy'], {
4067
+ cwd: __dirname,
4068
+ env: {
4069
+ ...process.env,
4070
+ DATABASE_URL: databaseUrl,
4071
+ },
4072
+ });
4073
+ });
4074
+ };
4075
+ "
4076
+ `;
4077
+
4078
+ exports[`ts#rdb generator > should generate the aurora shared construct > packages/db/src/prisma.ts 1`] = `
4079
+ "import { PrismaPg } from '@prisma/adapter-pg';
4080
+ import { Signer } from '@aws-sdk/rds-signer';
4081
+ import { Pool } from 'pg';
4082
+ import { PrismaClient } from '../generated/prisma/client.js';
4083
+ import {
4084
+ DB_PACKAGE_NAME,
4085
+ LOCAL_DB_HOST,
4086
+ LOCAL_DB_NAME,
4087
+ LOCAL_DB_PASSWORD,
4088
+ LOCAL_DB_PORT,
4089
+ LOCAL_DB_USER,
4090
+ } from './constants.js';
4091
+ import { getDatabaseConfig } from './utils.js';
4092
+
4093
+ let prismaPromise: Promise<PrismaClient> | undefined;
4094
+
4095
+ export const getPrisma = (): Promise<PrismaClient> => {
4096
+ prismaPromise ??= (async () => {
4097
+ if (process.env.SERVE_LOCAL === 'true') {
4098
+ const adapter = new PrismaPg(
4099
+ new Pool({
4100
+ host: LOCAL_DB_HOST,
4101
+ port: LOCAL_DB_PORT,
4102
+ database: LOCAL_DB_NAME,
4103
+ user: LOCAL_DB_USER,
4104
+ password: LOCAL_DB_PASSWORD,
4105
+ allowExitOnIdle: true,
4106
+ }),
4107
+ );
4108
+ return new PrismaClient({ adapter });
4109
+ }
4110
+
4111
+ const { hostname, port, database, dbUser, region } =
4112
+ await getDatabaseConfig(DB_PACKAGE_NAME);
4113
+ const adapter = new PrismaPg(
4114
+ new Pool({
4115
+ host: hostname,
4116
+ port,
4117
+ database,
4118
+ user: dbUser,
4119
+ ssl: {
4120
+ rejectUnauthorized: true,
4121
+ },
4122
+ allowExitOnIdle: true,
4123
+ password: async () => {
4124
+ const token = await new Signer({
4125
+ hostname,
4126
+ port,
4127
+ region,
4128
+ username: dbUser,
4129
+ }).getAuthToken();
4130
+ return token;
4131
+ },
4132
+ }),
4133
+ );
4134
+
4135
+ return new PrismaClient({ adapter });
4136
+ })();
4137
+
4138
+ return prismaPromise;
4139
+ };
4140
+ "
4141
+ `;
4142
+
4143
+ exports[`ts#rdb generator > should generate the aurora shared construct > packages/db/src/utils.ts 1`] = `
4144
+ "import { getAppConfig } from '@aws-lambda-powertools/parameters/appconfig';
4145
+ import {
4146
+ GetSecretValueCommand,
4147
+ SecretsManagerClient,
4148
+ } from '@aws-sdk/client-secrets-manager';
4149
+
4150
+ export type DatabaseConfig = {
4151
+ hostname: string;
4152
+ port: number;
4153
+ database: string;
4154
+ adminUser: string;
4155
+ dbUser: string;
4156
+ region: string;
4157
+ };
4158
+
4159
+ export type DatabaseSecret = {
4160
+ dbname: string;
4161
+ username: string;
4162
+ password: string;
4163
+ host: string;
4164
+ port: number;
4165
+ };
4166
+
4167
+ const databaseConfigPromises: Record<string, Promise<DatabaseConfig>> = {};
4168
+
4169
+ const getSecretValue = async (secretArn: string): Promise<string> => {
4170
+ const client = new SecretsManagerClient();
4171
+ const data = await client.send(
4172
+ new GetSecretValueCommand({
4173
+ SecretId: secretArn,
4174
+ }),
4175
+ );
4176
+
4177
+ if (!data.SecretString) {
4178
+ throw new Error('Database secret does not contain SecretString.');
4179
+ }
4180
+
4181
+ return data.SecretString;
4182
+ };
4183
+
4184
+ export const getDatabaseSecret = async (): Promise<DatabaseSecret> => {
4185
+ const secretArn = process.env.DATABASE_SECRET_ARN;
4186
+
4187
+ if (!secretArn) {
4188
+ throw new Error(
4189
+ 'Missing required environment variable DATABASE_SECRET_ARN.',
4190
+ );
4191
+ }
4192
+
4193
+ return JSON.parse(await getSecretValue(secretArn)) as DatabaseSecret;
4194
+ };
4195
+
4196
+ const loadDatabaseConfig = async (
4197
+ runtimeConfigKey: string,
4198
+ ): Promise<DatabaseConfig> => {
4199
+ const appId = process.env.RUNTIME_CONFIG_APP_ID;
4200
+
4201
+ if (!appId) {
4202
+ throw new Error(
4203
+ 'Missing required environment variable RUNTIME_CONFIG_APP_ID.',
4204
+ );
4205
+ }
4206
+
4207
+ const config = await getAppConfig<{
4208
+ [key: string]: DatabaseConfig | undefined;
4209
+ }>('database', {
4210
+ application: appId,
4211
+ environment: 'default',
4212
+ transform: 'json',
4213
+ });
4214
+
4215
+ const databaseConfig = config?.[runtimeConfigKey];
4216
+
4217
+ if (!databaseConfig) {
4218
+ throw new Error(\`RuntimeConfig is missing database.\${runtimeConfigKey}.\`);
4219
+ }
4220
+
4221
+ return databaseConfig;
4222
+ };
4223
+
4224
+ export const getDatabaseConfig = (
4225
+ runtimeConfigKey: string,
4226
+ ): Promise<DatabaseConfig> => {
4227
+ databaseConfigPromises[runtimeConfigKey] ??=
4228
+ loadDatabaseConfig(runtimeConfigKey);
4229
+ return databaseConfigPromises[runtimeConfigKey];
4230
+ };
4231
+
4232
+ // Aurora's writer endpoint is briefly unreachable from within the VPC right
4233
+ // after the cluster reports ready, and IAM policy attachments can take tens
4234
+ // of seconds to propagate before RDS accepts an IAM auth token. Both surface
4235
+ // as errors that resolve on retry.
4236
+ const transientErrorPatterns = [
4237
+ 'ETIMEDOUT',
4238
+ 'ECONNREFUSED',
4239
+ 'ENOTFOUND',
4240
+ 'P1000', // Prisma: authentication failed
4241
+ 'ER_ACCESS_DENIED_ERROR', // MySQL: access denied
4242
+ ];
4243
+
4244
+ const asString = (value: unknown): string => {
4245
+ if (value == null) return '';
4246
+ if (typeof value === 'string') return value;
4247
+ if (Buffer.isBuffer(value)) return value.toString('utf-8');
4248
+ return String(value);
4249
+ };
4250
+
4251
+ export const isTransientConnectionError = (error: unknown): boolean => {
4252
+ const err = error as {
4253
+ message?: unknown;
4254
+ code?: unknown;
4255
+ stderr?: unknown;
4256
+ stdout?: unknown;
4257
+ };
4258
+ const haystack = [
4259
+ error instanceof Error ? error.message : asString(err?.message),
4260
+ asString(err?.code),
4261
+ asString(err?.stderr),
4262
+ asString(err?.stdout),
4263
+ ].join('\\n');
4264
+ return transientErrorPatterns.some((p) => haystack.includes(p));
4265
+ };
4266
+
4267
+ export const withConnectionRetry = async <T>(
4268
+ fn: () => Promise<T>,
4269
+ {
4270
+ maxAttempts = 6,
4271
+ delayMs = 10_000,
4272
+ }: { maxAttempts?: number; delayMs?: number } = {},
4273
+ ): Promise<T> => {
4274
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
4275
+ try {
4276
+ return await fn();
4277
+ } catch (error) {
4278
+ if (attempt === maxAttempts || !isTransientConnectionError(error)) {
4279
+ throw error;
4280
+ }
4281
+ const wait = delayMs * attempt;
4282
+ console.log(
4283
+ \`Transient connection error (attempt \${attempt}/\${maxAttempts}), retrying in \${wait}ms: \${
4284
+ error instanceof Error ? error.message : String(error)
4285
+ }\`,
4286
+ );
4287
+ await new Promise((resolve) => setTimeout(resolve, wait));
4288
+ }
4289
+ }
4290
+ throw new Error('unreachable');
4291
+ };
4292
+ "
4293
+ `;
4294
+
4295
+ exports[`ts#rdb generator > should generate the aurora shared construct 1`] = `
4296
+ "import { CfnOutput, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib';
4297
+ import { CustomResource } from 'aws-cdk-lib';
4298
+ import { IConnectable, IVpc, Port, SubnetType } from 'aws-cdk-lib/aws-ec2';
4299
+ import { IGrantable, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
4300
+ import { Key } from 'aws-cdk-lib/aws-kms';
4301
+ import { Platform } from 'aws-cdk-lib/aws-ecr-assets';
4302
+ import {
4303
+ Architecture,
4304
+ Code,
4305
+ DockerImageCode,
4306
+ DockerImageFunction,
4307
+ Function,
4308
+ Runtime,
4309
+ Tracing,
4310
+ } from 'aws-cdk-lib/aws-lambda';
4311
+ import {
4312
+ AuroraMysqlEngineVersion,
4313
+ AuroraPostgresEngineVersion,
4314
+ ClusterInstance,
4315
+ Credentials,
4316
+ DatabaseCluster,
4317
+ DatabaseClusterEngine,
4318
+ DatabaseClusterProps,
4319
+ DatabaseProxy,
4320
+ DefaultAuthScheme,
4321
+ IClusterEngine,
4322
+ } from 'aws-cdk-lib/aws-rds';
4323
+ import { Provider } from 'aws-cdk-lib/custom-resources';
4324
+ import { Trigger } from 'aws-cdk-lib/triggers';
4325
+ import { Construct } from 'constructs';
4326
+ import { RuntimeConfig } from '../runtime-config.js';
4327
+
4328
+ export type AuroraDatabaseEngineVersion =
4329
+ | AuroraMysqlEngineVersion
4330
+ | AuroraPostgresEngineVersion;
4331
+
4332
+ export interface AuroraDatabaseEngine {
4333
+ /**
4334
+ * Discriminant identifying the engine type.
4335
+ */
4336
+ readonly type: 'mysql' | 'postgres';
4337
+
4338
+ /**
4339
+ * Default Aurora engine version used when one is not explicitly provided.
4340
+ */
4341
+ readonly defaultVersion: AuroraDatabaseEngineVersion;
4342
+
4343
+ /**
4344
+ * Builds the CDK cluster engine for the provided engine version.
4345
+ */
4346
+ clusterEngine(version: AuroraDatabaseEngineVersion): IClusterEngine;
4347
+ }
4348
+
4349
+ export class AuroraDatabaseEngines {
4350
+ public static mysql({
4351
+ defaultVersion = AuroraMysqlEngineVersion.VER_3_12_0,
4352
+ }: {
4353
+ defaultVersion?: AuroraMysqlEngineVersion;
4354
+ }): AuroraDatabaseEngine {
4355
+ return {
4356
+ type: 'mysql',
4357
+ defaultVersion,
4358
+ clusterEngine: (version) =>
4359
+ DatabaseClusterEngine.auroraMysql({
4360
+ version: version as AuroraMysqlEngineVersion,
4361
+ }),
4362
+ };
4363
+ }
4364
+
4365
+ public static postgres({
4366
+ defaultVersion = AuroraPostgresEngineVersion.VER_17_7,
4367
+ }: {
4368
+ defaultVersion?: AuroraPostgresEngineVersion;
4369
+ }): AuroraDatabaseEngine {
4370
+ return {
4371
+ type: 'postgres',
4372
+ defaultVersion,
4373
+ clusterEngine: (version) =>
4374
+ DatabaseClusterEngine.auroraPostgres({
4375
+ version: version as AuroraPostgresEngineVersion,
4376
+ }),
4377
+ };
4378
+ }
4379
+ }
4380
+
4381
+ type _AuroraDatabaseProps = Omit<
4382
+ DatabaseClusterProps,
4383
+ | 'credentials'
4384
+ | 'defaultDatabaseName'
4385
+ | 'engine'
4386
+ | 'iamAuthentication'
4387
+ | 'instanceProps'
4388
+ | 'writer'
4389
+ >;
4390
+
4391
+ export interface AuroraDatabaseProps extends _AuroraDatabaseProps {
4392
+ /**
4393
+ * VPC where the Aurora cluster will be deployed.
4394
+ */
4395
+ readonly vpc: IVpc;
4396
+
4397
+ /**
4398
+ * Aurora engine preset used to build the cluster.
4399
+ */
4400
+ readonly engine: AuroraDatabaseEngine;
4401
+
4402
+ /**
4403
+ * The engine version to deploy.
4404
+ *
4405
+ * @default - engine.defaultVersion
4406
+ */
4407
+ readonly engineVersion?: AuroraDatabaseEngineVersion;
4408
+
4409
+ /**
4410
+ * Admin username used when generating credentials.
4411
+ */
4412
+ readonly adminUser: string;
4413
+
4414
+ /**
4415
+ * The initial database created in the cluster.
4416
+ */
4417
+ readonly databaseName: string;
4418
+
4419
+ /**
4420
+ * RuntimeConfig key used under the \`database\` namespace.
4421
+ */
4422
+ readonly runtimeConfigKey: string;
4423
+
4424
+ /**
4425
+ * Migration bundle used to create a migration handler Lambda.
4426
+ */
4427
+ readonly migrationBundleDir: string;
4428
+
4429
+ /**
4430
+ * Bundle used to create or reconcile the application database user.
4431
+ */
4432
+ readonly createDbUserBundleDir: string;
4433
+
4434
+ /**
4435
+ * Writer instance for the Aurora cluster.
4436
+ */
4437
+ readonly writer?: DatabaseClusterProps['writer'];
4438
+
4439
+ /**
4440
+ * Whether to provision an RDS Proxy in front of the Aurora cluster.
4441
+ *
4442
+ * @default true
4443
+ */
4444
+ readonly enableRdsProxy?: boolean;
4445
+
4446
+ /**
4447
+ * Whether to enable automatic credential rotation for the admin secret.
4448
+ *
4449
+ * @default true
4450
+ */
4451
+ readonly enableCredentialRotation?: boolean;
4452
+
4453
+ /**
4454
+ * Whether to enable deletion protection on the Aurora cluster.
4455
+ *
4456
+ * @default true
4457
+ */
4458
+ readonly deletionProtection?: boolean;
4459
+
4460
+ /**
4461
+ * Removal policy applied to the Aurora cluster.
4462
+ *
4463
+ * @default RemovalPolicy.RETAIN
4464
+ */
4465
+ readonly removalPolicy?: RemovalPolicy;
4466
+
4467
+ /**
4468
+ * Whether to enable automatic key rotation on the KMS key used to encrypt the Aurora cluster and its credentials secret.
4469
+ *
4470
+ * @default true
4471
+ */
4472
+ readonly enableKeyRotation?: boolean;
4473
+ }
4474
+
4475
+ /**
4476
+ * Reusable Aurora database construct that supports different Aurora engines
4477
+ * through typed engine presets.
4478
+ */
4479
+ export abstract class AuroraDatabase extends Construct {
4480
+ public readonly cluster: DatabaseCluster;
4481
+ public readonly proxy?: DatabaseProxy;
4482
+ private readonly adminUser: string;
4483
+ private readonly dbUser: string;
4484
+
4485
+ constructor(
4486
+ scope: Construct,
4487
+ id: string,
4488
+ {
4489
+ vpc,
4490
+ adminUser,
4491
+ databaseName,
4492
+ runtimeConfigKey,
4493
+ migrationBundleDir,
4494
+ createDbUserBundleDir,
4495
+ vpcSubnets,
4496
+ writer,
4497
+ enableRdsProxy = true,
4498
+ enableCredentialRotation = true,
4499
+ deletionProtection = true,
4500
+ removalPolicy = RemovalPolicy.RETAIN,
4501
+ enableKeyRotation = true,
4502
+ engine,
4503
+ engineVersion,
4504
+ ...clusterProps
4505
+ }: AuroraDatabaseProps,
4506
+ ) {
4507
+ super(scope, id);
4508
+
4509
+ this.adminUser = adminUser;
4510
+
4511
+ const key = new Key(this, 'EncryptionKey', { enableKeyRotation });
4512
+ this.cluster = new DatabaseCluster(this, 'DatabaseCluster', {
4513
+ ...clusterProps,
4514
+ vpc,
4515
+ vpcSubnets,
4516
+ engine: engine.clusterEngine(engineVersion ?? engine.defaultVersion),
4517
+ writer: writer ?? ClusterInstance.serverlessV2('writer'),
4518
+ credentials: Credentials.fromGeneratedSecret(adminUser, {
4519
+ encryptionKey: key,
4520
+ }),
4521
+ iamAuthentication: true,
4522
+ monitoringInterval: Duration.seconds(5),
4523
+ defaultDatabaseName: databaseName,
4524
+ storageEncrypted: true,
4525
+ storageEncryptionKey: key,
4526
+ deletionProtection,
4527
+ removalPolicy,
4528
+ });
4529
+
4530
+ if (enableCredentialRotation) {
4531
+ this.cluster.addRotationSingleUser({
4532
+ automaticallyAfter: Duration.days(30),
4533
+ vpcSubnets: {
4534
+ subnetType: SubnetType.PRIVATE_WITH_EGRESS,
4535
+ },
4536
+ });
4537
+ }
4538
+
4539
+ const proxyRole = enableRdsProxy
4540
+ ? new Role(this, 'DatabaseProxyRole', {
4541
+ assumedBy: new ServicePrincipal('rds.amazonaws.com'),
4542
+ })
4543
+ : undefined;
4544
+
4545
+ if (enableRdsProxy) {
4546
+ this.proxy = this.cluster.addProxy('DatabaseProxy', {
4547
+ vpc,
4548
+ vpcSubnets,
4549
+ defaultAuthScheme: DefaultAuthScheme.IAM_AUTH,
4550
+ role: proxyRole,
4551
+ });
4552
+ }
4553
+
4554
+ const databaseHostname =
4555
+ this.proxy?.endpoint ?? this.cluster.clusterEndpoint.hostname;
4556
+ const databasePort = this.cluster.clusterEndpoint.port;
4557
+
4558
+ const createDbUserHandler = new Function(this, 'CreateDbUserHandler', {
4559
+ code: Code.fromAsset(createDbUserBundleDir),
4560
+ handler: 'index.handler',
4561
+ runtime: Runtime.NODEJS_LATEST,
4562
+ timeout: Duration.minutes(5),
4563
+ tracing: Tracing.ACTIVE,
4564
+ vpc,
4565
+ vpcSubnets: {
4566
+ subnetType: SubnetType.PRIVATE_WITH_EGRESS,
4567
+ },
4568
+ environment: {
4569
+ DATABASE_SECRET_ARN: this.cluster.secret!.secretArn,
4570
+ NODE_EXTRA_CA_CERTS: '/var/runtime/ca-cert.pem',
4571
+ },
4572
+ architecture: Architecture.ARM_64,
4573
+ });
4574
+
4575
+ this.cluster.connections.allowDefaultPortFrom(
4576
+ createDbUserHandler,
4577
+ 'Allow the create-db-user handler to connect to the database',
4578
+ );
4579
+ this.grantSecretRead(createDbUserHandler);
4580
+
4581
+ const createDbUserProvider = new Provider(this, 'CreateDbUserProvider', {
4582
+ onEventHandler: createDbUserHandler,
4583
+ });
4584
+
4585
+ const createDbUserResource = new CustomResource(
4586
+ this,
4587
+ 'CreateDbUserResource',
4588
+ {
4589
+ serviceToken: createDbUserProvider.serviceToken,
4590
+ properties: {
4591
+ clusterIdentifier: this.cluster.clusterIdentifier,
4592
+ },
4593
+ },
4594
+ );
4595
+ createDbUserResource.node.addDependency(this.cluster);
4596
+ this.dbUser = createDbUserResource.getAttString('dbUser');
4597
+ if (this.proxy && proxyRole) {
4598
+ this.cluster.grantConnect(proxyRole, this.dbUser);
4599
+ }
4600
+
4601
+ const rc = RuntimeConfig.ensure(this);
4602
+ rc.set('database', runtimeConfigKey, {
4603
+ hostname: databaseHostname,
4604
+ port: databasePort,
4605
+ database: databaseName,
4606
+ adminUser: this.adminUser,
4607
+ dbUser: this.dbUser,
4608
+ region: Stack.of(this).region,
4609
+ });
4610
+
4611
+ const migrationHandler = new DockerImageFunction(this, 'MigrationHandler', {
4612
+ code: DockerImageCode.fromImageAsset(migrationBundleDir, {
4613
+ platform: Platform.LINUX_ARM64,
4614
+ }),
4615
+ memorySize: 1024,
4616
+ timeout: Duration.minutes(5),
4617
+ tracing: Tracing.ACTIVE,
4618
+ vpc,
4619
+ environment:
4620
+ engine.type === 'mysql'
4621
+ ? {
4622
+ DATABASE_SECRET_ARN: this.cluster.secret!.secretArn,
4623
+ }
4624
+ : {
4625
+ HOSTNAME: databaseHostname,
4626
+ DATABASE: databaseName,
4627
+ PORT: databasePort.toString(),
4628
+ DBUSER: this.dbUser,
4629
+ },
4630
+ architecture: Architecture.ARM_64,
4631
+ });
4632
+
4633
+ if (engine.type === 'mysql') {
4634
+ this.cluster.connections.allowDefaultPortFrom(migrationHandler);
4635
+ this.grantSecretRead(migrationHandler);
4636
+ } else {
4637
+ this.allowDefaultPortFrom(migrationHandler);
4638
+ this.grantConnect(migrationHandler);
4639
+ }
4640
+
4641
+ const trigger = new Trigger(this, 'MigrationTrigger', {
4642
+ handler: migrationHandler,
4643
+ });
4644
+ trigger.node.addDependency(createDbUserResource);
4645
+
4646
+ new CfnOutput(this, 'ClusterEndpoint', {
4647
+ value: this.cluster.clusterEndpoint.hostname,
4648
+ });
4649
+
4650
+ if (this.proxy?.endpoint) {
4651
+ new CfnOutput(this, 'ProxyEndpoint', {
4652
+ value: this.proxy?.endpoint,
4653
+ });
4654
+ }
4655
+ }
4656
+
4657
+ public allowDefaultPortFrom(other: IConnectable, description?: string): void {
4658
+ if (this.proxy) {
4659
+ this.proxy.connections.allowFrom(
4660
+ other,
4661
+ Port.tcp(this.cluster.clusterEndpoint.port),
4662
+ description,
4663
+ );
4664
+ } else {
4665
+ this.cluster.connections.allowDefaultPortFrom(other, description);
4666
+ }
4667
+ }
4668
+
4669
+ public grantSecretRead(grantee: IGrantable) {
4670
+ return this.cluster.secret!.grantRead(grantee);
4671
+ }
4672
+
4673
+ public grantConnect(grantee: IGrantable) {
4674
+ return this.proxy
4675
+ ? this.proxy.grantConnect(grantee, this.dbUser)
4676
+ : this.cluster.grantConnect(grantee, this.dbUser);
4677
+ }
4678
+ }
4679
+ "
4680
+ `;
4681
+
4682
+ exports[`ts#rdb generator > should generate the aurora shared construct 2`] = `
4683
+ "import * as path from 'path';
4684
+ import * as url from 'url';
4685
+ import { Construct } from 'constructs';
4686
+ import { DB_PACKAGE_NAME } from ':proj/db';
4687
+ import {
4688
+ AuroraDatabase,
4689
+ AuroraDatabaseEngines,
4690
+ AuroraDatabaseProps,
4691
+ } from '../../core/rdb/aurora.js';
4692
+ import { findWorkspaceRoot } from '../../core/workspace.js';
4693
+
4694
+ export type DbProps = Omit<
4695
+ AuroraDatabaseProps,
4696
+ | 'databaseName'
4697
+ | 'adminUser'
4698
+ | 'createDbUserBundleDir'
4699
+ | 'engine'
4700
+ | 'runtimeConfigKey'
4701
+ | 'migrationBundleDir'
4702
+ >;
4703
+
4704
+ /**
4705
+ * CDK construct that provisions an Aurora Serverless v2 cluster.
4706
+ */
4707
+ export class Db extends AuroraDatabase {
4708
+ constructor(scope: Construct, id: string, props: DbProps) {
4709
+ super(scope, id, {
4710
+ ...props,
4711
+ databaseName: 'database_name',
4712
+ adminUser: 'databaseUser',
4713
+ runtimeConfigKey: DB_PACKAGE_NAME,
4714
+ migrationBundleDir: path.join(
4715
+ findWorkspaceRoot(url.fileURLToPath(new URL(import.meta.url))),
4716
+ 'dist/packages/db/bundle/migration',
4717
+ ),
4718
+ createDbUserBundleDir: path.join(
4719
+ findWorkspaceRoot(url.fileURLToPath(new URL(import.meta.url))),
4720
+ 'dist/packages/db/bundle/create-db-user',
4721
+ ),
4722
+ engine: AuroraDatabaseEngines.postgres({}),
4723
+ });
4724
+ }
4725
+ }
4726
+ "
4727
+ `;
4728
+
4729
+ exports[`ts#rdb generator > should generate the aurora shared construct 3`] = `
4730
+ "import { defineConfig } from 'prisma/config';
4731
+
4732
+ const getDatabaseUrl = async () => {
4733
+ if (process.env.SERVE_LOCAL === 'true') {
4734
+ const {
4735
+ LOCAL_DB_HOST,
4736
+ LOCAL_DB_NAME,
4737
+ LOCAL_DB_PASSWORD,
4738
+ LOCAL_DB_PORT,
4739
+ LOCAL_DB_USER,
4740
+ } = await import('./src/constants.js');
4741
+ return \`postgresql://\${LOCAL_DB_USER}:\${LOCAL_DB_PASSWORD}@\${LOCAL_DB_HOST}:\${LOCAL_DB_PORT}/\${LOCAL_DB_NAME}\`;
4742
+ }
4743
+ return process.env.DATABASE_URL;
4744
+ };
4745
+
4746
+ export default defineConfig({
4747
+ schema: 'prisma/',
4748
+ migrations: {
4749
+ path: 'prisma/migrations',
4750
+ },
4751
+ datasource: {
4752
+ url: await getDatabaseUrl(),
4753
+ },
4754
+ });
4755
+ "
4756
+ `;
4757
+
4758
+ exports[`ts#rdb generator > should generate the aurora shared construct 4`] = `
4759
+ "FROM public.ecr.aws/lambda/nodejs:24
4760
+
4761
+ WORKDIR \${LAMBDA_TASK_ROOT}
4762
+
4763
+ RUN npm install prisma@7.8.0
4764
+
4765
+ COPY index.js ./index.js
4766
+ COPY prisma ./prisma
4767
+ COPY prisma.config.ts ./prisma.config.ts
4768
+
4769
+ RUN curl -fsSL "https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem" \\
4770
+ -o /etc/pki/ca-trust/source/anchors/rds-bundle.pem && \\
4771
+ update-ca-trust
4772
+
4773
+ CMD ["index.handler"]
4774
+ "
4775
+ `;
4776
+
4777
+ exports[`ts#rdb generator > should generate the aurora shared construct 5`] = `
4778
+ "import { defineConfig } from 'rolldown';
4779
+
4780
+ export default defineConfig([
4781
+ {
4782
+ tsconfig: 'tsconfig.lib.json',
4783
+ input: 'src/migration-handler.ts',
4784
+ output: {
4785
+ file: '../../dist/packages/db/bundle/migration/index.js',
4786
+ format: 'cjs',
4787
+ inlineDynamicImports: true,
4788
+ },
4789
+ platform: 'node',
4790
+ },
4791
+ {
4792
+ tsconfig: 'tsconfig.lib.json',
4793
+ input: 'src/create-db-user-handler.ts',
4794
+ output: {
4795
+ file: '../../dist/packages/db/bundle/create-db-user/index.js',
4796
+ format: 'cjs',
4797
+ inlineDynamicImports: true,
4798
+ },
4799
+ platform: 'node',
4800
+ },
4801
+ ]);
4802
+ "
4803
+ `;
4804
+
4805
+ exports[`ts#rdb generator > should generate the aurora shared construct 6`] = `
4806
+ "export * from './rdb/aurora.js';
4807
+ export * from './app.js';
4808
+ export * from './checkov.js';
4809
+ export * from './runtime-config.js';
4810
+ export * from './workspace.js';
4811
+ "
4812
+ `;
4813
+
4814
+ exports[`ts#rdb generator > should generate the aurora shared construct 7`] = `
4815
+ "export * from './dbs/index.js';
4816
+ "
4817
+ `;
4818
+
4819
+ exports[`ts#rdb generator > should generate the aurora shared construct 8`] = `
4820
+ "export * from './db.js';
4821
+ "
4822
+ `;