From 41b6c490d6c0a9563ecea0bcea931f8870ed7e7a Mon Sep 17 00:00:00 2001 From: vincent de smet Date: Thu, 6 Aug 2026 23:44:13 +0700 Subject: [PATCH] feat(aws): storage.rds DatabaseProxy + ServerlessCluster v1 (deprecation-kept) at v2.263.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RDS PR 2e (series finale): proxy.ts (DatabaseProxy, ProxyTarget, SessionPinningFilter), proxy-endpoint.ts (DbProxyEndpoint), and serverless-cluster.ts — ported IN FULL per user directive with 14 ADDED @deprecated markers (upstream has zero; the file predates the convention) because AWS retired Aurora Serverless v1 (engine_mode serverless is no longer creatable) — unit-validation only, documented on the class. addProxy() wiring re-enabled on instance/cluster (earlier TODO deferrals). Upstream's single CfnDBProxyTargetGroup splits into aws_db_proxy_default_target_group + aws_db_proxy_target (depends_on edge); full connection_pool_config surface regression-tested. Live-caught defect: provider validates aws_db_proxy.name (and endpoint name) lowercase-only at plan — generated defaults now lowercased (regression test added). clientPasswordAuthType stays omitted when unset — live drift oracle proves the server-side default does NOT drift. 723 rds tests. Live integ rds.proxy: RDS Proxy fronting MySQL db.t3.micro, target registered through the split, drift-clean, PASS 665.77s. --- integ/aws/storage/Makefile | 4 + integ/aws/storage/apps/rds.proxy.ts | 89 ++ integ/aws/storage/rds_proxy_test.go | 77 + src/aws/storage/rds/cluster-ref.ts | 14 +- src/aws/storage/rds/cluster.ts | 15 +- src/aws/storage/rds/index.ts | 6 +- src/aws/storage/rds/instance.ts | 31 +- src/aws/storage/rds/proxy-endpoint.ts | 273 ++++ src/aws/storage/rds/proxy.ts | 892 ++++++++++++ src/aws/storage/rds/serverless-cluster.ts | 1276 +++++++++++++++++ .../__snapshots__/proxy-endpoint.test.ts.snap | 513 +++++++ .../rds/__snapshots__/proxy.test.ts.snap | 498 +++++++ test/aws/storage/rds/proxy-endpoint.test.ts | 193 +++ test/aws/storage/rds/proxy.test.ts | 998 +++++++++++++ .../serverless-cluster-from-snapshot.test.ts | 200 +++ .../storage/rds/serverless-cluster.test.ts | 1100 ++++++++++++++ 16 files changed, 6147 insertions(+), 32 deletions(-) create mode 100644 integ/aws/storage/apps/rds.proxy.ts create mode 100644 integ/aws/storage/rds_proxy_test.go create mode 100644 src/aws/storage/rds/proxy-endpoint.ts create mode 100644 src/aws/storage/rds/proxy.ts create mode 100644 src/aws/storage/rds/serverless-cluster.ts create mode 100644 test/aws/storage/rds/__snapshots__/proxy-endpoint.test.ts.snap create mode 100644 test/aws/storage/rds/__snapshots__/proxy.test.ts.snap create mode 100644 test/aws/storage/rds/proxy-endpoint.test.ts create mode 100644 test/aws/storage/rds/proxy.test.ts create mode 100644 test/aws/storage/rds/serverless-cluster-from-snapshot.test.ts create mode 100644 test/aws/storage/rds/serverless-cluster.test.ts diff --git a/integ/aws/storage/Makefile b/integ/aws/storage/Makefile index 530a54e1..85b94781 100644 --- a/integ/aws/storage/Makefile +++ b/integ/aws/storage/Makefile @@ -36,6 +36,10 @@ rds.cluster: ## Test DatabaseCluster L2 (live Aurora PostgreSQL Serverless v2 + go test -v -count 1 -timeout 60m ./... -run ^TestRdsCluster$ .PHONY: rds.cluster +rds.proxy: ## Test DatabaseProxy L2 (live RDS Proxy fronting MySQL db.t3.micro) + go test -v -count 1 -timeout 45m ./... -run ^TestRdsProxy$ +.PHONY: rds.proxy + bucket-notifications: ## Test S3 Bucket with EventBridge Notifications go test -v -count 1 -timeout 15m ./... -run ^TestBucketNotifications$ .PHONY: bucket-notifications diff --git a/integ/aws/storage/apps/rds.proxy.ts b/integ/aws/storage/apps/rds.proxy.ts new file mode 100644 index 00000000..98abb792 --- /dev/null +++ b/integ/aws/storage/apps/rds.proxy.ts @@ -0,0 +1,89 @@ +// Live test for the storage.rds DatabaseProxy L2 (RDS PR 2e): a real RDS +// Proxy fronting a MySQL db.t3.micro deployed through the DatabaseInstance L2, +// authenticating via the instance's generated DatabaseSecret. Exercises the +// CfnDBProxyTargetGroup -> aws_db_proxy_default_target_group + aws_db_proxy_target +// resource split and the proxy role's secret grants. +// +// ServerlessCluster v1 (also in this PR) is deliberately NOT live-tested: AWS +// retired Aurora Serverless v1 (engine_mode "serverless" is no longer +// creatable) -- the L2 ships deprecation-marked for API/migration parity with +// unit-level validation only (see serverless-cluster.ts class docs). +// +// NOTE: the auto-generated DatabaseSecret has a deterministic name and no +// recovery-window override -- a re-run within 30 days of destroy needs +// `aws secretsmanager delete-secret --force-delete-without-recovery` first. +import { App, LocalBackend, TerraformOutput } from "cdktn"; +import { aws, Duration } from "../../../../src"; + +const environmentName = process.env.ENVIRONMENT_NAME ?? "test"; +const region = process.env.AWS_REGION ?? "us-east-1"; +const outdir = process.env.OUT_DIR ?? "cdktf.out"; +const stackName = process.env.STACK_NAME ?? "rds.proxy"; + +const app = new App({ + outdir, +}); + +const stack = new aws.AwsStack(app, stackName, { + gridUUID: "g22222222-2222", + environmentName, + providerConfig: { + region, + }, +}); +new LocalBackend(stack, { + path: `${stackName}.tfstate`, +}); + +const vpc = new aws.compute.Vpc(stack, "Vpc", { + maxAzs: 2, + natGateways: 0, + subnetConfiguration: [ + { + name: "isolated", + subnetType: aws.compute.SubnetType.PRIVATE_ISOLATED, + cidrMask: 24, + }, + ], +}); + +const instance = new aws.storage.rds.DatabaseInstance(stack, "Database", { + // Major-only version: AWS picks the latest available minor, sidestepping + // the retired-minor-version trap the rds.cluster fixture hit. + engine: aws.storage.rds.DatabaseInstanceEngine.mysql({ + version: aws.storage.rds.MysqlEngineVersion.VER_8_0, + }), + instanceType: aws.compute.InstanceType.of( + aws.compute.InstanceClass.BURSTABLE3, + aws.compute.InstanceSize.MICRO, + ), + vpc, + vpcSubnets: { subnetType: aws.compute.SubnetType.PRIVATE_ISOLATED }, + credentials: aws.storage.rds.Credentials.fromGeneratedSecret("dbadmin"), + allocatedStorage: 20, + backupRetention: Duration.days(0), + multiAz: false, + skipFinalSnapshot: true, +}); + +const proxy = new aws.storage.rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: aws.storage.rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + vpcSubnets: { subnetType: aws.compute.SubnetType.PRIVATE_ISOLATED }, +}); + +new TerraformOutput(stack, "proxy_name", { + value: proxy.dbProxyName, + staticId: true, +}); +new TerraformOutput(stack, "proxy_arn", { + value: proxy.dbProxyArn, + staticId: true, +}); +new TerraformOutput(stack, "instance_identifier", { + value: instance.instanceIdentifier, + staticId: true, +}); + +app.synth(); diff --git a/integ/aws/storage/rds_proxy_test.go b/integ/aws/storage/rds_proxy_test.go new file mode 100644 index 00000000..c7f4868f --- /dev/null +++ b/integ/aws/storage/rds_proxy_test.go @@ -0,0 +1,77 @@ +package test + +import ( + "context" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/rds" + "github.com/gruntwork-io/terratest/modules/terraform" + test_structure "github.com/gruntwork-io/terratest/modules/test-structure" + "github.com/stretchr/testify/require" +) + +// Run the apps/rds.proxy.ts integration test: a real RDS Proxy fronting a +// MySQL db.t3.micro through the DatabaseProxy L2. Validates proxy read-back +// (engine family, TLS), target registration through the +// default-target-group/target resource split, and the post-apply drift oracle. +func TestRdsProxy(t *testing.T) { + runStorageIntegrationTest(t, "rds.proxy", "us-east-1", validateRdsProxy) +} + +func validateRdsProxy(t *testing.T, tfWorkingDir string, awsRegion string) { + terraformOptions := test_structure.LoadTerraformOptions(t, tfWorkingDir) + outputs := terraform.OutputAll(t, terraformOptions) + + proxyName := outputs["proxy_name"].(string) + instanceID := outputs["instance_identifier"].(string) + + ctx := context.Background() + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(awsRegion)) + require.NoError(t, err) + client := rds.NewFromConfig(cfg) + + // --- 1. Proxy read-back. --- + dp, err := client.DescribeDBProxies(ctx, &rds.DescribeDBProxiesInput{ + DBProxyName: &proxyName, + }) + require.NoError(t, err) + require.Len(t, dp.DBProxies, 1) + p := dp.DBProxies[0] + require.Equal(t, "available", string(p.Status)) + require.NotNil(t, p.EngineFamily) + require.Equal(t, "MYSQL", *p.EngineFamily) + require.NotNil(t, p.RequireTLS) + require.True(t, *p.RequireTLS, "requireTLS default must map to require_tls") + t.Logf("rds-proxy: %s available (engine family %s, TLS required)", proxyName, *p.EngineFamily) + + // --- 2. The instance is registered as a target through the + // default-target-group + target resource split. Registration is + // asynchronous -- poll briefly. --- + deadline := time.Now().Add(5 * time.Minute) + registered := false + for time.Now().Before(deadline) { + targets, terr := client.DescribeDBProxyTargets(ctx, &rds.DescribeDBProxyTargetsInput{ + DBProxyName: &proxyName, + }) + require.NoError(t, terr) + for _, tgt := range targets.Targets { + if tgt.RdsResourceId != nil && *tgt.RdsResourceId == instanceID { + registered = true + } + } + if registered { + break + } + time.Sleep(15 * time.Second) + } + require.True(t, registered, "instance %s must be registered as a proxy target", instanceID) + t.Logf("rds-proxy: instance %s registered as proxy target", instanceID) + + // --- Drift oracle: re-planning the already-applied stack must show zero + // changes (proves the default-target-group/target split reads back cleanly). --- + planExitCode := terraform.PlanExitCode(t, terraformOptions) + require.Equal(t, terraform.DefaultSuccessExitCode, planExitCode, + "expected `tofu plan -detailed-exitcode` to report no drift after apply (got exit code %d)", planExitCode) +} diff --git a/src/aws/storage/rds/cluster-ref.ts b/src/aws/storage/rds/cluster-ref.ts index 7bfff50c..d43124d8 100644 --- a/src/aws/storage/rds/cluster-ref.ts +++ b/src/aws/storage/rds/cluster-ref.ts @@ -2,10 +2,7 @@ import type { IClusterEngine } from "./cluster-engine"; import type { Endpoint } from "./endpoint"; -// TODO: omitted — upstream also imports `DatabaseProxy`/`DatabaseProxyOptions` from `./proxy` for -// `IDatabaseCluster.addProxy()` below. `./proxy` is not ported in this repo yet — it lands in a -// later PR (RDS PR 2e), matching the existing barrel deferral in `./index.ts` — -// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/cluster-ref.ts#L3 +import type { DatabaseProxy, DatabaseProxyOptions } from "./proxy"; import { IAwsConstruct } from "../../aws-construct"; import * as ec2 from "../../compute"; import * as secretsmanager from "../../encryption"; @@ -67,11 +64,10 @@ export interface IDatabaseCluster */ readonly clusterArn: string; - // TODO: omitted — upstream also declares `addProxy(id, options): DatabaseProxy` here. `DatabaseProxy` - // (and the `./proxy` module it lives in) is not ported in this repo yet — it lands in a later PR - // (RDS PR 2e), matching the existing barrel deferral in `./index.ts` — - // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/cluster-ref.ts#L60-L62 - // addProxy(id: string, options: DatabaseProxyOptions): DatabaseProxy; + /** + * Add a new db proxy to this cluster. + */ + addProxy(id: string, options: DatabaseProxyOptions): DatabaseProxy; /** * Grant the given identity connection access to the Cluster. diff --git a/src/aws/storage/rds/cluster.ts b/src/aws/storage/rds/cluster.ts index eee64217..b16841a2 100644 --- a/src/aws/storage/rds/cluster.ts +++ b/src/aws/storage/rds/cluster.ts @@ -40,6 +40,8 @@ import type { SnapshotCredentials, } from "./props"; import { Credentials, PerformanceInsightRetention } from "./props"; +import type { DatabaseProxyOptions } from "./proxy"; +import { DatabaseProxy, ProxyTarget } from "./proxy"; import type { ISubnetGroup } from "./subnet-group"; import { SubnetGroup } from "./subnet-group"; import { validateDatabaseClusterProps } from "./validate-database-insights"; @@ -746,10 +748,15 @@ export abstract class DatabaseClusterBase }); } - // TODO: omitted — upstream also declares `addProxy(id, options): DatabaseProxy` here. - // `DatabaseProxy`/`./proxy` is not ported yet (RDS PR 2e) — same deferral as - // `DatabaseInstanceBase.addProxy` in `./instance.ts` — - // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/cluster.ts#L677-L685 + /** + * Add a new db proxy to this cluster. + */ + public addProxy(id: string, options: DatabaseProxyOptions): DatabaseProxy { + return new DatabaseProxy(this, id, { + proxyTarget: ProxyTarget.fromCluster(this), + ...options, + }); + } /** * Renders the secret attachment target specifications. diff --git a/src/aws/storage/rds/index.ts b/src/aws/storage/rds/index.ts index ea2b9870..b483eab1 100644 --- a/src/aws/storage/rds/index.ts +++ b/src/aws/storage/rds/index.ts @@ -12,9 +12,9 @@ export * from "./database-secret"; export * from "./endpoint"; export * from "./option-group"; export * from "./instance"; -// TODO: omitted — upstream also exports `./proxy`, `./proxy-endpoint`, and `./serverless-cluster` -// here (DatabaseProxy/-Endpoint, ServerlessCluster v1). Those land in a later PR (RDS PR 2e) — -// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/index.ts#L15-L17 +export * from "./proxy"; +export * from "./proxy-endpoint"; +export * from "./serverless-cluster"; export * from "./subnet-group"; export * from "./aurora-cluster-instance"; diff --git a/src/aws/storage/rds/instance.ts b/src/aws/storage/rds/instance.ts index 793b1aa1..0088f32d 100644 --- a/src/aws/storage/rds/instance.ts +++ b/src/aws/storage/rds/instance.ts @@ -25,6 +25,8 @@ import type { SnapshotCredentials, } from "./props"; import { Credentials, PerformanceInsightRetention } from "./props"; +import type { DatabaseProxyOptions } from "./proxy"; +import { DatabaseProxy, ProxyTarget } from "./proxy"; import type { ISubnetGroup } from "./subnet-group"; import { SubnetGroup } from "./subnet-group"; import { validateDatabaseInstanceProps } from "./validate-database-insights"; @@ -96,10 +98,10 @@ export interface IDatabaseInstance */ readonly engine?: IInstanceEngine; - // TODO: omitted — upstream also declares `addProxy(id, options): DatabaseProxy` here. `DatabaseProxy` - // (and the `./proxy` module it lives in) is not ported in this repo yet — it lands in a later PR - // (RDS PR 2e), matching the existing barrel deferral in `./index.ts` — - // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L86-L89 + /** + * Add a new db proxy to this instance. + */ + addProxy(id: string, options: DatabaseProxyOptions): DatabaseProxy; /** * Grant the given identity connection access to the database. @@ -292,18 +294,15 @@ export abstract class DatabaseInstanceBase */ public abstract readonly connections: ec2.Connections; - // TODO: omitted — see the TODO on `IDatabaseInstance` above; `DatabaseProxy`/`./proxy` is not - // ported yet (RDS PR 2e) — - // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/instance.ts#L246-L251 - // /** - // * Add a new db proxy to this instance. - // */ - // public addProxy(id: string, options: DatabaseProxyOptions): DatabaseProxy { - // return new DatabaseProxy(this, id, { - // proxyTarget: ProxyTarget.fromInstance(this), - // ...options, - // }); - // } + /** + * Add a new db proxy to this instance. + */ + public addProxy(id: string, options: DatabaseProxyOptions): DatabaseProxy { + return new DatabaseProxy(this, id, { + proxyTarget: ProxyTarget.fromInstance(this), + ...options, + }); + } /** * [disable-awslint:no-grants] diff --git a/src/aws/storage/rds/proxy-endpoint.ts b/src/aws/storage/rds/proxy-endpoint.ts new file mode 100644 index 00000000..7ceed7bf --- /dev/null +++ b/src/aws/storage/rds/proxy-endpoint.ts @@ -0,0 +1,273 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/proxy-endpoint.ts + +import { dbProxyEndpoint } from "@cdktn/provider-aws"; +import type { Construct } from "constructs"; +import type { IDatabaseProxy } from "./proxy"; +import { ValidationError } from "../../../errors"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import * as ec2 from "../../compute"; + +/** + * A DB proxy endpoint. + * + * TODO: omitted — upstream also extends `aws_rds.IDBProxyEndpointRef`, a CloudFormation + * cross-stack "Reference" marker interface generated from the CFN resource spec. TerraConstructs + * has no equivalent generated-reference layer (identical omission to `dbProxyRef` on + * `IDatabaseProxy` in `./proxy.ts` — see the TODO there), so `dbProxyEndpointRef` is dropped — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/proxy-endpoint.ts#L14 + */ +export interface IDatabaseProxyEndpoint extends IAwsConstruct { + /** + * DB Proxy Endpoint Name + * + * @attribute + */ + readonly dbProxyEndpointName: string; + + /** + * DB Proxy Endpoint ARN + * + * @attribute + */ + readonly dbProxyEndpointArn: string; + + /** + * Endpoint + * + * @attribute + */ + readonly endpoint: string; +} + +/** + * Options for a new DatabaseProxyEndpoint + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), + * which upstream's `DatabaseProxyEndpointOptions` does not — matching the base-idiom used + * throughout this repo (e.g. `DatabaseProxyOptions` in `./proxy.ts`) for cross-account/-region + * construct placement. + */ +export interface DatabaseProxyEndpointOptions extends AwsConstructProps { + /** + * The name of the DB proxy endpoint + * + * @default - a CDK generated name + */ + readonly dbProxyEndpointName?: string; + + /** + * The VPC of the DB proxy endpoint. + */ + readonly vpc: ec2.IVpc; + + /** + * The VPC security groups to associate with the new proxy endpoint. + * + * @default - Default security group for the VPC + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * The subnets of DB proxy endpoint. + * + * @default - the VPC default strategy if not specified. + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * A value that indicates whether the DB proxy endpoint can be used for read/write or read-only operations. + * + * @default - ProxyEndpointTargetRole.READ_WRITE + */ + readonly targetRole?: ProxyEndpointTargetRole; +} + +/** + * Construction properties for a DatabaseProxyEndpoint + * + * TERRACONSTRUCTS DEVIATION: `dbProxy` is typed as `IDatabaseProxy` (this repo's own interface) + * rather than upstream's `aws_rds.IDBProxyRef` — the generated cross-stack "Reference" marker + * layer isn't ported here (see the TODO on `IDatabaseProxyEndpoint` above and on `IDatabaseProxy` + * in `./proxy.ts`), so the concrete L2 interface is used directly instead. + */ +export interface DatabaseProxyEndpointProps + extends DatabaseProxyEndpointOptions { + /** + * The DB proxy associated with the DB proxy endpoint. + */ + readonly dbProxy: IDatabaseProxy; +} + +/** + * Properties that describe an existing DB Proxy Endpoint + */ +export interface DatabaseProxyEndpointAttributes { + /** + * DB Proxy Endpoint Name + */ + readonly dbProxyEndpointName: string; + + /** + * DB Proxy Endpoint ARN + */ + readonly dbProxyEndpointArn: string; + + /** + * The endpoint that you can use to connect to the DB proxy + */ + readonly endpoint: string; +} + +/** + * A value that indicates whether the DB proxy endpoint can be used for read/write or read-only operations. + */ +export enum ProxyEndpointTargetRole { + /** + * The proxy endpoint can be used for both read and write operations. + */ + READ_WRITE = "READ_WRITE", + + /** + * The proxy endpoint can be used only for read operations. + */ + READ_ONLY = "READ_ONLY", +} + +/** + * Represents an RDS Database Proxy Endpoint. + */ +abstract class DatabaseProxyEndpointBase + extends AwsConstructBase + implements IDatabaseProxyEndpoint +{ + public abstract readonly dbProxyEndpointName: string; + public abstract readonly dbProxyEndpointArn: string; + public abstract readonly endpoint: string; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * `DatabaseProxyBase`/`DatabaseInstanceBase`/`SubnetGroup`) — bare, bound-per-construct `outputs` + * for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + dbProxyEndpointName: this.dbProxyEndpointName, + dbProxyEndpointArn: this.dbProxyEndpointArn, + endpoint: this.endpoint, + }; + } + + // TODO: omitted — see the TODO on `IDatabaseProxyEndpoint` above; upstream's `dbProxyEndpointRef` + // getter (returning `aws_rds.DBProxyEndpointReference`) has no equivalent generated-reference + // type in this repo. + // public get dbProxyEndpointRef(): aws_rds.DBProxyEndpointReference { + // return { + // dbProxyEndpointName: this.dbProxyEndpointName, + // dbProxyEndpointArn: this.dbProxyEndpointArn, + // }; + // } +} + +/** + * RDS Database Proxy Endpoint + * + * @resource aws_db_proxy_endpoint + */ +export class DatabaseProxyEndpoint extends DatabaseProxyEndpointBase { + /** + * Uniquely identifies this class. + */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.rds.DatabaseProxyEndpoint"; + + /** + * Import an existing database proxy endpoint. + */ + public static fromDatabaseProxyEndpointAttributes( + scope: Construct, + id: string, + attrs: DatabaseProxyEndpointAttributes, + ): IDatabaseProxyEndpoint { + class Import extends DatabaseProxyEndpointBase { + public readonly dbProxyEndpointName = attrs.dbProxyEndpointName; + public readonly dbProxyEndpointArn = attrs.dbProxyEndpointArn; + public readonly endpoint = attrs.endpoint; + } + return new Import(scope, id); + } + + /** + * DB Proxy Endpoint Name + * + * @attribute + */ + public readonly dbProxyEndpointName: string; + + /** + * DB Proxy Endpoint ARN + * + * @attribute + */ + public readonly dbProxyEndpointArn: string; + + /** + * The endpoint that you can use to connect to the DB proxy + * + * @attribute + */ + public readonly endpoint: string; + + /** + * The underlying `aws_db_proxy_endpoint` L1. + */ + public readonly resource: dbProxyEndpoint.DbProxyEndpoint; + + constructor(scope: Construct, id: string, props: DatabaseProxyEndpointProps) { + super(scope, id, props); + + // TERRACONSTRUCTS DEVIATION: repo invariant -- unnamed resources get a gridUUID-scoped + // `uniqueResourceName` default instead of relying on CloudFormation's Ref-based logical-id + // naming (which this repo has no equivalent of) -- same idiom as `DatabaseProxy` in + // `./proxy.ts`. `name` is a REQUIRED, non-computed argument on the Terraform + // `aws_db_proxy_endpoint` resource (see + // `node_modules/@cdktn/provider-aws/lib/db-proxy-endpoint/index.d.ts`), unlike + // CloudFormation's `AWS::RDS::DBProxyEndpoint` (which auto-generates a name when + // `DBProxyEndpointName` is omitted), so a synth-time default must always be computed. + // Lowercased for the same provider-side validation as `aws_db_proxy.name` + // (lowercase alphanumeric + hyphens only) — see the note in ./proxy.ts. + const physicalName = + props.dbProxyEndpointName ?? + this.stack.uniqueResourceName(this, { maxLength: 60 }).toLowerCase(); + + const vpcSubnetIds = props.vpc.selectSubnets(props.vpcSubnets).subnetIds; + if (vpcSubnetIds.length < 2) { + throw new ValidationError( + `\`subnets\` requires at least 2 subnets, got ${vpcSubnetIds.length}`, + this, + ); + } + + if (props.securityGroups && props.securityGroups.length == 0) { + throw new ValidationError( + "`securityGroups` must be undefined or a non-empty array.", + this, + ); + } + + this.resource = new dbProxyEndpoint.DbProxyEndpoint(this, "Resource", { + dbProxyEndpointName: physicalName, + dbProxyName: props.dbProxy.dbProxyName, + vpcSubnetIds, + vpcSecurityGroupIds: props.securityGroups?.map((e) => e.securityGroupId), + targetRole: props.targetRole, + }); + + this.dbProxyEndpointName = this.resource.dbProxyEndpointName; + this.dbProxyEndpointArn = this.resource.arn; + this.endpoint = this.resource.endpoint; + } +} diff --git a/src/aws/storage/rds/proxy.ts b/src/aws/storage/rds/proxy.ts new file mode 100644 index 00000000..2f5635b8 --- /dev/null +++ b/src/aws/storage/rds/proxy.ts @@ -0,0 +1,892 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/proxy.ts + +import { + dbProxy, + dbProxyDefaultTargetGroup, + dbProxyTarget, + rdsClusterInstance, +} from "@cdktn/provider-aws"; +import { Lazy, TerraformResource, Token } from "cdktn"; +import type { Construct } from "constructs"; +import type { IDatabaseCluster } from "./cluster-ref"; +import type { IEngine } from "./engine"; +import type { IDatabaseInstance } from "./instance"; +import { engineDescription } from "./private/util"; +import type { + DatabaseProxyEndpointOptions, + IDatabaseProxyEndpoint, +} from "./proxy-endpoint"; +import { DatabaseProxyEndpoint } from "./proxy-endpoint"; +import type { Duration } from "../../../duration"; +import { ValidationError } from "../../../errors"; +import { ArnFormat } from "../../arn"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import * as ec2 from "../../compute"; +import * as secretsmanager from "../../encryption"; +import * as iam from "../../iam"; + +/** + * Client password authentication type used by a proxy to log in as a specific database user. + */ +export enum ClientPasswordAuthType { + /** + * MySQL Native Password client authentication type. + */ + MYSQL_NATIVE_PASSWORD = "MYSQL_NATIVE_PASSWORD", + /** + * SCRAM SHA 256 client authentication type. + */ + POSTGRES_SCRAM_SHA_256 = "POSTGRES_SCRAM_SHA_256", + /** + * PostgreSQL MD5 client authentication type. + */ + POSTGRES_MD5 = "POSTGRES_MD5", + /** + * SQL Server Authentication client authentication type. + */ + SQL_SERVER_AUTHENTICATION = "SQL_SERVER_AUTHENTICATION", + /** + * MySQL Caching SHA2 Password client authentication type. + */ + MYSQL_CACHING_SHA2_PASSWORD = "MYSQL_CACHING_SHA2_PASSWORD", +} + +/** + * The default authentication scheme that the proxy uses for client connections to the proxy and connections from the proxy to the underlying database. + */ +export enum DefaultAuthScheme { + /** + * IAM authentication. + */ + IAM_AUTH = "IAM_AUTH", + + /** + * No default authentication. + */ + NONE = "NONE", +} + +/** + * SessionPinningFilter + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy.html#rds-proxy-pinning + */ +export class SessionPinningFilter { + /** + * You can opt out of session pinning for the following kinds of application statements: + * + * - Setting session variables and configuration settings. + */ + public static readonly EXCLUDE_VARIABLE_SETS = new SessionPinningFilter( + "EXCLUDE_VARIABLE_SETS", + ); + + /** + * custom filter + */ + public static of(filterName: string): SessionPinningFilter { + return new SessionPinningFilter(filterName); + } + + private constructor( + /** + * Filter name + */ + public readonly filterName: string, + ) {} +} + +/** + * Proxy target: Instance or Cluster + * + * A target group is a collection of databases that the proxy can connect to. + * Currently, you can specify only one RDS DB instance or Aurora DB cluster. + */ +export class ProxyTarget { + /** + * From instance + * + * @param instance RDS database instance + */ + public static fromInstance(instance: IDatabaseInstance): ProxyTarget { + return new ProxyTarget(instance, undefined); + } + + /** + * From cluster + * + * @param cluster RDS database cluster + */ + public static fromCluster(cluster: IDatabaseCluster): ProxyTarget { + return new ProxyTarget(undefined, cluster); + } + + private constructor( + private readonly dbInstance: IDatabaseInstance | undefined, + private readonly dbCluster: IDatabaseCluster | undefined, + ) {} + + /** + * Bind this target to the specified database proxy. + */ + public bind(proxy: DatabaseProxy): ProxyTargetConfig { + const engine: IEngine | undefined = + this.dbInstance?.engine ?? this.dbCluster?.engine; + + if (!engine) { + const errorResource = this.dbCluster ?? this.dbInstance; + throw new ValidationError( + `Could not determine engine for proxy target '${errorResource?.node.path}'. ` + + "Please provide it explicitly when importing the resource", + proxy, + ); + } + + const engineFamily = engine.engineFamily; + if (!engineFamily) { + throw new ValidationError( + "RDS proxies require an engine family to be specified on the database cluster or instance. " + + `No family specified for engine '${engineDescription(engine)}'`, + proxy, + ); + } + + // allow connecting to the Cluster/Instance from the Proxy + this.dbCluster?.connections.allowDefaultPortFrom( + proxy, + "Allow connections to the database Cluster from the Proxy", + ); + this.dbInstance?.connections.allowDefaultPortFrom( + proxy, + "Allow connections to the database Instance from the Proxy", + ); + + return { + engineFamily, + dbClusters: this.dbCluster ? [this.dbCluster] : undefined, + dbInstances: this.dbInstance ? [this.dbInstance] : undefined, + }; + } +} + +/** + * The result of binding a `ProxyTarget` to a `DatabaseProxy`. + */ +export interface ProxyTargetConfig { + /** + * The engine family of the database instance or cluster this proxy connects with. + */ + readonly engineFamily: string; + + /** + * The database instances to which this proxy connects. + * Either this or `dbClusters` will be set and the other `undefined`. + * @default - `undefined` if `dbClusters` is set. + */ + readonly dbInstances?: IDatabaseInstance[]; + + /** + * The database clusters to which this proxy connects. + * Either this or `dbInstances` will be set and the other `undefined`. + * @default - `undefined` if `dbInstances` is set. + */ + readonly dbClusters?: IDatabaseCluster[]; +} + +/** + * Options for a new DatabaseProxy + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), which + * upstream's `DatabaseProxyOptions` does not — matching the base-idiom used throughout this repo + * (e.g. `DatabaseInstanceNewProps`, `DatabaseClusterBaseProps`) for cross-account/-region construct + * placement. + */ +export interface DatabaseProxyOptions extends AwsConstructProps { + /** + * The identifier for the proxy. + * This name must be unique for all proxies owned by your AWS account in the specified AWS Region. + * An identifier must begin with a letter and must contain only ASCII letters, digits, and hyphens; + * it can't end with a hyphen or contain two consecutive hyphens. + * + * @default - Generated by CloudFormation (recommended) + */ + readonly dbProxyName?: string; + + /** + * The duration for a proxy to wait for a connection to become available in the connection pool. + * Only applies when the proxy has opened its maximum number of connections and all connections are busy with client + * sessions. + * + * Value must be between 1 second and 1 hour, or `Duration.seconds(0)` to represent unlimited. + * + * @default cdk.Duration.seconds(120) + */ + readonly borrowTimeout?: Duration; + + /** + * One or more SQL statements for the proxy to run when opening each new database connection. + * Typically used with SET statements to make sure that each connection has identical settings such as time zone + * and character set. + * For multiple statements, use semicolons as the separator. + * You can also include multiple variables in a single SET statement, such as SET x=1, y=2. + * + * not currently supported for PostgreSQL. + * + * @default - no initialization query + */ + readonly initQuery?: string; + + /** + * The maximum size of the connection pool for each target in a target group. + * For Aurora MySQL, it is expressed as a percentage of the max_connections setting for the RDS DB instance or Aurora DB + * cluster used by the target group. + * + * 1-100 + * + * @default 100 + */ + readonly maxConnectionsPercent?: number; + + /** + * Controls how actively the proxy closes idle database connections in the connection pool. + * A high value enables the proxy to leave a high percentage of idle connections open. + * A low value causes the proxy to close idle client connections and return the underlying database connections + * to the connection pool. + * For Aurora MySQL, it is expressed as a percentage of the max_connections setting for the RDS DB instance + * or Aurora DB cluster used by the target group. + * + * between 0 and MaxConnectionsPercent + * + * @default 50 + */ + readonly maxIdleConnectionsPercent?: number; + + /** + * Each item in the list represents a class of SQL operations that normally cause all later statements in a session + * using a proxy to be pinned to the same underlying database connection. + * Including an item in the list exempts that class of SQL operations from the pinning behavior. + * + * @default - no session pinning filters + */ + readonly sessionPinningFilters?: SessionPinningFilter[]; + + /** + * Whether the proxy includes detailed information about SQL statements in its logs. + * This information helps you to debug issues involving SQL behavior or the performance and scalability of the proxy connections. + * The debug information includes the text of SQL statements that you submit through the proxy. + * Thus, only enable this setting when needed for debugging, and only when you have security measures in place to safeguard any sensitive + * information that appears in the logs. + * + * @default false + */ + readonly debugLogging?: boolean; + + /** + * Whether to require or disallow AWS Identity and Access Management (IAM) authentication for connections to the proxy. + * + * @default false + */ + readonly iamAuth?: boolean; + + /** + * The number of seconds that a connection to the proxy can be inactive before the proxy disconnects it. + * You can set this value higher or lower than the connection timeout limit for the associated database. + * + * @default cdk.Duration.minutes(30) + */ + readonly idleClientTimeout?: Duration; + + /** + * A Boolean parameter that specifies whether Transport Layer Security (TLS) encryption is required for connections to the proxy. + * By enabling this setting, you can enforce encrypted TLS connections to the proxy. + * + * @default true + */ + readonly requireTLS?: boolean; + + /** + * IAM role that the proxy uses to access secrets in AWS Secrets Manager. + * + * @default - A role will automatically be created + */ + readonly role?: iam.IRole; + + /** + * The secret that the proxy uses to authenticate to the RDS DB instance or Aurora DB cluster. + * These secrets are stored within Amazon Secrets Manager. + * One or more secrets are required when defaultAuthScheme is `DefaultAuthScheme.NONE`. + * + * @default None + */ + readonly secrets?: secretsmanager.ISecret[]; + + /** + * One or more VPC security groups to associate with the new proxy. + * + * @default - No security groups + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * The subnets used by the proxy. + * + * @default - the VPC default strategy if not specified. + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * The VPC to associate with the new proxy. + */ + readonly vpc: ec2.IVpc; + + /** + * Specifies the details of authentication used by a proxy to log in as a specific database user. + * + * @default - CloudFormation defaults will apply given the specified database engine. + */ + readonly clientPasswordAuthType?: ClientPasswordAuthType; + + /** + * The default authentication scheme that the proxy uses for client connections to the proxy and connections from the proxy to the underlying database. + * When set to `DefaultAuthScheme.IAM_AUTH`, the proxy uses end-to-end IAM authentication to connect to the database. + * + * @default DefaultAuthScheme.NONE + */ + readonly defaultAuthScheme?: DefaultAuthScheme; +} + +/** + * Construction properties for a DatabaseProxy + */ +export interface DatabaseProxyProps extends DatabaseProxyOptions { + /** + * DB proxy target: Instance or Cluster + */ + readonly proxyTarget: ProxyTarget; +} + +/** + * Properties that describe an existing DB Proxy + */ +export interface DatabaseProxyAttributes { + /** + * DB Proxy Name + */ + readonly dbProxyName: string; + + /** + * DB Proxy ARN + */ + readonly dbProxyArn: string; + + /** + * Endpoint + */ + readonly endpoint: string; + + /** + * The security groups of the instance. + */ + readonly securityGroups: ec2.ISecurityGroup[]; +} + +/** + * DB Proxy + * + * TODO: omitted — upstream also extends `aws_rds.IDBProxyRef`, a CloudFormation cross-stack + * "Reference" marker interface generated from the CFN resource spec. TerraConstructs has no + * equivalent generated-reference layer (identical omission to `dbInstanceRef`/`IDBInstanceRef` on + * `IDatabaseInstance` in `./instance.ts` — see the TODO there), so `dbProxyRef` is dropped — + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/proxy.ts#L369 + */ +export interface IDatabaseProxy extends IAwsConstruct { + /** + * DB Proxy Name + * + * @attribute + */ + readonly dbProxyName: string; + + /** + * DB Proxy ARN + * + * @attribute + */ + readonly dbProxyArn: string; + + /** + * Endpoint + * + * @attribute + */ + readonly endpoint: string; + + /** + * Grant the given identity connection access to the proxy. + * + * @param grantee the Principal to grant the permissions to + * @param dbUser the name of the database user to allow connecting as to the proxy + * + * @default - if the Proxy had been provided a single Secret value, + * the user will be taken from that Secret + */ + grantConnect(grantee: iam.IGrantable, dbUser?: string): iam.Grant; +} + +/** + * Represents an RDS Database Proxy. + * + */ +abstract class DatabaseProxyBase + extends AwsConstructBase + implements IDatabaseProxy +{ + public abstract readonly dbProxyName: string; + public abstract readonly dbProxyArn: string; + public abstract readonly endpoint: string; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * `DatabaseInstanceBase`/`SubnetGroup`/`OptionGroup`/`ParameterGroup`) — bare, bound-per-construct + * `outputs` for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + dbProxyName: this.dbProxyName, + dbProxyArn: this.dbProxyArn, + endpoint: this.endpoint, + }; + } + + // TODO: omitted — see the TODO on `IDatabaseProxy` above; upstream's `dbProxyRef` getter + // (returning `aws_rds.DBProxyReference`) has no equivalent generated-reference type in this repo. + // public get dbProxyRef(): aws_rds.DBProxyReference { + // return { + // dbProxyName: this.dbProxyName, + // dbProxyArn: this.dbProxyArn, + // }; + // } + + public grantConnect(grantee: iam.IGrantable, dbUser?: string): iam.Grant { + if (!dbUser) { + throw new ValidationError( + "For imported Database Proxies, the dbUser is required in grantConnect()", + this, + ); + } + const proxyGeneratedId = this.stack.splitArn( + this.dbProxyArn, + ArnFormat.COLON_RESOURCE_NAME, + ).resourceName; + const userArn = this.stack.formatArn({ + service: "rds-db", + resource: "dbuser", + resourceName: `${proxyGeneratedId}/${dbUser}`, + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }); + return iam.Grant.addToPrincipal({ + grantee, + actions: ["rds-db:connect"], + resourceArns: [userArn], + }); + } +} + +/** + * RDS Database Proxy + * + * @resource aws_db_proxy + */ +export class DatabaseProxy + extends DatabaseProxyBase + implements ec2.IConnectable, secretsmanager.ISecretAttachmentTarget +{ + /** + * Uniquely identifies this class. + */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.rds.DatabaseProxy"; + + /** + * Import an existing database proxy. + */ + public static fromDatabaseProxyAttributes( + scope: Construct, + id: string, + attrs: DatabaseProxyAttributes, + ): IDatabaseProxy { + // NOTE: mirrors upstream exactly -- `attrs.securityGroups` is accepted for API-shape parity + // but, just like upstream's `Import` class, is not actually wired to anything below: an + // imported `DatabaseProxy` (via `DatabaseProxyBase`) never gained a `connections`/ + // `ec2.IConnectable` member on its base interface upstream either, so there is nothing here to + // attach the security groups to. + class Import extends DatabaseProxyBase { + public readonly dbProxyName = attrs.dbProxyName; + public readonly dbProxyArn = attrs.dbProxyArn; + public readonly endpoint = attrs.endpoint; + } + return new Import(scope, id); + } + + /** + * DB Proxy Name + * + * @attribute + */ + public readonly dbProxyName: string; + + /** + * DB Proxy ARN + * + * @attribute + */ + public readonly dbProxyArn: string; + + /** + * Endpoint + * + * @attribute + */ + public readonly endpoint: string; + + /** + * Access to network connections. + */ + public readonly connections: ec2.Connections; + + /** + * The underlying `aws_db_proxy` L1. + */ + public readonly resource: dbProxy.DbProxy; + + private readonly secrets?: secretsmanager.ISecret[]; + private readonly vpc: ec2.IVpc; + + constructor(scope: Construct, id: string, props: DatabaseProxyProps) { + super(scope, id, props); + + // TERRACONSTRUCTS DEVIATION: repo invariant -- unnamed resources get a gridUUID-scoped + // `uniqueResourceName` default instead of relying on CloudFormation's Ref-based logical-id + // naming (which this repo has no equivalent of) or the provider's own generated + // `terraform-` fallback -- same idiom as `DatabaseInstanceNew`/`DatabaseClusterNew` in + // `./instance.ts`/`./cluster.ts`. This also drops upstream's + // `DATABASE_PROXY_UNIQUE_RESOURCE_NAME` feature-flag branch (which toggles between the + // construct id and a unique name) -- `name` is a REQUIRED, non-computed argument on the + // Terraform `aws_db_proxy` resource (see `node_modules/@cdktn/provider-aws/lib/db-proxy/index.d.ts`), + // unlike CloudFormation's `AWS::RDS::DBProxy` (which auto-generates a name when `DBProxyName` + // is omitted), so a synth-time default must always be computed -- there is no "let + // CloudFormation/the flag decide" branch to preserve. Unlike `DatabaseInstanceNew`'s + // `instanceIdentifier` default, the generated default IS lowercased: the Terraform AWS + // provider validates `aws_db_proxy.name` as lowercase-alphanumeric-and-hyphens at plan time + // ("only lowercase alphanumeric characters and hyphens allowed in \"name\"") — live-caught by + // integ/aws/storage TestRdsProxy. Caller-supplied names are passed through untouched (the + // provider rejects uppercase with a clear error). + const physicalName = + props.dbProxyName ?? + this.stack.uniqueResourceName(this, { maxLength: 60 }).toLowerCase(); + + const role = + props.role || + new iam.Role(this, "IAMRole", { + assumedBy: new iam.ServicePrincipal("rds.amazonaws.com"), + }); + + if (props.secrets) { + for (const secret of props.secrets) { + secret.grantRead(role); + if (secret.encryptionKey) { + secret.encryptionKey.grantDecrypt(role); + } + } + } + + const securityGroups = props.securityGroups ?? [ + new ec2.SecurityGroup(this, "ProxySecurityGroup", { + description: "SecurityGroup for Database Proxy", + vpc: props.vpc, + }), + ]; + this.connections = new ec2.Connections({ securityGroups }); + + const bindResult = props.proxyTarget.bind(this); + + const requiresSecrets = + !props.defaultAuthScheme || + props.defaultAuthScheme === DefaultAuthScheme.NONE; + if (requiresSecrets && !props.secrets?.length) { + throw new ValidationError( + "One or more secrets are required when defaultAuthScheme is not specified or is NONE.", + this, + ); + } + this.secrets = props.secrets; + + this.validateClientPasswordAuthType( + bindResult.engineFamily, + props.clientPasswordAuthType, + ); + + this.resource = new dbProxy.DbProxy(this, "Resource", { + // NOTE: `clientPasswordAuthType` stays omitted when unset -- AWS applies an + // engine-family default server-side and the provider does NOT report drift on + // the omission (live-verified by integ/aws/storage TestRdsProxy's drift oracle, + // PASS with a clean post-apply plan). + auth: props.secrets?.map((_) => { + return { + authScheme: "SECRETS", + clientPasswordAuthType: props.clientPasswordAuthType, + iamAuth: props.iamAuth ? "REQUIRED" : "DISABLED", + secretArn: _.secretArn, + }; + }), + name: physicalName, + debugLogging: props.debugLogging, + engineFamily: bindResult.engineFamily, + idleClientTimeout: props.idleClientTimeout?.toSeconds(), + requireTls: props.requireTLS ?? true, + roleArn: role.roleArn, + vpcSecurityGroupIds: Lazy.listValue({ + produce: () => + this.connections.securityGroups.map((_) => _.securityGroupId), + }), + vpcSubnetIds: props.vpc.selectSubnets(props.vpcSubnets).subnetIds, + defaultAuthScheme: props.defaultAuthScheme, + }); + + this.dbProxyName = this.resource.name; + this.dbProxyArn = this.resource.arn; + this.endpoint = this.resource.endpoint; + this.vpc = props.vpc; + + // TERRACONSTRUCTS DEVIATION: upstream's single `CfnDBProxyTargetGroup` (`AWS::RDS::DBProxyTargetGroup`) + // has NO equivalent single Terraform resource -- the AWS provider splits target-GROUP + // configuration (connection pool settings) from target-MEMBERSHIP (which instance/cluster + // belongs to the group) into two distinct resource types: `aws_db_proxy_default_target_group` + // (this repo's `dbProxyDefaultTargetGroup.DbProxyDefaultTargetGroup`) and `aws_db_proxy_target` + // (`dbProxyTarget.DbProxyTarget`). Both are created below. See + // `node_modules/@cdktn/provider-aws/lib/db-proxy-default-target-group/index.d.ts` and + // `.../db-proxy-target/index.d.ts`. + const defaultTargetGroup = + new dbProxyDefaultTargetGroup.DbProxyDefaultTargetGroup( + this, + "ProxyTargetGroup", + { + dbProxyName: this.dbProxyName, + connectionPoolConfig: toConnectionPoolConfigurationInfo(props), + }, + ); + // The default target group is implicitly created by RDS alongside the proxy itself; depending + // on the proxy resource keeps ordering explicit (mirrors the implicit CFN `DBProxyName` `Ref` + // dependency upstream gets "for free" from `CfnDBProxyTargetGroup.dbProxyName: this.dbProxyName`). + defaultTargetGroup.node.addDependency(this.resource); + + let dbInstanceIdentifier: string | undefined; + if (bindResult.dbInstances) { + // support for only single instance + dbInstanceIdentifier = bindResult.dbInstances[0].instanceIdentifier; + } + + let dbClusterIdentifier: string | undefined; + if (bindResult.dbClusters) { + // TERRACONSTRUCTS DEVIATION: `ProxyTarget` only ever binds a single instance OR a single + // cluster (see `ProxyTarget.bind()` above -- `dbInstances`/`dbClusters` are always + // `undefined` or a 1-element array), and the Terraform `aws_db_proxy_target` resource takes a + // single `dbClusterIdentifier`/`dbInstanceIdentifier` string (not the array upstream's CFN + // `DBClusterIdentifiers`/`DBInstanceIdentifiers` properties accept), so only the first (only) + // element is ever used. + dbClusterIdentifier = bindResult.dbClusters[0].clusterIdentifier; + } + + if (!!dbInstanceIdentifier && !!dbClusterIdentifier) { + throw new ValidationError( + "Cannot specify both dbInstanceIdentifiers and dbClusterIdentifiers", + this, + ); + } + + const proxyTarget = new dbProxyTarget.DbProxyTarget(this, "ProxyTarget", { + dbProxyName: this.dbProxyName, + targetGroupName: "default", + dbInstanceIdentifier, + dbClusterIdentifier, + }); + proxyTarget.node.addDependency(defaultTargetGroup); + + // When a `DatabaseProxy` is created by `DatabaseCluster.addProxy`, + // the `DatabaseProxy` and `DBProxyTarget` are created as a child of the `DatabaseCluster`, + // so if multiple `DatabaseProxy` are created by `DatabaseCluster.addProxy`, + // using `node.addDependency` will cause circular dependencies. + // To avoid this, dependencies are added directly on the synthesized `aws_db_proxy_target` + // resource (mirrors upstream's `CfnResource.addResourceDependency` on `proxyTargetGroup`, + // adapted to this repo's split target-group/target resources -- see the deviation note above). + bindResult.dbClusters?.forEach((cluster) => { + cluster.node.children.forEach((child) => { + // Legacy case using the `instanceProps` property of `DatabaseCluster` -- the child IS the + // `aws_rds_cluster_instance` L1 directly (see `legacyCreateInstances` in `./cluster.ts`, + // which is this repo's Terraform-native replacement for upstream's `CfnDBInstance`-based + // cluster members -- see the deviation note there). + if (child instanceof rdsClusterInstance.RdsClusterInstance) { + proxyTarget.node.addDependency(child); + } + // The case of `AuroraClusterInstance` constructs passed via the `writer` and `readers` + // properties of `DatabaseCluster`. We can't use the `AuroraClusterInstance` class to check + // the type with `instanceof` because the class is not exported. The `defaultChild` that the + // construct has should be a `rdsClusterInstance.RdsClusterInstance`, so check it (see + // `AuroraClusterInstance`'s constructor in `./aurora-cluster-instance.ts`, which always + // creates its L1 with construct id `"Resource"`, the id `constructs`' `Node.defaultChild` + // resolves to automatically). + const resource = child.node.defaultChild; + if (resource instanceof rdsClusterInstance.RdsClusterInstance) { + proxyTarget.node.addDependency(resource); + } + }); + const clusterResource = cluster.node.defaultChild; + if ( + clusterResource && + TerraformResource.isTerraformResource(clusterResource) + ) { + proxyTarget.node.addDependency(clusterResource); + } + }); + } + + /** + * Add an Endpoint to this DB Proxy + */ + public addEndpoint( + id: string, + options?: DatabaseProxyEndpointOptions, + ): IDatabaseProxyEndpoint { + return new DatabaseProxyEndpoint(this, id, { + dbProxy: this, + vpc: this.vpc, + ...options, + }); + } + + /** + * Renders the secret attachment target specifications. + * + * TERRACONSTRUCTS DEVIATION (AWS REALITY NOTE): unlike `DatabaseInstanceBase`/ + * `DatabaseClusterNew` (which populate `connectionFields` because the Terraform provider has no + * server-side merge -- see `ISecretAttachmentTarget` in `../../encryption/secret.ts`), no + * `connectionFields` are supplied here. CloudFormation's own + * `AWS::SecretsManager::SecretTargetAttachment` `TargetType` property does NOT actually document + * `AWS::RDS::DBProxy` as a supported value -- only `AWS::RDS::DBInstance` | `AWS::RDS::DBCluster` | + * `AWS::Redshift::Cluster` | `AWS::RedshiftServerless::Namespace` | `AWS::DocDB::DBInstance` | + * `AWS::DocDB::DBCluster` | `AWS::DocDBElastic::Cluster` are listed -- + * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-secretsmanager-secrettargetattachment.html. + * Upstream's `RDS_DB_PROXY` target type (and this class implementing `ISecretAttachmentTarget` at + * all) is therefore itself API-shape-only parity, never server-side-verified by AWS -- this port + * mirrors that shape (`targetId`/`targetType` only, no connection fields) for the same reason. + * `secret.attach(proxy)` is accepted here for interface compatibility, but is not expected to + * behave meaningfully -- which is moot in practice, since the underlying `aws_secretsmanager_secret` + * resource (see `Secret.attach()`) never calls any CloudFormation-only API to begin with. + */ + public asSecretAttachmentTarget(): secretsmanager.SecretAttachmentTargetProps { + return { + targetId: this.dbProxyName, + targetType: secretsmanager.AttachmentTargetType.RDS_DB_PROXY, + }; + } + + /** + * [disable-awslint:no-grants] + */ + public grantConnect(grantee: iam.IGrantable, dbUser?: string): iam.Grant { + if (!dbUser) { + if (!this.secrets?.length) { + throw new ValidationError( + "When using IAM authentication without secrets, you must specify a dbUser parameter in grantConnect().", + this, + ); + } + if (this.secrets.length > 1) { + throw new ValidationError( + "When the Proxy contains multiple Secrets, you must pass a dbUser explicitly to grantConnect()", + this, + ); + } + // TERRACONSTRUCTS DEVIATION: upstream defaults `dbUser` here by reading it back out of the + // secret's JSON value via `secret.secretValueFromJson('username').unsafeUnwrap()` -- a + // CloudFormation dynamic reference, not portable (see the `ISecret` deviation note in + // `../../encryption/secret.ts`, and the identical adaptation for `DatabaseInstanceSource`'s + // `masterUsername` stash in `./instance.ts`). Unlike `DatabaseInstanceSource`, `DatabaseProxy` + // does not create its own secret -- `secrets` is an arbitrary caller-supplied + // `secretsmanager.ISecret[]` -- so there is no plain-string username known at construction + // time to stash the way `DatabaseInstanceSource` does. Callers must pass `dbUser` explicitly. + throw new ValidationError( + "grantConnect() without a dbUser is not supported in TerraConstructs when the Proxy has a single secret (depends on ISecret.secretValueFromJson, not ported). Pass dbUser explicitly.", + this, + ); + } + return super.grantConnect(grantee, dbUser); + } + + private validateClientPasswordAuthType( + engineFamily: string, + clientPasswordAuthType?: ClientPasswordAuthType, + ) { + if (!clientPasswordAuthType || Token.isUnresolved(clientPasswordAuthType)) + return; + if ( + clientPasswordAuthType === ClientPasswordAuthType.MYSQL_NATIVE_PASSWORD && + engineFamily !== "MYSQL" + ) { + throw new ValidationError( + `${ClientPasswordAuthType.MYSQL_NATIVE_PASSWORD} client password authentication type requires MYSQL engineFamily, got ${engineFamily}`, + this, + ); + } + if ( + clientPasswordAuthType === + ClientPasswordAuthType.POSTGRES_SCRAM_SHA_256 && + engineFamily !== "POSTGRESQL" + ) { + throw new ValidationError( + `${ClientPasswordAuthType.POSTGRES_SCRAM_SHA_256} client password authentication type requires POSTGRESQL engineFamily, got ${engineFamily}`, + this, + ); + } + if ( + clientPasswordAuthType === ClientPasswordAuthType.POSTGRES_MD5 && + engineFamily !== "POSTGRESQL" + ) { + throw new ValidationError( + `${ClientPasswordAuthType.POSTGRES_MD5} client password authentication type requires POSTGRESQL engineFamily, got ${engineFamily}`, + this, + ); + } + if ( + clientPasswordAuthType === + ClientPasswordAuthType.SQL_SERVER_AUTHENTICATION && + engineFamily !== "SQLSERVER" + ) { + throw new ValidationError( + `${ClientPasswordAuthType.SQL_SERVER_AUTHENTICATION} client password authentication type requires SQLSERVER engineFamily, got ${engineFamily}`, + this, + ); + } + } +} + +/** + * ConnectionPoolConfiguration (L2 => L1) + */ +function toConnectionPoolConfigurationInfo( + props: DatabaseProxyProps, +): dbProxyDefaultTargetGroup.DbProxyDefaultTargetGroupConnectionPoolConfig { + return { + connectionBorrowTimeout: props.borrowTimeout?.toSeconds(), + initQuery: props.initQuery, + maxConnectionsPercent: props.maxConnectionsPercent, + maxIdleConnectionsPercent: props.maxIdleConnectionsPercent, + sessionPinningFilters: props.sessionPinningFilters?.map( + (_) => _.filterName, + ), + }; +} diff --git a/src/aws/storage/rds/serverless-cluster.ts b/src/aws/storage/rds/serverless-cluster.ts new file mode 100644 index 00000000..bc40a32d --- /dev/null +++ b/src/aws/storage/rds/serverless-cluster.ts @@ -0,0 +1,1276 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/serverless-cluster.ts + +import { rdsCluster } from "@cdktn/provider-aws"; +import { Annotations, Lazy, Token, Tokenization } from "cdktn"; +import { Construct } from "constructs"; +import type { IClusterEngine } from "./cluster-engine"; +import { DatabaseSecret } from "./database-secret"; +import { Endpoint } from "./endpoint"; +import type { IParameterGroup } from "./parameter-group"; +import { DATA_API_ACTIONS } from "./perms"; +import { + applyDefaultRotationOptions, + defaultDeletionProtection, +} from "./private/util"; +import type { + Credentials, + RotationMultiUserOptions, + RotationSingleUserOptions, + SnapshotCredentials, +} from "./props"; +import type { ISubnetGroup } from "./subnet-group"; +import { SubnetGroup } from "./subnet-group"; +import { Duration } from "../../../duration"; +import { ValidationError } from "../../../errors"; +import { ArnFormat } from "../../arn"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import * as ec2 from "../../compute"; +import type * as encryption from "../../encryption"; +import * as secretsmanager from "../../encryption"; +import * as iam from "../../iam"; + +/** + * TERRACONSTRUCTS DEVIATION (AWS REALITY NOTE): Aurora Serverless v1 was RETIRED by AWS -- the + * `engine_mode = "serverless"` variant of `aws_rds_cluster` is no longer creatable as of 2025 (AWS + * strongly recommends migrating to Aurora Serverless v2, ported as `serverlessV2MinCapacity` / + * `serverlessV2MaxCapacity` / `ClusterInstance.serverlessV2()` on `./cluster.ts`'s `DatabaseCluster` + * instead). This entire module (`ServerlessCluster`, `ServerlessClusterFromSnapshot`, and their + * shared plumbing) is ported below strictly for API/migration parity with upstream aws-cdk-lib -- + * live deployability against a real AWS account is NOT expected. Upstream aws-cdk-lib does not + * itself mark this module `@deprecated` (Aurora Serverless v1 predates aws-cdk-lib's own + * `@deprecated` convention), but every class in this file is marked `@deprecated` here to reflect + * that reality for TerraConstructs consumers, in addition to this class-level retirement note. See + * `./cluster.ts`'s `DatabaseCluster` (Aurora Serverless v2) for the actively-supported serverless + * story. + */ + +/** + * Interface representing a serverless database cluster. + * + * TODO: omitted -- upstream also extends `aws_rds.IDBClusterRef`, a CloudFormation cross-stack + * "Reference" marker interface generated from the CFN resource spec. TerraConstructs has no + * equivalent generated-reference layer (identical omission to `IDatabaseCluster.dbClusterRef` in + * `./cluster-ref.ts` -- see the TODO there), so `dbClusterRef` is dropped -- + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/serverless-cluster.ts#L45 + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note above. Use `DatabaseCluster` with `ClusterInstance.serverlessV2()` (Aurora Serverless v2) + * instead. + */ +export interface IServerlessCluster + extends IAwsConstruct, + ec2.IConnectable, + secretsmanager.ISecretAttachmentTarget { + /** + * Identifier of the cluster + */ + readonly clusterIdentifier: string; + + /** + * The ARN of the cluster + */ + readonly clusterArn: string; + + /** + * The endpoint to use for read/write operations + */ + readonly clusterEndpoint: Endpoint; + + /** + * Endpoint to use for load-balanced read-only operations. + */ + readonly clusterReadEndpoint: Endpoint; + + /** + * Grant the given identity to access to the Data API. + * + * [disable-awslint:no-grants] + * + * @param grantee The principal to grant access to + */ + grantDataApiAccess(grantee: iam.IGrantable): iam.Grant; +} + +/** + * Common Properties to configure new Aurora Serverless v1 Cluster or Aurora Serverless v1 Cluster from snapshot + * + * TERRACONSTRUCTS DEVIATION: extends `AwsConstructProps` (account/region/environmentFromArn), which + * upstream's `ServerlessClusterNewProps` does not -- matching the base-idiom used throughout this + * repo (e.g. `DatabaseClusterBaseProps` in `./cluster.ts`) for cross-account/-region construct + * placement. + */ +interface ServerlessClusterNewProps extends AwsConstructProps { + /** + * What kind of database to start + */ + readonly engine: IClusterEngine; + + /** + * An optional identifier for the cluster + * + * @default - a gridUUID-scoped generated name + */ + readonly clusterIdentifier?: string; + + /** + * The number of days during which automatic DB snapshots are retained. + * Automatic backup retention cannot be disabled on serverless clusters. + * Must be a value from 1 day to 35 days. + * + * @default Duration.days(1) + */ + readonly backupRetention?: Duration; + + /** + * Name of a database which is automatically created inside the cluster + * + * @default - Database is not created in cluster. + */ + readonly defaultDatabaseName?: string; + + /** + * Indicates whether the DB cluster should have deletion protection enabled. + * + * TERRACONSTRUCTS DEVIATION: upstream defaults this to `true` when `removalPolicy` is `RETAIN`. + * `core.RemovalPolicy` is not ported in this repo (see `skipFinalSnapshot`/`finalSnapshotIdentifier` + * below, and the identical omission on `DatabaseClusterBaseProps.deletionProtection` in + * `./cluster.ts`), so only the explicit flag is honored (see `defaultDeletionProtection` in + * `./private/util.ts`). + * + * @default false + */ + readonly deletionProtection?: boolean; + + // TODO: omitted -- upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.SNAPSHOT`) is + // CloudFormation's DeletionPolicy concept. `core.RemovalPolicy` is not ported anywhere in this repo + // (see the identical omission on `DatabaseClusterBaseProps.removalPolicy` in `./cluster.ts`). + // Terraform's `aws_rds_cluster` exposes the equivalent semantics natively via + // `skipFinalSnapshot`/`finalSnapshotIdentifier` below -- the TERRACONSTRUCTS-native replacement, + // mirroring `./cluster.ts` exactly -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/serverless-cluster.ts#L149-L154 + // readonly removalPolicy?: RemovalPolicy; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream -- native Terraform replacement for upstream's + * `removalPolicy` (see the TODO above). Whether Terraform should take a final DB snapshot before + * destroying this cluster. When `false` (the default -- matching upstream's `RemovalPolicy.SNAPSHOT` + * default) and `finalSnapshotIdentifier` is not set, `terraform destroy`/replace will FAIL at + * apply-time with an AWS API error (native `aws_rds_cluster` behavior, not enforced here at synth + * time). Mirrors `DatabaseClusterBaseProps.skipFinalSnapshot` in `./cluster.ts`. + * + * @default false (a final snapshot is taken on delete/replace, so `finalSnapshotIdentifier` should + * also be set) + */ + readonly skipFinalSnapshot?: boolean; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream -- see `skipFinalSnapshot` above. The identifier + * for the final DB cluster snapshot Terraform takes before destroying this cluster. Unlike + * CloudFormation (which auto-generates a snapshot name), Terraform requires this to be supplied + * explicitly. Mirrors `DatabaseClusterBaseProps.finalSnapshotIdentifier` in `./cluster.ts`. + * + * @default - no final snapshot identifier; required unless `skipFinalSnapshot` is `true` + */ + readonly finalSnapshotIdentifier?: string; + + /** + * Whether to enable the Data API. + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/data-api.html + * + * @default false + */ + readonly enableDataApi?: boolean; + + /** + * The VPC that this Aurora Serverless v1 Cluster has been created in. + * + * @default - the default VPC in the account and region will be used + */ + readonly vpc?: ec2.IVpc; + + /** + * Where to place the instances within the VPC. + * If provided, the `vpc` property must also be specified. + * + * @default - the VPC default strategy if not specified. + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * Scaling configuration of an Aurora Serverless database cluster. + * + * @default - Serverless cluster is automatically paused after 5 minutes of being idle. + * minimum capacity: 2 ACU + * maximum capacity: 16 ACU + */ + readonly scaling?: ServerlessScalingOptions; + + /** + * Security group. + * + * @default - a new security group is created if `vpc` was provided. + * If the `vpc` property was not provided, no VPC security groups will be associated with the DB cluster. + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * Additional parameters to pass to the database engine + * + * @default - no parameter group. + */ + readonly parameterGroup?: IParameterGroup; + + /** + * Existing subnet group for the cluster. + * + * TERRACONSTRUCTS DEVIATION: `ISubnetGroup` instead of upstream's `aws_rds.IDBSubnetGroupRef` -- + * see the identical omission on `DatabaseClusterBaseProps.subnetGroup` in `./cluster.ts`. + * + * @default - a new subnet group is created if `vpc` was provided. + * If the `vpc` property was not provided, no subnet group will be associated with the DB cluster + */ + readonly subnetGroup?: ISubnetGroup; + + /** + * Whether to copy tags to the snapshot when a snapshot is created. + * + * @default - true + */ + readonly copyTagsToSnapshot?: boolean; +} + +/** + * Properties that describe an existing cluster instance + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note on `IServerlessCluster` above. + */ +export interface ServerlessClusterAttributes { + /** + * Identifier for the cluster + */ + readonly clusterIdentifier: string; + + /** + * The database port + * + * @default - none + */ + readonly port?: number; + + /** + * The security groups of the database cluster + * + * @default - no security groups + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * Cluster endpoint address + * + * @default - no endpoint address + */ + readonly clusterEndpointAddress?: string; + + /** + * Reader endpoint address + * + * @default - no reader address + */ + readonly readerEndpointAddress?: string; + + /** + * The secret attached to the database cluster + * + * @default - no secret + */ + readonly secret?: secretsmanager.ISecret; +} + +/** + * Aurora capacity units (ACUs). + * Each ACU is a combination of processing and memory capacity. + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-serverless.setting-capacity.html + * @see https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-serverless.how-it-works.html#aurora-serverless.architecture + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note on `IServerlessCluster` above. + */ +export enum AuroraCapacityUnit { + /** 1 Aurora Capacity Unit */ + ACU_1 = 1, + /** 2 Aurora Capacity Units */ + ACU_2 = 2, + /** 4 Aurora Capacity Units */ + ACU_4 = 4, + /** 8 Aurora Capacity Units */ + ACU_8 = 8, + /** 16 Aurora Capacity Units */ + ACU_16 = 16, + /** 32 Aurora Capacity Units */ + ACU_32 = 32, + /** 64 Aurora Capacity Units */ + ACU_64 = 64, + /** 128 Aurora Capacity Units */ + ACU_128 = 128, + /** 192 Aurora Capacity Units */ + ACU_192 = 192, + /** 256 Aurora Capacity Units */ + ACU_256 = 256, + /** 384 Aurora Capacity Units */ + ACU_384 = 384, +} + +/** + * TimeoutAction defines the action to take when a timeout occurs if a scaling point is not found. + * + * @see https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-serverless-v1.how-it-works.html#aurora-serverless.how-it-works.timeout-action + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note on `IServerlessCluster` above. + */ +export enum TimeoutAction { + /** + * FORCE_APPLY_CAPACITY_CHANGE sets the capacity to the specified value as soon as possible. + * Transactions may be interrupted, and connections to temporary tables and locks may be dropped. + * Only select this option if your application can recover from dropped connections or incomplete transactions. + */ + FORCE_APPLY_CAPACITY_CHANGE = "ForceApplyCapacityChange", + + /** + * ROLLBACK_CAPACITY_CHANGE ignores the capacity change if a scaling point is not found. + * This is the default behavior. + */ + ROLLBACK_CAPACITY_CHANGE = "RollbackCapacityChange", +} + +/** + * Options for configuring scaling on an Aurora Serverless v1 Cluster + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note on `IServerlessCluster` above. + */ +export interface ServerlessScalingOptions { + /** + * The minimum capacity for an Aurora Serverless database cluster. + * + * @default - determined by Aurora based on database engine + */ + readonly minCapacity?: AuroraCapacityUnit; + + /** + * The maximum capacity for an Aurora Serverless database cluster. + * + * @default - determined by Aurora based on database engine + */ + readonly maxCapacity?: AuroraCapacityUnit; + + /** + * The time before an Aurora Serverless database cluster is paused. + * A database cluster can be paused only when it is idle (it has no connections). + * Auto pause time must be between 5 minutes and 1 day. + * + * If a DB cluster is paused for more than seven days, the DB cluster might be + * backed up with a snapshot. In this case, the DB cluster is restored when there + * is a request to connect to it. + * + * Set to 0 to disable + * + * @default - automatic pause enabled after 5 minutes + */ + readonly autoPause?: Duration; + + /** + * The amount of time that Aurora Serverless v1 tries to find a scaling point to perform + * seamless scaling before enforcing the timeout action. + * + * @default - 5 minutes + */ + readonly timeout?: Duration; + + /** + * The action to take when the timeout is reached. + * Selecting ForceApplyCapacityChange will force the capacity to the specified value as soon as possible, even without a scaling point. + * Selecting RollbackCapacityChange will ignore the capacity change if a scaling point is not found. This is the default behavior. + * + * @default - TimeoutAction.ROLLBACK_CAPACITY_CHANGE + */ + readonly timeoutAction?: TimeoutAction; +} + +/** + * New or imported Serverless Cluster + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note on `IServerlessCluster` above. + */ +abstract class ServerlessClusterBase + extends AwsConstructBase + implements IServerlessCluster +{ + /** + * Identifier of the cluster + */ + public abstract readonly clusterIdentifier: string; + + /** + * The endpoint to use for read/write operations + */ + public abstract readonly clusterEndpoint: Endpoint; + + /** + * The endpoint to use for read/write operations + */ + public abstract readonly clusterReadEndpoint: Endpoint; + + /** + * Access to the network connections + */ + public abstract readonly connections: ec2.Connections; + + /** + * The secret attached to this cluster + */ + public abstract readonly secret?: secretsmanager.ISecret; + + protected abstract enableDataApi?: boolean; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Stashed so `asSecretAttachmentTarget()` below + * can contribute `engine` to the attached secret's connection fields -- mirrors + * `DatabaseClusterBase.engine` in `./cluster.ts`. Not part of the public `IServerlessCluster` + * interface (upstream's `IServerlessCluster` doesn't expose `engine` either), so this stays + * `protected`. Always `undefined` on an imported cluster (`ServerlessClusterAttributes` has no + * `engine` field, matching upstream). + */ + protected engine?: IClusterEngine; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Stashed so `asSecretAttachmentTarget()` below + * can contribute `dbname` to the attached secret's connection fields -- mirrors + * `DatabaseClusterBase.defaultDatabaseName` in `./cluster.ts`. + */ + protected defaultDatabaseName?: string; + + /** + * The ARN of the cluster + */ + public get clusterArn(): string { + return this.stack.formatArn({ + service: "rds", + resource: "cluster", + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: this.clusterIdentifier, + }); + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. `clusterEndpoint`/`clusterReadEndpoint` are + * abstract getters that THROW on an `ImportedServerlessCluster` built via + * `fromServerlessClusterAttributes()` without the corresponding endpoint address (see + * `ImportedServerlessCluster` below). `asSecretAttachmentTarget()` and `outputs` (both below) need + * to tolerate that -- host/port and the endpoint outputs are simply omitted for a minimally + * imported cluster rather than throwing. Mirrors `DatabaseClusterBase.tryGetClusterEndpoint()` in + * `./cluster.ts`. + */ + protected tryGetClusterEndpoint(): Endpoint | undefined { + return this.clusterEndpoint; + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream -- see `tryGetClusterEndpoint()` above. + */ + protected tryGetClusterReadEndpoint(): Endpoint | undefined { + return this.clusterReadEndpoint; + } + + /** + * Grant the given identity to access to the Data API, including read access to the secret attached to the cluster if present + * + * [disable-awslint:no-grants] + * + * @param grantee The principal to grant access to + */ + public grantDataApiAccess(grantee: iam.IGrantable): iam.Grant { + if (this.enableDataApi === false) { + throw new ValidationError( + "Cannot grant Data API access when the Data API is disabled", + this, + ); + } + + this.enableDataApi = true; + const ret = iam.Grant.addToPrincipal({ + grantee, + actions: DATA_API_ACTIONS, + resourceArns: ["*"], + }); + this.secret?.grantRead(grantee); + return ret; + } + + /** + * Renders the secret attachment target specifications. + * + * TERRACONSTRUCTS DEVIATION: mirrors the identical deviation note on + * `DatabaseClusterBase.asSecretAttachmentTarget()` in `./cluster.ts` -- upstream returns only + * `{ targetId, targetType }` because CloudFormation's `AWS::SecretsManager::SecretTargetAttachment` + * resolves engine/host/port server-side from those two fields. The Terraform AWS provider has no + * such server-side merge, so `connectionFields` is supplied here too, using `dbClusterIdentifier` + * (not `dbInstanceIdentifier`) as CFN's `SecretTargetAttachment` does for RDS cluster targets. + */ + public asSecretAttachmentTarget(): secretsmanager.SecretAttachmentTargetProps { + const endpoint = this.tryGetClusterEndpoint(); + return { + targetId: this.clusterIdentifier, + targetType: secretsmanager.AttachmentTargetType.RDS_DB_CLUSTER, + connectionFields: { + dbClusterIdentifier: this.clusterIdentifier, + ...(this.engine?.engineType ? { engine: this.engine.engineType } : {}), + ...(this.defaultDatabaseName + ? { dbname: this.defaultDatabaseName } + : {}), + ...(endpoint + ? { + host: endpoint.hostname, + port: Tokenization.stringifyNumber(endpoint.port), + } + : {}), + }, + }; + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * `DatabaseClusterBase.outputs` in `./cluster.ts`) -- bare, bound-per-construct `outputs` for use + * with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + const endpoint = this.tryGetClusterEndpoint(); + const readEndpoint = this.tryGetClusterReadEndpoint(); + return { + identifier: this.clusterIdentifier, + arn: this.clusterArn, + ...(endpoint && { + endpointAddress: endpoint.hostname, + endpointPort: Tokenization.stringifyNumber(endpoint.port), + }), + ...(readEndpoint && { readEndpointAddress: readEndpoint.hostname }), + }; + } +} + +/** + * Create an Aurora Serverless v1 Cluster + * + * @resource aws_rds_cluster + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note on `IServerlessCluster` above. + */ +abstract class ServerlessClusterNew extends ServerlessClusterBase { + protected readonly securityGroups: ec2.ISecurityGroup[]; + + /** + * TERRACONSTRUCTS DEVIATION: typed loosely (`Record`, mirroring upstream's + * `CfnDBClusterProps` grab-bag and the identical idiom on `DatabaseClusterNew.newClusterProps` in + * `./cluster.ts`) rather than `Partial` -- `enableHttpEndpoint` below + * is a `Lazy` token that doesn't structurally match the L1 config's typed shape until the final + * `as rdsCluster.RdsClusterConfig` cast in each leaf class. + */ + protected readonly newClusterProps: Record; + + protected enableDataApi?: boolean; + + constructor(scope: Construct, id: string, props: ServerlessClusterNewProps) { + super(scope, id, props); + + this.engine = props.engine; + this.defaultDatabaseName = props.defaultDatabaseName; + + if (props.vpc === undefined) { + if (props.vpcSubnets !== undefined) { + throw new ValidationError( + "A VPC is required to use vpcSubnets in ServerlessCluster. Please add a VPC or remove vpcSubnets", + this, + ); + } + if (props.subnetGroup !== undefined) { + throw new ValidationError( + "A VPC is required to use subnetGroup in ServerlessCluster. Please add a VPC or remove subnetGroup", + this, + ); + } + if (props.securityGroups !== undefined) { + throw new ValidationError( + "A VPC is required to use securityGroups in ServerlessCluster. Please add a VPC or remove securityGroups", + this, + ); + } + } + + let subnetGroup: ISubnetGroup | undefined = props.subnetGroup; + this.securityGroups = props.securityGroups ?? []; + if (props.vpc !== undefined) { + const { subnetIds } = props.vpc.selectSubnets(props.vpcSubnets); + + // Cannot test whether the subnets are in different AZs, but at least we can test the amount. + // + // TERRACONSTRUCTS DEVIATION: upstream uses `Annotations.of(this)._addTrackableError(...)`, a + // CDK-internal-only API not exposed by `cdktn`'s `Annotations`. `addError` is the closest + // public equivalent (synthesis fails when errors are reported) -- mirrors the identical + // deviation on `DatabaseClusterNew` in `./cluster.ts`. + if (subnetIds.length < 2) { + Annotations.of(this).addError( + `Cluster requires at least 2 subnets, got ${subnetIds.length}`, + ); + } + + subnetGroup = + props.subnetGroup ?? + new SubnetGroup(this, "Subnets", { + description: `Subnets for ${id} database`, + vpc: props.vpc, + vpcSubnets: props.vpcSubnets, + // TERRACONSTRUCTS DEVIATION: no `removalPolicy` to pass -- see the omission note on + // `SubnetGroupProps.removalPolicy` in `./subnet-group.ts`. + }); + + this.securityGroups = props.securityGroups ?? [ + new ec2.SecurityGroup(this, "SecurityGroup", { + description: "RDS security group", + vpc: props.vpc, + }), + ]; + } + + if (props.backupRetention) { + const backupRetentionDays = props.backupRetention.toDays(); + if (backupRetentionDays < 1 || backupRetentionDays > 35) { + throw new ValidationError( + `backup retention period must be between 1 and 35 days. received: ${backupRetentionDays}`, + this, + ); + } + } + + // bind the engine to the Cluster + const clusterEngineBindConfig = props.engine.bindToCluster(this, { + parameterGroup: props.parameterGroup, + }); + const clusterParameterGroup = + props.parameterGroup ?? clusterEngineBindConfig.parameterGroup; + const clusterParameterGroupConfig = clusterParameterGroup?.bindToCluster( + {}, + ); + + // TERRACONSTRUCTS DEVIATION: upstream branches on the `RDS_LOWERCASE_DB_IDENTIFIER` feature + // flag between lowercasing `clusterIdentifier` (corrected) or leaving it as-is (legacy). `core. + // FeatureFlags` is not ported in this repo (see the identical, always-corrected-behavior note on + // `DatabaseClusterNew`'s `clusterIdentifier` in `./cluster.ts`), so the corrected (always + // lowercase) behavior is simply the only behavior. Additionally, per the repo invariant that + // unnamed resources get a gridUUID-scoped `uniqueResourceName` default (see the same idiom on + // `DatabaseClusterNew`'s `clusterIdentifier`), an omitted `clusterIdentifier` falls back to + // `uniqueResourceName`. `DBClusterIdentifier` is capped at 63 characters, so `maxLength` is + // passed explicitly here. + const clusterIdentifier = Token.isUnresolved(props.clusterIdentifier) + ? props.clusterIdentifier + : ( + props.clusterIdentifier ?? + this.stack.uniqueResourceName(this, { maxLength: 63 }) + ).toLowerCase(); + + this.newClusterProps = { + backupRetentionPeriod: props.backupRetention?.toDays(), + databaseName: props.defaultDatabaseName, + clusterIdentifier, + dbClusterParameterGroupName: + clusterParameterGroupConfig?.parameterGroupName, + dbSubnetGroupName: subnetGroup?.subnetGroupName, + deletionProtection: defaultDeletionProtection(props.deletionProtection), + engine: props.engine.engineType, + engineVersion: props.engine.engineVersion?.fullVersion, + engineMode: "serverless", + enableHttpEndpoint: Lazy.anyValue({ + produce: () => this.enableDataApi, + }), + scalingConfiguration: props.scaling + ? this.renderScalingConfiguration(props.scaling) + : undefined, + storageEncrypted: true, + vpcSecurityGroupIds: this.securityGroups.map((sg) => sg.securityGroupId), + copyTagsToSnapshot: props.copyTagsToSnapshot ?? true, + skipFinalSnapshot: props.skipFinalSnapshot, + finalSnapshotIdentifier: props.finalSnapshotIdentifier, + }; + + // TERRACONSTRUCTS DEVIATION: mirrors the identical `skipFinalSnapshot`/`finalSnapshotIdentifier` + // synth-time warning on `DatabaseClusterNew` in `./cluster.ts` -- see that note for the full + // rationale. + if (props.skipFinalSnapshot !== true && !props.finalSnapshotIdentifier) { + Annotations.of(this).addWarning( + "Neither `skipFinalSnapshot` nor `finalSnapshotIdentifier` is set: `terraform destroy` (or any change that replaces this cluster) will FAIL at apply time because the AWS provider requires `finalSnapshotIdentifier` when `skipFinalSnapshot` is not `true`. Set `skipFinalSnapshot: true` to skip the final snapshot, or set `finalSnapshotIdentifier` to a snapshot name.", + ); + } + } + + private renderScalingConfiguration( + options: ServerlessScalingOptions, + ): rdsCluster.RdsClusterScalingConfiguration { + const minCapacity = options.minCapacity; + const maxCapacity = options.maxCapacity; + const timeout = options.timeout?.toSeconds(); + + if (minCapacity && maxCapacity && minCapacity > maxCapacity) { + throw new ValidationError( + "maximum capacity must be greater than or equal to minimum capacity.", + this, + ); + } + + const secondsToAutoPause = options.autoPause?.toSeconds(); + if ( + secondsToAutoPause && + (secondsToAutoPause < 300 || secondsToAutoPause > 86400) + ) { + throw new ValidationError( + "auto pause time must be between 5 minutes and 1 day.", + this, + ); + } + + if (timeout && (timeout < 60 || timeout > 600)) { + throw new ValidationError( + `timeout must be between 60 and 600 seconds, but got ${timeout} seconds.`, + this, + ); + } + + return { + autoPause: secondsToAutoPause === 0 ? false : true, + minCapacity: options.minCapacity, + maxCapacity: options.maxCapacity, + secondsUntilAutoPause: + secondsToAutoPause === 0 ? undefined : secondsToAutoPause, + secondsBeforeTimeout: timeout, + timeoutAction: options.timeoutAction, + }; + } +} + +/** + * Properties for a new Aurora Serverless v1 Cluster + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note on `IServerlessCluster` above. + */ +export interface ServerlessClusterProps extends ServerlessClusterNewProps { + /** + * Credentials for the administrative user + * + * @default - A username of 'admin' and SecretsManager-generated password + */ + readonly credentials?: Credentials; + + /** + * The KMS key for storage encryption. + * + * TERRACONSTRUCTS DEVIATION: `encryption.IKey` instead of upstream's `kms.IKey` -- see + * `DatabaseClusterBaseProps.storageEncryptionKey` in `./cluster.ts`. + * + * @default - the default master key will be used for storage encryption + */ + readonly storageEncryptionKey?: encryption.IKey; +} + +/** + * Create an Aurora Serverless v1 Cluster + * + * @resource aws_rds_cluster + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note on `IServerlessCluster` above. Use `DatabaseCluster` with `ClusterInstance.serverlessV2()` + * (Aurora Serverless v2) instead. + */ +export class ServerlessCluster extends ServerlessClusterNew { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.rds.ServerlessCluster"; + + /** + * Import an existing DatabaseCluster from properties + */ + public static fromServerlessClusterAttributes( + scope: Construct, + id: string, + attrs: ServerlessClusterAttributes, + ): IServerlessCluster { + return new ImportedServerlessCluster(scope, id, attrs); + } + + public readonly clusterIdentifier: string; + public readonly clusterEndpoint: Endpoint; + public readonly clusterReadEndpoint: Endpoint; + public readonly connections: ec2.Connections; + + public readonly secret?: secretsmanager.ISecret; + + /** + * The underlying `aws_rds_cluster` L1. NOTE: this construct owns `lifecycle.ignore_changes` on + * it (see the `ignore_changes`/password-drift note in the constructor) -- code calling + * `resource.addOverride("lifecycle.ignore_changes", ...)` directly will REPLACE that list rather + * than merge with it. Mirrors `DatabaseCluster.resource` in `./cluster.ts`. + */ + public readonly resource: rdsCluster.RdsCluster; + + private readonly vpc?: ec2.IVpc; + private readonly vpcSubnets?: ec2.SubnetSelection; + + private readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication; + private readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication; + + constructor(scope: Construct, id: string, props: ServerlessClusterProps) { + super(scope, id, props); + + this.vpc = props.vpc; + this.vpcSubnets = props.vpcSubnets; + + this.singleUserRotationApplication = + props.engine.singleUserRotationApplication; + this.multiUserRotationApplication = + props.engine.multiUserRotationApplication; + + this.enableDataApi = props.enableDataApi; + + const rendered = renderServerlessClusterCredentials( + this, + props.engine, + props.credentials, + ); + const secret = rendered.secret; + + const cluster = new rdsCluster.RdsCluster(this, "Resource", { + ...this.newClusterProps, + masterUsername: rendered.username, + masterPassword: rendered.password, + kmsKeyId: props.storageEncryptionKey?.keyArn, + } as rdsCluster.RdsClusterConfig); + + this.resource = cluster; + this.clusterIdentifier = cluster.clusterIdentifier; + + // TERRACONSTRUCTS DEVIATION: mirrors the identical `ignore_changes` note on `DatabaseCluster` in + // `./cluster.ts` -- `secret` is only set here when a new `DatabaseSecret` was just generated for + // us (see `renderServerlessClusterCredentials`), in which case `masterPassword` above is the SAME + // regenerating-on-every-plan `aws_secretsmanager_random_password` token stored in that secret. + // Without `ignore_changes`, every apply after the first would drift and REPLACE the live master + // password. + const ignoreChanges: string[] = []; + if (secret) { + ignoreChanges.push("master_password"); + } + if (ignoreChanges.length > 0) { + cluster.addOverride("lifecycle.ignore_changes", ignoreChanges); + } + + // NOTE: must be set before `secret.attach(this)` below -- `attach()` calls + // `asSecretAttachmentTarget()` synchronously, which reads `this.clusterEndpoint`. + this.clusterEndpoint = new Endpoint(cluster.endpoint, cluster.port); + this.clusterReadEndpoint = new Endpoint( + cluster.readerEndpoint, + cluster.port, + ); + this.connections = new ec2.Connections({ + securityGroups: this.securityGroups, + defaultPort: ec2.Port.tcp(this.clusterEndpoint.port), + }); + + if (secret) { + this.secret = secret.attach(this); + } + } + + /** + * Adds the single user rotation of the master password to this cluster. + */ + public addRotationSingleUser( + options: RotationSingleUserOptions = {}, + ): secretsmanager.SecretRotation { + if (!this.secret) { + throw new ValidationError( + "Cannot add single user rotation for a cluster without secret.", + this, + ); + } + + if (this.vpc === undefined) { + throw new ValidationError( + "Cannot add single user rotation for a cluster without VPC.", + this, + ); + } + + const id = "RotationSingleUser"; + const existing = this.node.tryFindChild(id); + if (existing) { + throw new ValidationError( + "A single user rotation was already added to this cluster.", + this, + ); + } + + return new secretsmanager.SecretRotation(this, id, { + ...applyDefaultRotationOptions(options, this.vpcSubnets), + secret: this.secret, + application: this.singleUserRotationApplication, + vpc: this.vpc, + target: this, + }); + } + + /** + * Adds the multi user rotation to this cluster. + */ + public addRotationMultiUser( + id: string, + options: RotationMultiUserOptions, + ): secretsmanager.SecretRotation { + if (!this.secret) { + throw new ValidationError( + "Cannot add multi user rotation for a cluster without secret.", + this, + ); + } + + if (this.vpc === undefined) { + throw new ValidationError( + "Cannot add multi user rotation for a cluster without VPC.", + this, + ); + } + + return new secretsmanager.SecretRotation(this, id, { + ...applyDefaultRotationOptions(options, this.vpcSubnets), + secret: options.secret, + masterSecret: this.secret, + application: this.multiUserRotationApplication, + vpc: this.vpc, + target: this, + }); + } + + public get outputs(): Record { + return { + ...super.outputs, + ...(this.secret && { secretArn: this.secret.secretArn }), + }; + } +} + +/** + * Represents an imported database cluster. + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note on `IServerlessCluster` above. + */ +class ImportedServerlessCluster + extends ServerlessClusterBase + implements IServerlessCluster +{ + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.rds.ImportedServerlessCluster"; + public readonly clusterIdentifier: string; + public readonly connections: ec2.Connections; + + public readonly secret?: secretsmanager.ISecret; + + protected readonly enableDataApi = true; + + private readonly _clusterEndpoint?: Endpoint; + private readonly _clusterReadEndpoint?: Endpoint; + + constructor( + scope: Construct, + id: string, + attrs: ServerlessClusterAttributes, + ) { + super(scope, id, {}); + + this.clusterIdentifier = attrs.clusterIdentifier; + + const defaultPort = attrs.port ? ec2.Port.tcp(attrs.port) : undefined; + this.connections = new ec2.Connections({ + securityGroups: attrs.securityGroups, + defaultPort, + }); + + this.secret = attrs.secret; + + this._clusterEndpoint = + attrs.clusterEndpointAddress && attrs.port + ? new Endpoint(attrs.clusterEndpointAddress, attrs.port) + : undefined; + this._clusterReadEndpoint = + attrs.readerEndpointAddress && attrs.port + ? new Endpoint(attrs.readerEndpointAddress, attrs.port) + : undefined; + } + + public get clusterEndpoint() { + if (!this._clusterEndpoint) { + throw new ValidationError( + "Cannot access `clusterEndpoint` of an imported cluster without an endpoint address and port", + this, + ); + } + return this._clusterEndpoint; + } + + public get clusterReadEndpoint() { + if (!this._clusterReadEndpoint) { + throw new ValidationError( + "Cannot access `clusterReadEndpoint` of an imported cluster without a readerEndpointAddress and port", + this, + ); + } + return this._clusterReadEndpoint; + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream -- see `ServerlessClusterBase.tryGetClusterEndpoint()`. + * Returns `undefined` instead of throwing when this import was constructed without an endpoint + * address/port. + */ + protected tryGetClusterEndpoint(): Endpoint | undefined { + return this._clusterEndpoint; + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream -- see `ServerlessClusterBase.tryGetClusterEndpoint()`. + */ + protected tryGetClusterReadEndpoint(): Endpoint | undefined { + return this._clusterReadEndpoint; + } +} + +/** + * Properties for ``ServerlessClusterFromSnapshot`` + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note on `IServerlessCluster` above. + */ +export interface ServerlessClusterFromSnapshotProps + extends ServerlessClusterNewProps { + /** + * The identifier for the DB instance snapshot or DB cluster snapshot to restore from. + * You can use either the name or the Amazon Resource Name (ARN) to specify a DB cluster snapshot. + * However, you can use only the ARN to specify a DB instance snapshot. + */ + readonly snapshotIdentifier: string; + + /** + * Master user credentials. + * + * Note - It is not possible to change the master username for a snapshot; + * however, it is possible to provide (or generate) a new password. + * + * @default - The existing username and password from the snapshot will be used. + */ + readonly credentials?: SnapshotCredentials; +} + +/** + * A Aurora Serverless v1 Cluster restored from a snapshot. + * + * @resource aws_rds_cluster + * + * @deprecated Aurora Serverless v1 has been retired by AWS -- see the module-level TERRACONSTRUCTS + * note on `IServerlessCluster` above. + */ +export class ServerlessClusterFromSnapshot extends ServerlessClusterNew { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.rds.ServerlessClusterFromSnapshot"; + + public readonly clusterIdentifier: string; + public readonly clusterEndpoint: Endpoint; + public readonly clusterReadEndpoint: Endpoint; + public readonly connections: ec2.Connections; + public readonly secret?: secretsmanager.ISecret; + + /** + * The underlying `aws_rds_cluster` L1. NOTE: see the identical `ignore_changes` ownership note on + * `ServerlessCluster.resource` above. + */ + public readonly resource: rdsCluster.RdsCluster; + + constructor( + scope: Construct, + id: string, + props: ServerlessClusterFromSnapshotProps, + ) { + super(scope, id, props); + + this.enableDataApi = props.enableDataApi; + + const credentials = renderServerlessClusterSnapshotCredentials( + this, + props.credentials, + ); + const secret = credentials?.secret; + + const cluster = new rdsCluster.RdsCluster(this, "Resource", { + ...this.newClusterProps, + snapshotIdentifier: props.snapshotIdentifier, + masterPassword: credentials?.password, + } as rdsCluster.RdsClusterConfig); + + this.resource = cluster; + this.clusterIdentifier = cluster.clusterIdentifier; + + const ignoreChanges: string[] = []; + if (secret) { + ignoreChanges.push("master_password"); + } + if (ignoreChanges.length > 0) { + cluster.addOverride("lifecycle.ignore_changes", ignoreChanges); + } + + // NOTE: must be set before `secret.attach(this)` below -- `attach()` calls + // `asSecretAttachmentTarget()` synchronously, which reads `this.clusterEndpoint`. + this.clusterEndpoint = new Endpoint(cluster.endpoint, cluster.port); + this.clusterReadEndpoint = new Endpoint( + cluster.readerEndpoint, + cluster.port, + ); + this.connections = new ec2.Connections({ + securityGroups: this.securityGroups, + defaultPort: ec2.Port.tcp(this.clusterEndpoint.port), + }); + + if (secret) { + this.secret = secret.attach(this); + } + } + + public get outputs(): Record { + return { + ...super.outputs, + ...(this.secret && { secretArn: this.secret.secretArn }), + }; + } +} + +/** + * TERRACONSTRUCTS DEVIATION: replaces upstream's `renderCredentials` (`./private/util.ts`), which is + * commented out there because it depends on `Credentials.fromSecret` (itself commented out in + * `./props.ts` -- needs `ISecret.secretValueFromJson`, not portable). This local equivalent produces + * the same three pieces of information (username / password token / owned secret) directly, using + * `Secret._generatedPassword` -- mirrors `renderClusterCredentials` in `./cluster.ts`. + */ +function renderServerlessClusterCredentials( + scope: Construct, + engine: IClusterEngine, + credentials?: Credentials, +): { + username: string; + password?: string; + secret?: secretsmanager.ISecret; +} { + const rendered = credentials ?? { + username: engine.defaultUsername ?? "admin", + }; + + if (rendered.secret) { + // TERRACONSTRUCTS DEVIATION: see the identical note in `renderClusterCredentials` in + // `./cluster.ts` -- `Credentials.fromSecret()` is not available in this repo. + throw new ValidationError( + "Credentials with an existing `secret` are not supported in TerraConstructs (depends on ISecret.secretValueFromJson, not ported). Use Credentials.fromPassword(), Credentials.fromUsername(), or leave `credentials` unset to auto-generate a DatabaseSecret.", + scope, + ); + } + + if (rendered.password) { + return { username: rendered.username, password: rendered.password }; + } + + const secret = new DatabaseSecret(scope, "Secret", { + username: rendered.username, + secretName: rendered.secretName, + encryptionKey: rendered.encryptionKey, + excludeCharacters: rendered.excludeCharacters, + replaceOnPasswordCriteriaChanges: credentials?.usernameAsString, + replicaRegions: rendered.replicaRegions, + }); + + return { + username: rendered.username, + password: secret._generatedPassword, + secret, + }; +} + +/** + * TERRACONSTRUCTS DEVIATION: replaces upstream's inlined snapshot-secret handling in + * `ServerlessClusterFromSnapshot`'s constructor (not the `renderSnapshotCredentials` helper + * in `./private/util.ts`, which `DatabaseClusterFromSnapshot` uses) -- commented out here for + * the identical reason as `renderCredentials` above (depends on `SnapshotCredentials.fromSecret`, + * not portable). Shaped like `renderClusterSnapshotCredentials` in `./cluster.ts` for house-pattern + * consistency, but the generated secret's construct id is "Secret" -- matching upstream + * `ServerlessClusterFromSnapshot` (v2.263.0 serverless-cluster.ts#L806), NOT + * `DatabaseClusterFromSnapshot`'s "SnapshotSecret" id (which `./cluster.ts`'s sibling helper + * mirrors instead). + * https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/lib/serverless-cluster.ts#L806 + */ +function renderServerlessClusterSnapshotCredentials( + scope: Construct, + credentials?: SnapshotCredentials, +): + | { + password?: string; + secret?: secretsmanager.ISecret; + } + | undefined { + if (!credentials) { + return undefined; + } + + if (credentials.secret) { + // TERRACONSTRUCTS DEVIATION: see `renderServerlessClusterCredentials` above -- an existing, + // caller-supplied secret's password cannot be read back out portably + // (`secretValueFromJson` not ported). + throw new ValidationError( + "SnapshotCredentials with an existing `secret` are not supported in TerraConstructs (depends on ISecret.secretValueFromJson, not ported). Use SnapshotCredentials.fromPassword(), SnapshotCredentials.fromGeneratedSecret()/fromGeneratedPassword(), or leave `credentials` unset to keep the snapshot's existing password.", + scope, + ); + } + + if (credentials.generatePassword) { + if (!credentials.username) { + throw new ValidationError( + "`credentials` `username` must be specified when `generatePassword` is set to true", + scope, + ); + } + + const secret = new DatabaseSecret(scope, "Secret", { + username: credentials.username, + encryptionKey: credentials.encryptionKey, + excludeCharacters: credentials.excludeCharacters, + replaceOnPasswordCriteriaChanges: + credentials.replaceOnPasswordCriteriaChanges, + replicaRegions: credentials.replicaRegions, + }); + + return { password: secret._generatedPassword, secret }; + } + + return { password: credentials.password }; +} diff --git a/test/aws/storage/rds/__snapshots__/proxy-endpoint.test.ts.snap b/test/aws/storage/rds/__snapshots__/proxy-endpoint.test.ts.snap new file mode 100644 index 00000000..3dd7e935 --- /dev/null +++ b/test/aws/storage/rds/__snapshots__/proxy-endpoint.test.ts.snap @@ -0,0 +1,513 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Proxy endpoint create a DB proxy from an instance 1`] = ` +"{ + "data": { + "aws_availability_zones": { + "AvailabilityZones": { + "provider": "aws" + } + }, + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_iam_policy_document": { + "Proxy_IAMRole_AssumeRolePolicy_308A029B": { + "statement": [ + { + "actions": [ + "sts:AssumeRole" + ], + "effect": "Allow", + "principals": [ + { + "identifiers": [ + "\${data.aws_service_principal.aws_svcp_default_region_rds.name}" + ], + "type": "Service" + } + ] + } + ] + }, + "Proxy_IAMRole_DefaultPolicy_2BA59AB7": { + "statement": [ + { + "actions": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ], + "effect": "Allow", + "resources": [ + "\${aws_secretsmanager_secret.Instance_Secret_478E0A47.arn}" + ] + } + ] + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + }, + "aws_secretsmanager_random_password": { + "Instance_Secret_RandomPassword_930AA29C": { + "exclude_characters": " %+~\`#$&*()|[]{}:;<>?!'/@\\"\\\\", + "exclude_lowercase": false, + "exclude_numbers": false, + "exclude_punctuation": false, + "exclude_uppercase": false, + "include_space": false, + "password_length": 30, + "require_each_included_type": true + } + }, + "aws_service_principal": { + "aws_svcp_default_region_rds": { + "service_name": "rds" + } + } + }, + "provider": { + "aws": [ + { + "region": "us-east-1" + } + ] + }, + "resource": { + "aws_db_instance": { + "Instance_C1063A87": { + "allocated_storage": 100, + "copy_tags_to_snapshot": true, + "db_subnet_group_name": "\${aws_db_subnet_group.Instance_SubnetGroup_F2CBA54F.name}", + "engine": "mysql", + "engine_version": "8.4.5", + "identifier": "mystackinstancec8b5f353", + "instance_class": "db.m5.large", + "lifecycle": { + "ignore_changes": [ + "password" + ] + }, + "password": "\${data.aws_secretsmanager_random_password.Instance_Secret_RandomPassword_930AA29C.random_password}", + "storage_type": "gp2", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "username": "admin", + "vpc_security_group_ids": [ + "\${aws_security_group.Instance_SecurityGroup_B4E5FA83.id}" + ] + } + }, + "aws_db_proxy": { + "Proxy_CB0DFB71": { + "auth": [ + { + "auth_scheme": "SECRETS", + "iam_auth": "DISABLED", + "secret_arn": "\${aws_secretsmanager_secret.Instance_Secret_478E0A47.arn}" + } + ], + "engine_family": "MYSQL", + "name": "mystackproxy142a6b29", + "require_tls": true, + "role_arn": "\${aws_iam_role.Proxy_IAMRole_2FE8AB0F.arn}", + "tags": { + "Name": "Test-Proxy", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_security_group_ids": [ + "\${aws_security_group.Proxy_ProxySecurityGroup_C42FC3CE.id}" + ], + "vpc_subnet_ids": [ + "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}", + "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}" + ] + } + }, + "aws_db_proxy_default_target_group": { + "Proxy_ProxyTargetGroup_B462B5C5": { + "db_proxy_name": "\${aws_db_proxy.Proxy_CB0DFB71.name}", + "depends_on": [ + "aws_db_proxy.Proxy_CB0DFB71" + ] + } + }, + "aws_db_proxy_endpoint": { + "ProxyEndpoint_A881902B": { + "db_proxy_endpoint_name": "mystackproxyendpoint3a2da0ab", + "db_proxy_name": "\${aws_db_proxy.Proxy_CB0DFB71.name}", + "tags": { + "Name": "Test-ProxyEndpoint", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_subnet_ids": [ + "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}", + "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}" + ] + } + }, + "aws_db_proxy_target": { + "Proxy_ProxyTarget_4D257DB1": { + "db_instance_identifier": "\${aws_db_instance.Instance_C1063A87.identifier}", + "db_proxy_name": "\${aws_db_proxy.Proxy_CB0DFB71.name}", + "depends_on": [ + "aws_db_proxy_default_target_group.Proxy_ProxyTargetGroup_B462B5C5" + ], + "target_group_name": "default" + } + }, + "aws_db_subnet_group": { + "Instance_SubnetGroup_F2CBA54F": { + "description": "Subnet group for Instance database", + "name": "mystackinstancesubnetgroup4dc37c5c", + "subnet_ids": [ + "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}", + "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}" + ], + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_eip": { + "VPC_PublicSubnet1_EIP_6AD938E8": { + "domain": "vpc", + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + }, + "VPC_PublicSubnet2_EIP_4947BC00": { + "domain": "vpc", + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_iam_role": { + "Proxy_IAMRole_2FE8AB0F": { + "assume_role_policy": "\${data.aws_iam_policy_document.Proxy_IAMRole_AssumeRolePolicy_308A029B.json}", + "name_prefix": "a123e4567-e89b-12d3MyStackProxyIAMRole", + "tags": { + "Name": "Test-Proxy", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_iam_role_policy": { + "Proxy_IAMRole_DefaultPolicy_ResourceRoles0_09CC7916": { + "name": "MyStackProxyIAMRoleDefaultPolicy53F763CA", + "policy": "\${data.aws_iam_policy_document.Proxy_IAMRole_DefaultPolicy_2BA59AB7.json}", + "role": "\${aws_iam_role.Proxy_IAMRole_2FE8AB0F.name}" + } + }, + "aws_internet_gateway": { + "VPC_IGW_B7E252D3": { + "tags": { + "Name": "MyStack/VPC", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_internet_gateway_attachment": { + "VPC_VPCGW_99B986DC": { + "internet_gateway_id": "\${aws_internet_gateway.VPC_IGW_B7E252D3.id}", + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_nat_gateway": { + "VPC_PublicSubnet1_NATGateway_E0556630": { + "allocation_id": "\${aws_eip.VPC_PublicSubnet1_EIP_6AD938E8.allocation_id}", + "depends_on": [ + "aws_route_table_association.VPC_PublicSubnet1_RouteTableAssociation_0B0896DC", + "aws_route.VPC_PublicSubnet1_DefaultRoute_91CEF279" + ], + "subnet_id": "\${aws_subnet.VPC_PublicSubnet1_0D1B5E48.id}", + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + }, + "VPC_PublicSubnet2_NATGateway_3C070193": { + "allocation_id": "\${aws_eip.VPC_PublicSubnet2_EIP_4947BC00.allocation_id}", + "depends_on": [ + "aws_route_table_association.VPC_PublicSubnet2_RouteTableAssociation_5A808732", + "aws_route.VPC_PublicSubnet2_DefaultRoute_B7481BBA" + ], + "subnet_id": "\${aws_subnet.VPC_PublicSubnet2_E52FD57B.id}", + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_route": { + "VPC_PrivateSubnet1_DefaultRoute_AE1D6490": { + "destination_cidr_block": "0.0.0.0/0", + "nat_gateway_id": "\${aws_nat_gateway.VPC_PublicSubnet1_NATGateway_E0556630.id}", + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet1_RouteTable_BE8A6027.id}" + }, + "VPC_PrivateSubnet2_DefaultRoute_F4F5CFD2": { + "destination_cidr_block": "0.0.0.0/0", + "nat_gateway_id": "\${aws_nat_gateway.VPC_PublicSubnet2_NATGateway_3C070193.id}", + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet2_RouteTable_0A19E10E.id}" + }, + "VPC_PublicSubnet1_DefaultRoute_91CEF279": { + "depends_on": [ + "aws_internet_gateway_attachment.VPC_VPCGW_99B986DC" + ], + "destination_cidr_block": "0.0.0.0/0", + "gateway_id": "\${aws_internet_gateway.VPC_IGW_B7E252D3.id}", + "route_table_id": "\${aws_route_table.VPC_PublicSubnet1_RouteTable_FEE4B781.id}" + }, + "VPC_PublicSubnet2_DefaultRoute_B7481BBA": { + "depends_on": [ + "aws_internet_gateway_attachment.VPC_VPCGW_99B986DC" + ], + "destination_cidr_block": "0.0.0.0/0", + "gateway_id": "\${aws_internet_gateway.VPC_IGW_B7E252D3.id}", + "route_table_id": "\${aws_route_table.VPC_PublicSubnet2_RouteTable_6F1A15F1.id}" + } + }, + "aws_route_table": { + "VPC_PrivateSubnet1_RouteTable_BE8A6027": { + "tags": { + "Name": "MyStack/VPC/PrivateSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PrivateSubnet2_RouteTable_0A19E10E": { + "tags": { + "Name": "MyStack/VPC/PrivateSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet1_RouteTable_FEE4B781": { + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet2_RouteTable_6F1A15F1": { + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_route_table_association": { + "VPC_PrivateSubnet1_RouteTableAssociation_347902D1": { + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet1_RouteTable_BE8A6027.id}", + "subnet_id": "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}" + }, + "VPC_PrivateSubnet2_RouteTableAssociation_0C73D413": { + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet2_RouteTable_0A19E10E.id}", + "subnet_id": "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}" + }, + "VPC_PublicSubnet1_RouteTableAssociation_0B0896DC": { + "route_table_id": "\${aws_route_table.VPC_PublicSubnet1_RouteTable_FEE4B781.id}", + "subnet_id": "\${aws_subnet.VPC_PublicSubnet1_0D1B5E48.id}" + }, + "VPC_PublicSubnet2_RouteTableAssociation_5A808732": { + "route_table_id": "\${aws_route_table.VPC_PublicSubnet2_RouteTable_6F1A15F1.id}", + "subnet_id": "\${aws_subnet.VPC_PublicSubnet2_E52FD57B.id}" + } + }, + "aws_secretsmanager_secret": { + "Instance_Secret_478E0A47": { + "description": "Generated by the CDK for stack: MyStack", + "name": "MyStackInstanceSecretF7BF4815", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_secretsmanager_secret_version": { + "Instance_Secret_SecretVersion_AE6914F2": { + "lifecycle": { + "ignore_changes": [ + "secret_string" + ] + }, + "secret_id": "\${aws_secretsmanager_secret.Instance_Secret_478E0A47.arn}", + "secret_string": "\${jsonencode(merge(jsondecode(jsonencode({\\"username\\" = \\"admin\\", \\"password\\" = data.aws_secretsmanager_random_password.Instance_Secret_RandomPassword_930AA29C.random_password})), {\\"dbInstanceIdentifier\\" = aws_db_instance.Instance_C1063A87.identifier, \\"engine\\" = \\"mysql\\", \\"host\\" = aws_db_instance.Instance_C1063A87.address, \\"port\\" = aws_db_instance.Instance_C1063A87.port}))}" + } + }, + "aws_security_group": { + "Instance_SecurityGroup_B4E5FA83": { + "description": "Security group for Instance database", + "egress": [ + { + "cidr_blocks": [ + "0.0.0.0/0" + ], + "description": "Allow all outbound traffic by default", + "from_port": 0, + "ipv6_cidr_blocks": null, + "prefix_list_ids": null, + "protocol": "-1", + "security_groups": null, + "self": null, + "to_port": 0 + } + ], + "name": "a123e4567-e89b-12d3MyStackInstanceSecurityGroup981C92B4", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "Proxy_ProxySecurityGroup_C42FC3CE": { + "description": "SecurityGroup for Database Proxy", + "egress": [ + { + "cidr_blocks": [ + "0.0.0.0/0" + ], + "description": "Allow all outbound traffic by default", + "from_port": 0, + "ipv6_cidr_blocks": null, + "prefix_list_ids": null, + "protocol": "-1", + "security_groups": null, + "self": null, + "to_port": 0 + } + ], + "name": "a123e4567-e89b-12d3MyStackProxyProxySecurityGroup7CCC817A", + "tags": { + "Name": "Test-Proxy", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_subnet": { + "VPC_PrivateSubnet1_05F5A6DA": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 0)}", + "cidr_block": "10.0.128.0/18", + "map_public_ip_on_launch": false, + "tags": { + "Name": "MyStack/VPC/PrivateSubnet1", + "aws-cdk:subnet-name": "Private", + "aws-cdk:subnet-type": "Private", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PrivateSubnet2_8C0AEF3A": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 1)}", + "cidr_block": "10.0.192.0/18", + "map_public_ip_on_launch": false, + "tags": { + "Name": "MyStack/VPC/PrivateSubnet2", + "aws-cdk:subnet-name": "Private", + "aws-cdk:subnet-type": "Private", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet1_0D1B5E48": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 0)}", + "cidr_block": "10.0.0.0/18", + "map_public_ip_on_launch": true, + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "aws-cdk:subnet-name": "Public", + "aws-cdk:subnet-type": "Public", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet2_E52FD57B": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 1)}", + "cidr_block": "10.0.64.0/18", + "map_public_ip_on_launch": true, + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "aws-cdk:subnet-name": "Public", + "aws-cdk:subnet-type": "Public", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_vpc": { + "VPC_B9E5F0B4": { + "cidr_block": "10.0.0.0/16", + "enable_dns_hostnames": true, + "enable_dns_support": true, + "instance_tenancy": "default", + "tags": { + "Name": "MyStack/VPC", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_vpc_security_group_ingress_rule": { + "Instance_SecurityGroup_fromMyStackProxyProxySecurityGroup7CCC817AIndirectPort_E5659C5A": { + "description": "Allow connections to the database Instance from the Proxy", + "from_port": "\${aws_db_instance.Instance_C1063A87.port}", + "ip_protocol": "tcp", + "referenced_security_group_id": "\${aws_security_group.Proxy_ProxySecurityGroup_C42FC3CE.id}", + "security_group_id": "\${aws_security_group.Instance_SecurityGroup_B4E5FA83.id}", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "to_port": "\${aws_db_instance.Instance_C1063A87.port}" + } + } + }, + "terraform": { + "backend": { + "http": { + "address": "http://localhost:3000" + } + }, + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": "6.58.0" + } + } + } +}" +`; diff --git a/test/aws/storage/rds/__snapshots__/proxy.test.ts.snap b/test/aws/storage/rds/__snapshots__/proxy.test.ts.snap new file mode 100644 index 00000000..c58a7488 --- /dev/null +++ b/test/aws/storage/rds/__snapshots__/proxy.test.ts.snap @@ -0,0 +1,498 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`proxy create a DB proxy from an instance 1`] = ` +"{ + "data": { + "aws_availability_zones": { + "AvailabilityZones": { + "provider": "aws" + } + }, + "aws_caller_identity": { + "CallerIdentity": { + "provider": "aws" + } + }, + "aws_iam_policy_document": { + "Proxy_IAMRole_AssumeRolePolicy_308A029B": { + "statement": [ + { + "actions": [ + "sts:AssumeRole" + ], + "effect": "Allow", + "principals": [ + { + "identifiers": [ + "\${data.aws_service_principal.aws_svcp_default_region_rds.name}" + ], + "type": "Service" + } + ] + } + ] + }, + "Proxy_IAMRole_DefaultPolicy_2BA59AB7": { + "statement": [ + { + "actions": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ], + "effect": "Allow", + "resources": [ + "\${aws_secretsmanager_secret.Instance_Secret_478E0A47.arn}" + ] + } + ] + } + }, + "aws_partition": { + "Partitition": { + "provider": "aws" + } + }, + "aws_secretsmanager_random_password": { + "Instance_Secret_RandomPassword_930AA29C": { + "exclude_characters": " %+~\`#$&*()|[]{}:;<>?!'/@\\"\\\\", + "exclude_lowercase": false, + "exclude_numbers": false, + "exclude_punctuation": false, + "exclude_uppercase": false, + "include_space": false, + "password_length": 30, + "require_each_included_type": true + } + }, + "aws_service_principal": { + "aws_svcp_default_region_rds": { + "service_name": "rds" + } + } + }, + "provider": { + "aws": [ + { + "region": "us-east-1" + } + ] + }, + "resource": { + "aws_db_instance": { + "Instance_C1063A87": { + "allocated_storage": 100, + "copy_tags_to_snapshot": true, + "db_subnet_group_name": "\${aws_db_subnet_group.Instance_SubnetGroup_F2CBA54F.name}", + "engine": "mysql", + "engine_version": "5.7", + "identifier": "mystackinstancec8b5f353", + "instance_class": "db.m5.large", + "lifecycle": { + "ignore_changes": [ + "password" + ] + }, + "password": "\${data.aws_secretsmanager_random_password.Instance_Secret_RandomPassword_930AA29C.random_password}", + "storage_type": "gp2", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "username": "admin", + "vpc_security_group_ids": [ + "\${aws_security_group.Instance_SecurityGroup_B4E5FA83.id}" + ] + } + }, + "aws_db_proxy": { + "Proxy_CB0DFB71": { + "auth": [ + { + "auth_scheme": "SECRETS", + "iam_auth": "DISABLED", + "secret_arn": "\${aws_secretsmanager_secret.Instance_Secret_478E0A47.arn}" + } + ], + "engine_family": "MYSQL", + "name": "mystackproxy142a6b29", + "require_tls": true, + "role_arn": "\${aws_iam_role.Proxy_IAMRole_2FE8AB0F.arn}", + "tags": { + "Name": "Test-Proxy", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_security_group_ids": [ + "\${aws_security_group.Proxy_ProxySecurityGroup_C42FC3CE.id}" + ], + "vpc_subnet_ids": [ + "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}", + "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}" + ] + } + }, + "aws_db_proxy_default_target_group": { + "Proxy_ProxyTargetGroup_B462B5C5": { + "db_proxy_name": "\${aws_db_proxy.Proxy_CB0DFB71.name}", + "depends_on": [ + "aws_db_proxy.Proxy_CB0DFB71" + ] + } + }, + "aws_db_proxy_target": { + "Proxy_ProxyTarget_4D257DB1": { + "db_instance_identifier": "\${aws_db_instance.Instance_C1063A87.identifier}", + "db_proxy_name": "\${aws_db_proxy.Proxy_CB0DFB71.name}", + "depends_on": [ + "aws_db_proxy_default_target_group.Proxy_ProxyTargetGroup_B462B5C5" + ], + "target_group_name": "default" + } + }, + "aws_db_subnet_group": { + "Instance_SubnetGroup_F2CBA54F": { + "description": "Subnet group for Instance database", + "name": "mystackinstancesubnetgroup4dc37c5c", + "subnet_ids": [ + "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}", + "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}" + ], + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_eip": { + "VPC_PublicSubnet1_EIP_6AD938E8": { + "domain": "vpc", + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + }, + "VPC_PublicSubnet2_EIP_4947BC00": { + "domain": "vpc", + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_iam_role": { + "Proxy_IAMRole_2FE8AB0F": { + "assume_role_policy": "\${data.aws_iam_policy_document.Proxy_IAMRole_AssumeRolePolicy_308A029B.json}", + "name_prefix": "a123e4567-e89b-12d3MyStackProxyIAMRole", + "tags": { + "Name": "Test-Proxy", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_iam_role_policy": { + "Proxy_IAMRole_DefaultPolicy_ResourceRoles0_09CC7916": { + "name": "MyStackProxyIAMRoleDefaultPolicy53F763CA", + "policy": "\${data.aws_iam_policy_document.Proxy_IAMRole_DefaultPolicy_2BA59AB7.json}", + "role": "\${aws_iam_role.Proxy_IAMRole_2FE8AB0F.name}" + } + }, + "aws_internet_gateway": { + "VPC_IGW_B7E252D3": { + "tags": { + "Name": "MyStack/VPC", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_internet_gateway_attachment": { + "VPC_VPCGW_99B986DC": { + "internet_gateway_id": "\${aws_internet_gateway.VPC_IGW_B7E252D3.id}", + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_nat_gateway": { + "VPC_PublicSubnet1_NATGateway_E0556630": { + "allocation_id": "\${aws_eip.VPC_PublicSubnet1_EIP_6AD938E8.allocation_id}", + "depends_on": [ + "aws_route_table_association.VPC_PublicSubnet1_RouteTableAssociation_0B0896DC", + "aws_route.VPC_PublicSubnet1_DefaultRoute_91CEF279" + ], + "subnet_id": "\${aws_subnet.VPC_PublicSubnet1_0D1B5E48.id}", + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + }, + "VPC_PublicSubnet2_NATGateway_3C070193": { + "allocation_id": "\${aws_eip.VPC_PublicSubnet2_EIP_4947BC00.allocation_id}", + "depends_on": [ + "aws_route_table_association.VPC_PublicSubnet2_RouteTableAssociation_5A808732", + "aws_route.VPC_PublicSubnet2_DefaultRoute_B7481BBA" + ], + "subnet_id": "\${aws_subnet.VPC_PublicSubnet2_E52FD57B.id}", + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_route": { + "VPC_PrivateSubnet1_DefaultRoute_AE1D6490": { + "destination_cidr_block": "0.0.0.0/0", + "nat_gateway_id": "\${aws_nat_gateway.VPC_PublicSubnet1_NATGateway_E0556630.id}", + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet1_RouteTable_BE8A6027.id}" + }, + "VPC_PrivateSubnet2_DefaultRoute_F4F5CFD2": { + "destination_cidr_block": "0.0.0.0/0", + "nat_gateway_id": "\${aws_nat_gateway.VPC_PublicSubnet2_NATGateway_3C070193.id}", + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet2_RouteTable_0A19E10E.id}" + }, + "VPC_PublicSubnet1_DefaultRoute_91CEF279": { + "depends_on": [ + "aws_internet_gateway_attachment.VPC_VPCGW_99B986DC" + ], + "destination_cidr_block": "0.0.0.0/0", + "gateway_id": "\${aws_internet_gateway.VPC_IGW_B7E252D3.id}", + "route_table_id": "\${aws_route_table.VPC_PublicSubnet1_RouteTable_FEE4B781.id}" + }, + "VPC_PublicSubnet2_DefaultRoute_B7481BBA": { + "depends_on": [ + "aws_internet_gateway_attachment.VPC_VPCGW_99B986DC" + ], + "destination_cidr_block": "0.0.0.0/0", + "gateway_id": "\${aws_internet_gateway.VPC_IGW_B7E252D3.id}", + "route_table_id": "\${aws_route_table.VPC_PublicSubnet2_RouteTable_6F1A15F1.id}" + } + }, + "aws_route_table": { + "VPC_PrivateSubnet1_RouteTable_BE8A6027": { + "tags": { + "Name": "MyStack/VPC/PrivateSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PrivateSubnet2_RouteTable_0A19E10E": { + "tags": { + "Name": "MyStack/VPC/PrivateSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet1_RouteTable_FEE4B781": { + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet2_RouteTable_6F1A15F1": { + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_route_table_association": { + "VPC_PrivateSubnet1_RouteTableAssociation_347902D1": { + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet1_RouteTable_BE8A6027.id}", + "subnet_id": "\${aws_subnet.VPC_PrivateSubnet1_05F5A6DA.id}" + }, + "VPC_PrivateSubnet2_RouteTableAssociation_0C73D413": { + "route_table_id": "\${aws_route_table.VPC_PrivateSubnet2_RouteTable_0A19E10E.id}", + "subnet_id": "\${aws_subnet.VPC_PrivateSubnet2_8C0AEF3A.id}" + }, + "VPC_PublicSubnet1_RouteTableAssociation_0B0896DC": { + "route_table_id": "\${aws_route_table.VPC_PublicSubnet1_RouteTable_FEE4B781.id}", + "subnet_id": "\${aws_subnet.VPC_PublicSubnet1_0D1B5E48.id}" + }, + "VPC_PublicSubnet2_RouteTableAssociation_5A808732": { + "route_table_id": "\${aws_route_table.VPC_PublicSubnet2_RouteTable_6F1A15F1.id}", + "subnet_id": "\${aws_subnet.VPC_PublicSubnet2_E52FD57B.id}" + } + }, + "aws_secretsmanager_secret": { + "Instance_Secret_478E0A47": { + "description": "Generated by the CDK for stack: MyStack", + "name": "MyStackInstanceSecretF7BF4815", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_secretsmanager_secret_version": { + "Instance_Secret_SecretVersion_AE6914F2": { + "lifecycle": { + "ignore_changes": [ + "secret_string" + ] + }, + "secret_id": "\${aws_secretsmanager_secret.Instance_Secret_478E0A47.arn}", + "secret_string": "\${jsonencode(merge(jsondecode(jsonencode({\\"username\\" = \\"admin\\", \\"password\\" = data.aws_secretsmanager_random_password.Instance_Secret_RandomPassword_930AA29C.random_password})), {\\"dbInstanceIdentifier\\" = aws_db_instance.Instance_C1063A87.identifier, \\"engine\\" = \\"mysql\\", \\"host\\" = aws_db_instance.Instance_C1063A87.address, \\"port\\" = aws_db_instance.Instance_C1063A87.port}))}" + } + }, + "aws_security_group": { + "Instance_SecurityGroup_B4E5FA83": { + "description": "Security group for Instance database", + "egress": [ + { + "cidr_blocks": [ + "0.0.0.0/0" + ], + "description": "Allow all outbound traffic by default", + "from_port": 0, + "ipv6_cidr_blocks": null, + "prefix_list_ids": null, + "protocol": "-1", + "security_groups": null, + "self": null, + "to_port": 0 + } + ], + "name": "a123e4567-e89b-12d3MyStackInstanceSecurityGroup981C92B4", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "Proxy_ProxySecurityGroup_C42FC3CE": { + "description": "SecurityGroup for Database Proxy", + "egress": [ + { + "cidr_blocks": [ + "0.0.0.0/0" + ], + "description": "Allow all outbound traffic by default", + "from_port": 0, + "ipv6_cidr_blocks": null, + "prefix_list_ids": null, + "protocol": "-1", + "security_groups": null, + "self": null, + "to_port": 0 + } + ], + "name": "a123e4567-e89b-12d3MyStackProxyProxySecurityGroup7CCC817A", + "tags": { + "Name": "Test-Proxy", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_subnet": { + "VPC_PrivateSubnet1_05F5A6DA": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 0)}", + "cidr_block": "10.0.128.0/18", + "map_public_ip_on_launch": false, + "tags": { + "Name": "MyStack/VPC/PrivateSubnet1", + "aws-cdk:subnet-name": "Private", + "aws-cdk:subnet-type": "Private", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PrivateSubnet2_8C0AEF3A": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 1)}", + "cidr_block": "10.0.192.0/18", + "map_public_ip_on_launch": false, + "tags": { + "Name": "MyStack/VPC/PrivateSubnet2", + "aws-cdk:subnet-name": "Private", + "aws-cdk:subnet-type": "Private", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet1_0D1B5E48": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 0)}", + "cidr_block": "10.0.0.0/18", + "map_public_ip_on_launch": true, + "tags": { + "Name": "MyStack/VPC/PublicSubnet1", + "aws-cdk:subnet-name": "Public", + "aws-cdk:subnet-type": "Public", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + }, + "VPC_PublicSubnet2_E52FD57B": { + "availability_zone": "\${element(data.aws_availability_zones.AvailabilityZones.names, 1)}", + "cidr_block": "10.0.64.0/18", + "map_public_ip_on_launch": true, + "tags": { + "Name": "MyStack/VPC/PublicSubnet2", + "aws-cdk:subnet-name": "Public", + "aws-cdk:subnet-type": "Public", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "vpc_id": "\${aws_vpc.VPC_B9E5F0B4.id}" + } + }, + "aws_vpc": { + "VPC_B9E5F0B4": { + "cidr_block": "10.0.0.0/16", + "enable_dns_hostnames": true, + "enable_dns_support": true, + "instance_tenancy": "default", + "tags": { + "Name": "MyStack/VPC", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + } + } + }, + "aws_vpc_security_group_ingress_rule": { + "Instance_SecurityGroup_fromMyStackProxyProxySecurityGroup7CCC817AIndirectPort_E5659C5A": { + "description": "Allow connections to the database Instance from the Proxy", + "from_port": "\${aws_db_instance.Instance_C1063A87.port}", + "ip_protocol": "tcp", + "referenced_security_group_id": "\${aws_security_group.Proxy_ProxySecurityGroup_C42FC3CE.id}", + "security_group_id": "\${aws_security_group.Instance_SecurityGroup_B4E5FA83.id}", + "tags": { + "Name": "Test-Instance", + "grid:EnvironmentName": "Test", + "grid:UUID": "a123e4567-e89b-12d3" + }, + "to_port": "\${aws_db_instance.Instance_C1063A87.port}" + } + } + }, + "terraform": { + "backend": { + "http": { + "address": "http://localhost:3000" + } + }, + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": "6.58.0" + } + } + } +}" +`; diff --git a/test/aws/storage/rds/proxy-endpoint.test.ts b/test/aws/storage/rds/proxy-endpoint.test.ts new file mode 100644 index 00000000..b52fd634 --- /dev/null +++ b/test/aws/storage/rds/proxy-endpoint.test.ts @@ -0,0 +1,193 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/proxy-endpoint.test.ts + +import { dbProxyEndpoint } from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as compute from "../../../../src/aws/compute"; +import * as rds from "../../../../src/aws/storage/rds"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +let app: App; +let stack: AwsStack; +let vpc: compute.IVpc; +beforeEach(() => { + app = Testing.app(); + stack = new AwsStack(app, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); + vpc = new compute.Vpc(stack, "VPC", { maxAzs: 2 }); +}); + +describe("Proxy endpoint", () => { + test("create a DB proxy from an instance", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_4_5, + }), + vpc, + }); + + const dbProxy = new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + }); + + // WHEN + new rds.DatabaseProxyEndpoint(stack, "ProxyEndpoint", { + dbProxy, + vpc, + }); + + // THEN + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties(dbProxyEndpoint.DbProxyEndpoint, { + db_proxy_name: stack.resolve(dbProxy.dbProxyName), + }); + }); + + test("can specify targetRole", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_4_5, + }), + vpc, + }); + + const dbProxy = new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + }); + + // WHEN + new rds.DatabaseProxyEndpoint(stack, "ProxyEndpoint", { + dbProxy, + vpc, + targetRole: rds.ProxyEndpointTargetRole.READ_ONLY, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxyEndpoint.DbProxyEndpoint, { + db_proxy_name: stack.resolve(dbProxy.dbProxyName), + target_role: "READ_ONLY", + }); + }); + + test("can specify security group", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_4_5, + }), + vpc, + }); + + const dbProxy = new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + }); + + const sg = new compute.SecurityGroup(stack, "SecurityGroup", { + vpc, + }); + + // WHEN + new rds.DatabaseProxyEndpoint(stack, "ProxyEndpoint", { + dbProxyEndpointName: "test", + dbProxy, + vpc, + securityGroups: [sg], + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxyEndpoint.DbProxyEndpoint, { + db_proxy_endpoint_name: "test", + db_proxy_name: stack.resolve(dbProxy.dbProxyName), + vpc_security_group_ids: [stack.resolve(sg.securityGroupId)], + }); + }); + + test("throw when security group empty", () => { + expect(() => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_4_5, + }), + vpc, + }); + + const dbProxy = new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + }); + + new rds.DatabaseProxyEndpoint(stack, "ProxyEndpoint", { + dbProxy, + vpc, + securityGroups: [], + }); + }).toThrow(/`securityGroups` must be undefined or a non-empty array/); + }); + + test("throw when less than 2 subnets", () => { + expect(() => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_8_4_5, + }), + vpc, + }); + + const dbProxy = new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + }); + + new rds.DatabaseProxyEndpoint(stack, "ProxyEndpoint", { + dbProxy, + vpc, + vpcSubnets: vpc.selectSubnets({ subnets: [vpc.privateSubnets[0]] }), + }); + }).toThrow(/`subnets` requires at least 2 subnets/); + }); + + test("import an existing DB proxy endpoint", () => { + const imported = + rds.DatabaseProxyEndpoint.fromDatabaseProxyEndpointAttributes( + stack, + "ImportedProxyEndpoint", + { + dbProxyEndpointName: "my-proxy-endpoint", + dbProxyEndpointArn: + "arn:aws:rds:us-east-1:123456789012:db-proxy-endpoint:prxend-1234abcd", + endpoint: "my-endpoint", + }, + ); + + expect(imported.dbProxyEndpointName).toEqual("my-proxy-endpoint"); + expect(imported.endpoint).toEqual("my-endpoint"); + }); +}); diff --git a/test/aws/storage/rds/proxy.test.ts b/test/aws/storage/rds/proxy.test.ts new file mode 100644 index 00000000..8fb61283 --- /dev/null +++ b/test/aws/storage/rds/proxy.test.ts @@ -0,0 +1,998 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/proxy.test.ts +// +// TERRACONSTRUCTS DEVIATION: upstream's default (unset) `dbProxyName` renders as the CFN logical +// id derived from the construct id ("Proxy") -- see `proxy.ts`'s deviation note on `physicalName`. +// This repo instead always defaults to a gridUUID-scoped `uniqueResourceName` (there is no +// CloudFormation Ref-based logical-id concept here), so upstream's `DBProxyName: 'Proxy'` +// assertions are simply omitted below rather than replaced with an unstable synthesized value -- +// same idiom as `identifier` on `DatabaseInstance`/`DatabaseCluster` tests. +// +// TERRACONSTRUCTS DEVIATION: upstream's `describe('feature flag @aws-cdk/aws-rds:databaseProxyUniqueResourceName', ...)` +// block is omitted entirely -- both its tests exercise a CDK context feature flag toggling between +// the construct id and a unique name; this repo has no such flag (unique names are always used, see +// above), so the "with a unique resource name" case is moot; the "with a proxy name in the +// constructor" case is covered by the explicit-`dbProxyName` test in the `proxy` describe block. + +import { + dbProxy, + dbProxyDefaultTargetGroup, + dbProxyEndpoint, + dbProxyTarget, + dataAwsIamPolicyDocument, + securityGroup, + vpcSecurityGroupIngressRule, +} from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { ArnFormat, AwsStack } from "../../../../src/aws"; +import * as compute from "../../../../src/aws/compute"; +import * as encryption from "../../../../src/aws/encryption"; +import { AccountPrincipal, Role } from "../../../../src/aws/iam"; +import * as rds from "../../../../src/aws/storage/rds"; +import { Duration } from "../../../../src/duration"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +let app: App; +let stack: AwsStack; +let vpc: compute.IVpc; +beforeEach(() => { + app = Testing.app(); + stack = new AwsStack(app, "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); + vpc = new compute.Vpc(stack, "VPC", { maxAzs: 2 }); +}); + +describe("proxy", () => { + test("create a DB proxy from an instance", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + vpc, + }); + + // WHEN + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + }); + + // THEN + const t = new Template(stack, { snapshot: true }); + t.expect.toHaveResourceWithProperties(dbProxy.DbProxy, { + auth: [ + { + auth_scheme: "SECRETS", + iam_auth: "DISABLED", + secret_arn: stack.resolve(instance.secret!.secretArn), + }, + ], + engine_family: "MYSQL", + require_tls: true, + }); + // `connectionPoolConfig` is omitted entirely from the synthesized resource when every + // sub-field is `undefined` (no pool-tuning props were passed) -- presence of the target group + // resource itself is asserted above/below via `db_proxy_name`/`target_group_name`. + t.resourceCountIs(dbProxyDefaultTargetGroup.DbProxyDefaultTargetGroup, 1); + t.expect.toHaveResourceWithProperties(dbProxyTarget.DbProxyTarget, { + target_group_name: "default", + db_instance_identifier: stack.resolve(instance.instanceIdentifier), + }); + }); + + test("explicit dbProxyName maps to aws_db_proxy name", () => { + // GIVEN -- https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/proxy.test.ts#L1088 + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + vpc, + }); + + // WHEN + new rds.DatabaseProxy(stack, "Proxy", { + dbProxyName: "my-proxy-name", + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxy.DbProxy, { + name: "my-proxy-name", + }); + }); + + test("generated default proxy name is lowercase (provider validates lowercase-only)", () => { + // GIVEN -- live-caught by TestRdsProxy: the provider rejects uppercase in + // aws_db_proxy.name at plan time. + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + vpc, + }); + + // WHEN + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + }); + + // THEN + const t = new Template(stack); + const [proxyResource]: any[] = t.resourceTypeArray(dbProxy.DbProxy); + expect(proxyResource.name).toMatch(/^[a-z0-9-]+$/); + }); + + test("pool-tuning and proxy-tuning props map end-to-end across the resource split", () => { + // GIVEN -- the CfnDBProxyTargetGroup -> default-target-group/target split is the largest + // deviation in this file; every connection_pool_config sub-field plus the aws_db_proxy + // tuning args must be asserted through it. + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + vpc, + }); + + // WHEN + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + borrowTimeout: Duration.seconds(30), + initQuery: "SET x=1", + maxConnectionsPercent: 50, + maxIdleConnectionsPercent: 20, + sessionPinningFilters: [rds.SessionPinningFilter.EXCLUDE_VARIABLE_SETS], + debugLogging: true, + idleClientTimeout: Duration.minutes(3), + requireTLS: false, + iamAuth: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxy.DbProxy, { + debug_logging: true, + idle_client_timeout: 180, + require_tls: false, + auth: [ + { + auth_scheme: "SECRETS", + iam_auth: "REQUIRED", + secret_arn: stack.resolve(instance.secret!.secretArn), + }, + ], + }); + t.expect.toHaveResourceWithProperties( + dbProxyDefaultTargetGroup.DbProxyDefaultTargetGroup, + { + connection_pool_config: { + connection_borrow_timeout: 30, + init_query: "SET x=1", + max_connections_percent: 50, + max_idle_connections_percent: 20, + session_pinning_filters: ["EXCLUDE_VARIABLE_SETS"], + }, + }, + ); + }); + + test("create a DB proxy for a MariaDB instance", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mariaDb({ + version: rds.MariaDbEngineVersion.VER_10_6_16, + }), + vpc, + }); + + // WHEN + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + }); + + // THEN + // MariaDB shares RDS Proxy's MYSQL engine family. + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxy.DbProxy, { + engine_family: "MYSQL", + }); + }); + + test("create a DB proxy from a cluster", () => { + // GIVEN + const cluster = new rds.DatabaseCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.auroraPostgres({ + version: rds.AuroraPostgresEngineVersion.VER_10_7, + }), + instanceProps: { vpc }, + }); + + // WHEN + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromCluster(cluster), + secrets: [cluster.secret!], + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxy.DbProxy, { + auth: [ + { + auth_scheme: "SECRETS", + iam_auth: "DISABLED", + secret_arn: stack.resolve(cluster.secret!.secretArn), + }, + ], + engine_family: "POSTGRESQL", + require_tls: true, + }); + t.expect.toHaveResourceWithProperties(dbProxyTarget.DbProxyTarget, { + db_cluster_identifier: stack.resolve(cluster.clusterIdentifier), + }); + // no db_instance_identifier set for a cluster target + const [target] = t.resourceTypeArray(dbProxyTarget.DbProxyTarget) as any[]; + expect(target.db_instance_identifier).toBeUndefined(); + + t.expect.toHaveResourceWithProperties( + vpcSecurityGroupIngressRule.VpcSecurityGroupIngressRule, + { + ip_protocol: "tcp", + description: "Allow connections to the database Cluster from the Proxy", + }, + ); + }); + + test("throws when defaultAuthScheme is NONE and no secrets are provided", () => { + // GIVEN + const cluster = new rds.DatabaseCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.auroraPostgres({ + version: rds.AuroraPostgresEngineVersion.VER_10_7, + }), + instanceProps: { vpc }, + }); + + // WHEN + expect(() => { + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromCluster(cluster), + secrets: [], // No secret + vpc, + defaultAuthScheme: rds.DefaultAuthScheme.NONE, + }); + }).toThrow( + "One or more secrets are required when defaultAuthScheme is not specified or is NONE.", + ); + }); + + test("throws when defaultAuthScheme is undefined and no secrets are provided", () => { + // GIVEN + const cluster = new rds.DatabaseCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.auroraPostgres({ + version: rds.AuroraPostgresEngineVersion.VER_10_7, + }), + instanceProps: { vpc }, + }); + + // WHEN + expect(() => { + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromCluster(cluster), + secrets: [], // No secret + vpc, + // defaultAuthScheme is not specified (undefined) + }); + }).toThrow( + "One or more secrets are required when defaultAuthScheme is not specified or is NONE.", + ); + }); + + test("fails when trying to create a proxy for a target without an engine", () => { + const importedCluster = rds.DatabaseCluster.fromDatabaseClusterAttributes( + stack, + "Cluster", + { + clusterIdentifier: "my-cluster", + }, + ); + + expect(() => { + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromCluster(importedCluster), + vpc, + secrets: [new encryption.Secret(stack, "Secret")], + }); + }).toThrow( + /Could not determine engine for proxy target '.*Cluster'\. Please provide it explicitly when importing the resource/, + ); + }); + + test("fails when trying to create a proxy for a target with an engine that doesn't have engineFamily", () => { + const importedInstance = + rds.DatabaseInstance.fromDatabaseInstanceAttributes(stack, "Cluster", { + instanceIdentifier: "my-instance", + instanceEndpointAddress: "instance-address", + port: 5432, + securityGroups: [], + engine: rds.DatabaseInstanceEngine.oracleEe({ + version: rds.OracleEngineVersion.VER_21, + }), + }); + + expect(() => { + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(importedInstance), + vpc, + secrets: [new encryption.Secret(stack, "Secret")], + }); + }).toThrow( + /RDS proxies require an engine family to be specified on the database cluster or instance\. No family specified for engine 'oracle-ee-21'/, + ); + }); + + test("correctly creates a proxy for an imported Cluster if its engine is known", () => { + const importedCluster = rds.DatabaseCluster.fromDatabaseClusterAttributes( + stack, + "Cluster", + { + clusterIdentifier: "my-cluster", + engine: rds.DatabaseClusterEngine.auroraPostgres({ + version: rds.AuroraPostgresEngineVersion.VER_9_6_11, + }), + port: 5432, + }, + ); + + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromCluster(importedCluster), + vpc, + secrets: [new encryption.Secret(stack, "Secret")], + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxy.DbProxy, { + engine_family: "POSTGRESQL", + }); + t.expect.toHaveResourceWithProperties(dbProxyTarget.DbProxyTarget, { + db_cluster_identifier: "my-cluster", + }); + t.expect.toHaveResourceWithProperties(securityGroup.SecurityGroup, { + description: "SecurityGroup for Database Proxy", + }); + }); + + describe("imported Proxies", () => { + let importedDbProxy: rds.IDatabaseProxy; + beforeEach(() => { + importedDbProxy = rds.DatabaseProxy.fromDatabaseProxyAttributes( + stack, + "Proxy", + { + dbProxyName: "my-proxy", + dbProxyArn: + "arn:aws:rds:us-east-1:123456789012:db-proxy:prx-1234abcd", + endpoint: "my-endpoint", + securityGroups: [], + }, + ); + }); + + test("grant rds-db:connect in grantConnect() with a dbUser explicitly passed", () => { + // WHEN + const role = new Role(stack, "DBProxyRole", { + assumedBy: new AccountPrincipal(stack.account), + }); + const databaseUser = "test"; + importedDbProxy.grantConnect(role, databaseUser); + + // THEN + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["rds-db:connect"], + effect: "Allow", + resources: [ + stack.resolve( + stack.formatArn({ + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + service: "rds-db", + resource: "dbuser", + resourceName: "prx-1234abcd/test", + }), + ), + ], + }, + ], + }, + ); + }); + + test("throws when grantConnect() is used without a dbUser", () => { + // WHEN + const role = new Role(stack, "DBProxyRole", { + assumedBy: new AccountPrincipal(stack.account), + }); + + // THEN + expect(() => { + importedDbProxy.grantConnect(role); + }).toThrow( + /For imported Database Proxies, the dbUser is required in grantConnect/, + ); + }); + }); + + // TERRACONSTRUCTS DEVIATION: upstream's 'new Proxy with a single Secret can use grantConnect() + // without a dbUser passed' test defaults `dbUser` by reading it back out of the secret's JSON + // value via `secret.secretValueFromJson('username').unsafeUnwrap()` -- a CloudFormation dynamic + // reference, not portable here (see the deviation note on `DatabaseProxy.grantConnect()`). This + // repo throws instead, directing callers to pass `dbUser` explicitly. + test("new Proxy with a single Secret cannot use grantConnect() without a dbUser passed", () => { + // GIVEN + const cluster = new rds.DatabaseCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + instanceProps: { vpc }, + }); + + const proxy = new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromCluster(cluster), + secrets: [cluster.secret!], + vpc, + }); + + // WHEN + const role = new Role(stack, "DBProxyRole", { + assumedBy: new AccountPrincipal(stack.account), + }); + + // THEN + expect(() => { + proxy.grantConnect(role); + }).toThrow( + /grantConnect\(\) without a dbUser is not supported in TerraConstructs when the Proxy has a single secret/, + ); + }); + + test("new Proxy with multiple Secrets cannot use grantConnect() without a dbUser passed", () => { + // GIVEN + const cluster = new rds.DatabaseCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + instanceProps: { vpc }, + }); + + const proxy = new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromCluster(cluster), + secrets: [cluster.secret!, new encryption.Secret(stack, "ProxySecret")], + vpc, + }); + + // WHEN + const role = new Role(stack, "DBProxyRole", { + assumedBy: new AccountPrincipal(stack.account), + }); + + // THEN + expect(() => { + proxy.grantConnect(role); + }).toThrow( + /When the Proxy contains multiple Secrets, you must pass a dbUser explicitly to grantConnect/, + ); + }); + + test("throws when grantConnect() is used without dbUser on Proxy with no secrets", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + iamAuthentication: true, + }); + + const proxy = new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + vpc, + defaultAuthScheme: rds.DefaultAuthScheme.IAM_AUTH, + }); + + // WHEN + const role = new Role(stack, "DBProxyRole", { + assumedBy: new AccountPrincipal(stack.account), + }); + + // THEN + expect(() => { + proxy.grantConnect(role); + }).toThrow( + /When using IAM authentication without secrets, you must specify a dbUser parameter in grantConnect/, + ); + }); + + test("new Proxy with no secrets can use grantConnect() with a dbUser specified", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + iamAuthentication: true, + }); + + const proxy = new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + vpc, + defaultAuthScheme: rds.DefaultAuthScheme.IAM_AUTH, + // No secrets provided + }); + + // WHEN + const role = new Role(stack, "DBProxyRole", { + assumedBy: new AccountPrincipal(stack.account), + }); + const databaseUser = "testuser"; + proxy.grantConnect(role, databaseUser); + + // THEN + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["rds-db:connect"], + effect: "Allow", + resources: [expect.stringContaining("/testuser")], + }, + ], + }, + ); + }); + + test("new Proxy with kms encrypted Secrets has permissions to kms:Decrypt that secret using its key", () => { + // GIVEN + const cluster = new rds.DatabaseCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + instanceProps: { vpc }, + }); + + const kmsKey = new encryption.Key(stack, "Key"); + + const kmsEncryptedSecret = new encryption.Secret(stack, "Secret", { + encryptionKey: kmsKey, + }); + + // WHEN + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromCluster(cluster), + vpc, + secrets: [kmsEncryptedSecret], + }); + + // THEN + // NOTE: `secret.grantRead(role)` and `secret.encryptionKey.grantDecrypt(role)` both grant to + // the SAME auto-created proxy role, so both statements are merged onto that role's single IAM + // policy document (one `data.aws_iam_policy_document` with two statements), rather than two + // separate documents. + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: expect.arrayContaining([ + expect.objectContaining({ + actions: [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + ], + effect: "Allow", + resources: [stack.resolve(kmsEncryptedSecret.secretArn)], + }), + expect.objectContaining({ + actions: ["kms:Decrypt"], + effect: "Allow", + resources: [stack.resolve(kmsKey.keyArn)], + }), + ]), + }, + ); + }); + + test("DbProxyTarget should have dependency on the proxy targets", () => { + // GIVEN + const cluster = new rds.DatabaseCluster(stack, "cluster", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + instanceProps: { + vpc, + }, + }); + + // WHEN + new rds.DatabaseProxy(stack, "proxy", { + proxyTarget: rds.ProxyTarget.fromCluster(cluster), + secrets: [cluster.secret!], + vpc, + }); + + // THEN + // TERRACONSTRUCTS DEVIATION: upstream asserts an explicit CFN `DependsOn` (the cluster + // instances'/cluster's logical ids) on the single `AWS::RDS::DBProxyTargetGroup`. There is no + // logical-id/`Ref` concept here; the equivalent Terraform ordering constraint is a `depends_on` + // entry (added via `node.addDependency()`) on the `aws_db_proxy_target` resource (see the + // deviation note on `DatabaseProxy`'s constructor in `proxy.ts`), asserted loosely below (by + // substring) since the exact synthesized address depends on `cluster.ts`'s internal + // construct-id choices. + const t = new Template(stack); + const [target] = t.resourceTypeArray(dbProxyTarget.DbProxyTarget) as any[]; + expect(target.depends_on).toEqual( + expect.arrayContaining([ + expect.stringContaining("Instance1"), + expect.stringContaining("Instance2"), + expect.stringContaining("cluster"), + ]), + ); + }); + + test("Correct dependencies are created when multiple DatabaseProxy are created with addProxy", () => { + // GIVEN + const cluster = new rds.DatabaseCluster(stack, "cluster", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + instanceProps: { + vpc, + }, + }); + + // WHEN + cluster.addProxy("Proxy", { + vpc, + secrets: [cluster.secret!], + }); + cluster.addProxy("Proxy2", { + vpc, + secrets: [cluster.secret!], + }); + + // THEN + const t = new Template(stack); + const targets = t.resourceTypeArray(dbProxyTarget.DbProxyTarget) as any[]; + expect(targets).toHaveLength(2); + targets.forEach((target) => { + expect(target.depends_on).toEqual( + expect.arrayContaining([ + expect.stringContaining("Instance1"), + expect.stringContaining("Instance2"), + expect.stringContaining("cluster"), + ]), + ); + }); + }); + + test("DbProxyTarget should have dependency on the proxy targets when using cluster with writer and readers properties", () => { + // GIVEN + const cluster = new rds.DatabaseCluster(stack, "cluster", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + writer: rds.ClusterInstance.provisioned("writer"), + readers: [rds.ClusterInstance.provisioned("reader")], + }); + + // WHEN + new rds.DatabaseProxy(stack, "proxy", { + proxyTarget: rds.ProxyTarget.fromCluster(cluster), + secrets: [cluster.secret!], + vpc, + }); + + // THEN + const t = new Template(stack); + const [target] = t.resourceTypeArray(dbProxyTarget.DbProxyTarget) as any[]; + expect(target.depends_on).toEqual( + expect.arrayContaining([ + expect.stringContaining("writer"), + expect.stringContaining("reader"), + expect.stringContaining("cluster"), + ]), + ); + }); + + test("Correct dependencies are created when multiple DatabaseProxy are created with addProxy for cluster with writer and readers properties", () => { + // GIVEN + const cluster = new rds.DatabaseCluster(stack, "cluster", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + writer: rds.ClusterInstance.provisioned("writer"), + readers: [rds.ClusterInstance.provisioned("reader")], + }); + + // WHEN + cluster.addProxy("Proxy", { + vpc, + secrets: [cluster.secret!], + }); + cluster.addProxy("Proxy2", { + vpc, + secrets: [cluster.secret!], + }); + + // THEN + const t = new Template(stack); + const targets = t.resourceTypeArray(dbProxyTarget.DbProxyTarget) as any[]; + expect(targets).toHaveLength(2); + targets.forEach((target) => { + expect(target.depends_on).toEqual( + expect.arrayContaining([ + expect.stringContaining("writer"), + expect.stringContaining("reader"), + expect.stringContaining("cluster"), + ]), + ); + }); + }); + + describe("clientPasswordAuthType", () => { + test("create a DB proxy with specified client password authentication type", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + vpc, + }); + + // WHEN + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + clientPasswordAuthType: + rds.ClientPasswordAuthType.MYSQL_NATIVE_PASSWORD, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxy.DbProxy, { + auth: [ + expect.objectContaining({ + auth_scheme: "SECRETS", + iam_auth: "DISABLED", + client_password_auth_type: "MYSQL_NATIVE_PASSWORD", + }), + ], + engine_family: "MYSQL", + }); + }); + + test("MYSQL_NATIVE_PASSWORD clientPasswordAuthType requires MYSQL engine family", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_11, + }), + vpc, + }); + + // WHEN + // THEN + expect(() => { + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + clientPasswordAuthType: + rds.ClientPasswordAuthType.MYSQL_NATIVE_PASSWORD, + }); + }).toThrow( + /MYSQL_NATIVE_PASSWORD client password authentication type requires MYSQL engineFamily, got POSTGRESQL/, + ); + }); + + test("POSTGRES_SCRAM_SHA_256 clientPasswordAuthType requires POSTGRESQL engine family", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + vpc, + }); + + // WHEN + // THEN + expect(() => { + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + clientPasswordAuthType: + rds.ClientPasswordAuthType.POSTGRES_SCRAM_SHA_256, + }); + }).toThrow( + /POSTGRES_SCRAM_SHA_256 client password authentication type requires POSTGRESQL engineFamily, got MYSQL/, + ); + }); + + test("POSTGRES_MD5 clientPasswordAuthType requires POSTGRESQL engine family", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + vpc, + }); + + // WHEN + // THEN + expect(() => { + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + clientPasswordAuthType: rds.ClientPasswordAuthType.POSTGRES_MD5, + }); + }).toThrow( + /POSTGRES_MD5 client password authentication type requires POSTGRESQL engineFamily, got MYSQL/, + ); + }); + + test("SQL_SERVER_AUTHENTICATION clientPasswordAuthType requires SQLSERVER engine family", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + vpc, + }); + + // WHEN + // THEN + expect(() => { + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + clientPasswordAuthType: + rds.ClientPasswordAuthType.SQL_SERVER_AUTHENTICATION, + }); + }).toThrow( + /SQL_SERVER_AUTHENTICATION client password authentication type requires SQLSERVER engineFamily, got MYSQL/, + ); + }); + }); + + describe("defaultAuthScheme", () => { + test("create a DB proxy with IAM_AUTH default authentication scheme", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + iamAuthentication: true, + }); + + // WHEN + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + vpc, + defaultAuthScheme: rds.DefaultAuthScheme.IAM_AUTH, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxy.DbProxy, { + engine_family: "POSTGRESQL", + require_tls: true, + default_auth_scheme: "IAM_AUTH", + }); + }); + + test("create a DB proxy with NONE default authentication scheme and secrets", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.postgres({ + version: rds.PostgresEngineVersion.VER_16_3, + }), + vpc, + }); + + // WHEN + new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + defaultAuthScheme: rds.DefaultAuthScheme.NONE, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxy.DbProxy, { + auth: [ + { + auth_scheme: "SECRETS", + iam_auth: "DISABLED", + secret_arn: stack.resolve(instance.secret!.secretArn), + }, + ], + engine_family: "POSTGRESQL", + require_tls: true, + default_auth_scheme: "NONE", + }); + }); + }); + + test("add additional proxy endpoint", () => { + // GIVEN + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + vpc, + }); + + const dbProxyL2 = new rds.DatabaseProxy(stack, "Proxy", { + proxyTarget: rds.ProxyTarget.fromInstance(instance), + secrets: [instance.secret!], + vpc, + }); + + // WHEN + dbProxyL2.addEndpoint("ProxyEndpoint", { + vpc, + targetRole: rds.ProxyEndpointTargetRole.READ_ONLY, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxyEndpoint.DbProxyEndpoint, { + db_proxy_name: stack.resolve(dbProxyL2.dbProxyName), + target_role: "READ_ONLY", + }); + }); +}); + +describe("instance.addProxy", () => { + test("creates a proxy targeting the instance", () => { + const instance = new rds.DatabaseInstance(stack, "Instance", { + engine: rds.DatabaseInstanceEngine.mysql({ + version: rds.MysqlEngineVersion.VER_5_7, + }), + vpc, + }); + + instance.addProxy("Proxy", { + vpc, + secrets: [instance.secret!], + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(dbProxy.DbProxy, { + engine_family: "MYSQL", + }); + t.expect.toHaveResourceWithProperties(dbProxyTarget.DbProxyTarget, { + db_instance_identifier: stack.resolve(instance.instanceIdentifier), + }); + }); +}); diff --git a/test/aws/storage/rds/serverless-cluster-from-snapshot.test.ts b/test/aws/storage/rds/serverless-cluster-from-snapshot.test.ts new file mode 100644 index 00000000..59601bf9 --- /dev/null +++ b/test/aws/storage/rds/serverless-cluster-from-snapshot.test.ts @@ -0,0 +1,200 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/serverless-cluster-from-snapshot.test.ts +// +// Aurora Serverless v1 was RETIRED by AWS -- see the module-level TERRACONSTRUCTS note in +// `../../../../src/aws/storage/rds/serverless-cluster.ts`. This is a FULL port for API/migration +// parity; individual behavioral gaps are documented inline with TERRACONSTRUCTS DEVIATION/TODO notes +// at each call site, mirroring the identical omissions already established for +// `DatabaseClusterFromSnapshot` in `./cluster.test.ts`. + +import { + rdsCluster, + dbSubnetGroup, + secretsmanagerSecret, + dataAwsSecretsmanagerRandomPassword, +} from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as compute from "../../../../src/aws/compute"; +import * as encryption from "../../../../src/aws/encryption"; +import * as rds from "../../../../src/aws/storage/rds"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +describe("serverless cluster from snapshot", () => { + test("create a serverless cluster from a snapshot", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessClusterFromSnapshot(stack, "ServerlessDatabase", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + snapshotIdentifier: "my-snapshot", + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + engine: "aurora-mysql", + copy_tags_to_snapshot: true, + db_cluster_parameter_group_name: "default.aurora-mysql5.7", + engine_mode: "serverless", + snapshot_identifier: "my-snapshot", + storage_encrypted: true, + }); + t.expect.toHaveResourceWithProperties(dbSubnetGroup.DbSubnetGroup, { + description: "Subnets for ServerlessDatabase database", + }); + }); + + test("can generate a new snapshot password", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessClusterFromSnapshot(stack, "ServerlessDatabase", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + snapshotIdentifier: "mySnapshot", + credentials: rds.SnapshotCredentials.fromGeneratedSecret("admin", { + excludeCharacters: '"@/\\', + }), + }); + + // THEN + // TERRACONSTRUCTS DEVIATION: mirrors `DatabaseClusterFromSnapshot`'s + // `renderClusterSnapshotCredentials`/`Secret._generatedPassword` house pattern (see + // `./cluster.ts`) rather than upstream's CFN dynamic-reference (`{{resolve:secretsmanager:...}}`) + // syntax. + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + rdsCluster.RdsCluster, + ) as any[]; + expect(clusterResource.master_username).toBeUndefined(); + expect(clusterResource.master_password).toBeDefined(); + expect(clusterResource.lifecycle).toEqual({ + ignore_changes: ["master_password"], + }); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecret.SecretsmanagerSecret, + { + description: expect.stringContaining("Generated by the CDK for stack:"), + }, + ); + t.expect.toHaveDataSourceWithProperties( + dataAwsSecretsmanagerRandomPassword.DataAwsSecretsmanagerRandomPassword, + { + exclude_characters: '"@/\\', + password_length: 30, + }, + ); + }); + + test("fromGeneratedSecret with replica regions", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessClusterFromSnapshot(stack, "ServerlessDatabase", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + snapshotIdentifier: "mySnapshot", + credentials: rds.SnapshotCredentials.fromGeneratedSecret("admin", { + replicaRegions: [{ region: "eu-west-1" }], + }), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecret.SecretsmanagerSecret, + { + replica: [{ region: "eu-west-1" }], + }, + ); + }); + + test("throws if generating a new password without a username", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + expect( + () => + new rds.ServerlessClusterFromSnapshot(stack, "ServerlessDatabase", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + snapshotIdentifier: "mySnapshot", + credentials: { generatePassword: true } as rds.SnapshotCredentials, + }), + ).toThrow( + /`credentials` `username` must be specified when `generatePassword` is set to true/, + ); + }); + + test("can set a new snapshot password from an existing password", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessClusterFromSnapshot(stack, "ServerlessDatabase", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + snapshotIdentifier: "mySnapshot", + credentials: rds.SnapshotCredentials.fromPassword("mysecretpassword"), + }); + + // THEN + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + rdsCluster.RdsCluster, + ) as any[]; + expect(clusterResource.master_username).toBeUndefined(); + expect(clusterResource.master_password).toEqual("mysecretpassword"); + }); + + // TODO: omitted -- "can set a new snapshot password from an existing Secret" exercises + // `SnapshotCredentials.fromSecret()`, which depends on `ISecret.secretValueFromJson` -- not ported + // in this repo (see the commented-out `SnapshotCredentials.fromSecret` in + // `../../../../src/aws/storage/rds/props.ts`, and the identical omission exercised by + // "rejects snapshotCredentials created with SnapshotCredentials.fromSecret()" in `./cluster.test.ts`) -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/serverless-cluster-from-snapshot.test.ts#L139-L162 + test("rejects credentials created with SnapshotCredentials.fromSecret()", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const secret = new rds.DatabaseSecret(stack, "DBSecret", { + username: "admin", + encryptionKey: new encryption.Key(stack, "PasswordKey"), + }); + + expect( + () => + new rds.ServerlessClusterFromSnapshot(stack, "ServerlessDatabase", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + snapshotIdentifier: "mySnapshot", + credentials: { secret } as unknown as rds.SnapshotCredentials, + }), + ).toThrow( + /SnapshotCredentials with an existing `secret` are not supported in TerraConstructs/, + ); + }); +}); diff --git a/test/aws/storage/rds/serverless-cluster.test.ts b/test/aws/storage/rds/serverless-cluster.test.ts new file mode 100644 index 00000000..1004e5a3 --- /dev/null +++ b/test/aws/storage/rds/serverless-cluster.test.ts @@ -0,0 +1,1100 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/serverless-cluster.test.ts +// +// Aurora Serverless v1 was RETIRED by AWS -- see the module-level TERRACONSTRUCTS note in +// `../../../../src/aws/storage/rds/serverless-cluster.ts`. This is a FULL port for API/migration +// parity; individual behavioral gaps are documented inline with TERRACONSTRUCTS DEVIATION/TODO notes +// at each call site, mirroring the identical omissions already established for +// `DatabaseCluster`/`DatabaseInstance` in `./cluster.test.ts`/`./instance.test.ts`. + +import { + rdsCluster, + dbSubnetGroup, + secretsmanagerSecret, + secretsmanagerSecretRotation, + dataAwsIamPolicyDocument, + dataAwsSecretsmanagerRandomPassword, + vpcSecurityGroupEgressRule, +} from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { ArnFormat, AwsStack } from "../../../../src/aws"; +import * as compute from "../../../../src/aws/compute"; +import * as encryption from "../../../../src/aws/encryption"; +import * as iam from "../../../../src/aws/iam"; +import * as rds from "../../../../src/aws/storage/rds"; +import { Duration } from "../../../../src/duration"; +import { Annotations, Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +describe("serverless cluster", () => { + test("can create a Serverless Cluster with Aurora Postgres database engine", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessCluster(stack, "ServerlessDatabase", { + engine: rds.DatabaseClusterEngine.AURORA_POSTGRESQL, + vpc, + credentials: { + username: "admin", + password: "tooshort", + } as rds.Credentials, + parameterGroup: rds.ParameterGroup.fromParameterGroupName( + stack, + "ParameterGroup", + "default.aurora-postgresql11", + ), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + engine: "aurora-postgresql", + copy_tags_to_snapshot: true, + db_cluster_parameter_group_name: "default.aurora-postgresql11", + engine_mode: "serverless", + master_username: "admin", + master_password: "tooshort", + storage_encrypted: true, + }); + t.expect.toHaveResourceWithProperties(dbSubnetGroup.DbSubnetGroup, { + description: "Subnets for ServerlessDatabase database", + }); + }); + + test("can create a Serverless Cluster with Aurora Mysql database engine", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessCluster(stack, "ServerlessDatabase", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + }); + + // THEN + // TERRACONSTRUCTS DEVIATION: mirrors `DatabaseCluster`'s `renderClusterCredentials`/ + // `Secret._generatedPassword` house pattern (see `./cluster.ts`) rather than upstream's CFN + // dynamic-reference (`{{resolve:secretsmanager:...}}`) syntax. + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + engine: "aurora-mysql", + copy_tags_to_snapshot: true, + db_cluster_parameter_group_name: "default.aurora-mysql5.7", + engine_mode: "serverless", + storage_encrypted: true, + }); + const [clusterResource] = t.resourceTypeArray( + rdsCluster.RdsCluster, + ) as any[]; + expect(clusterResource.master_username).toBeDefined(); + expect(clusterResource.master_password).toBeDefined(); + expect(clusterResource.lifecycle).toEqual({ + ignore_changes: ["master_password"], + }); + }); + + test("can create a Serverless cluster with imported vpc and security group", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const sg = compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "SecurityGroupId12345", + ); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_POSTGRESQL, + vpc, + securityGroups: [sg], + parameterGroup: rds.ParameterGroup.fromParameterGroupName( + stack, + "ParameterGroup", + "default.aurora-postgresql11", + ), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + engine: "aurora-postgresql", + db_cluster_parameter_group_name: "default.aurora-postgresql11", + engine_mode: "serverless", + vpc_security_group_ids: ["SecurityGroupId12345"], + }); + }); + + // TODO: omitted -- "sets the retention policy of the SubnetGroup to 'Retain' if the Serverless + // Cluster is created with 'Retain'" exercises `cdk.RemovalPolicy.RETAIN` propagating from the + // cluster onto the auto-created `AWS::RDS::DBSubnetGroup`'s CFN `DeletionPolicy`. `core. + // RemovalPolicy` is not ported in this repo (see the `skipFinalSnapshot`/`finalSnapshotIdentifier` + // TODO on `ServerlessClusterNewProps.removalPolicy` in + // `../../../../src/aws/storage/rds/serverless-cluster.ts`, and the identical omission on + // `SubnetGroupProps.removalPolicy` in `../../../../src/aws/storage/rds/subnet-group.ts`) -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/serverless-cluster.test.ts#L143-L157 + + test("creates a secret when master credentials are not specified", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + credentials: { + username: "myuser", + excludeCharacters: '"@/\\', + } as rds.Credentials, + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + secretsmanagerSecret.SecretsmanagerSecret, + {}, + ); + t.expect.toHaveDataSourceWithProperties( + dataAwsSecretsmanagerRandomPassword.DataAwsSecretsmanagerRandomPassword, + { + exclude_characters: '"@/\\', + password_length: 30, + }, + ); + const [clusterResource] = t.resourceTypeArray( + rdsCluster.RdsCluster, + ) as any[]; + expect(clusterResource.master_username).toEqual("myuser"); + expect(clusterResource.master_password).toBeDefined(); + }); + + test("create an Serverless cluster with custom KMS key for storage", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const key = new encryption.Key(stack, "Key"); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + storageEncryptionKey: key, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + kms_key_id: stack.resolve(key.keyArn), + }); + }); + + test("create a cluster using a specific version of Postgresql", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.auroraPostgres({ + version: rds.AuroraPostgresEngineVersion.VER_10_7, + }), + vpc, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + engine: "aurora-postgresql", + engine_mode: "serverless", + engine_version: "10.7", + }); + }); + + test("cluster exposes different read and write endpoints", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + const cluster = new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + credentials: { username: "admin" } as rds.Credentials, + vpc, + }); + + // THEN + expect(stack.resolve(cluster.clusterEndpoint)).not.toEqual( + stack.resolve(cluster.clusterReadEndpoint), + ); + }); + + test("imported cluster with imported security group honors allowAllOutbound", () => { + // GIVEN + const stack = testStack(); + + const cluster = rds.ServerlessCluster.fromServerlessClusterAttributes( + stack, + "Database", + { + clusterEndpointAddress: "addr", + clusterIdentifier: "identifier", + port: 3306, + readerEndpointAddress: "reader-address", + securityGroups: [ + compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "sg-123456789", + { + allowAllOutbound: false, + }, + ), + ], + }, + ); + + // WHEN + cluster.connections.allowToAnyIpv4(compute.Port.tcp(443)); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + vpcSecurityGroupEgressRule.VpcSecurityGroupEgressRule, + { + security_group_id: "sg-123456789", + }, + ); + }); + + test("can import a serverless cluster with minimal attributes", () => { + const stack = testStack(); + + const cluster = rds.ServerlessCluster.fromServerlessClusterAttributes( + stack, + "Database", + { + clusterIdentifier: "identifier", + }, + ); + + expect(cluster.clusterIdentifier).toEqual("identifier"); + }); + + test("minimal imported cluster throws on accessing attributes for missing parameters", () => { + const stack = testStack(); + + const cluster = rds.ServerlessCluster.fromServerlessClusterAttributes( + stack, + "Database", + { + clusterIdentifier: "identifier", + }, + ); + + expect(() => cluster.clusterEndpoint).toThrow( + /Cannot access `clusterEndpoint` of an imported cluster/, + ); + expect(() => cluster.clusterReadEndpoint).toThrow( + /Cannot access `clusterReadEndpoint` of an imported cluster/, + ); + }); + + test("imported cluster can access properties if attributes are provided", () => { + const stack = testStack(); + + const cluster = rds.ServerlessCluster.fromServerlessClusterAttributes( + stack, + "Database", + { + clusterEndpointAddress: "addr", + clusterIdentifier: "identifier", + port: 3306, + readerEndpointAddress: "reader-address", + securityGroups: [ + compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "sg-123456789", + { + allowAllOutbound: false, + }, + ), + ], + }, + ); + + expect(cluster.clusterEndpoint.socketAddress).toEqual("addr:3306"); + expect(cluster.clusterReadEndpoint.socketAddress).toEqual( + "reader-address:3306", + ); + }); + + test("throws when trying to add single-user rotation to a serverless cluster without secret", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + const cluster = new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + credentials: { + username: "admin", + password: "tooshort", + } as rds.Credentials, + vpc, + }); + + // THEN + expect(() => cluster.addRotationSingleUser()).toThrow(/without secret/); + }); + + test("throws when trying to add single user rotation multiple times", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const cluster = new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + credentials: { username: "admin" } as rds.Credentials, + vpc, + }); + + // WHEN + cluster.addRotationSingleUser(); + + // THEN + expect(() => cluster.addRotationSingleUser()).toThrow( + /A single user rotation was already added to this cluster/, + ); + + const t = new Template(stack); + t.resourceCountIs( + secretsmanagerSecretRotation.SecretsmanagerSecretRotation, + 1, + ); + }); + + test("throws when trying to add single-user rotation to a serverless cluster without VPC", () => { + // GIVEN + const stack = testStack(); + + // WHEN + const cluster = new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + }); + + // THEN + expect(() => { + cluster.addRotationSingleUser(); + }).toThrow(/Cannot add single user rotation for a cluster without VPC/); + }); + + test("throws when trying to add multi-user rotation to a serverless cluster without VPC", () => { + // GIVEN + const stack = testStack(); + const secret = new rds.DatabaseSecret(stack, "Secret", { + username: "admin", + }); + + // WHEN + const cluster = new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + }); + + // THEN + expect(() => { + cluster.addRotationMultiUser("someId", { secret }); + }).toThrow(/Cannot add multi user rotation for a cluster without VPC/); + }); + + test("can set deletion protection", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + deletionProtection: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + deletion_protection: true, + }); + }); + + test("can set backup retention", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + backupRetention: Duration.days(2), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + backup_retention_period: 2, + }); + }); + + // TODO: omitted -- "does not throw (but adds a node error) if a (dummy) VPC does not have + // sufficient subnets" depends on `ec2.Vpc.fromLookup({ isDefault: true })`, the CDK CLI's + // synth-time context-provider lookup mechanism with no CDKTF equivalent (same omission as + // `DatabaseClusterBase`/`DatabaseInstanceBase.fromLookup` elsewhere in this port). The underlying + // "Cluster requires at least 2 subnets" `Annotations.addError` behavior it exercises is otherwise + // portable and is not separately re-tested here -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/serverless-cluster.test.ts#L431-L450 + + test("can set scaling configuration", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + scaling: { + minCapacity: rds.AuroraCapacityUnit.ACU_1, + maxCapacity: rds.AuroraCapacityUnit.ACU_128, + autoPause: Duration.minutes(10), + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + scaling_configuration: { + auto_pause: true, + max_capacity: 128, + min_capacity: 1, + seconds_until_auto_pause: 600, + }, + }); + }); + + test("can enable Data API", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + enableDataApi: true, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + enable_http_endpoint: true, + }); + }); + + test("default scaling options", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + scaling: {}, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + scaling_configuration: { + auto_pause: true, + }, + }); + }); + + test("auto pause is disabled if a time of zero is specified", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + scaling: { + autoPause: Duration.seconds(0), + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + scaling_configuration: { + auto_pause: false, + }, + }); + }); + + test("throws when invalid auto pause time is specified", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // THEN + expect( + () => + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + scaling: { + autoPause: Duration.seconds(30), + }, + }), + ).toThrow(/auto pause time must be between 5 minutes and 1 day./); + + expect( + () => + new rds.ServerlessCluster(stack, "Another Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + scaling: { + autoPause: Duration.days(2), + }, + }), + ).toThrow(/auto pause time must be between 5 minutes and 1 day./); + }); + + test("throws when invalid backup retention period is specified", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + expect( + () => + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + backupRetention: Duration.days(0), + }), + ).toThrow( + /backup retention period must be between 1 and 35 days. received: 0/, + ); + + expect( + () => + new rds.ServerlessCluster(stack, "Another Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + backupRetention: Duration.days(36), + }), + ).toThrow( + /backup retention period must be between 1 and 35 days. received: 36/, + ); + }); + + test("throws error when min capacity is greater than max capacity", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + expect( + () => + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + scaling: { + minCapacity: rds.AuroraCapacityUnit.ACU_2, + maxCapacity: rds.AuroraCapacityUnit.ACU_1, + }, + }), + ).toThrow( + /maximum capacity must be greater than or equal to minimum capacity./, + ); + }); + + test("check that clusterArn property works", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const cluster = new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + }); + + // THEN + expect(stack.resolve(cluster.clusterArn)).toEqual( + stack.resolve( + stack.formatArn({ + service: "rds", + resource: "cluster", + resourceName: cluster.clusterIdentifier, + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }), + ), + ); + }); + + test("can grant Data API access", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const cluster = new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + enableDataApi: true, + }); + const user = new iam.User(stack, "User"); + + // WHEN + cluster.grantDataApiAccess(user); + + // THEN + // TERRACONSTRUCTS DEVIATION: mirrors upstream's `resourceArns: ['*']` on + // `ServerlessClusterBase.grantDataApiAccess()` (`../../../../src/aws/storage/rds/serverless-cluster.ts`) + // -- unlike `DatabaseCluster.grantDataApiAccess()`, which scopes to `[this.clusterArn]`. + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: expect.arrayContaining([ + expect.objectContaining({ + actions: [ + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:ExecuteStatement", + "rds-data:RollbackTransaction", + ], + effect: "Allow", + resources: ["*"], + }), + expect.objectContaining({ + actions: [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + ], + effect: "Allow", + resources: [stack.resolve(cluster.secret!.secretArn)], + }), + ]), + }, + ); + }); + + test("can grant Data API access on imported cluster with given secret", () => { + // GIVEN + const stack = testStack(); + const secret = new rds.DatabaseSecret(stack, "Secret", { + username: "admin", + }); + const cluster = rds.ServerlessCluster.fromServerlessClusterAttributes( + stack, + "Cluster", + { + clusterIdentifier: "ImportedDatabase", + secret, + }, + ); + const user = new iam.User(stack, "User"); + + // WHEN + cluster.grantDataApiAccess(user); + + // THEN + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: expect.arrayContaining([ + expect.objectContaining({ + actions: [ + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:ExecuteStatement", + "rds-data:RollbackTransaction", + ], + effect: "Allow", + resources: ["*"], + }), + expect.objectContaining({ + actions: [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + ], + effect: "Allow", + resources: [stack.resolve(secret.secretArn)], + }), + ]), + }, + ); + }); + + test("grant Data API access enables the Data API", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const cluster = new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + }); + const user = new iam.User(stack, "User"); + + // WHEN + cluster.grantDataApiAccess(user); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + enable_http_endpoint: true, + }); + }); + + test("grant Data API access throws if the Data API is disabled", () => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + const cluster = new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + enableDataApi: false, + }); + const user = new iam.User(stack, "User"); + + // WHEN + expect(() => cluster.grantDataApiAccess(user)).toThrow( + /Cannot grant Data API access when the Data API is disabled/, + ); + }); + + test("changes the case of the cluster identifier", () => { + // GIVEN + const stack = testStack(); + const clusterIdentifier = "TestClusterIdentifier"; + const vpc = new compute.Vpc(stack, "VPC"); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + clusterIdentifier, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + cluster_identifier: clusterIdentifier.toLowerCase(), + }); + }); + + // TODO: omitted -- "does not change the case of the cluster identifier if the + // lowercaseDbIdentifier feature flag is disabled" exercises the + // `@aws-cdk/aws-rds:lowercaseDbIdentifier` `cx-api` CDK context feature flag. CDK's synth-time + // feature-flag registry is not ported in this repo (same omission as `RDS_LOWERCASE_DB_IDENTIFIER` + // on `DatabaseInstance`/`DatabaseClusterNew`'s identifier handling); unconditional lowercasing (the + // corrected behavior, exercised above) is the only behavior here -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/serverless-cluster.test.ts#L788-L806 + + test("can create a Serverless cluster without VPC", () => { + // GIVEN + const stack = testStack(); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + }); + + // THEN + const t = new Template(stack); + const [clusterResource] = t.resourceTypeArray( + rdsCluster.RdsCluster, + ) as any[]; + expect(clusterResource.engine).toEqual("aurora-mysql"); + expect(clusterResource.engine_mode).toEqual("serverless"); + expect(clusterResource.db_subnet_group_name).toBeUndefined(); + expect(clusterResource.vpc_security_group_ids).toEqual([]); + }); + + test("cannot create a Serverless cluster without VPC but specifying a security group", () => { + // GIVEN + const stack = testStack(); + const sg = compute.SecurityGroup.fromSecurityGroupId( + stack, + "SG", + "SecurityGroupId12345", + ); + + // THEN + expect( + () => + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + securityGroups: [sg], + }), + ).toThrow( + /A VPC is required to use securityGroups in ServerlessCluster. Please add a VPC or remove securityGroups/, + ); + }); + + test("cannot create a Serverless cluster without VPC but specifying a subnet group", () => { + // GIVEN + const stack = testStack(); + const subnetGroupName = "SubnetGroupId12345"; + const subnetGroup = rds.SubnetGroup.fromSubnetGroupName( + stack, + "SubnetGroup12345", + subnetGroupName, + ); + + // THEN + expect( + () => + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + subnetGroup, + }), + ).toThrow( + /A VPC is required to use subnetGroup in ServerlessCluster. Please add a VPC or remove subnetGroup/, + ); + }); + + test("cannot create a Serverless cluster without VPC but specifying VPC subnets", () => { + // GIVEN + const stack = testStack(); + + // WHEN + const vpcSubnets = { + subnetGroupName: "AVpcSubnet", + }; + + // THEN + expect( + () => + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpcSubnets, + }), + ).toThrow( + /A VPC is required to use vpcSubnets in ServerlessCluster. Please add a VPC or remove vpcSubnets/, + ); + }); + + // TODO: omitted -- "can call exportValue on endpoint.port" exercises `cdk.Stack.exportValue()`, a + // CFN cross-stack `Fn::ImportValue`/`Export` mechanism. `AwsStack` has no `exportValue` equivalent + // in this repo (Terraform cross-stack references work differently, via remote state / outputs) -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/aws-cdk-lib/aws-rds/test/serverless-cluster.test.ts#L867-L886 + + test("cluster with copyTagsToSnapshot default", () => { + // GIVEN + const stack = testStack(); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_POSTGRESQL, + parameterGroup: rds.ParameterGroup.fromParameterGroupName( + stack, + "ParameterGroup", + "default.aurora-postgresql11", + ), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + copy_tags_to_snapshot: true, + }); + }); + + test("cluster with copyTagsToSnapshot disabled", () => { + // GIVEN + const stack = testStack(); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_POSTGRESQL, + copyTagsToSnapshot: false, + parameterGroup: rds.ParameterGroup.fromParameterGroupName( + stack, + "ParameterGroup", + "default.aurora-postgresql11", + ), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + copy_tags_to_snapshot: false, + }); + }); + + test("cluster with copyTagsToSnapshot enabled", () => { + // GIVEN + const stack = testStack(); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_POSTGRESQL, + copyTagsToSnapshot: true, + parameterGroup: rds.ParameterGroup.fromParameterGroupName( + stack, + "ParameterGroup", + "default.aurora-postgresql11", + ), + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + copy_tags_to_snapshot: true, + }); + }); + + test("check properties propagation of ServerlessScalingOptions", () => { + // GIVEN + const stack = testStack(); + + // WHEN + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc: new compute.Vpc(stack, "Vpc"), + scaling: { + autoPause: Duration.minutes(10), + minCapacity: rds.AuroraCapacityUnit.ACU_8, + maxCapacity: rds.AuroraCapacityUnit.ACU_32, + timeout: Duration.minutes(10), + timeoutAction: rds.TimeoutAction.FORCE_APPLY_CAPACITY_CHANGE, + }, + }); + + // THEN + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + scaling_configuration: { + auto_pause: true, + max_capacity: 32, + min_capacity: 8, + seconds_until_auto_pause: 600, + seconds_before_timeout: 600, + timeout_action: "ForceApplyCapacityChange", + }, + }); + }); + + test.each([Duration.seconds(59), Duration.seconds(601)])( + "invalid ServerlessScalingOptions throws", + (duration) => { + // GIVEN + const stack = testStack(); + const vpc = new compute.Vpc(stack, "Vpc"); + + // THEN + expect( + () => + new rds.ServerlessCluster(stack, "Database1", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + scaling: { + autoPause: Duration.minutes(10), + minCapacity: rds.AuroraCapacityUnit.ACU_8, + maxCapacity: rds.AuroraCapacityUnit.ACU_32, + timeout: duration, + }, + }), + ).toThrow(/timeout must be between 60 and 600 seconds/); + }, + ); + + // TERRACONSTRUCTS DEVIATION: not present upstream. Mirrors the "removal policy replacement props" + // describe block in `./instance.test.ts` -- see the note there for why `skipFinalSnapshot`/ + // `finalSnapshotIdentifier`/`deletionProtection` (native `aws_rds_cluster` fields) replace + // upstream's `removalPolicy` here. + describe("removal policy replacement props", () => { + test("skipFinalSnapshot, finalSnapshotIdentifier and deletionProtection are rendered when set", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + skipFinalSnapshot: false, + finalSnapshotIdentifier: "my-final-snapshot", + deletionProtection: true, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + skip_final_snapshot: false, + final_snapshot_identifier: "my-final-snapshot", + deletion_protection: true, + }); + }); + + test("skipFinalSnapshot true omits finalSnapshotIdentifier", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + skipFinalSnapshot: true, + }); + + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(rdsCluster.RdsCluster, { + skip_final_snapshot: true, + }); + const [clusterResource] = t.resourceTypeArray( + rdsCluster.RdsCluster, + ) as any[]; + expect(clusterResource.final_snapshot_identifier).toBeUndefined(); + }); + + test("warns when neither skipFinalSnapshot nor finalSnapshotIdentifier is set", () => { + const stack = testStack(); + const vpc = new compute.Vpc(stack, "VPC"); + + new rds.ServerlessCluster(stack, "Database", { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + vpc, + }); + + Annotations.fromStack(stack).hasWarnings({ + constructPath: "MyStack/Database", + message: expect.stringContaining( + "Neither `skipFinalSnapshot` nor `finalSnapshotIdentifier` is set", + ), + }); + }); + }); +});