From d5c8564f613344cf679d23f6578e7a03038e8ceb Mon Sep 17 00:00:00 2001 From: CorentinDoue Date: Tue, 9 Nov 2021 10:33:56 +0100 Subject: [PATCH] feat(aws-rds): make serverless-cluster vpc optional --- .../aws-rds/lib/serverless-cluster.ts | 76 +++++++----- ...eg.serverless-cluster-no-vpc.expected.json | 17 +++ .../test/integ.serverless-cluster-no-vpc.ts | 17 +++ .../aws-rds/test/serverless-cluster.test.ts | 110 +++++++++++++++++- 4 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 packages/@aws-cdk/aws-rds/test/integ.serverless-cluster-no-vpc.expected.json create mode 100644 packages/@aws-cdk/aws-rds/test/integ.serverless-cluster-no-vpc.ts diff --git a/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts b/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts index 0365bfd98c9e9..690e987aac48b 100644 --- a/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts +++ b/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts @@ -99,11 +99,13 @@ interface ServerlessClusterNewProps { /** * The VPC that this Aurora Serverless cluster has been created in. + * + * @default - no VPC will be used */ - 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. */ @@ -129,7 +131,8 @@ interface ServerlessClusterNewProps { /** * 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[]; @@ -143,7 +146,8 @@ interface ServerlessClusterNewProps { /** * 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; } @@ -345,25 +349,42 @@ abstract class ServerlessClusterBase extends Resource implements IServerlessClus abstract class ServerlessClusterNew extends ServerlessClusterBase { public readonly connections: ec2.Connections; protected readonly newCfnProps: CfnDBClusterProps; - protected readonly securityGroups: ec2.ISecurityGroup[]; + protected readonly securityGroups?: ec2.ISecurityGroup[]; protected enableDataApi?: boolean; constructor(scope: Construct, id: string, props: ServerlessClusterNewProps) { super(scope, id); - const { subnetIds } = props.vpc.selectSubnets(props.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 (props.vpc === undefined && props.vpcSubnets !== undefined) { + throw new Error('A VPC is required to use vpcSubnets in ServerlessCluster. Please add a VPC or remove vpcSubnets'); + } + if (props.vpc === undefined && props.securityGroups !== undefined) { + throw new Error('A VPC is required to use securityGroups in ServerlessCluster. Please add a VPC or remove securityGroups'); } - const 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 subnetGroup: ISubnetGroup | undefined; + 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. + 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, + removalPolicy: props.removalPolicy === RemovalPolicy.RETAIN ? props.removalPolicy : undefined, + }); + + this.securityGroups = props.securityGroups ?? [ + new ec2.SecurityGroup(this, 'SecurityGroup', { + description: 'RDS security group', + vpc: props.vpc, + }), + ]; + } if (props.backupRetention) { const backupRetentionDays = props.backupRetention.toDays(); @@ -379,12 +400,6 @@ abstract class ServerlessClusterNew extends ServerlessClusterBase { const clusterParameterGroup = props.parameterGroup ?? clusterEngineBindConfig.parameterGroup; const clusterParameterGroupConfig = clusterParameterGroup?.bindToCluster({}); - this.securityGroups = props.securityGroups ?? [ - new ec2.SecurityGroup(this, 'SecurityGroup', { - description: 'RDS security group', - vpc: props.vpc, - }), - ]; const clusterIdentifier = FeatureFlags.of(this).isEnabled(cxapi.RDS_LOWERCASE_DB_IDENTIFIER) ? props.clusterIdentifier?.toLowerCase() @@ -395,7 +410,7 @@ abstract class ServerlessClusterNew extends ServerlessClusterBase { databaseName: props.defaultDatabaseName, dbClusterIdentifier: clusterIdentifier, dbClusterParameterGroupName: clusterParameterGroupConfig?.parameterGroupName, - dbSubnetGroupName: subnetGroup.subnetGroupName, + dbSubnetGroupName: subnetGroup?.subnetGroupName, deletionProtection: defaultDeletionProtection(props.deletionProtection, props.removalPolicy), engine: props.engine.engineType, engineVersion: props.engine.engineVersion?.fullVersion, @@ -403,7 +418,7 @@ abstract class ServerlessClusterNew extends ServerlessClusterBase { enableHttpEndpoint: Lazy.any({ produce: () => this.enableDataApi }), scalingConfiguration: props.scaling ? this.renderScalingConfiguration(props.scaling) : undefined, storageEncrypted: true, - vpcSecurityGroupIds: this.securityGroups.map(sg => sg.securityGroupId), + vpcSecurityGroupIds: this.securityGroups?.map(sg => sg.securityGroupId), }; this.connections = new ec2.Connections({ @@ -476,7 +491,7 @@ export class ServerlessCluster extends ServerlessClusterNew { public readonly secret?: secretsmanager.ISecret; - private readonly vpc: ec2.IVpc; + private readonly vpc?: ec2.IVpc; private readonly vpcSubnets?: ec2.SubnetSelection; private readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication; @@ -525,6 +540,10 @@ export class ServerlessCluster extends ServerlessClusterNew { throw new Error('Cannot add single user rotation for a cluster without secret.'); } + if (this.vpc === undefined) { + throw new Error('Cannot add single user rotation for a cluster without VPC.'); + } + const id = 'RotationSingleUser'; const existing = this.node.tryFindChild(id); if (existing) { @@ -549,6 +568,11 @@ export class ServerlessCluster extends ServerlessClusterNew { 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, @@ -680,4 +704,4 @@ export class ServerlessClusterFromSnapshot extends ServerlessClusterNew { this.secret = secret.attach(this); } } -} \ No newline at end of file +} 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..6d07d5922f7b2 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', () => { @@ -345,7 +345,7 @@ describe('serverless cluster', () => { }); - test('throws when trying to add rotation to a serverless cluster without secret', () => { + test('throws when trying to add single-user rotation to a serverless cluster without secret', () => { // GIVEN const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'VPC'); @@ -385,6 +385,39 @@ describe('serverless cluster', () => { }); + test('throws when trying to add single-user 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 multi-user 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 {