diff --git a/docs/resource-providers/rds-providers.md b/docs/resource-providers/rds-providers.md new file mode 100644 index 000000000..9baeb6fea --- /dev/null +++ b/docs/resource-providers/rds-providers.md @@ -0,0 +1,129 @@ +# Relational Database Service Resource Providers + +The relational database resource provider tightly couples the lifetime of the RDS resources with the cluster in the +blueprints. It should only be used in production with the centralized/management cluster pattern. + +To prevent accidental deletion of data while rebuilding EKS clusters, the default retention policy assigned to the RDS +instances and clusters is `RetainPolicy.SNAPSHOT` which ensures that while the cluster and database is being deleted, it +is first backed-up and then deleted. + +In the management cluster pattern, this resource provider is like resources provisioned with ACK or Crossplane. Removal +of the resource from the management cluster by default will drop such resources as well. + +### CreateRDSInstanceProvider + +Creates an RDS Instance and make it available to the blueprint constructs with the provided name. + +This method creates an RDS Instance with the database engine of your choice and in the VPC included in the resource +context or creates a VPC for you. + +The `rdsProps` transparently exposes the underlying RDS Instance properties and will accept and pass them upstream to +the RDS instance method creating the database. + +Example implementation without providing a custom VPC: + +```typescript +const stack = blueprints.EksBlueprint.builder() + .resourceProvider( + GlobalResources.Rds, + new CreateRDSProvider( + { + rdsProps: { + credentials: Credentials.fromGeneratedSecret('admin'), + engine: DatabaseInstanceEngine.mariaDb({ + version: MariaDbEngineVersion.VER_10_3 + }) + }, + name: "rds-instance-no-vpc" + } + ) + ) + .account("123456789") + .region("us-east-1") + .build(app, 'rds-instance-no-vpc'); +``` + +Example implementation while providing a custom VPC: + +```typescript +const stack = blueprints.EksBlueprint.builder() + .resourceProvider( + GlobalResources.Vpc, + new VpcProvider( + undefined, + { + primaryCidr: "10.0.0.0/16" + }, + ) + ) + .resourceProvider( + GlobalResources.Rds, + new CreateRDSProvider({ + rdsProps: { + credentials: Credentials.fromGeneratedSecret('admin'), + engine: DatabaseInstanceEngine.postgres({ + version: PostgresEngineVersion.VER_15_2 + }) + }, + name: 'rds-instance-w-vpc' + }) + ) + .account("1234567889") + .region("us-east-1") + .build(app, 'rds-instance-w-vpc'); + +``` + +### CreateAuroraClusterProvider + +Creates an RDS Aurora Cluster and makes it available to the blueprint constructs with the provided name. This method +creates an RDS Cluster with the database engine of your choice and in the VPC included in the resource context or +creates a VPC for you. + +The `rdsProps` transparently exposes the underlying RDS Cluster properties and will accept and pass them upstream to the +RDS cluster method creating the database. We recommend using either Aurora Serverless or creating specific reader and +writer instances. + +Example implementation without providing a custom VPC: + +```typescript +const stack = blueprints.EksBlueprint.builder() + .resourceProvider( + GlobalResources.Rds, + new CreateAuroraClusterProvider({ + clusterEngine: DatabaseClusterEngine.auroraPostgres( + {version: AuroraPostgresEngineVersion.VER_14_6} + ), + name: "aurora-cluster-no-vpc" + }) + ) + .account("123456789") + .region("us-east-1") + .build(app, 'aurora-cluster-no-vpc'); +``` + +Example implementation while providing a custom VPC: + +```typescript +const stack = blueprints.EksBlueprint.builder() + .resourceProvider( + GlobalResources.Vpc, + new VpcProvider( + undefined, + { + primaryCidr: "10.0.0.0/16", + }) + ) + .resourceProvider( + GlobalResources.Rds, + new CreateAuroraClusterProvider({ + clusterEngine: DatabaseClusterEngine.auroraPostgres( + {version: AuroraPostgresEngineVersion.VER_14_6} + ), + name: "aurora-cluster-w-vpc" + }) + ) + .account("1234567889") + .region("us-east-1") + .build(app, 'aurora-cluster-w-vpc'); +``` \ No newline at end of file diff --git a/lib/resource-providers/index.ts b/lib/resource-providers/index.ts index c403c2d3e..7d3480bf0 100644 --- a/lib/resource-providers/index.ts +++ b/lib/resource-providers/index.ts @@ -7,3 +7,4 @@ export * from './vpc'; export * from './efs'; export * from './s3'; export * from './amp'; +export * from './rds'; diff --git a/lib/resource-providers/rds.ts b/lib/resource-providers/rds.ts new file mode 100644 index 000000000..db6e6e21e --- /dev/null +++ b/lib/resource-providers/rds.ts @@ -0,0 +1,111 @@ +import {IVpc} from "aws-cdk-lib/aws-ec2"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as rds from "aws-cdk-lib/aws-rds"; +import {CfnOutput, RemovalPolicy} from "aws-cdk-lib"; +import {GlobalResources, ResourceContext, ResourceProvider} from "../spi"; + +export interface CreateRDSInstanceProviderProps { + readonly name?: string; + readonly rdsProps?: Omit; +} + +export class CreateRDSProvider implements ResourceProvider { + readonly options: CreateRDSInstanceProviderProps; + + /** + * Constructs a new RDS Provider. + * + * @param {CreateRDSInstanceProviderProps} options - The options for creating the RDS Provider. + */ + + constructor(options: CreateRDSInstanceProviderProps) { + this.options = options; + } + + provide(context: ResourceContext): rds.IDatabaseInstance { + const id = context.scope.node.id; + + const rdsVpc = context.get(GlobalResources.Vpc) as IVpc ?? new ec2.Vpc( + context.scope, + `${this.options.name}-${id}-Vpc` + ); + + const instanceProps: rds.DatabaseInstanceProps = { + ...this.options.rdsProps, + vpc: rdsVpc, + removalPolicy: RemovalPolicy.SNAPSHOT, + deletionProtection: true, + vpcSubnets: { + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + } + } as rds.DatabaseInstanceProps; + + let rdsInstance: rds.DatabaseInstance = new rds.DatabaseInstance( + context.scope, + this.options.name || `${id}-RDSInstance`, + instanceProps + ); + + new CfnOutput(context.scope, "RDSInstanceId", { + value: rdsInstance.instanceIdentifier + }); + + new CfnOutput(context.scope, "RDSSecretIdentifier", { + value: rdsInstance.secret!.secretArn + }); + + return rdsInstance; + } +} + +export interface CreateAuroraClusterProviderProps { + readonly name?: string; + readonly clusterEngine: rds.IClusterEngine; + readonly clusterProps?: Omit +} + +export class CreateAuroraClusterProvider implements ResourceProvider { + readonly options: CreateAuroraClusterProviderProps; + + /** + * Constructor for the CreateAuroraClusterProvider class. + * + * @param {CreateAuroraClusterProviderProps} options - The options for creating an Aurora cluster provider. + */ + constructor(options: CreateAuroraClusterProviderProps) { + this.options = options; + } + + provide(context: ResourceContext): rds.IDatabaseCluster { + const id = context.scope.node.id; + + const auroraVpc = context.get(GlobalResources.Vpc) as IVpc ?? new ec2.Vpc( + context.scope, + `${this.options.name}-${id}-Vpc` + ); + + const clusterProps: rds.DatabaseClusterProps = { + ...this.options.clusterProps, + removalPolicy: RemovalPolicy.SNAPSHOT, + deletionProtection: true, + vpc: auroraVpc, + } as rds.DatabaseClusterProps; + + let auroraInstance = new rds.DatabaseCluster( + context.scope, + this.options.name || `${id}-AuroraCluster`, + clusterProps + ); + + new CfnOutput(context.scope, "AuroraClusterId", { + value: auroraInstance.clusterIdentifier + }); + + new CfnOutput(context.scope, "AuroraSecretIdentifier", { + value: auroraInstance.secret!.secretArn + }); + + return auroraInstance; + } + +} diff --git a/lib/spi/types.ts b/lib/spi/types.ts index d9cf15d7b..cb2f836bd 100644 --- a/lib/spi/types.ts +++ b/lib/spi/types.ts @@ -47,7 +47,7 @@ export interface BlockDeviceMapping { ebs?: EbsVolumeMapping; noDevice?: string; } - + export interface EbsVolumeMapping { deleteOnTermination?: boolean; iops?: number; @@ -154,6 +154,7 @@ export enum GlobalResources { Certificate = 'certificate', KmsKey = 'kms-key', Amp = 'amp', + Rds = 'rds' } /** diff --git a/test/resource-providers/rds.test.ts b/test/resource-providers/rds.test.ts new file mode 100644 index 000000000..3eeab4d69 --- /dev/null +++ b/test/resource-providers/rds.test.ts @@ -0,0 +1,230 @@ +import { App } from "aws-cdk-lib"; +import * as blueprints from "../../lib"; +import { Match, Template } from "aws-cdk-lib/assertions"; +import {CreateAuroraClusterProvider, CreateRDSProvider} from "../../lib"; +import {GlobalResources, VpcProvider} from "../../lib"; +import { + AuroraPostgresEngineVersion, Credentials, + DatabaseClusterEngine, + DatabaseInstanceEngine, MariaDbEngineVersion, + MysqlEngineVersion, PostgresEngineVersion +} from "aws-cdk-lib/aws-rds"; + + +describe("AuroraClusterProvider", () => { + test("Stack created with AuroraProvider without VPC should create AuroraCluster", () => { + const app = new App(); + + const stack = blueprints.EksBlueprint.builder() + .resourceProvider( + GlobalResources.Rds, + new CreateAuroraClusterProvider({ + clusterEngine: DatabaseClusterEngine.auroraPostgres( + { version: AuroraPostgresEngineVersion.VER_14_6 } + ), + name: "aurora-rp-test-no-vpc" + }) + ) + .account("123456789") + .region("us-east-1") + .build(app, 'aurora-test-no-vpc'); + + const template = Template.fromStack(stack); + + template.hasResource("AWS::RDS::DBCluster", { + Properties: { + Engine: Match.anyValue(), + EngineVersion: Match.anyValue(), + VpcSecurityGroupIds: Match.anyValue() + } + }); + }); + + + test("Stack created with AuroraProvider with VPC should also create AuroraCluster", () => { + const app = new App(); + + const stack = blueprints.EksBlueprint.builder() + .resourceProvider( + GlobalResources.Vpc, + new VpcProvider( + undefined, + { + primaryCidr: "10.0.0.0/16", + }) + ) + .resourceProvider( + GlobalResources.Rds, + new CreateAuroraClusterProvider({ + clusterEngine: DatabaseClusterEngine.auroraPostgres( + { version: AuroraPostgresEngineVersion.VER_14_6 } + ), + name: "aurora-rp-test-w-vpc" + }) + ) + .account("1234567889") + .region("us-east-1") + .build(app, 'aurora-test-w-vpc'); + + const template = Template.fromStack(stack); + + template.hasResource("AWS::RDS::DBCluster", { + Properties: { + Engine: Match.anyValue(), + EngineVersion: Match.anyValue(), + VpcSecurityGroupIds: Match.anyValue() + } + }); + }); + + test("Stack created with arbitrary user props passed to aurora should be honoured", () => { + const app = new App(); + + const stack = blueprints.EksBlueprint.builder() + .resourceProvider( + GlobalResources.Vpc, + new VpcProvider( + undefined, + { + primaryCidr: "10.0.0.0/16", + }) + ) + .resourceProvider( + GlobalResources.Rds, + new CreateAuroraClusterProvider({ + clusterEngine: DatabaseClusterEngine.auroraPostgres( + { version: AuroraPostgresEngineVersion.VER_14_6 } + ), + name: "aurora-rp-test-w-vpc" + }) + ) + .account("1234567889") + .region("us-east-1") + .build(app, 'aurora-test-w-vpc'); + + const template = Template.fromStack(stack); + + template.hasResource("AWS::RDS::DBCluster", { + Properties: { + Engine: Match.anyValue(), + EngineVersion: Match.anyValue(), + VpcSecurityGroupIds: Match.anyValue() + } + }); + }); +}); + +describe("RDSInstanceProvider", () => { + test("Stack created with RDSProvider without VPC should create RDSInstance", () => { + const app = new App(); + + const stack = blueprints.EksBlueprint.builder() + .resourceProvider( + GlobalResources.Rds, + new CreateRDSProvider( + { + rdsProps: { + credentials: Credentials.fromGeneratedSecret('admin'), + engine: DatabaseInstanceEngine.mariaDb({ + version: MariaDbEngineVersion.VER_10_3 + }) + }, + name: "rds-rp-test-no-vpc" + } + ) + ) + .account("123456789") + .region("us-east-1") + .build(app, 'rds-test-no-vpc'); + + const template = Template.fromStack(stack); + + template.hasResource("AWS::RDS::DBInstance", { + Properties: { + Engine: Match.anyValue(), + EngineVersion: Match.anyValue(), + VPCSecurityGroups: Match.anyValue() + } + }); + }); + + + test("Stack created with RDSProvider with VPC should also create RDS Instance", () => { + const app = new App(); + + const stack = blueprints.EksBlueprint.builder() + .resourceProvider( + GlobalResources.Vpc, + new VpcProvider( + undefined, + { + primaryCidr: "10.0.0.0/16" + }, + ) + ) + .resourceProvider( + GlobalResources.Rds, + new CreateRDSProvider({ + rdsProps: { + credentials: Credentials.fromGeneratedSecret('admin'), + engine: DatabaseInstanceEngine.postgres({ + version: PostgresEngineVersion.VER_15_2 + }) + }, + name: 'rds-rp-test-w-vpc' + }) + ) + .account("1234567889") + .region("us-east-1") + .build(app, 'rds-test-w-vpc'); + + const template = Template.fromStack(stack); + + template.hasResource("AWS::RDS::DBInstance", { + Properties: { + Engine: Match.anyValue(), + EngineVersion: Match.anyValue(), + VPCSecurityGroups: Match.anyValue() + } + }); + }); + + test("Stack created with arbitrary user props passed to RDS Instance should be honoured", () => { + const app = new App(); + + const stack = blueprints.EksBlueprint.builder() + .resourceProvider( + GlobalResources.Vpc, + new VpcProvider( + undefined, + { + primaryCidr: "10.0.0.0/16" + }, + ) + ) + .resourceProvider( + GlobalResources.Rds, + new CreateRDSProvider({ + rdsProps: { + engine: DatabaseInstanceEngine.mysql({ + version: MysqlEngineVersion.VER_8_0 + }), + }, + name: 'rds-rp-test-arbitrary-props' + }) + ) + .account("1234567889") + .region("us-east-1") + .build(app, 'rds-test-arbitrary-props'); + + const template = Template.fromStack(stack); + + template.hasResource("AWS::RDS::DBInstance", { + Properties: { + Engine: Match.exact("mysql"), + EngineVersion: Match.exact("8.0"), + VPCSecurityGroups: Match.anyValue() + } + }); + }); +}); \ No newline at end of file