diff --git a/packages/@aws-cdk/aws-redshift/README.md b/packages/@aws-cdk/aws-redshift/README.md index 2bf53e2033f34..05736c4c15c2c 100644 --- a/packages/@aws-cdk/aws-redshift/README.md +++ b/packages/@aws-cdk/aws-redshift/README.md @@ -9,4 +9,52 @@ --- +### Starting a Redshift Cluster Database + +To set up a Redshift cluster, define a `Cluster`. It will be launched in a VPC. +You can specify a VPC, otherwise one will be created. The nodes are always launched in private subnets and are encrypted by default. + +``` typescript +import redshift = require('@aws-cdk/aws-redshift'); +... +const cluster = new redshift.Cluster(this, 'Redshift', { + masterUser: { + masterUsername: 'admin', + }, + vpc + }); +``` +By default, the master password will be generated and stored in AWS Secrets Manager. + +A default database named `default_db` will be created in the cluster. To change the name of this database set the `defaultDatabaseName` attribute in the constructor properties. + +### Connecting + +To control who can access the cluster, use the `.connections` attribute. Redshift Clusters have +a default port, so you don't need to specify the port: + +```ts +cluster.connections.allowFromAnyIpv4('Open to the world'); +``` + +The endpoint to access your database cluster will be available as the `.clusterEndpoint` attribute: + +```ts +cluster.clusterEndpoint.socketAddress; // "HOSTNAME:PORT" +``` + +### Rotating credentials + +When the master password is generated and stored in AWS Secrets Manager, it can be rotated automatically: +```ts +cluster.addRotationSingleUser(); // Will rotate automatically after 30 days +``` + +The multi user rotation scheme is also available: +```ts +cluster.addRotationMultiUser('MyUser', { + secret: myImportedSecret +}); +``` + This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. diff --git a/packages/@aws-cdk/aws-redshift/lib/cluster.ts b/packages/@aws-cdk/aws-redshift/lib/cluster.ts new file mode 100644 index 0000000000000..48caa7aabf1db --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/cluster.ts @@ -0,0 +1,540 @@ +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 s3 from '@aws-cdk/aws-s3'; +import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; +import { Construct, Duration, IResource, RemovalPolicy, Resource, SecretValue, Token } from '@aws-cdk/core'; +import { DatabaseSecret } from './database-secret'; +import { Endpoint } from './endpoint'; +import { IClusterParameterGroup } from './parameter-group'; +import { CfnCluster, CfnClusterSubnetGroup } from './redshift.generated'; + +/** + * Possible Node Types to use in the cluster + * used for defining {@link ClusterProps.nodeType}. + */ +export enum NodeType { + /** + * ds2.xlarge + */ + DS2_XLARGE = 'ds2.xlarge', + /** + * ds2.8xlarge + */ + DS2_8XLARGE = 'ds2.8xlarge', + /** + * dc1.large + */ + DC1_LARGE = 'dc1.large', + /** + * dc1.8xlarge + */ + DC1_8XLARGE = 'dc1.8xlarge', + /** + * dc2.large + */ + DC2_LARGE = 'dc2.large', + /** + * dc2.8xlarge + */ + DC2_8XLARGE = 'dc2.8xlarge', + /** + * ra3.16xlarge + */ + RA3_16XLARGE = 'ra3.16xlarge', +} + +/** + * What cluster type to use. + * Used by {@link ClusterProps.clusterType} + */ +export enum ClusterType { + /** + * single-node cluster, the {@link ClusterProps.numberOfNodes} parameter is not required + */ + SINGLE_NODE = 'single-node', + /** + * multi-node cluster, set the amount of nodes using {@link ClusterProps.numberOfNodes} parameter + */ + MULTI_NODE = 'multi-node', +} + +/** + * Username and password combination + */ +export interface Login { + /** + * Username + */ + readonly masterUsername: string; + + /** + * Password + * + * Do not put passwords in your CDK code directly. + * + * @default a Secrets Manager generated password + */ + readonly masterPassword?: SecretValue; + + /** + * KMS encryption key to encrypt the generated secret. + * + * @default default master key + */ + readonly encryptionKey?: kms.IKey; +} + +/** + * Options to add the multi user rotation + */ +export interface RotationMultiUserOptions { + /** + * The secret to rotate. It must be a JSON string with the following format: + * ``` + * { + * "engine": , + * "host": , + * "username": , + * "password": , + * "dbname": , + * "port": , + * "masterarn": + * } + * ``` + */ + readonly secret: secretsmanager.ISecret; + + /** + * Specifies the number of days after the previous rotation before + * Secrets Manager triggers the next automatic rotation. + * + * @default Duration.days(30) + */ + readonly automaticallyAfter?: Duration; +} + +/** + * Create a Redshift Cluster with a given number of nodes. + * Implemented by {@link Cluster} via {@link ClusterBase}. + */ +export interface ICluster extends IResource, ec2.IConnectable, secretsmanager.ISecretAttachmentTarget { + /** + * Name of the cluster + * + * @attribute ClusterName + */ + readonly clusterName: string; + + /** + * The endpoint to use for read/write operations + * + * @attribute EndpointAddress,EndpointPort + */ + readonly clusterEndpoint: Endpoint; +} + +/** + * Properties that describe an existing cluster instance + */ +export interface ClusterAttributes { + /** + * The security groups of the redshift cluster + * + * @default no security groups will be attached to the import + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * Identifier for the cluster + */ + readonly clusterName: string; + + /** + * Cluster endpoint address + */ + readonly clusterEndpointAddress: string; + + /** + * Cluster endpoint port + */ + readonly clusterEndpointPort: number; + +} + +/** + * Properties for a new database cluster + */ +export interface ClusterProps { + + /** + * An optional identifier for the cluster + * + * @default - A name is automatically generated. + */ + readonly clusterName?: string; + + /** + * Additional parameters to pass to the database engine + * https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-parameter-groups.html + * + * @default - No parameter group. + */ + readonly parameterGroup?: IClusterParameterGroup; + + /** + * Number of compute nodes in the cluster + * + * Value must be at least 1 and no more than 100. + * + * @default 1 + */ + readonly numberOfNodes?: number; + + /** + * The node type to be provisioned for the cluster. + * + * @default {@link NodeType.DC2_LARGE} + */ + readonly nodeType?: NodeType; + + /** + * Settings for the individual instances that are launched + * + * @default {@link ClusterType.MULTI_NODE} + */ + readonly clusterType?: ClusterType; + + /** + * What port to listen on + * + * @default - The default for the engine is used. + */ + readonly port?: number; + + /** + * Whether to enable encryption of data at rest in the cluster. + * + * @default true + */ + readonly encrypted?: boolean + + /** + * The KMS key to use for encryption of data at rest. + * + * @default - AWS-managed key, if encryption at rest is enabled + */ + readonly encryptionKey?: kms.IKey; + + /** + * A preferred maintenance window day/time range. Should be specified as a range ddd:hh24:mi-ddd:hh24:mi (24H Clock UTC). + * + * Example: 'Sun:23:45-Mon:00:15' + * + * @default - 30-minute window selected at random from an 8-hour block of time for + * each AWS Region, occurring on a random day of the week. + * @see https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/USER_UpgradeDBInstance.Maintenance.html#Concepts.DBMaintenance + */ + readonly preferredMaintenanceWindow?: string; + + /** + * The VPC to place the cluster in. + */ + readonly vpc: ec2.IVpc; + + /** + * Where to place the instances within the VPC + * + * @default private subnets + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * Security group. + * + * @default a new security group is created. + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * Username and password for the administrative user + */ + readonly masterUser: Login; + + /** + * A list of AWS Identity and Access Management (IAM) role that can be used by the cluster to access other AWS services. + * Specify a maximum of 10 roles. + * + * @default - No role is attached to the cluster. + */ + readonly roles?: iam.IRole[]; + + /** + * Name of a database which is automatically created inside the cluster + * + * @default - default_db + */ + readonly defaultDatabaseName?: string; + + /** + * Bucket to send logs to. + * Logging information includes queries and connection attempts, for the specified Amazon Redshift cluster. + * + * @default - No Logs + */ + readonly loggingBucket?: s3.IBucket + + /** + * Prefix used for logging + * + * @default - no prefix + */ + readonly loggingKeyPrefix?: string + + /** + * The removal policy to apply when the cluster and its instances are removed + * from the stack or replaced during an update. + * + * @default RemovalPolicy.RETAIN + */ + readonly removalPolicy?: RemovalPolicy +} + +/** + * A new or imported clustered database. + */ +abstract class ClusterBase extends Resource implements ICluster { + /** + * Name of the cluster + */ + public abstract readonly clusterName: string; + + /** + * The endpoint to use for read/write operations + */ + public abstract readonly clusterEndpoint: Endpoint; + + /** + * Access to the network connections + */ + public abstract readonly connections: ec2.Connections; + + /** + * Renders the secret attachment target specifications. + */ + public asSecretAttachmentTarget(): secretsmanager.SecretAttachmentTargetProps { + return { + targetId: this.clusterName, + targetType: secretsmanager.AttachmentTargetType.REDSHIFT_CLUSTER, + }; + } +} + +/** + * Create a Redshift cluster a given number of nodes. + * + * @resource AWS::Redshift::Cluster + */ +export class Cluster extends ClusterBase { + /** + * Import an existing DatabaseCluster from properties + */ + public static fromClusterAttributes(scope: Construct, id: string, attrs: ClusterAttributes): ICluster { + class Import extends ClusterBase { + public readonly connections = new ec2.Connections({ + securityGroups: attrs.securityGroups, + defaultPort: ec2.Port.tcp(attrs.clusterEndpointPort), + }); + public readonly clusterName = attrs.clusterName; + public readonly instanceIdentifiers: string[] = []; + public readonly clusterEndpoint = new Endpoint(attrs.clusterEndpointAddress, attrs.clusterEndpointPort); + } + + return new Import(scope, id); + } + + /** + * Identifier of the cluster + */ + public readonly clusterName: string; + + /** + * The endpoint to use for read/write operations + */ + public readonly clusterEndpoint: Endpoint; + + /** + * Access to the network connections + */ + public readonly connections: ec2.Connections; + + /** + * The secret attached to this cluster + */ + public readonly secret?: secretsmanager.ISecret; + + private readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication; + private readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication; + + /** + * The VPC where the DB subnet group is created. + */ + private readonly vpc: ec2.IVpc; + + /** + * The subnets used by the DB subnet group. + */ + private readonly vpcSubnets?: ec2.SubnetSelection; + + constructor(scope: Construct, id: string, props: ClusterProps) { + super(scope, id); + + this.vpc = props.vpc; + this.vpcSubnets = props.vpcSubnets ? props.vpcSubnets : { + subnetType: ec2.SubnetType.PRIVATE, + }; + + const removalPolicy = props.removalPolicy ? props.removalPolicy : RemovalPolicy.RETAIN; + + const { subnetIds } = this.vpc.selectSubnets(this.vpcSubnets); + + const subnetGroup = new CfnClusterSubnetGroup(this, 'Subnets', { + description: `Subnets for ${id} Redshift cluster`, + subnetIds, + }); + + subnetGroup.applyRemovalPolicy(removalPolicy, { + applyToUpdateReplacePolicy: true, + }); + + const securityGroups = props.securityGroups !== undefined ? + props.securityGroups : [new ec2.SecurityGroup(this, 'SecurityGroup', { + description: 'Redshift security group', + vpc: this.vpc, + securityGroupName: 'redshift SG', + })]; + + const securityGroupIds = securityGroups.map(sg => sg.securityGroupId); + + let secret: DatabaseSecret | undefined; + if (!props.masterUser.masterPassword) { + secret = new DatabaseSecret(this, 'Secret', { + username: props.masterUser.masterUsername, + encryptionKey: props.masterUser.encryptionKey, + }); + } + + const clusterType = props.clusterType || ClusterType.MULTI_NODE; + const nodeCount = props.numberOfNodes !== undefined ? props.numberOfNodes : (clusterType === ClusterType.MULTI_NODE ? 2 : 1); + + if (clusterType === ClusterType.MULTI_NODE && nodeCount < 2) { + throw new Error('Number of nodes for cluster type multi-node must be at least 2'); + } + + if (props.encrypted === false && props.encryptionKey !== undefined) { + throw new Error('Cannot set property encryptionKey without enabling encryption!'); + } + + this.singleUserRotationApplication = secretsmanager.SecretRotationApplication.REDSHIFT_ROTATION_SINGLE_USER; + this.multiUserRotationApplication = secretsmanager.SecretRotationApplication.REDSHIFT_ROTATION_MULTI_USER; + + let loggingProperties; + if (props.loggingBucket) { + loggingProperties = { + bucketName: props.loggingBucket.bucketName, + s3KeyPrefix: props.loggingKeyPrefix, + }; + } + + const cluster = new CfnCluster(this, 'Resource', { + // Basic + allowVersionUpgrade: true, + automatedSnapshotRetentionPeriod: 1, + clusterType, + clusterIdentifier: props.clusterName, + clusterSubnetGroupName: subnetGroup.ref, + vpcSecurityGroupIds: securityGroupIds, + port: props.port, + clusterParameterGroupName: props.parameterGroup && props.parameterGroup.clusterParameterGroupName, + // Admin + masterUsername: secret ? secret.secretValueFromJson('username').toString() : props.masterUser.masterUsername, + masterUserPassword: secret + ? secret.secretValueFromJson('password').toString() + : (props.masterUser.masterPassword + ? props.masterUser.masterPassword.toString() + : 'default'), + preferredMaintenanceWindow: props.preferredMaintenanceWindow, + nodeType: props.nodeType || NodeType.DC2_LARGE, + numberOfNodes: nodeCount, + loggingProperties, + iamRoles: props.roles ? props.roles.map(role => role.roleArn) : undefined, + dbName: props.defaultDatabaseName || 'default_db', + publiclyAccessible: false, + // Encryption + kmsKeyId: props.encryptionKey && props.encryptionKey.keyArn, + encrypted: props.encrypted !== undefined ? props.encrypted : true, + }); + + cluster.applyRemovalPolicy(removalPolicy, { + applyToUpdateReplacePolicy: true, + }); + + this.clusterName = cluster.ref; + + // create a number token that represents the port of the cluster + const portAttribute = Token.asNumber(cluster.attrEndpointPort); + this.clusterEndpoint = new Endpoint(cluster.attrEndpointAddress, portAttribute); + + if (secret) { + this.secret = secret.attach(this); + } + + const defaultPort = ec2.Port.tcp(this.clusterEndpoint.port); + this.connections = new ec2.Connections({ securityGroups, defaultPort }); + } + + /** + * Adds the single user rotation of the master password to this cluster. + * + * @param [automaticallyAfter=Duration.days(30)] Specifies the number of days after the previous rotation + * before Secrets Manager triggers the next automatic rotation. + */ + public addRotationSingleUser(automaticallyAfter?: Duration): secretsmanager.SecretRotation { + if (!this.secret) { + throw new Error('Cannot add single user rotation for a cluster without secret.'); + } + + const id = 'RotationSingleUser'; + const existing = this.node.tryFindChild(id); + if (existing) { + throw new Error('A single user rotation was already added to this cluster.'); + } + + return new secretsmanager.SecretRotation(this, id, { + secret: this.secret, + automaticallyAfter, + application: this.singleUserRotationApplication, + vpc: this.vpc, + vpcSubnets: this.vpcSubnets, + target: this, + }); + } + + /** + * Adds the multi user rotation to this cluster. + */ + public addRotationMultiUser(id: string, options: RotationMultiUserOptions): secretsmanager.SecretRotation { + if (!this.secret) { + throw new Error('Cannot add multi user rotation for a cluster without secret.'); + } + return new secretsmanager.SecretRotation(this, id, { + secret: options.secret, + masterSecret: this.secret, + automaticallyAfter: options.automaticallyAfter, + application: this.multiUserRotationApplication, + vpc: this.vpc, + vpcSubnets: this.vpcSubnets, + target: this, + }); + } +} diff --git a/packages/@aws-cdk/aws-redshift/lib/database-secret.ts b/packages/@aws-cdk/aws-redshift/lib/database-secret.ts new file mode 100644 index 0000000000000..7e7617be2be83 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/database-secret.ts @@ -0,0 +1,39 @@ +import * as kms from '@aws-cdk/aws-kms'; +import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; +import { Construct } from '@aws-cdk/core'; + +/** + * Construction properties for a DatabaseSecret. + */ +export interface DatabaseSecretProps { + /** + * The username. + */ + readonly username: string; + + /** + * The KMS key to use to encrypt the secret. + * + * @default default master key + */ + readonly encryptionKey?: kms.IKey; +} + +/** + * A database secret. + * + * @resource AWS::SecretsManager::Secret + */ +export class DatabaseSecret extends secretsmanager.Secret { + constructor(scope: Construct, id: string, props: DatabaseSecretProps) { + super(scope, id, { + encryptionKey: props.encryptionKey, + generateSecretString: { + passwordLength: 30, // Redshift password could be up to 64 characters + secretStringTemplate: JSON.stringify({ username: props.username }), + generateStringKey: 'password', + excludeCharacters: '"@/\\\ \'', + }, + }); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-redshift/lib/endpoint.ts b/packages/@aws-cdk/aws-redshift/lib/endpoint.ts new file mode 100644 index 0000000000000..0ee19b8d82113 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/endpoint.ts @@ -0,0 +1,31 @@ +import { Token } from '@aws-cdk/core'; + +/** + * Connection endpoint of a redshift cluster + * + * Consists of a combination of hostname and port. + */ +export class Endpoint { + /** + * The hostname of the endpoint + */ + public readonly hostname: string; + + /** + * The port of the endpoint + */ + public readonly port: number; + + /** + * The combination of "HOSTNAME:PORT" for this endpoint + */ + public readonly socketAddress: string; + + constructor(address: string, port: number) { + this.hostname = address; + this.port = port; + + const portDesc = Token.isUnresolved(port) ? Token.asString(port) : port; + this.socketAddress = `${address}:${portDesc}`; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-redshift/lib/index.ts b/packages/@aws-cdk/aws-redshift/lib/index.ts index e1441fcf6bb03..6d5e5d00bb134 100644 --- a/packages/@aws-cdk/aws-redshift/lib/index.ts +++ b/packages/@aws-cdk/aws-redshift/lib/index.ts @@ -1,2 +1,7 @@ +export * from './cluster'; +export * from './parameter-group'; +export * from './database-secret'; +export * from './endpoint'; + // AWS::Redshift CloudFormation Resources: export * from './redshift.generated'; diff --git a/packages/@aws-cdk/aws-redshift/lib/parameter-group.ts b/packages/@aws-cdk/aws-redshift/lib/parameter-group.ts new file mode 100644 index 0000000000000..ea5698b235628 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/parameter-group.ts @@ -0,0 +1,77 @@ +import { Construct, IResource, Resource } from '@aws-cdk/core'; +import { CfnClusterParameterGroup } from './redshift.generated'; + +/** + * A parameter group + */ +export interface IClusterParameterGroup extends IResource { + /** + * The name of this parameter group + * + * @attribute + */ + readonly clusterParameterGroupName: string; +} + +/** + * A new cluster or instance parameter group + */ +abstract class ClusterParameterGroupBase extends Resource implements IClusterParameterGroup { + /** + * The name of the parameter group + */ + public abstract readonly clusterParameterGroupName: string; +} + +/** + * Properties for a parameter group + */ +export interface ClusterParameterGroupProps { + /** + * Description for this parameter group + * + * @default a CDK generated description + */ + readonly description?: string; + + /** + * The parameters in this parameter group + */ + readonly parameters: { [name: string]: string }; +} + +/** + * A cluster parameter group + * + * @resource AWS::Redshift::ClusterParameterGroup + */ +export class ClusterParameterGroup extends ClusterParameterGroupBase { + /** + * Imports a parameter group + */ + public static fromClusterParameterGroupName(scope: Construct, id: string, clusterParameterGroupName: string): IClusterParameterGroup { + class Import extends Resource implements IClusterParameterGroup { + public readonly clusterParameterGroupName = clusterParameterGroupName; + } + return new Import(scope, id); + } + + /** + * The name of the parameter group + */ + public readonly clusterParameterGroupName: string; + + constructor(scope: Construct, id: string, props: ClusterParameterGroupProps) { + super(scope, id); + + const resource = new CfnClusterParameterGroup(this, 'Resource', { + description: props.description || 'Cluster parameter group for family redshift-1.0', + parameterGroupFamily: 'redshift-1.0', + parameters: Object.entries(props.parameters).map(([name, value]) => { + return {parameterName: name, parameterValue: value}; + }), + }); + + this.clusterParameterGroupName = resource.ref; + } +} diff --git a/packages/@aws-cdk/aws-redshift/package.json b/packages/@aws-cdk/aws-redshift/package.json index 07283d9304b9f..3b645e15ba91e 100644 --- a/packages/@aws-cdk/aws-redshift/package.json +++ b/packages/@aws-cdk/aws-redshift/package.json @@ -66,20 +66,39 @@ "@aws-cdk/assert": "0.0.0", "cdk-build-tools": "0.0.0", "cfn2ts": "0.0.0", + "jest": "^25.5.3", "pkglint": "0.0.0" }, "dependencies": { + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-secretsmanager": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.0.2" }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-secretsmanager": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.0.2" }, "engines": { "node": ">= 10.13.0 <13 || >=13.7.0" }, + "awslint": { + "exclude": [ + "docs-public-apis:@aws-cdk/aws-redshift.ParameterGroupParameters.parameterName", + "docs-public-apis:@aws-cdk/aws-redshift.ParameterGroupParameters.parameterValue", + "props-physical-name:@aws-cdk/aws-redshift.ClusterParameterGroupProps", + "props-physical-name:@aws-cdk/aws-redshift.DatabaseSecretProps" + ] + }, "stability": "experimental", "maturity": "cfn-only", "awscdkio": { diff --git a/packages/@aws-cdk/aws-redshift/test/cluster.test.ts b/packages/@aws-cdk/aws-redshift/test/cluster.test.ts new file mode 100644 index 0000000000000..385a2f53208b5 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/cluster.test.ts @@ -0,0 +1,329 @@ +import { expect as cdkExpect, haveResource, ResourcePart } from '@aws-cdk/assert'; +import '@aws-cdk/assert/jest'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as kms from '@aws-cdk/aws-kms'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; + +import { Cluster, ClusterParameterGroup, ClusterType, NodeType } from '../lib'; + +test('check that instantiation works', () => { + // GIVEN + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + new Cluster(stack, 'Redshift', { + masterUser: { + masterUsername: 'admin', + masterPassword: cdk.SecretValue.plainText('tooshort'), + }, + vpc, + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::Redshift::Cluster', { + Properties: { + AllowVersionUpgrade: true, + MasterUsername: 'admin', + MasterUserPassword: 'tooshort', + ClusterType: 'multi-node', + AutomatedSnapshotRetentionPeriod: 1, + Encrypted: true, + NumberOfNodes: 2, + NodeType: 'dc2.large', + DBName: 'default_db', + PubliclyAccessible: false, + ClusterSubnetGroupName: { Ref: 'RedshiftSubnetsDFE70E0A' }, + VpcSecurityGroupIds: [{ 'Fn::GetAtt': ['RedshiftSecurityGroup796D74A7', 'GroupId'] }], + }, + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }, ResourcePart.CompleteDefinition)); + + cdkExpect(stack).to(haveResource('AWS::Redshift::ClusterSubnetGroup', { + Properties: { + Description: 'Subnets for Redshift Redshift cluster', + SubnetIds: [ + { Ref: 'VPCPrivateSubnet1Subnet8BCA10E0' }, + { Ref: 'VPCPrivateSubnet2SubnetCFCDAA7A' }, + { Ref: 'VPCPrivateSubnet3Subnet3EDCD457' }, + ], + }, + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }, ResourcePart.CompleteDefinition)); +}); + +test('can create a cluster with imported vpc and security group', () => { + // GIVEN + const stack = testStack(); + const vpc = ec2.Vpc.fromLookup(stack, 'VPC', { + vpcId: 'VPC12345', + }); + const sg = ec2.SecurityGroup.fromSecurityGroupId(stack, 'SG', 'SecurityGroupId12345'); + + // WHEN + new Cluster(stack, 'Redshift', { + masterUser: { + masterUsername: 'admin', + masterPassword: cdk.SecretValue.plainText('tooshort'), + }, + vpc, + securityGroups: [sg], + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::Redshift::Cluster', { + ClusterSubnetGroupName: { Ref: 'RedshiftSubnetsDFE70E0A' }, + MasterUsername: 'admin', + MasterUserPassword: 'tooshort', + VpcSecurityGroupIds: ['SecurityGroupId12345'], + })); +}); + +test('creates a secret when master credentials are not specified', () => { + // GIVEN + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + new Cluster(stack, 'Redshift', { + masterUser: { + masterUsername: 'admin', + }, + vpc, + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::Redshift::Cluster', { + MasterUsername: { + 'Fn::Join': [ + '', + [ + '{{resolve:secretsmanager:', + { + Ref: 'RedshiftSecretA08D42D6', + }, + ':SecretString:username::}}', + ], + ], + }, + MasterUserPassword: { + 'Fn::Join': [ + '', + [ + '{{resolve:secretsmanager:', + { + Ref: 'RedshiftSecretA08D42D6', + }, + ':SecretString:password::}}', + ], + ], + }, + })); + + cdkExpect(stack).to(haveResource('AWS::SecretsManager::Secret', { + GenerateSecretString: { + ExcludeCharacters: '"@/\\\ \'', + GenerateStringKey: 'password', + PasswordLength: 30, + SecretStringTemplate: '{"username":"admin"}', + }, + })); +}); + +test('SIngle Node CLusters spawn only single node', () => { + // GIVEN + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + new Cluster(stack, 'Redshift', { + masterUser: { + masterUsername: 'admin', + }, + vpc, + nodeType: NodeType.DC1_8XLARGE, + clusterType: ClusterType.SINGLE_NODE, + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::Redshift::Cluster', { + ClusterType: 'single-node', + NodeType: 'dc1.8xlarge', + NumberOfNodes: 1, + })); +}); + +test('create an encrypted cluster with custom KMS key', () => { + // GIVEN + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + new Cluster(stack, 'Redshift', { + masterUser: { + masterUsername: 'admin', + }, + encryptionKey: new kms.Key(stack, 'Key'), + vpc, + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::Redshift::Cluster', { + KmsKeyId: { + 'Fn::GetAtt': [ + 'Key961B73FD', + 'Arn', + ], + }, + })); +}); + +test('cluster with parameter group', () => { + // GIVEN + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + const group = new ClusterParameterGroup(stack, 'Params', { + description: 'bye', + parameters: { + param: 'value', + }, + }); + + new Cluster(stack, 'Redshift', { + masterUser: { + masterUsername: 'admin', + }, + vpc, + parameterGroup: group, + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::Redshift::Cluster', { + ClusterParameterGroupName: { Ref: 'ParamsA8366201' }, + })); + +}); + +test('imported cluster with imported security group honors allowAllOutbound', () => { + // GIVEN + const stack = testStack(); + + const cluster = Cluster.fromClusterAttributes(stack, 'Database', { + clusterEndpointAddress: 'addr', + clusterName: 'identifier', + clusterEndpointPort: 3306, + securityGroups: [ + ec2.SecurityGroup.fromSecurityGroupId(stack, 'SG', 'sg-123456789', { + allowAllOutbound: false, + }), + ], + }); + + // WHEN + cluster.connections.allowToAnyIpv4(ec2.Port.tcp(443)); + + // THEN + cdkExpect(stack).to(haveResource('AWS::EC2::SecurityGroupEgress', { + GroupId: 'sg-123456789', + })); +}); + +test('can create a cluster with logging enabled', () => { + // GIVEN + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const bucket = s3.Bucket.fromBucketName(stack, 'bucket', 'logging-bucket'); + + // WHEN + new Cluster(stack, 'Redshift', { + masterUser: { + masterUsername: 'admin', + }, + vpc, + loggingBucket: bucket, + loggingKeyPrefix: 'prefix', + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::Redshift::Cluster', { + LoggingProperties: { + BucketName: 'logging-bucket', + S3KeyPrefix: 'prefix', + }, + })); +}); + +test('throws when trying to add rotation to a cluster without secret', () => { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + const cluster = new Cluster(stack, 'Redshift', { + masterUser: { + masterUsername: 'admin', + masterPassword: cdk.SecretValue.plainText('tooshort'), + }, + vpc, + }); + + // THEN + expect(() => { + cluster.addRotationSingleUser(); + }).toThrowError(); + +}); + +test('throws validation error when trying to set encryptionKey without enabling encryption', () => { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const key = new kms.Key(stack, 'kms-key'); + + // WHEN + const props = { + encrypted: false, + encryptionKey: key, + masterUser: { + masterUsername: 'admin', + }, + vpc, + }; + + // THEN + expect(() => { + new Cluster(stack, 'Redshift', props ); + }).toThrowError(); + +}); + +test('throws when trying to add single user rotation multiple times', () => { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const cluster = new Cluster(stack, 'Redshift', { + masterUser: { + masterUsername: 'admin', + }, + vpc, + }); + + // WHEN + cluster.addRotationSingleUser(); + + // THEN + expect(() => { + cluster.addRotationSingleUser(); + }).toThrowError(); +}); + +function testStack() { + const stack = new cdk.Stack(undefined, undefined, { env: { account: '12345', region: 'us-test-1' } }); + stack.node.setContext('availability-zones:12345:us-test-1', ['us-test-1a', 'us-test-1b']); + return stack; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-redshift/test/parameter-group.test.ts b/packages/@aws-cdk/aws-redshift/test/parameter-group.test.ts new file mode 100644 index 0000000000000..ca5923ee36ba6 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/parameter-group.test.ts @@ -0,0 +1,29 @@ +import { expect as cdkExpect, haveResource } from '@aws-cdk/assert'; +import * as cdk from '@aws-cdk/core'; +import { ClusterParameterGroup } from '../lib'; + +test('create a cluster parameter group', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new ClusterParameterGroup(stack, 'Params', { + description: 'desc', + parameters: { + param: 'value', + }, + }); + + // THEN + cdkExpect(stack).to(haveResource('AWS::Redshift::ClusterParameterGroup', { + Description: 'desc', + ParameterGroupFamily: 'redshift-1.0', + Parameters: [ + { + ParameterName: 'param', + ParameterValue: 'value', + }, + ], + })); + +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-redshift/test/redshift.test.ts b/packages/@aws-cdk/aws-redshift/test/redshift.test.ts deleted file mode 100644 index e394ef336bfb4..0000000000000 --- a/packages/@aws-cdk/aws-redshift/test/redshift.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '@aws-cdk/assert/jest'; -import {} from '../lib'; - -test('No tests are specified for this package', () => { - expect(true).toBe(true); -});