diff --git a/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts b/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts index 0bebd4d2505c9..26b25868b39d7 100644 --- a/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts +++ b/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts @@ -106,11 +106,13 @@ export interface ServerlessClusterProps { /** * The VPC that this Aurora Serverless cluster has been created in. + * + * @default - No VPC related construct will be created: */ - readonly vpc: ec2.IVpc; + readonly vpc?: ec2.IVpc; /** - * Where to place the instances within the VPC + * Where to place the instances within the VPC. If set, a VPC must be specified. * * @default - the VPC default strategy if not specified. */ @@ -136,7 +138,8 @@ export interface ServerlessClusterProps { /** * Security group. * - * @default - a new security group is created. + * @default - a new security group is created if a vpc is provided. + * If vpc is not provided, no VPC security groups will be associated with the DB cluster. */ readonly securityGroups?: ec2.ISecurityGroup[]; @@ -157,7 +160,8 @@ export interface ServerlessClusterProps { /** * Existing subnet group for the cluster. * - * @default - a new subnet group will be created. + * @default - a new subnet group will be created if a vpc is provided. + * If vpc is not provided, no DB subnet group will be associated with the DB cluster. */ readonly subnetGroup?: ISubnetGroup; } @@ -377,8 +381,8 @@ export class ServerlessCluster extends ServerlessClusterBase { protected enableDataApi?: boolean - private readonly subnetGroup: ISubnetGroup; - private readonly vpc: ec2.IVpc; + private readonly subnetGroup?: ISubnetGroup; + private readonly vpc?: ec2.IVpc; private readonly vpcSubnets?: ec2.SubnetSelection; private readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication; @@ -389,25 +393,40 @@ export class ServerlessCluster extends ServerlessClusterBase { this.vpc = props.vpc; this.vpcSubnets = props.vpcSubnets; + this.subnetGroup = props.subnetGroup; this.singleUserRotationApplication = props.engine.singleUserRotationApplication; this.multiUserRotationApplication = props.engine.multiUserRotationApplication; this.enableDataApi = props.enableDataApi; - const { subnetIds } = this.vpc.selectSubnets(this.vpcSubnets); - - // Cannot test whether the subnets are in different AZs, but at least we can test the amount. - if (subnetIds.length < 2) { - Annotations.of(this).addError(`Cluster requires at least 2 subnets, got ${subnetIds.length}`); + if (this.vpc === undefined && this.vpcSubnets !== undefined) { + throw new Error('A VPC is required to use vpcSubnets in ServerlessCluster. Please add a VPC or remove vpcSubnets'); } - this.subnetGroup = props.subnetGroup ?? new SubnetGroup(this, 'Subnets', { - description: `Subnets for ${id} database`, - vpc: props.vpc, - vpcSubnets: props.vpcSubnets, - removalPolicy: props.removalPolicy === RemovalPolicy.RETAIN ? props.removalPolicy : undefined, - }); + let securityGroups: ec2.ISecurityGroup[] | undefined = props.securityGroups; + if (this.vpc !== undefined) { + const { subnetIds } = this.vpc.selectSubnets(this.vpcSubnets); + + // Cannot test whether the subnets are in different AZs, but at least we can test the amount. + if (subnetIds.length < 2) { + Annotations.of(this).addError(`Cluster requires at least 2 subnets, got ${subnetIds.length}`); + } + + this.subnetGroup = props.subnetGroup ?? new SubnetGroup(this, 'Subnets', { + description: `Subnets for ${id} database`, + vpc: this.vpc, + vpcSubnets: this.vpcSubnets, + removalPolicy: props.removalPolicy === RemovalPolicy.RETAIN ? props.removalPolicy : undefined, + }); + + securityGroups = props.securityGroups ?? [ + new ec2.SecurityGroup(this, 'SecurityGroup', { + description: 'RDS security group', + vpc: this.vpc, + }), + ]; + } if (props.backupRetention) { const backupRetentionDays = props.backupRetention.toDays(); @@ -426,13 +445,6 @@ export class ServerlessCluster extends ServerlessClusterBase { const clusterParameterGroup = props.parameterGroup ?? clusterEngineBindConfig.parameterGroup; const clusterParameterGroupConfig = clusterParameterGroup?.bindToCluster({}); - const securityGroups = props.securityGroups ?? [ - new ec2.SecurityGroup(this, 'SecurityGroup', { - description: 'RDS security group', - vpc: this.vpc, - }), - ]; - const clusterIdentifier = FeatureFlags.of(this).isEnabled(cxapi.RDS_LOWERCASE_DB_IDENTIFIER) ? props.clusterIdentifier?.toLowerCase() : props.clusterIdentifier; @@ -442,7 +454,7 @@ export class ServerlessCluster extends ServerlessClusterBase { databaseName: props.defaultDatabaseName, dbClusterIdentifier: clusterIdentifier, dbClusterParameterGroupName: clusterParameterGroupConfig?.parameterGroupName, - dbSubnetGroupName: this.subnetGroup.subnetGroupName, + dbSubnetGroupName: this.subnetGroup?.subnetGroupName, deletionProtection: defaultDeletionProtection(props.deletionProtection, props.removalPolicy), engine: props.engine.engineType, engineVersion: props.engine.engineVersion?.fullVersion, @@ -453,7 +465,7 @@ export class ServerlessCluster extends ServerlessClusterBase { masterUserPassword: credentials.password?.toString(), scalingConfiguration: props.scaling ? this.renderScalingConfiguration(props.scaling) : undefined, storageEncrypted: true, - vpcSecurityGroupIds: securityGroups.map(sg => sg.securityGroupId), + vpcSecurityGroupIds: securityGroups?.map(sg => sg.securityGroupId), }); this.clusterIdentifier = cluster.ref; @@ -488,6 +500,10 @@ export class ServerlessCluster extends ServerlessClusterBase { throw new Error('A single user rotation was already added to this cluster.'); } + if (this.vpc === undefined) { + throw new Error('Cannot add single user rotation for a cluster without VPC.'); + } + return new secretsmanager.SecretRotation(this, id, { secret: this.secret, application: this.singleUserRotationApplication, @@ -506,6 +522,9 @@ export class ServerlessCluster extends ServerlessClusterBase { if (!this.secret) { throw new Error('Cannot add multi user rotation for a cluster without secret.'); } + if (this.vpc === undefined) { + throw new Error('Cannot add multi user rotation for a cluster without VPC.'); + } return new secretsmanager.SecretRotation(this, id, { ...options, excludeCharacters: options.excludeCharacters ?? DEFAULT_PASSWORD_EXCLUDE_CHARS, diff --git a/packages/@aws-cdk/aws-rds/test/integ.serverless-cluster-no-vpc.expected.json b/packages/@aws-cdk/aws-rds/test/integ.serverless-cluster-no-vpc.expected.json new file mode 100644 index 0000000000000..def1b8a4cc98b --- /dev/null +++ b/packages/@aws-cdk/aws-rds/test/integ.serverless-cluster-no-vpc.expected.json @@ -0,0 +1,17 @@ +{ + "Resources": { + "ServerlessDatabaseWithoutVPC93F9A752": { + "Type": "AWS::RDS::DBCluster", + "Properties": { + "Engine": "aurora-mysql", + "DBClusterParameterGroupName": "default.aurora-mysql5.7", + "EngineMode": "serverless", + "MasterUsername": "admin", + "MasterUserPassword": "7959866cacc02c2d243ecfe177464fe6", + "StorageEncrypted": true + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-rds/test/integ.serverless-cluster-no-vpc.ts b/packages/@aws-cdk/aws-rds/test/integ.serverless-cluster-no-vpc.ts new file mode 100644 index 0000000000000..807cf1a894601 --- /dev/null +++ b/packages/@aws-cdk/aws-rds/test/integ.serverless-cluster-no-vpc.ts @@ -0,0 +1,17 @@ +import * as cdk from '@aws-cdk/core'; +import * as rds from '../lib'; + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-cdk-sls-cluster-no-vpc-integ'); + +const cluster = new rds.ServerlessCluster(stack, 'Serverless Database Without VPC', { + engine: rds.DatabaseClusterEngine.AURORA_MYSQL, + credentials: { + username: 'admin', + password: cdk.SecretValue.plainText('7959866cacc02c2d243ecfe177464fe6'), + }, + removalPolicy: cdk.RemovalPolicy.DESTROY, +}); +cluster.connections.allowDefaultPortFromAnyIpv4('Open to the world'); + +app.synth(); diff --git a/packages/@aws-cdk/aws-rds/test/serverless-cluster.test.ts b/packages/@aws-cdk/aws-rds/test/serverless-cluster.test.ts index d401648a7c1be..26f0db505f3f5 100644 --- a/packages/@aws-cdk/aws-rds/test/serverless-cluster.test.ts +++ b/packages/@aws-cdk/aws-rds/test/serverless-cluster.test.ts @@ -1,11 +1,11 @@ import '@aws-cdk/assert-internal/jest'; -import { ResourcePart, SynthUtils } from '@aws-cdk/assert-internal'; +import { ABSENT, ResourcePart, SynthUtils } from '@aws-cdk/assert-internal'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as iam from '@aws-cdk/aws-iam'; import * as kms from '@aws-cdk/aws-kms'; import * as cdk from '@aws-cdk/core'; import * as cxapi from '@aws-cdk/cx-api'; -import { AuroraPostgresEngineVersion, ServerlessCluster, DatabaseClusterEngine, ParameterGroup, AuroraCapacityUnit, DatabaseSecret } from '../lib'; +import { AuroraPostgresEngineVersion, ServerlessCluster, DatabaseClusterEngine, ParameterGroup, AuroraCapacityUnit, DatabaseSecret, SubnetGroup } from '../lib'; describe('serverless cluster', () => { test('can create a Serverless Cluster with Aurora Postgres database engine', () => { @@ -385,6 +385,39 @@ describe('serverless cluster', () => { }); + test('throws when trying to add rotation to a serverless cluster without VPC', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const cluster = new ServerlessCluster(stack, 'Database', { + engine: DatabaseClusterEngine.AURORA_MYSQL, + }); + + // THEN + expect(() => { + cluster.addRotationSingleUser(); + }).toThrow(/Cannot add single user rotation for a cluster without VPC/); + }); + + test('throws when trying to add rotation to a serverless cluster without VPC', () => { + // GIVEN + const stack = new cdk.Stack(); + const secret = new DatabaseSecret(stack, 'Secret', { + username: 'admin', + }); + + // WHEN + const cluster = new ServerlessCluster(stack, 'Database', { + engine: 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(); @@ -830,6 +863,77 @@ describe('serverless cluster', () => { }); + + test('can create a Serverless cluster without VPC', () => { + // GIVEN + const stack = testStack(); + + // WHEN + new ServerlessCluster(stack, 'Database', { + engine: DatabaseClusterEngine.AURORA_MYSQL, + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::RDS::DBCluster', { + Engine: 'aurora-mysql', + EngineMode: 'serverless', + DbSubnetGroupName: ABSENT, + VpcSecurityGroupIds: ABSENT, + }); + }); + + test('can create a Serverless cluster without vpc but with imported security group', () => { + // GIVEN + const stack = testStack(); + const sg = ec2.SecurityGroup.fromSecurityGroupId(stack, 'SG', 'SecurityGroupId12345'); + + // WHEN + new ServerlessCluster(stack, 'Database', { + engine: DatabaseClusterEngine.AURORA_MYSQL, + securityGroups: [sg], + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::RDS::DBCluster', { + VpcSecurityGroupIds: ['SecurityGroupId12345'], + DbSubnetGroupName: ABSENT, + }); + }); + + test('can create a Serverless cluster without VPC but with imported subnet group', () => { + // GIVEN + const stack = testStack(); + const SubnetGroupName = 'SubnetGroupId12345'; + const subnetGroup = SubnetGroup.fromSubnetGroupName(stack, 'SubnetGroup12345', SubnetGroupName); + + // WHEN + new ServerlessCluster(stack, 'Database', { + engine: DatabaseClusterEngine.AURORA_MYSQL, + subnetGroup, + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::RDS::DBCluster', { + DBSubnetGroupName: SubnetGroupName, + VpcSecurityGroupIds: ABSENT, + }); + }); + + test("can't create a Serverless cluster without VPC but with imported VPC subnets", () => { + // GIVEN + const stack = testStack(); + + // WHEN + const vpcSubnets = { + subnetName: 'AVpcSubnet', + }; + + // THEN + expect(() => new ServerlessCluster(stack, 'Database', { + engine: DatabaseClusterEngine.AURORA_MYSQL, + vpcSubnets, + })).toThrow(/A VPC is required to use vpcSubnets in ServerlessCluster. Please add a VPC or remove vpcSubnets/); + }); }); function testStack(app?: cdk.App, id?: string): cdk.Stack {