diff --git a/packages/aws-rfdk/lib/core/lib/mongodb-instance.ts b/packages/aws-rfdk/lib/core/lib/mongodb-instance.ts index ecea8a4bd..830653cf8 100644 --- a/packages/aws-rfdk/lib/core/lib/mongodb-instance.ts +++ b/packages/aws-rfdk/lib/core/lib/mongodb-instance.ts @@ -286,6 +286,12 @@ export interface IMongoDb extends IConnectable, IConstruct { * The version of MongoDB that is running on this instance. */ readonly version: MongoDbVersion; + + /** + * Adds security groups to the database. + * @param securityGroups The security groups to add. + */ + addSecurityGroup(...securityGroups: ISecurityGroup[]): void; } /** @@ -487,6 +493,13 @@ export class MongoDbInstance extends Construct implements IMongoDb, IGrantable { tagConstruct(this); } + /** + * @inheritdoc + */ + public addSecurityGroup(...securityGroups: ISecurityGroup[]): void { + securityGroups?.forEach(securityGroup => this.server.autoscalingGroup.addSecurityGroup(securityGroup)); + } + /** * Adds UserData commands to install & configure the CloudWatch Agent onto the instance. * diff --git a/packages/aws-rfdk/lib/core/test/mongodb-instance.test.ts b/packages/aws-rfdk/lib/core/test/mongodb-instance.test.ts index 046ba8dae..9912f299b 100644 --- a/packages/aws-rfdk/lib/core/test/mongodb-instance.test.ts +++ b/packages/aws-rfdk/lib/core/test/mongodb-instance.test.ts @@ -724,6 +724,31 @@ describe('Test MongoDbInstance', () => { })); }); + test('adds security group', () => { + // GIVEN + const securityGroup = new SecurityGroup(stack, 'NewSecurityGroup', { + vpc, + }); + const instance = new MongoDbInstance(stack, 'MongoDbInstance', { + mongoDb: { + version, + dnsZone, + hostname, + serverCertificate: serverCert, + userSsplAcceptance, + }, + vpc, + }); + + // WHEN + instance.addSecurityGroup(securityGroup); + + // THEN + cdkExpect(stack).to(haveResourceLike('AWS::AutoScaling::LaunchConfiguration', { + SecurityGroups: arrayWith(stack.resolve(securityGroup.securityGroupId)), + })); + }); + testConstructTags({ constructName: 'MongoDbInstance', createConstruct: () => { diff --git a/packages/aws-rfdk/lib/deadline/lib/database-connection.ts b/packages/aws-rfdk/lib/deadline/lib/database-connection.ts index 8cefbdcb8..544b51275 100644 --- a/packages/aws-rfdk/lib/deadline/lib/database-connection.ts +++ b/packages/aws-rfdk/lib/deadline/lib/database-connection.ts @@ -7,10 +7,12 @@ import * as path from 'path'; import { CfnDBCluster, CfnDBInstance, + DatabaseCluster, IDatabaseCluster, } from '@aws-cdk/aws-docdb'; import { IConnectable, + ISecurityGroup, OperatingSystemType, Port, } from '@aws-cdk/aws-ec2'; @@ -155,6 +157,13 @@ export abstract class DatabaseConnection { * @param child The child to make dependent upon this database. */ public abstract addChildDependency(child: IConstruct): void; + + /** + * Adds a security group to the database. + * + * @param securityGroups The security group to add. + */ + public abstract addSecurityGroup(...securityGroups: ISecurityGroup[]): void; } /** @@ -267,6 +276,42 @@ class DocDBDatabaseConnection extends DatabaseConnection { } } + /** + * @inheritdoc + */ + public addSecurityGroup(...securityGroups: ISecurityGroup[]): void { + let added = false; + const errorReasons: string[] = []; + if (this.props.database instanceof DatabaseCluster) { + const resource = (this.props.database as DatabaseCluster).node.tryFindChild('Resource'); + + // TODO: Replace this code with the addSecurityGroup method of DatabaseCluster once this PR is merged: + // https://github.com/aws/aws-cdk/pull/13290 + if (resource instanceof CfnDBCluster) { + const cfnCluster = resource as CfnDBCluster; + const securityGroupIds = securityGroups.map(sg => sg.securityGroupId); + + if (cfnCluster.vpcSecurityGroupIds === undefined) { + cfnCluster.vpcSecurityGroupIds = securityGroupIds; + } else { + cfnCluster.vpcSecurityGroupIds.push(...securityGroupIds); + } + added = true; + } else { + errorReasons.push('The internal implementation of AWS CDK\'s DocumentDB cluster construct has changed.'); + } + } else { + errorReasons.push('The "database" property passed to this class is not an instance of AWS CDK\'s DocumentDB cluster construct.'); + } + + if (!added) { + Annotations.of(this.props.database).addWarning( + `Failed to add the following security groups to ${this.props.database.node.id}: ${securityGroups.map(sg => sg.node.id).join(', ')}. ` + + errorReasons.join(' '), + ); + } + } + /** * Deadline is only compatible with MongoDB 3.6. This function attempts to determine whether * the given DocDB version is compatible. @@ -375,6 +420,13 @@ class MongoDbInstanceDatabaseConnection extends DatabaseConnection { } } + /** + * @inheritdoc + */ + public addSecurityGroup(...securityGroups: ISecurityGroup[]): void { + this.props.database.addSecurityGroup(...securityGroups); + } + /** * Download the client PKCS#12 certificate for authenticating to the MongoDB, and place it into * the path defined by: DB_CERT_LOCATION diff --git a/packages/aws-rfdk/lib/deadline/lib/render-queue-ref.ts b/packages/aws-rfdk/lib/deadline/lib/render-queue-ref.ts index 1f5db8ae6..8426d0f6c 100644 --- a/packages/aws-rfdk/lib/deadline/lib/render-queue-ref.ts +++ b/packages/aws-rfdk/lib/deadline/lib/render-queue-ref.ts @@ -8,6 +8,7 @@ import { } from '@aws-cdk/aws-certificatemanager'; import { InstanceType, + ISecurityGroup, IVpc, SubnetSelection, } from '@aws-cdk/aws-ec2'; @@ -211,6 +212,20 @@ export interface RenderQueueAccessLogProps { readonly prefix?: string; } +/** + * Security groups of the Render Queue. + */ +export interface RenderQueueSecurityGroups { + /** + * The security group for the backend components of the Render Queue, which consists of the AutoScalingGroup for the Deadline RCS. + */ + readonly backend?: ISecurityGroup; + /** + * The security group for the frontend of the Render Queue, which is its load balancer. + */ + readonly frontend?: ISecurityGroup; +} + /** * Properties for the Render Queue */ @@ -312,6 +327,13 @@ export interface RenderQueueProps { * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#deletion-protection */ readonly deletionProtection?: boolean; + + /** + * Security groups to use for the Render Queue. + * + * @default - new security groups are created + */ + readonly securityGroups?: RenderQueueSecurityGroups; } /** diff --git a/packages/aws-rfdk/lib/deadline/lib/render-queue.ts b/packages/aws-rfdk/lib/deadline/lib/render-queue.ts index 276a41bcf..590e25d25 100644 --- a/packages/aws-rfdk/lib/deadline/lib/render-queue.ts +++ b/packages/aws-rfdk/lib/deadline/lib/render-queue.ts @@ -15,6 +15,7 @@ import { Connections, IConnectable, InstanceType, + ISecurityGroup, Port, SubnetType, } from '@aws-cdk/aws-ec2'; @@ -304,6 +305,8 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { volume: BlockDeviceVolume.ebs(30, { encrypted: true }), }], updateType: UpdateType.ROLLING_UPDATE, + // @ts-ignore + securityGroup: props.securityGroups?.backend, }); /** @@ -347,6 +350,7 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { vpcSubnets: props.vpcSubnetsAlb ?? { subnetType: SubnetType.PRIVATE, onePerAz: true }, internetFacing: false, deletionProtection: props.deletionProtection ?? true, + securityGroup: props.securityGroups?.frontend, }); this.pattern = new ApplicationLoadBalancedEc2Service(this, 'AlbEc2ServicePattern', { @@ -519,6 +523,22 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { child.node.addDependency(this.taskDefinition); } + /** + * Adds security groups to the frontend of the Render Queue, which is its load balancer. + * @param securityGroups The security groups to add. + */ + public addFrontendSecurityGroups(...securityGroups: ISecurityGroup[]): void { + securityGroups.forEach(securityGroup => this.loadBalancer.addSecurityGroup(securityGroup)); + } + + /** + * Adds security groups to the backend components of the Render Queue, which consists of the AutoScalingGroup for the Deadline RCS. + * @param securityGroups The security groups to add. + */ + public addBackendSecurityGroups(...securityGroups: ISecurityGroup[]): void { + securityGroups.forEach(securityGroup => this.asg.addSecurityGroup(securityGroup)); + } + private createTaskDefinition(props: { image: ContainerImage, portNumber: number, diff --git a/packages/aws-rfdk/lib/deadline/lib/repository.ts b/packages/aws-rfdk/lib/deadline/lib/repository.ts index 9785c1a19..e3f45a316 100644 --- a/packages/aws-rfdk/lib/deadline/lib/repository.ts +++ b/packages/aws-rfdk/lib/deadline/lib/repository.ts @@ -24,6 +24,7 @@ import { InstanceClass, InstanceSize, InstanceType, + ISecurityGroup, IVpc, OperatingSystemType, SubnetSelection, @@ -242,6 +243,26 @@ export interface RepositoryRemovalPolicies { readonly filesystem?: RemovalPolicy; } +/** + * Options for the security groups of the Repository. + */ +export interface RepositorySecurityGroupsOptions { + /** + * The security group for the filesystem of the Repository. This is ignored if the Repository is not creating + * its own Amazon Elastic File System (EFS) because one was given. + */ + readonly fileSystem?: ISecurityGroup; + /** + * The security group for the database of the Repository. This is ignored if the Repository is not creating + * its own DocumentDB database because one was given. + */ + readonly database?: ISecurityGroup; + /** + * The security group for the AutoScalingGroup of the instance that runs the Deadline Repository installer. + */ + readonly installer?: ISecurityGroup; +} + /** * Properties for the Deadline repository */ @@ -339,6 +360,11 @@ export interface RepositoryProps { * @default Duration.days(15) for the database */ readonly backupOptions?: RepositoryBackupOptions; + + /** + * Options to add additional security groups to the Repository. + */ + readonly securityGroupsOptions?: RepositorySecurityGroupsOptions; } /** @@ -436,6 +462,12 @@ export class Repository extends Construct implements IRepository { */ public readonly fileSystem: IMountableLinuxFilesystem; + /** + * The underlying Amazon Elastic File System (EFS) used by the Repository. + * This is only defined if this Repository created its own filesystem, otherwise it will be `undefined`. + */ + public readonly efs?: EfsFileSystem; + /** * The autoscaling group for this repository's installer-running instance. */ @@ -456,17 +488,24 @@ export class Repository extends Construct implements IRepository { this.version = props.version; - // Set up the Filesystem and Database components of the repository - this.fileSystem = props.fileSystem ?? new MountableEfs(this, { - filesystem: new EfsFileSystem(this, 'FileSystem', { + // Set up the Filesystem of the repository + if (props.fileSystem !== undefined) { + this.fileSystem = props.fileSystem; + } else { + this.efs = new EfsFileSystem(this, 'FileSystem', { vpc: props.vpc, vpcSubnets: props.vpcSubnets ?? { subnetType: SubnetType.PRIVATE }, encrypted: true, lifecyclePolicy: EfsLifecyclePolicy.AFTER_14_DAYS, removalPolicy: props.removalPolicy?.filesystem ?? RemovalPolicy.RETAIN, - }), - }); + securityGroup: props.securityGroupsOptions?.fileSystem, + }); + this.fileSystem = new MountableEfs(this, { + filesystem: this.efs, + }); + } + // Set up the Database of the repository if (props.database) { this.databaseConnection = props.database; if (props.databaseAuditLogging !== undefined){ @@ -498,6 +537,7 @@ export class Repository extends Construct implements IRepository { instanceType: InstanceType.of(InstanceClass.R5, InstanceSize.LARGE), vpc: props.vpc, vpcSubnets: props.vpcSubnets ?? { subnetType: SubnetType.PRIVATE, onePerAz: true }, + securityGroup: props.securityGroupsOptions?.database, }, instances, backup: { @@ -550,6 +590,7 @@ export class Repository extends Construct implements IRepository { resourceSignalTimeout: (props.repositoryInstallationTimeout || Duration.minutes(15)), updateType: UpdateType.REPLACING_UPDATE, replacingUpdateMinSuccessfulInstancesPercent: 100, + securityGroup: props.securityGroupsOptions?.installer, }); this.node.defaultChild = this.installerGroup; // Ensure the DB is serving before we try to connect to it. diff --git a/packages/aws-rfdk/lib/deadline/test/database-connection.test.ts b/packages/aws-rfdk/lib/deadline/test/database-connection.test.ts index 113c18037..d7f727cdf 100644 --- a/packages/aws-rfdk/lib/deadline/test/database-connection.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/database-connection.test.ts @@ -11,9 +11,12 @@ import { } from '@aws-cdk/assert'; import { DatabaseCluster, + Endpoint, + IDatabaseCluster, } from '@aws-cdk/aws-docdb'; import { AmazonLinuxGeneration, + Connections, Instance, InstanceClass, InstanceSize, @@ -31,11 +34,17 @@ import { import { PrivateHostedZone, } from '@aws-cdk/aws-route53'; -import { Secret } from '@aws-cdk/aws-secretsmanager'; import { + Secret, + SecretAttachmentTargetProps, +} from '@aws-cdk/aws-secretsmanager'; +import { + Construct, Duration, + ResourceEnvironment, Stack, } from '@aws-cdk/core'; +import * as sinon from 'sinon'; import { IMongoDb, @@ -264,6 +273,70 @@ describe('DocumentDB', () => { }).toThrowError('Connecting to the Deadline Database is currently only supported for Linux.'); }); + test('adds warning annotation when a security group cannot be added due to unsupported IDatabaseCluster implementation', () => { + // GIVEN + class FakeDatabaseCluster extends Construct implements IDatabaseCluster { + public readonly clusterIdentifier: string = ''; + public readonly instanceIdentifiers: string[] = []; + public readonly clusterEndpoint: Endpoint = new Endpoint('address', 123); + public readonly clusterReadEndpoint: Endpoint = new Endpoint('readAddress', 123); + public readonly instanceEndpoints: Endpoint[] = []; + public readonly securityGroupId: string = ''; + public readonly connections: Connections = new Connections(); + + public readonly stack: Stack; + public readonly env: ResourceEnvironment; + + constructor(scope: Construct, id: string) { + super(scope, id); + this.stack = Stack.of(scope); + this.env = {account: this.stack.account, region: this.stack.region}; + } + + asSecretAttachmentTarget(): SecretAttachmentTargetProps { + throw new Error('Method not implemented.'); + } + } + const fakeDatabase = new FakeDatabaseCluster(stack, 'FakeDatabase'); + const securityGroup = new SecurityGroup(stack, 'NewSecurityGroup', { vpc }); + const connection = DatabaseConnection.forDocDB({database: fakeDatabase, login: database.secret!}); + + // WHEN + connection.addSecurityGroup(securityGroup); + + // THEN + expect(fakeDatabase.node.metadata).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'aws:cdk:warning', + data: expect.stringMatching(new RegExp(`Failed to add the following security groups to ${fakeDatabase.node.id}: .*\\. ` + + 'The \\"database\\" property passed to this class is not an instance of AWS CDK\'s DocumentDB cluster construct.')), + }), + ])); + }); + + // This test can be removed once the following CDK PR is merged: + // https://github.com/aws/aws-cdk/pull/13290 + test('adds warning annotation when a security group cannot be added due to implementation changes in DatabaseCluster', () => { + // GIVEN + if (!database.node.tryRemoveChild('Resource')) { + throw new Error('The internal implementation of AWS CDK\'s DocumentDB cluster construct has changed. The addSecurityGroup method needs to be updated.'); + } + const securityGroup = new SecurityGroup(stack, 'NewSecurityGroup', { vpc }); + const connection = DatabaseConnection.forDocDB({database, login: database.secret!}); + + // WHEN + connection.addSecurityGroup(securityGroup); + + // THEN + expect(database.node.metadata).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'aws:cdk:warning', + data: expect.stringMatching(new RegExp(`Failed to add the following security groups to ${database.node.id}: .*\\. ` + + 'The internal implementation of AWS CDK\'s DocumentDB cluster construct has changed.')), + }), + ])); + }); + }); describe('DocumentDB Version Checks', () => { @@ -616,4 +689,19 @@ describe('MongoDB', () => { connection.addConnectionDBArgs(instance); }).toThrowError('Connecting to the Deadline Database is currently only supported for Linux.'); }); + + test('adds security group', () => { + // GIVEN + const dbSpy = sinon.spy(database, 'addSecurityGroup'); + const connection = DatabaseConnection.forMongoDbInstance({database, clientCertificate: clientCert}); + const securityGroup = new SecurityGroup(stack, 'NewSecurityGroup', { + vpc, + }); + + // WHEN + connection.addSecurityGroup(securityGroup); + + // THEN + expect(dbSpy.calledOnce).toBeTruthy(); + }); }); diff --git a/packages/aws-rfdk/lib/deadline/test/render-queue.test.ts b/packages/aws-rfdk/lib/deadline/test/render-queue.test.ts index e7046138b..947998c3d 100644 --- a/packages/aws-rfdk/lib/deadline/test/render-queue.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/render-queue.test.ts @@ -26,6 +26,8 @@ import { InstanceSize, InstanceType, MachineImage, + Port, + SecurityGroup, Subnet, Vpc, WindowsVersion, @@ -65,6 +67,7 @@ import { RenderQueue, RenderQueueImages, RenderQueueProps, + RenderQueueSecurityGroups, Repository, VersionQuery, } from '../lib'; @@ -2271,6 +2274,120 @@ describe('RenderQueue', () => { }); }); + + describe('Security Groups', () => { + let backendSecurityGroup: SecurityGroup; + let frontendSecurityGroup: SecurityGroup; + + beforeEach(() => { + backendSecurityGroup = new SecurityGroup(stack, 'ASGSecurityGroup', { vpc }); + frontendSecurityGroup = new SecurityGroup(stack, 'LBSecurityGroup', { vpc }); + }); + + test('adds security groups on construction', () => { + // GIVEN + const securityGroups: RenderQueueSecurityGroups = { + backend: backendSecurityGroup, + frontend: frontendSecurityGroup, + }; + + // WHEN + new RenderQueue(stack, 'RenderQueue', { + images, + repository, + version: renderQueueVersion, + vpc, + securityGroups, + }); + + // THEN + assertSecurityGroupsWereAdded(securityGroups); + }); + + test('adds backend security groups post-construction', () => { + // GIVEN + const renderQueue = new RenderQueue(stack, 'RenderQueue', { + images, + repository, + version: renderQueueVersion, + vpc, + }); + + // WHEN + renderQueue.addBackendSecurityGroups(backendSecurityGroup); + + // THEN + assertSecurityGroupsWereAdded({ + backend: backendSecurityGroup, + }); + }); + + test('adds frontend security groups post-construction', () => { + // GIVEN + const renderQueue = new RenderQueue(stack, 'RenderQueue', { + images, + repository, + version: renderQueueVersion, + vpc, + }); + + // WHEN + renderQueue.addFrontendSecurityGroups(frontendSecurityGroup); + + // THEN + assertSecurityGroupsWereAdded({ + frontend: frontendSecurityGroup, + }); + }); + + test('security groups added post-construction are not attached to Connections object', () => { + // GIVEN + const renderQueue = new RenderQueue(stack, 'RenderQueue', { + images, + repository, + version: renderQueueVersion, + vpc, + }); + renderQueue.addBackendSecurityGroups(backendSecurityGroup); + renderQueue.addFrontendSecurityGroups(frontendSecurityGroup); + const peerSecurityGroup = new SecurityGroup(stack, 'PeerSecurityGroup', { vpc }); + + // WHEN + renderQueue.connections.allowFrom(peerSecurityGroup, Port.tcp(22)); + + // THEN + // Existing LoadBalancer security groups shouldn't have the ingress rule added + expectCDK(stack).notTo(haveResourceLike('AWS::EC2::SecurityGroupIngress', { + IpProtocol: 'tcp', + FromPort: 22, + ToPort: 22, + GroupId: stack.resolve(frontendSecurityGroup.securityGroupId), + SourceSecurityGroupId: stack.resolve(peerSecurityGroup.securityGroupId), + })); + // Existing AutoScalingGroup security groups shouldn't have the ingress rule added + expectCDK(stack).notTo(haveResourceLike('AWS::EC2::SecurityGroupIngress', { + IpProtocol: 'tcp', + FromPort: 22, + ToPort: 22, + GroupId: stack.resolve(backendSecurityGroup.securityGroupId), + SourceSecurityGroupId: stack.resolve(peerSecurityGroup.securityGroupId), + })); + }); + + function assertSecurityGroupsWereAdded(securityGroups: RenderQueueSecurityGroups) { + if (securityGroups.backend !== undefined) { + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::LaunchConfiguration', { + SecurityGroups: arrayWith(stack.resolve(securityGroups.backend.securityGroupId)), + })); + } + if (securityGroups.frontend !== undefined) { + expectCDK(stack).to(haveResourceLike('AWS::ElasticLoadBalancingV2::LoadBalancer', { + SecurityGroups: arrayWith(stack.resolve(securityGroups.frontend.securityGroupId)), + })); + } + } + }); + test('validates VersionQuery is not in a different stack', () => { // GIVEN const newStack = new Stack(app, 'NewStack'); diff --git a/packages/aws-rfdk/lib/deadline/test/repository.test.ts b/packages/aws-rfdk/lib/deadline/test/repository.test.ts index 0b930a501..61ab4b280 100644 --- a/packages/aws-rfdk/lib/deadline/test/repository.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/repository.test.ts @@ -21,8 +21,10 @@ import { InstanceClass, InstanceSize, InstanceType, + ISecurityGroup, IVpc, MachineImage, + SecurityGroup, Subnet, SubnetType, Vpc, @@ -1061,6 +1063,74 @@ describe('tagging', () => { }); }); +describe('Security Groups', () => { + let repositorySecurityGroup: ISecurityGroup; + + beforeEach(() => { + repositorySecurityGroup = new SecurityGroup(stack, 'AdditionalSecurityGroup', { vpc }); + }); + + describe('DocDB', () => { + + test('adds security groups on construction', () => { + // WHEN + new Repository(stack, 'Repository', { + version, + vpc, + securityGroupsOptions: { + database: repositorySecurityGroup, + }, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::DocDB::DBCluster', { + VpcSecurityGroupIds: arrayWith(stack.resolve(repositorySecurityGroup.securityGroupId)), + })); + }); + }); + + describe('EFS', () => { + + test('adds security groups on construction', () => { + // WHEN + new Repository(stack, 'Repository', { + version, + vpc, + securityGroupsOptions: { + fileSystem: repositorySecurityGroup, + }, + }); + + // THEN + // The EFS construct adds the security group to each mount target, and one mount target is generated per subnet. + const numMountTargets = vpc.selectSubnets().subnets.length; + expectCDK(stack).to(countResourcesLike('AWS::EFS::MountTarget', numMountTargets, { + SecurityGroups: arrayWith(stack.resolve(repositorySecurityGroup.securityGroupId)), + })); + }); + }); + + describe('Installer', () => { + + test('adds security groups on construction', () => { + // WHEN + new Repository(stack, 'Repository', { + version, + vpc, + securityGroupsOptions: { + installer: repositorySecurityGroup, + }, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::LaunchConfiguration', { + SecurityGroups: arrayWith(stack.resolve(repositorySecurityGroup.securityGroupId)), + })); + }); + + }); +}); + test('validates VersionQuery is not in a different stack', () => { // GIVEN const newStack = new Stack(app, 'NewStack');