diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efb4a050a..6641900d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,9 @@ name: CI on: push: - branches: [ mainline ] + branches: [ mainline, 'feature*' ] pull_request: - branches: [ mainline ] + branches: [ mainline, 'feature*' ] jobs: build: diff --git a/packages/aws-rfdk/.gitignore b/packages/aws-rfdk/.gitignore index aba9e2ca0..455191269 100644 --- a/packages/aws-rfdk/.gitignore +++ b/packages/aws-rfdk/.gitignore @@ -39,3 +39,6 @@ junit.xml # Include Mongo support scripts !lib/core/scripts/mongodb/**/*.js + +# Python cache folders +__pycache__ diff --git a/packages/aws-rfdk/lib/core/lib/deployment-instance.ts b/packages/aws-rfdk/lib/core/lib/deployment-instance.ts new file mode 100644 index 000000000..98103b610 --- /dev/null +++ b/packages/aws-rfdk/lib/core/lib/deployment-instance.ts @@ -0,0 +1,299 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AutoScalingGroup, + Signals, + UpdatePolicy, +} from '@aws-cdk/aws-autoscaling'; +import { + Connections, + IConnectable, + IMachineImage, + InstanceClass, + InstanceSize, + InstanceType, + IVpc, + MachineImage, + SubnetSelection, + SubnetType, +} from '@aws-cdk/aws-ec2'; +import { + PolicyStatement, +} from '@aws-cdk/aws-iam'; +import { + Construct, + Duration, + Names, + Stack, + Tags, +} from '@aws-cdk/core'; + +import { + CloudWatchConfigBuilder, + CloudWatchAgent, + IScriptHost, + LogGroupFactory, + LogGroupFactoryProps, +} from '.'; +import { tagConstruct } from './runtime-info'; + + +/** + * Properties for constructing a `DeploymentInstance` + */ +export interface DeploymentInstanceProps { + /** + * The VPC that the instance should be launched in. + */ + readonly vpc: IVpc; + + /** + * The amount of time that CloudFormation should wait for the success signals before failing the create/update. + * + * @default 15 minutes + */ + readonly executionTimeout?: Duration; + + /** + * The instance type to deploy + * + * @default t3.small + */ + readonly instanceType?: InstanceType; + + /** + * An optional EC2 keypair name to associate with the instance + * + * @default no EC2 keypair is associated with the instance + */ + readonly keyName?: string; + + /** + * The log group name for streaming CloudWatch logs + * + * @default the construct ID is used + */ + readonly logGroupName?: string; + + /** + * Properties for setting up the DeploymentInstance's LogGroup in CloudWatch + * + * @default the LogGroup will be created with all properties' default values to the LogGroup: /renderfarm/ + */ + readonly logGroupProps?: LogGroupFactoryProps; + + /** + * The machine image to use. + * + * @default latest Amazon Linux 2 image + */ + readonly machineImage?: IMachineImage; + + /** + * Whether the instance should self-terminate after the deployment succeeds + * + * @default true + */ + readonly selfTerminate?: boolean; + + /** + * The subnets to deploy the instance to + * + * @default private subnets + */ + readonly vpcSubnets?: SubnetSelection; +} + +/** + * Deploys an instance that runs its user data on deployment, waits for that user data to succeed, and optionally + * terminates itself afterwards. + * + * Resources Deployed + * ------------------------ + * - Auto Scaling Group (ASG) with max capacity of 1 instance. + * - IAM instance profile, IAM role, and IAM policy + * - An Amazon CloudWatch log group that contains the instance cloud-init logs + * - A Lambda Function to fetch and existing Log Group or create a new one + * - IAM role and policy for the Lambda Function + * + * Security Considerations + * ------------------------ + * - The instances deployed by this construct download and run scripts from your CDK bootstrap bucket when that instance + * is launched. You must limit write access to your CDK bootstrap bucket to prevent an attacker from modifying the actions + * performed by these scripts. We strongly recommend that you either enable Amazon S3 server access logging on your CDK + * bootstrap bucket, or enable AWS CloudTrail on your account to assist in post-incident analysis of compromised production + * environments. + */ +export class DeploymentInstance extends Construct implements IScriptHost, IConnectable { + /** + * The tag key name used as an IAM condition to restrict autoscaling API grants + */ + private static readonly ASG_TAG_KEY: string = 'resourceLogicalId'; + + /** + * How often the CloudWatch agent will flush its log files to CloudWatch + */ + private static readonly CLOUDWATCH_LOG_FLUSH_INTERVAL: Duration = Duration.seconds(15); + + /** + * The default timeout to wait for CloudFormation success signals before failing the resource create/update + */ + private static readonly DEFAULT_EXECUTION_TIMEOUT = Duration.minutes(15); + + /** + * Default prefix for a LogGroup if one isn't provided in the props. + */ + private static readonly DEFAULT_LOG_GROUP_PREFIX: string = '/renderfarm/'; + + /** + * @inheritdoc + */ + public readonly connections: Connections; + + /** + * The auto-scaling group + */ + protected readonly asg: AutoScalingGroup; + + constructor(scope: Construct, id: string, props: DeploymentInstanceProps) { + super(scope, id); + + this.asg = new AutoScalingGroup(this, 'ASG', { + instanceType: props.instanceType ?? InstanceType.of(InstanceClass.T3, InstanceSize.SMALL), + keyName: props.keyName, + machineImage: props.machineImage ?? MachineImage.latestAmazonLinux(), + vpc: props.vpc, + vpcSubnets: props.vpcSubnets ?? { + subnetType: SubnetType.PRIVATE, + }, + minCapacity: 1, + maxCapacity: 1, + signals: Signals.waitForAll({ + timeout: props.executionTimeout ?? DeploymentInstance.DEFAULT_EXECUTION_TIMEOUT, + }), + updatePolicy: UpdatePolicy.replacingUpdate(), + }); + this.node.defaultChild = this.asg; + + this.connections = this.asg.connections; + + const logGroupName = props.logGroupName ?? id; + this.configureCloudWatchAgent(this.asg, logGroupName, props.logGroupProps); + + if (props.selfTerminate ?? true) { + this.configureSelfTermination(); + } + this.asg.userData.addSignalOnExitCommand(this.asg); + + // Tag deployed resources with RFDK meta-data + tagConstruct(this); + } + + /** + * Make the execution of the instance dependent upon another construct + * + * @param dependency The construct that should be dependended upon + */ + public addExecutionDependency(dependency: any): void { + if (Construct.isConstruct(dependency)) { + this.asg.node.defaultChild!.node.addDependency(dependency); + } + } + + /** + * @inheritdoc + */ + public get osType() { + return this.asg.osType; + } + + /** + * @inheritdoc + */ + public get userData() { + return this.asg.userData; + } + + /** + * @inheritdoc + */ + public get grantPrincipal() { + return this.asg.grantPrincipal; + } + + /** + * Adds UserData commands to configure the CloudWatch Agent running on the deployment instance. + * + * The commands configure the agent to stream the following logs to a new CloudWatch log group: + * - The cloud-init log + * + * @param asg The auto-scaling group + * @param groupName The name of the Log Group, or suffix of the Log Group if `logGroupProps.logGroupPrefix` is + * specified + * @param logGroupProps The properties for LogGroupFactory to create or fetch the log group + */ + private configureCloudWatchAgent(asg: AutoScalingGroup, groupName: string, logGroupProps?: LogGroupFactoryProps) { + const prefix = logGroupProps?.logGroupPrefix ?? DeploymentInstance.DEFAULT_LOG_GROUP_PREFIX; + const defaultedLogGroupProps = { + ...logGroupProps, + logGroupPrefix: prefix, + }; + const logGroup = LogGroupFactory.createOrFetch(this, 'DeploymentInstanceLogGroupWrapper', groupName, defaultedLogGroupProps); + + logGroup.grantWrite(asg); + + const cloudWatchConfigurationBuilder = new CloudWatchConfigBuilder(DeploymentInstance.CLOUDWATCH_LOG_FLUSH_INTERVAL); + + cloudWatchConfigurationBuilder.addLogsCollectList(logGroup.logGroupName, + 'cloud-init-output', + '/var/log/cloud-init-output.log'); + + new CloudWatchAgent(this, 'CloudWatchAgent', { + cloudWatchConfig: cloudWatchConfigurationBuilder.generateCloudWatchConfiguration(), + host: asg, + }); + } + + private configureSelfTermination() { + // Add a policy to the ASG that allows it to modify itself. We cannot add the ASG name in resources as it will cause + // cyclic dependency. Hence, using Condition Keys + const tagCondition: { [key: string]: any } = {}; + tagCondition[`autoscaling:ResourceTag/${DeploymentInstance.ASG_TAG_KEY}`] = Names.uniqueId(this); + + Tags.of(this.asg).add(DeploymentInstance.ASG_TAG_KEY, Names.uniqueId(this)); + + this.asg.addToRolePolicy(new PolicyStatement({ + actions: [ + 'autoscaling:UpdateAutoScalingGroup', + ], + resources: ['*'], + conditions: { + StringEquals: tagCondition, + }, + })); + + // Following policy is required to read the aws tags within the instance + this.asg.addToRolePolicy(new PolicyStatement({ + actions: [ + 'ec2:DescribeTags', + ], + resources: ['*'], + })); + + // wait for the log flush interval to make sure that all the logs gets flushed. + // this wait can be avoided in future by using a life-cycle-hook on 'TERMINATING' state. + const terminationDelay = Math.ceil(DeploymentInstance.CLOUDWATCH_LOG_FLUSH_INTERVAL.toMinutes({integral: false})); + this.asg.userData.addOnExitCommands(`sleep ${terminationDelay}m`); + + // fetching the instance id and ASG name and then setting its capacity to 0 + this.asg.userData.addOnExitCommands( + 'TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 30" 2> /dev/null)', + 'INSTANCE="$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id 2> /dev/null)"', + 'ASG="$(aws --region ' + Stack.of(this).region + ' ec2 describe-tags --filters "Name=resource-id,Values=${INSTANCE}" "Name=key,Values=aws:autoscaling:groupName" --query "Tags[0].Value" --output text)"', + 'aws --region ' + Stack.of(this).region + ' autoscaling update-auto-scaling-group --auto-scaling-group-name ${ASG} --min-size 0 --max-size 0 --desired-capacity 0', + ); + } +} diff --git a/packages/aws-rfdk/lib/core/test/deployment-instance.test.ts b/packages/aws-rfdk/lib/core/test/deployment-instance.test.ts new file mode 100644 index 000000000..5e9c3bf15 --- /dev/null +++ b/packages/aws-rfdk/lib/core/test/deployment-instance.test.ts @@ -0,0 +1,765 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + arrayWith, + countResources, + expect as expectCDK, + haveResourceLike, + ResourcePart, + stringLike, +} from '@aws-cdk/assert'; +import { CfnLaunchConfiguration } from '@aws-cdk/aws-autoscaling'; +import { + AmazonLinuxImage, + ExecuteFileOptions, + InstanceType, + MachineImage, + SecurityGroup, + SubnetType, + Vpc, +} from '@aws-cdk/aws-ec2'; +import * as iam from '@aws-cdk/aws-iam'; +import { ILogGroup } from '@aws-cdk/aws-logs'; +import { Bucket } from '@aws-cdk/aws-s3'; +import { Asset } from '@aws-cdk/aws-s3-assets'; +import { StringParameter } from '@aws-cdk/aws-ssm'; +import * as cdk from '@aws-cdk/core'; + +import { + DeploymentInstance, + DeploymentInstanceProps, +} from '../lib/deployment-instance'; +import { resourceTagMatcher, testConstructTags } from './tag-helpers'; + + +const DEFAULT_CONSTRUCT_ID = 'DeploymentInstance'; + +/** + * Machine image that spies on the following user data methods: + * + * * `.addOnExitCommands` + * * `.addExecuteFileCommand` + */ +class AmazonLinuxWithUserDataSpy extends AmazonLinuxImage { + public getImage(scope: cdk.Construct) { + const result = super.getImage(scope); + jest.spyOn(result.userData, 'addOnExitCommands'); + jest.spyOn(result.userData, 'addExecuteFileCommand'); + return result; + } +} + +describe('DeploymentInstance', () => { + let app: cdk.App; + let depStack: cdk.Stack; + let stack: cdk.Stack; + let vpc: Vpc; + let target: DeploymentInstance; + + beforeAll(() => { + // GIVEN + app = new cdk.App(); + depStack = new cdk.Stack(app, 'DepStack'); + vpc = new Vpc(depStack, 'VPC'); + }); + + describe('defaults', () => { + + beforeAll(() => { + // GIVEN + stack = new cdk.Stack(app, 'DefaultsStack'); + target = new DeploymentInstance(stack, DEFAULT_CONSTRUCT_ID, { + vpc, + }); + }); + + describe('Auto-Scaling Group', () => { + // Only one ASG is deployed. This is an anchor for the tests that follow. Each test is independent and not + // guaranteed to match on the same resource in the CloudFormation template. Having a test that asserts a single + // ASG makes these assertions linked + test('deploys a single Auto-Scaling Group', () => { + // THEN + expectCDK(stack).to(countResources('AWS::AutoScaling::AutoScalingGroup', 1)); + }); + + test('MaxSize is 1', () => { + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + MaxSize: '1', + })); + }); + + test('MinSize is 1', () => { + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + MinSize: '1', + })); + }); + + test('uses private subnets', () => { + // GIVEN + const privateSubnetIDs = vpc.selectSubnets({ subnetType: SubnetType.PRIVATE }).subnetIds; + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + VPCZoneIdentifier: arrayWith( + ...stack.resolve(privateSubnetIDs), + ), + })); + }); + + test('waits 15 minutes for one signal', () => { + // THEN + expectCDK(stack).to(haveResourceLike( + 'AWS::AutoScaling::AutoScalingGroup', + { + CreationPolicy: { + ResourceSignal: { + Count: 1, + Timeout: 'PT15M', + }, + }, + }, + ResourcePart.CompleteDefinition, + )); + }); + + test('sets replacing update policy', () => { + // THEN + expectCDK(stack).to(haveResourceLike( + 'AWS::AutoScaling::AutoScalingGroup', + { + UpdatePolicy: { + AutoScalingReplacingUpdate: { + WillReplace: true, + }, + AutoScalingScheduledAction: { + IgnoreUnmodifiedGroupSizeProperties: true, + }, + }, + }, + ResourcePart.CompleteDefinition, + )); + }); + + test('uses Launch Configuration', () => { + // GIVEN + const launchConfig = target.node.findChild('ASG').node.findChild('LaunchConfig') as CfnLaunchConfiguration; + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + LaunchConfigurationName: stack.resolve(launchConfig.ref), + })); + }); + }); + + describe('Launch Configuration', () => { + // Only one ASG is deployed. This is an anchor for the tests that follow. Each test is independent and not + // guaranteed to match on the same resource in the CloudFormation template. Having a test that asserts a single + // ASG makes these assertions linked + test('deploys a single Launch Configuration', () => { + // THEN + expectCDK(stack).to(countResources('AWS::AutoScaling::LaunchConfiguration', 1)); + }); + + test('uses latest Amazon Linux machine image', () => { + // GIVEN + const amazonLinux = MachineImage.latestAmazonLinux(); + const imageId: { Ref: string } = stack.resolve(amazonLinux.getImage(stack)).imageId; + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::LaunchConfiguration', { + ImageId: imageId, + })); + }); + + test('uses t3.small', () => { + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::LaunchConfiguration', { + InstanceType: 't3.small', + })); + }); + + test('Uses created Security Group', () => { + // GIVEN + const securityGroup = (target + .node.findChild('ASG') + .node.findChild('InstanceSecurityGroup') + ) as SecurityGroup; + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::LaunchConfiguration', { + SecurityGroups: [ + stack.resolve(securityGroup.securityGroupId), + ], + })); + }); + + test('depends on policy', () => { + // GIVEN + const policy = ( + target + .node.findChild('ASG') + .node.findChild('InstanceRole') + .node.findChild('DefaultPolicy') + .node.defaultChild + ) as iam.CfnPolicy; + + // THEN + expectCDK(stack).to(haveResourceLike( + 'AWS::AutoScaling::LaunchConfiguration', + { + DependsOn: arrayWith( + stack.resolve(policy.logicalId), + ), + }, + ResourcePart.CompleteDefinition, + )); + }); + }); + + describe('Security Group', () => { + test('creates Security Group in the desired VPC', () => { + // THEN + expectCDK(stack).to(countResources('AWS::EC2::SecurityGroup', 1)); + expectCDK(stack).to(haveResourceLike('AWS::EC2::SecurityGroup', { + VpcId: stack.resolve(vpc.vpcId), + })); + }); + }); + + describe('ASG IAM role', () => { + let instanceRole: iam.CfnRole; + + beforeAll(() => { + // GIVEN + instanceRole = ( + target + .node.findChild('ASG') + .node.findChild('InstanceRole') + .node.defaultChild + ) as iam.CfnRole; + }); + + test('creates an instance profile', () => { + // THEN + expectCDK(stack).to(haveResourceLike('AWS::IAM::InstanceProfile', { + Roles: [ + { Ref: stack.getLogicalId(instanceRole) }, + ], + })); + }); + + test('creates a role that can be assumed by EC2', () => { + // GIVEN + const servicePrincipal = new iam.ServicePrincipal('ec2.amazonaws.com'); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: stack.resolve(servicePrincipal.policyFragment.principalJson).Service[0], + }, + }, + ], + }, + })); + }); + + test('can signal to CloudFormation', () => { + // THEN + expectCDK(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: 'cloudformation:SignalResource', + Effect: 'Allow', + Resource: { Ref: 'AWS::StackId' }, + }, + ), + }, + Roles: [ + stack.resolve(instanceRole.ref), + ], + })); + }); + + test('can write to the log group', () => { + // GIVEN + const logGroup = target.node.findChild(`${DEFAULT_CONSTRUCT_ID}LogGroup`) as ILogGroup; + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: [ + 'logs:CreateLogStream', + 'logs:PutLogEvents', + ], + Effect: 'Allow', + Resource: stack.resolve(logGroup.logGroupArn), + }, + ), + }, + Roles: [ + stack.resolve(instanceRole.ref), + ], + })); + }); + + test('can fetch the CloudWatch Agent install script', () => { + // GIVEN + const cloudWatchAgentScriptAsset = ( + target + .node.findChild('CloudWatchConfigurationScriptAsset') + ) as Asset; + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: [ + 's3:GetObject*', + 's3:GetBucket*', + 's3:List*', + ], + Effect: 'Allow', + Resource: stack.resolve([ + cloudWatchAgentScriptAsset.bucket.bucketArn, + cloudWatchAgentScriptAsset.bucket.arnForObjects('*'), + ]), + }, + ), + }, + Roles: [ + stack.resolve(instanceRole.ref), + ], + })); + }); + + test('can fetch the CloudWatch Agent configuration file SSM parameter', () => { + // GIVEN + const cloudWatchConfigSsmParam = ( + target + .node.findChild('StringParameter') + ) as StringParameter; + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: [ + 'ssm:DescribeParameters', + 'ssm:GetParameters', + 'ssm:GetParameter', + 'ssm:GetParameterHistory', + ], + Effect: 'Allow', + Resource: stack.resolve(cloudWatchConfigSsmParam.parameterArn), + }, + ), + }, + Roles: [ + stack.resolve(instanceRole.ref), + ], + })); + }); + + test('can fetch the CloudWatch Agent installer from S3', () => { + // GIVEN + const cloudWatchAgentInstallerBucket = Bucket.fromBucketArn(depStack, 'CloudWatchAgentInstallerBucket', `arn:aws:s3:::amazoncloudwatch-agent-${stack.region}` ); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: [ + 's3:GetObject*', + 's3:GetBucket*', + 's3:List*', + ], + Effect: 'Allow', + Resource: stack.resolve([ + cloudWatchAgentInstallerBucket.bucketArn, + cloudWatchAgentInstallerBucket.arnForObjects('*'), + ]), + }, + ), + }, + Roles: [ + stack.resolve(instanceRole.ref), + ], + })); + }); + + test('can fetch GPG installer from RFDK dependencies S3 bucket', () => { + // GIVEN + const rfdkExternalDepsBucket = Bucket.fromBucketArn(depStack, 'RfdkExternalDependenciesBucket', `arn:aws:s3:::rfdk-external-dependencies-${stack.region}` ); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: [ + 's3:GetObject*', + 's3:GetBucket*', + 's3:List*', + ], + Effect: 'Allow', + Resource: stack.resolve([ + rfdkExternalDepsBucket.bucketArn, + rfdkExternalDepsBucket.arnForObjects('*'), + ]), + }, + ), + }, + Roles: [ + stack.resolve(instanceRole.ref), + ], + })); + }); + + test('can scale the Auto-Scaling Group', () => { + // THEN + expectCDK(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: 'autoscaling:UpdateAutoScalingGroup', + Condition: { + // This tag is added by RFDK to scope down the permissions of the policy for least-privilege + StringEquals: { 'autoscaling:ResourceTag/resourceLogicalId': cdk.Names.uniqueId(target) }, + }, + Effect: 'Allow', + Resource: '*', + }, + // The instance determines its Auto-Scaling Group by reading the tag created on the instance by the EC2 + // Auto-Scaling service + { + Action: 'ec2:DescribeTags', + Effect: 'Allow', + Resource: '*', + }, + ), + }, + Roles: [ + stack.resolve(instanceRole.ref), + ], + })); + }); + }); + + describe('CloudWatch Agent config SSM parameter', () => { + test('configures log group', () => { + // GIVEN + const logGroup = target.node.findChild(`${DEFAULT_CONSTRUCT_ID}LogGroup`) as ILogGroup; + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::SSM::Parameter', { + Type: 'String', + Value: { + 'Fn::Join': [ + '', + arrayWith( + '{"logs":{"logs_collected":{"files":{"collect_list":[{"log_group_name":"', + stack.resolve(logGroup.logGroupName), + ), + ], + }, + })); + }); + + test('configures cloud-init log', () => { + // THEN + expectCDK(stack).to(haveResourceLike('AWS::SSM::Parameter', { + Type: 'String', + Value: { + 'Fn::Join': [ + '', + arrayWith( + stringLike('*"log_stream_name":"cloud-init-output-{instance_id}","file_path":"/var/log/cloud-init-output.log",*'), + ), + ], + }, + })); + }); + }); + + describe('Tags resources with RFDK meta-data', () => { + testConstructTags({ + constructName: 'DeploymentInstance', + createConstruct: () => { + return stack; + }, + resourceTypeCounts: { + 'AWS::EC2::SecurityGroup': 1, + 'AWS::IAM::Role': 1, + 'AWS::AutoScaling::AutoScalingGroup': 1, + 'AWS::SSM::Parameter': 1, + }, + }); + }); + + // RFDK adds the resourceLogicalId tag to the Auto-Scaling Group in order to scope down the permissions of the + // IAM policy given to the EC2 instance profile so that only that ASG can be scaled by the instance. + test('Tagging for self-termination', () => { + // THEN + const matcher = resourceTagMatcher('AWS::AutoScaling::AutoScalingGroup', 'resourceLogicalId', cdk.Names.uniqueId(target)); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', matcher)); + }); + }); + + describe('User Data', () => { + beforeAll(() => { + // GIVEN + stack = new cdk.Stack(app, 'UserDataStack'); + + // WHEN + target = new DeploymentInstance(stack, 'DeploymentInstanceNew', { + vpc, + // a hack to be able to spy on the user data's "addOnExitCommand" and "addExecuteFileCommand" methods. + machineImage: new AmazonLinuxWithUserDataSpy(), + }); + }); + + test('configures self-termination', () =>{ + // THEN + expect(target.userData.addOnExitCommands).toHaveBeenCalledWith( + 'TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 30" 2> /dev/null)', + 'INSTANCE="$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id 2> /dev/null)"', + `ASG="$(aws --region ${stack.region} ec2 describe-tags --filters "Name=resource-id,Values=\${INSTANCE}" "Name=key,Values=aws:autoscaling:groupName" --query "Tags[0].Value" --output text)"`, + `aws --region ${stack.region} autoscaling update-auto-scaling-group --auto-scaling-group-name \${ASG} --min-size 0 --max-size 0 --desired-capacity 0`, + ); + }); + + test('configures CloudWatch Agent', () =>{ + // GIVEN + const spy = target.userData.addExecuteFileCommand as jest.Mock; + const cloudWatchConfigSsmParam = ( + target + .node.findChild('StringParameter') + ) as StringParameter; + + // THEN + + // Should have been called + expect(spy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // The first call... + const executeFileOptions = spy.mock.calls[0][0]; + + // Should have been called with arguments + const args = executeFileOptions.arguments; + expect(args).not.toBeUndefined(); + + const splitArgs = args!.split(' '); + // Should have three arguments + expect(splitArgs).toHaveLength(3); + + // Specify the flag to install the CloudWatch Agent + expect(splitArgs[0]).toEqual('-i'); + // Should pass the region + expect(stack.resolve(splitArgs[1])).toEqual(stack.resolve(stack.region)); + // Should pass the SSM parameter containing the CloudWatch Agent configuration + expect(stack.resolve(splitArgs[2])).toEqual(stack.resolve(cloudWatchConfigSsmParam.parameterName)); + }); + + }); + + describe('Custom::LogRetention.LogGroupName', () => { + beforeEach(() => { + // We need a clean construct tree, because the tests use the same construct ID + app = new cdk.App(); + depStack = new cdk.Stack(app, 'DepStack'); + vpc = new Vpc(depStack, 'VPC'); + stack = new cdk.Stack(app, 'Stack'); + }); + + // GIVEN + test.each<[ + { + // optional logging props of DeploymentInstance + logGroupName?: string, + logGroupPrefix?: string, + }, + // expected final log group name + string, + ]>([ + [ + {}, + // defaults expected final log group name + `/renderfarm/${DEFAULT_CONSTRUCT_ID}`, + ], + [ + { logGroupName: 'foo' }, + // expected final log group name + '/renderfarm/foo', + ], + [ + { + logGroupPrefix: 'logGroupPrefix', + }, + // expected final log group name + `logGroupPrefix${DEFAULT_CONSTRUCT_ID}`, + ], + [ + { + logGroupName: 'logGroupName', + logGroupPrefix: 'logGroupPrefix', + }, + // expected final log group name + 'logGroupPrefixlogGroupName', + ], + ])('%s => %s', ({ logGroupName, logGroupPrefix }, expectedLogGroupName) => { + // WHEN + new DeploymentInstance(stack, DEFAULT_CONSTRUCT_ID, { + vpc, + logGroupName, + logGroupProps: logGroupPrefix ? { logGroupPrefix } : undefined, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('Custom::LogRetention', { + LogGroupName: expectedLogGroupName, + })); + }); + }); + + // GIVEN + test('uses specified instance type', () => { + // GIVEN + const instanceType = new InstanceType('c5.large'); + // We want an isolated stack to ensure expectCDK is only searching resources + // synthesized by the specific DeploymentInstance stack + stack = new cdk.Stack(app, 'InstanceTypeStack'); + + // WHEN + new DeploymentInstance(stack, DEFAULT_CONSTRUCT_ID, { + vpc, + instanceType, + }); + + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::LaunchConfiguration', { + InstanceType: instanceType.toString(), + })); + }); + + describe('.selfTermination = false', () => { + beforeAll(() => { + // GIVEN + stack = new cdk.Stack(app, 'SelfTerminationDisabledStack'); + // Spy on user data method calls + const machineImage = new AmazonLinuxWithUserDataSpy(); + + const deploymentInstanceProps: DeploymentInstanceProps = { + vpc, + selfTerminate: false, + machineImage, + }; + + // WHEN + target = new DeploymentInstance(stack, DEFAULT_CONSTRUCT_ID, deploymentInstanceProps); + }); + + test('does not add on-exit commands', () => { + // THEN + expect(target.userData.addOnExitCommands).not.toHaveBeenCalledWith(expect.arrayContaining([ + expect.stringMatching(/\baws\s+.*\bautoscaling\s+update-auto-scaling-group/), + ])); + }); + + test('is not granted IAM permissions to scale the Auto-Scaling Group', () => { + // GIVEN + const instanceRole = ( + target + .node.findChild('ASG') + .node.findChild('InstanceRole') + .node.defaultChild + ) as iam.CfnRole; + + // THEN + expectCDK(stack).notTo(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: 'autoscaling:UpdateAutoScalingGroup', + Condition: { + // This tag is added by RFDK to scope down the permissions of the policy for least-privilege + StringEquals: { 'autoscaling:ResourceTag/resourceLogicalId': cdk.Names.uniqueId(target) }, + }, + Effect: 'Allow', + Resource: '*', + }, + // The instance determines its Auto-Scaling Group by reading the tag created on the instance by the EC2 + // Auto-Scaling service + { + Action: 'ec2:DescribeTags', + Effect: 'Allow', + Resource: '*', + }, + ), + }, + Roles: [ + stack.resolve(instanceRole.ref), + ], + })); + }); + + test('does not tag for self-termination', () => { + // THEN + const matcher = resourceTagMatcher('AWS::AutoScaling::AutoScalingGroup', 'resourceLogicalId', cdk.Names.uniqueId(target)); + + // THEN + expectCDK(stack).notTo(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', matcher)); + }); + }); + + // GIVEN + describe('.executionTimeout is specified', () => { + const executionTimeout = cdk.Duration.minutes(30); + + beforeAll(() => { + // GIVEN + // Use a clean stack to not pollute other stacks with resources + stack = new cdk.Stack(app, 'ExecutionTimeout'); + const deploymentInstanceProps: DeploymentInstanceProps = { + vpc, + executionTimeout, + }; + + // WHEN + new DeploymentInstance(stack, DEFAULT_CONSTRUCT_ID, deploymentInstanceProps); + }); + + // THEN + test('AWS::AutoScaling::AutoScalingGroup creation policy signal timeout is set accordingly', () => { + expectCDK(stack).to(haveResourceLike( + 'AWS::AutoScaling::AutoScalingGroup', + { + CreationPolicy: { + ResourceSignal: { + Count: 1, + Timeout: executionTimeout.toIsoString(), + }, + }, + }, + ResourcePart.CompleteDefinition, + )); + }); + }); +}); diff --git a/packages/aws-rfdk/lib/core/test/tag-helpers.ts b/packages/aws-rfdk/lib/core/test/tag-helpers.ts index d82562dbb..2a7f70de3 100644 --- a/packages/aws-rfdk/lib/core/test/tag-helpers.ts +++ b/packages/aws-rfdk/lib/core/test/tag-helpers.ts @@ -31,25 +31,53 @@ const RFDK_VERSION = require('../../../package.json').version as string; // esli */ function getExpectedRfdkTagProperties(resourceType: string, constructName: string) { const expectedValue = `${RFDK_VERSION}:${constructName}`; + return resourceTagMatcher(resourceType, RFDK_TAG_NAME, expectedValue); +} + +/** + * Returns a CDK matcher for an expected tag key/value pair for a given Cfn resource type. + * This is known to support the following resource types: + * + * * `AWS::AutoScaling::AutoScalingGroup` + * * `AWS::EC2::SecurityGroup` + * * `AWS::IAM::Role` + * * `AWS::SSM::Parameter` + * + * All other resources are assumed to allow passing the following tag properties: + * + * ```js + * { + * Tags: [ + * { + * Key: 'key', + * Value: 'value', + * }, + * // ... + * ] + * } + * ``` + */ +/* eslint-disable-next-line jest/no-export */ +export function resourceTagMatcher(resourceType: string, tagName: string, tagValue: string) { if (resourceType === 'AWS::SSM::Parameter') { return { Tags: { - [RFDK_TAG_NAME]: expectedValue, + [tagName]: tagValue, }, }; } else if (resourceType === 'AWS::AutoScaling::AutoScalingGroup') { return { Tags: arrayWith({ - Key: RFDK_TAG_NAME, + Key: tagName, PropagateAtLaunch: true, - Value: expectedValue, + Value: tagValue, }), }; } else { return { Tags: arrayWith({ - Key: RFDK_TAG_NAME, - Value: expectedValue, + Key: tagName, + Value: tagValue, }), }; } diff --git a/packages/aws-rfdk/lib/deadline/lib/configure-spot-event-plugin.ts b/packages/aws-rfdk/lib/deadline/lib/configure-spot-event-plugin.ts index faef2c64d..bb0c8e885 100644 --- a/packages/aws-rfdk/lib/deadline/lib/configure-spot-event-plugin.ts +++ b/packages/aws-rfdk/lib/deadline/lib/configure-spot-event-plugin.ts @@ -26,6 +26,7 @@ import { } from '@aws-cdk/aws-lambda'; import { RetentionDays } from '@aws-cdk/aws-logs'; import { + Annotations, Construct, CustomResource, Duration, @@ -34,6 +35,7 @@ import { Lazy, Stack, } from '@aws-cdk/core'; + import { PluginSettings, SEPConfiguratorResourceProps, @@ -45,7 +47,14 @@ import { BlockDeviceMappingProperty, BlockDeviceProperty, } from '../../lambdas/nodejs/configure-spot-event-plugin'; -import { IRenderQueue, RenderQueue } from './render-queue'; +import { + IRenderQueue, + RenderQueue, +} from './render-queue'; +import { + SecretsManagementRegistrationStatus, + SecretsManagementRole, +} from './secrets-management-ref'; import { SpotEventPluginFleet } from './spot-event-plugin-fleet'; import { SpotFleetRequestType, @@ -499,6 +508,23 @@ export class ConfigureSpotEventPlugin extends Construct { // it is running before we try to send requests to it. resource.node.addDependency(props.renderQueue); + if (props.spotFleets && props.renderQueue.repository.secretsManagementSettings.enabled) { + props.spotFleets.forEach(spotFleet => { + if (spotFleet.defaultSubnets) { + Annotations.of(spotFleet).addWarning( + 'Deadline Secrets Management is enabled on the Repository and VPC subnets have not been supplied. Using dedicated subnets is recommended. See https://github.com/aws/aws-rfdk/blobs/release/packages/aws-rfdk/lib/deadline/README.md#using-dedicated-subnets-for-deadline-components', + ); + } + props.renderQueue.configureSecretsManagementAutoRegistration({ + dependent: resource, + role: SecretsManagementRole.CLIENT, + registrationStatus: SecretsManagementRegistrationStatus.REGISTERED, + vpc: props.vpc, + vpcSubnets: spotFleet.subnets, + }); + }); + } + this.node.defaultChild = resource; } diff --git a/packages/aws-rfdk/lib/deadline/lib/index.ts b/packages/aws-rfdk/lib/deadline/lib/index.ts index 4766ae4b3..7f877ead2 100644 --- a/packages/aws-rfdk/lib/deadline/lib/index.ts +++ b/packages/aws-rfdk/lib/deadline/lib/index.ts @@ -9,6 +9,7 @@ export * from './host-ref'; export * from './render-queue'; export * from './render-queue-ref'; export * from './repository'; +export * from './secrets-management-ref'; export * from './spot-event-plugin-fleet'; export * from './spot-event-plugin-fleet-ref'; export * from './stage'; diff --git a/packages/aws-rfdk/lib/deadline/lib/render-queue.ts b/packages/aws-rfdk/lib/deadline/lib/render-queue.ts index 5922d457e..e72942226 100644 --- a/packages/aws-rfdk/lib/deadline/lib/render-queue.ts +++ b/packages/aws-rfdk/lib/deadline/lib/render-queue.ts @@ -21,6 +21,7 @@ import { ISecurityGroup, IVpc, Port, + SubnetSelection, SubnetType, } from '@aws-cdk/aws-ec2'; import { @@ -71,9 +72,9 @@ import { RenderQueueHostNameProps, RenderQueueProps, RenderQueueSizeConstraints, + SubnetIdentityRegistrationSettingsProps, VersionQuery, } from '.'; - import { ConnectableApplicationEndpoint, ImportedAcmCertificate, @@ -82,12 +83,17 @@ import { X509CertificatePem, X509CertificatePkcs12, } from '../../core'; + +import { DeploymentInstance } from '../../core/lib/deployment-instance'; import { tagConstruct, } from '../../core/lib/runtime-info'; import { RenderQueueConnection, } from './rq-connection'; +import { + SecretsManagementIdentityRegistration, +} from './secrets-management'; import { Version } from './version'; import { WaitForStableService, @@ -97,6 +103,11 @@ import { * Interface for Deadline Render Queue. */ export interface IRenderQueue extends IConstruct, IConnectable { + /** + * The Deadline Repository that the Render Queue services. + */ + readonly repository: IRepository; + /** * The endpoint used to connect to the Render Queue */ @@ -112,6 +123,23 @@ export interface IRenderQueue extends IConstruct, IConnectable { * Configure an Instance/Autoscaling group to connect to a RenderQueue */ configureClientInstance(params: InstanceConnectOptions): void; + + /** + * Configure a rule to automatically register all Deadline Secrets Management identities connecting from a given + * subnet to a specified role and status. + * + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#identity-management-registration-settings-ref-label + * for details. + * + * All RFDK constructs that require Deadline Secrets Management identity registration call this method internally. + * End-users of RFDK should not need to use this method unless they have a special need and understand its inner + * workings. + * + * @param props Properties that specify the configuration to be applied to the Deadline Secrets Management identity + * registration settings. This specifies a VPC subnet and configures Deadline to automatically register identities of + * clients connecting from the subnet to a chosen Deadline Secrets Management role and status. + */ + configureSecretsManagementAutoRegistration(props: SubnetIdentityRegistrationSettingsProps): void; } /** @@ -154,6 +182,11 @@ abstract class RenderQueueBase extends Construct implements IRenderQueue { */ public abstract readonly connections: Connections; + /** + * @inheritdoc + */ + public abstract readonly repository: IRepository; + /** * Configures an ECS cluster to be able to connect to a RenderQueue * @returns An environment mapping that is used to configure the Docker Images @@ -164,6 +197,11 @@ abstract class RenderQueueBase extends Construct implements IRenderQueue { * Configure an Instance/Autoscaling group to connect to a RenderQueue */ public abstract configureClientInstance(params: InstanceConnectOptions): void; + + /** + * @inheritdoc + */ + public abstract configureSecretsManagementAutoRegistration(props: SubnetIdentityRegistrationSettingsProps): void; } /** @@ -210,6 +248,10 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { private static readonly DEFAULT_DOMAIN_NAME = 'aws-rfdk.com'; + private static readonly DEFAULT_VPC_SUBNETS_ALB: SubnetSelection = { subnetType: SubnetType.PRIVATE, onePerAz: true }; + + private static readonly DEFAULT_VPC_SUBNETS_OTHER: SubnetSelection = { subnetType: SubnetType.PRIVATE }; + /** * The minimum Deadline version required for the Remote Connection Server to support load-balancing */ @@ -274,6 +316,11 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { */ public readonly certChain?: ISecret; + /** + * @inheritdoc + */ + public readonly repository: IRepository; + /** * Whether SEP policies have been added */ @@ -325,9 +372,10 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { */ private ecsServiceStabilized: WaitForStableService; - constructor(scope: Construct, id: string, props: RenderQueueProps) { + constructor(scope: Construct, id: string, private readonly props: RenderQueueProps) { super(scope, id); + this.repository = props.repository; this.renderQueueSize = props?.renderQueueSize ?? {min: 1, max: 1}; if (props.version.isLessThan(RenderQueue.MINIMUM_LOAD_BALANCING_VERSION)) { @@ -380,7 +428,7 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { throw new Error(`renderQueueSize.desired capacity cannot be more than ${maxCapacity}: got ${this.renderQueueSize.desired}`); } this.asg = this.cluster.addCapacity('RCS Capacity', { - vpcSubnets: props.vpcSubnets ?? { subnetType: SubnetType.PRIVATE }, + vpcSubnets: props.vpcSubnets ?? RenderQueue.DEFAULT_VPC_SUBNETS_OTHER, instanceType: props.instanceType ?? new InstanceType('c5.large'), minCapacity, desiredCapacity: this.renderQueueSize?.desired, @@ -456,7 +504,7 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { const loadBalancer = new ApplicationLoadBalancer(this, 'LB', { vpc: this.cluster.vpc, - vpcSubnets: props.vpcSubnetsAlb ?? { subnetType: SubnetType.PRIVATE, onePerAz: true }, + vpcSubnets: props.vpcSubnetsAlb ?? RenderQueue.DEFAULT_VPC_SUBNETS_ALB, internetFacing: false, deletionProtection: props.deletionProtection ?? true, securityGroup: props.securityGroups?.frontend, @@ -605,6 +653,18 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { this.rqConnection.configureClientInstance(param); } + /** + * @inheritdoc + */ + public configureSecretsManagementAutoRegistration(props: SubnetIdentityRegistrationSettingsProps) { + if (!this.repository.secretsManagementSettings.enabled) { + // Secrets management is not enabled, so do nothing + return; + } + + this.identityRegistrationSettings.addSubnetIdentityRegistrationSetting(props); + } + /** * Adds AWS Managed Policies to the Render Queue so it is able to control Deadline's Spot Event Plugin. * @@ -894,4 +954,46 @@ export class RenderQueue extends RenderQueueBase implements IGrantable { } return `${hostname}.${zone.zoneName}`; } + + /** + * The instance that runs commands during the deployment. + */ + private get deploymentInstance(): DeploymentInstance { + const CONFIGURE_REPOSITORY_CONSTRUCT_ID = 'ConfigureRepository'; + const deploymentInstanceNode = this.node.tryFindChild(CONFIGURE_REPOSITORY_CONSTRUCT_ID); + if (deploymentInstanceNode === undefined) { + return new DeploymentInstance(this, CONFIGURE_REPOSITORY_CONSTRUCT_ID, { + vpc: this.props.vpc, + vpcSubnets: this.props.vpcSubnets ?? RenderQueue.DEFAULT_VPC_SUBNETS_OTHER, + }); + } else if (deploymentInstanceNode instanceof DeploymentInstance) { + return deploymentInstanceNode; + } else { + throw new Error(`Unexpected type for ${deploymentInstanceNode.node.path}. Expected ${DeploymentInstance.name}, but found ${typeof(deploymentInstanceNode)}.`); + } + } + + /** + * The construct that manages Deadline Secrets Management identity registration settings + */ + private get identityRegistrationSettings(): SecretsManagementIdentityRegistration { + const IDENTITY_REGISTRATION_CONSTRUCT_ID = 'SecretsManagementIdentityRegistration'; + const secretsManagementIdentityRegistration = this.node.tryFindChild(IDENTITY_REGISTRATION_CONSTRUCT_ID); + if (!secretsManagementIdentityRegistration) { + return new SecretsManagementIdentityRegistration( + this, IDENTITY_REGISTRATION_CONSTRUCT_ID, { + deploymentInstance: this.deploymentInstance, + repository: this.repository, + renderQueueSubnets: this.props.vpc.selectSubnets( + this.props.vpcSubnetsAlb ?? RenderQueue.DEFAULT_VPC_SUBNETS_ALB, + ), + version: this.props.version, + }, + ); + } else if (secretsManagementIdentityRegistration instanceof SecretsManagementIdentityRegistration) { + return secretsManagementIdentityRegistration; + } else { + throw new Error(`Unexpected type for ${secretsManagementIdentityRegistration.node.path}. Expected ${SecretsManagementIdentityRegistration.name}, but found ${typeof(secretsManagementIdentityRegistration)}.`); + } + } } diff --git a/packages/aws-rfdk/lib/deadline/lib/secrets-management-ref.ts b/packages/aws-rfdk/lib/deadline/lib/secrets-management-ref.ts new file mode 100644 index 000000000..5f93229fa --- /dev/null +++ b/packages/aws-rfdk/lib/deadline/lib/secrets-management-ref.ts @@ -0,0 +1,106 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + IVpc, + SubnetSelection, +} from '@aws-cdk/aws-ec2'; +import { Construct } from '@aws-cdk/core'; + +/** + * Deadline Secrets Management roles. + * + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#assigned-roles + */ +export enum SecretsManagementRole { + /** + * The administrator role is given to users that are created either by the Repository Installer when enabling the + * Deadline Secrets Management feature for the first time, or by running the CreateNewAdmin command. Note: there can + * be more than one Administrator user. All Administrators are equal and have full read and write access to all + * secrets. + */ + ADMINISTRATOR = 'Administrator', + + /** + * The Server role is intended to be granted to your machine(s) running the + * [Remote Connection Server](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/remote-connection-server.html#remote-connection-server-ref-label) + * application. The Server role is granted to a registered machine by an administrator in the + * [Monitor UI](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#identity-management-assigning-ref-label). + * In order to encrypt and decrypt secrets, the master key must be assigned to the Server by an Administrator user + * running the [GrantKeyAccessToServer command](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#deadline-secrets-management-command-grantkeyaccesstoserver). + * Servers can encrypt and decrypt all secrets, and are responsible for providing secrets to approved clients. + */ + SERVER = 'Server', + + /** + * The Client role is typically intended to be granted to any of your machines running the + * [Worker](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/worker.html#worker-ref-label) + * application. The Client role is granted to a registered machine by an administrator in the + * [Monitor UI](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#identity-management-assigning-ref-label). + * Clients can request storage of secrets not in the + * [Administrator Secret Access Level](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#deadline-secrets-management-secret-namespace-ref-label), + * and can retrieve secrets from all namespaces when authenticating through the server. + */ + CLIENT = 'Client', +}; + +/** + * The different possible Deadline Secrets Management registration statuses that a Deadline Client's identity can be set + * to. + * + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#registration-status + */ +export enum SecretsManagementRegistrationStatus { + /** + * This is the default status for an Identity that has just registered itself. It cannot access any secrets with this status. + */ + PENDING = 'Pending', + + /** + * This status allows Identities to make use of the Secrets API, so long as they have the appropriate + * [Identity Role](https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#identity-management-roles-ref-label). + */ + REGISTERED = 'Registered', + + /** + * Identities with this status will not be allowed to make use of the Secrets API. + */ + REVOKED = 'Revoked', +} + +/** + * Properties that specify how to deploy and configure an identity registration setting for a specified VPC subnet + */ +export interface SubnetIdentityRegistrationSettingsProps { + /** + * A construct node to make dependent on the registration setting being updated + */ + readonly dependent: Construct; + + /** + * The Deadline Secrets Management registration status to be applied to the Deadline Client identities that connect + * from the specified VPC subnets. + * + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#registration-status + */ + readonly registrationStatus: SecretsManagementRegistrationStatus; + + /** + * The role to be assigned to the Deadline Client identities that connect from the specified VPC subnets. + * + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#assigned-roles + */ + readonly role: SecretsManagementRole; + + /** + * The VPC of the Deadline Client host instances to be registered + */ + readonly vpc: IVpc; + + /** + * The VPC subnets of the Deadline Client host instances to be registered + */ + readonly vpcSubnets: SubnetSelection; +} diff --git a/packages/aws-rfdk/lib/deadline/lib/secrets-management.ts b/packages/aws-rfdk/lib/deadline/lib/secrets-management.ts new file mode 100644 index 000000000..6771d59f0 --- /dev/null +++ b/packages/aws-rfdk/lib/deadline/lib/secrets-management.ts @@ -0,0 +1,297 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'path'; + +import { SelectedSubnets } from '@aws-cdk/aws-ec2'; +import { PolicyStatement } from '@aws-cdk/aws-iam'; +import { ISecret } from '@aws-cdk/aws-secretsmanager'; +import { + Construct, + Lazy, + Stack, + Fn, + Annotations, +} from '@aws-cdk/core'; + +import { + IRepository, + IVersion, + SecretsManagementRole, + SecretsManagementRegistrationStatus, + SubnetIdentityRegistrationSettingsProps, +} from '.'; +import { + ScriptAsset, +} from '../../core'; +import { DeploymentInstance } from '../../core/lib/deployment-instance'; + +/** + * A data structure that contains the desired Deadline Secrets Management role and registration status to be applied to + * Deadline Clients. + * + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html + */ +interface RegistrationSettingEffect { + /** + * The Deadline Secrets Management registration status to be applied to the Deadline Client identities that connect + * from the specified VPC subnets. + * + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#registration-status + */ + readonly registrationStatus: SecretsManagementRegistrationStatus; + + /** + * The role to be assigned to the Deadline Client identities that connect from the specified VPC subnets. + * + * See https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/secrets-management/deadline-secrets-management.html#assigned-roles + */ + readonly role: SecretsManagementRole; +} + +/** + * Properties for configuring a Deadline Repository to auto-register Deadline Client identities that connect + */ +export interface SecretsManagementIdentityRegistrationProps { + /** + * The deployment instance to use for registration + */ + readonly deploymentInstance: DeploymentInstance; + + /** + * The Render Queue that will be applying the identity registration settings + */ + readonly renderQueueSubnets: SelectedSubnets; + + /** + * The Deadline Repository to configure auto-registration on + */ + readonly repository: IRepository; + + /** + * The version of the Deadline Client to use for performing the identity registration settings commands + */ + readonly version: IVersion; +} + +/** + * Construct that configures desired Deadline Secrets Management identity registration settings. + * + * Resources Deployed + * ------------------------ + * - IAM policy statements are added to the IAM policy that is attached to the IAM role of the DeploymentInstance. + * These statements grant the DeploymentInstance the ability to fetch the Deadline Client installer, get the value of + * the AWS Secrets Manager secert containing the Deadline Secrets Management administrator credentials, get the value + * of the AWS Secrets Manager secert containing the Deadline Repository's database credentials, + * - Security group ingress rule to allow the DeploymentInstance to connect to the Repository's database + * - Security group ingress rule to allow the DeploymentInstance to connect to the Repository's file-system + * Security Considerations + * ------------------------ + * - The instances deployed by this construct download and run scripts from your CDK bootstrap bucket when that instance + * is launched. You must limit write access to your CDK bootstrap bucket to prevent an attacker from modifying the actions + * performed by these scripts. We strongly recommend that you either enable Amazon S3 server access logging on your CDK + * bootstrap bucket, or enable AWS CloudTrail on your account to assist in post-incident analysis of compromised production + * environments. + * - The instance deployed by this construct has read/write access to the Deadline Repository (database and + * file-system), the AWS Secrets Manager secrets containing credentials for the Database and the Deadline Secrets + * Management administrator. Access to the instance permits command and control of the render farm and should be + * restricted. + */ +export class SecretsManagementIdentityRegistration extends Construct { + private readonly adminCredentials: ISecret; + + private readonly deploymentInstance: DeploymentInstance; + + private readonly renderQueueSubnets: SelectedSubnets; + + private readonly subnetRegistrations: Map; + + constructor(scope: Construct, id: string, props: SecretsManagementIdentityRegistrationProps) { + super(scope, id); + + this.subnetRegistrations = new Map(); + + if (!props.repository.secretsManagementSettings.enabled) { + throw new Error('Secrets management is not enabled on repository'); + } + /* istanbul ignore next */ + if (!props.repository.secretsManagementSettings.credentials) { + throw new Error('Repository does not contain secrets management credentials'); + } + this.adminCredentials = props.repository.secretsManagementSettings.credentials; + this.deploymentInstance = props.deploymentInstance; + this.renderQueueSubnets = props.renderQueueSubnets; + + // Download and install the Deadline Client + this.installDeadlineClient(props); + + // Configure the Deadline Client to direct-connect to the repository + props.repository.configureClientInstance({ + host: props.deploymentInstance, + mountPoint: '/mnt/repository', + }); + + // Install python dependencies + this.preparePythonEnvironment(props); + const localScriptFile = this.preparePythonScript(props); + this.runPythonScript(props, localScriptFile); + + props.deploymentInstance.addExecutionDependency(props.repository); + } + + public addSubnetIdentityRegistrationSetting(addSubnetProps: SubnetIdentityRegistrationSettingsProps) { + if (addSubnetProps.role === SecretsManagementRole.ADMINISTRATOR) { + throw new Error('The Administrator role cannot be set using a Deadline identity registration setting'); + } + const { vpc, vpcSubnets } = addSubnetProps; + const selectedSubnets = vpc.selectSubnets(vpcSubnets); + selectedSubnets.subnets.forEach(subnet => { + if (this.renderQueueSubnets.subnets.some(rqSubnet => subnet == rqSubnet)) { + Annotations.of(addSubnetProps.dependent).addWarning( + `Deadline Secrets Management is enabled on the Repository and VPC subnets of the Render Queue match the subnets of ${addSubnetProps.dependent.node.path}. Using dedicated subnets is recommended. See https://github.com/aws/aws-rfdk/blobs/release/packages/aws-rfdk/lib/deadline/README.md#using-dedicated-subnets-for-deadline-components`, + ); + } + const observedSubnet = this.subnetRegistrations.get(subnet.subnetId); + if (observedSubnet) { + if (observedSubnet.registrationStatus !== addSubnetProps.registrationStatus) { + throw new Error(`Subnet is already registered with registrationStatus "${observedSubnet.registrationStatus}" but another caller requested "${addSubnetProps.registrationStatus}"`); + } else if (observedSubnet.role !== addSubnetProps.role) { + throw new Error(`Subnet is already registered with role "${observedSubnet.role}" but another caller requested "${addSubnetProps.role}"`); + } + } else { + this.subnetRegistrations.set(subnet.subnetId, { + registrationStatus: addSubnetProps.registrationStatus, + role: addSubnetProps.role, + }); + } + }); + addSubnetProps.dependent.node.addDependency(this.deploymentInstance); + } + + private runPythonScript(props: SecretsManagementIdentityRegistrationProps, localScriptFile: string) { + // The command-line arguments to be passed to the script that configures the Deadline identity registration + // settings + const scriptArgs = Lazy.list({ + produce: () => { + return ([] as string[]).concat( + [ + // Region + '--region', + Stack.of(this).region, + // Admin credentials + '--credentials', + `"${this.adminCredentials.secretArn}"`, + ], + // Subnets of the load balancer + ( + props.renderQueueSubnets + .subnetIds + .map(subnetID => `--connection-subnet "${subnetID}"`) + ), + // Subnets of RFDK Deadline Client constructs + ( + Array.from(this.subnetRegistrations.entries()) + // Each setting becomes a comma (,) separated string of fields + // ,, + .map(subnetRegistrationEntry => { + const [subnetID, registrationSettingEffect] = subnetRegistrationEntry; + return [ + subnetID, + registrationSettingEffect.role.toString(), + (registrationSettingEffect.registrationStatus).toString(), + ].join(','); + }) + // convert into argument key/value pair + .map(joinedSubnetArgValue => `--source-subnet "${joinedSubnetArgValue}"`) + ), + ); + }, + }); + + // We can't use ScriptAsset.executeOn(...) because we need to run as "ec2-user". + // This is because Repository.configureClientInstance(...) used above will store the credentials + // in a per-user credential store that is only available to "ec2-user". + props.deploymentInstance.userData.addCommands( + `sudo --login -u ec2-user ${localScriptFile} ` + Fn.join( + ' ', + scriptArgs, + ), + ); + } + + private preparePythonScript(props: SecretsManagementIdentityRegistrationProps) { + const script = new ScriptAsset(this, 'ConfigureIdentityRegistrationSettingScript', { + path: path.join( + __dirname, + '..', + 'scripts', + 'python', + 'configure_identity_registration_settings.py', + ), + }); + + // Grant access to ec2:DescribeSubnets. Subnet IPv4 CIDR ranges are not exposed through + // CloudFormation attributes. Instead, we must query them using the EC2 API on the deployment instance + props.deploymentInstance.grantPrincipal.addToPrincipalPolicy(new PolicyStatement({ + actions: ['ec2:DescribeSubnets'], + // ec2:DescribeSubnets does not support resource level permissions. See + // https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonec2.html + resources: ['*'], + })); + + this.adminCredentials.grantRead(props.deploymentInstance); + + script.grantRead(props.deploymentInstance); + const localScriptFile = props.deploymentInstance.userData.addS3DownloadCommand({ + bucket: script.bucket, + bucketKey: script.s3ObjectKey, + localFile: '/home/ec2-user/configure_identity_registration_settings.py', + }); + props.deploymentInstance.userData.addCommands( + `chmod +x ${localScriptFile}`, + `chown ec2-user:ec2-user ${localScriptFile}`, + ); + return localScriptFile; + } + + private installDeadlineClient(props: SecretsManagementIdentityRegistrationProps) { + props.version.linuxInstallers.client.s3Bucket.grantRead( + props.deploymentInstance, + props.version.linuxInstallers.client.objectKey, + ); + const clientInstallerPath = props.deploymentInstance.userData.addS3DownloadCommand({ + bucket: props.version.linuxInstallers.client.s3Bucket, + bucketKey: props.version.linuxInstallers.client.objectKey, + }); + props.deploymentInstance.userData.addCommands('set -x'); + props.deploymentInstance.userData.addCommands(`chmod +x "${clientInstallerPath}"`); + props.deploymentInstance.userData.addCommands( + [ + // This is required b/c USER and HOME environment variables are not defined when running + // user-data + 'sudo', '--login', + + // Run the Deadline Client installer + `"${clientInstallerPath}"`, + '--mode', 'unattended', + '--connectiontype', 'Remote', + '--proxyrootdir', '127.0.0.1:8080', + '--noguimode', 'true', + '--slavestartup', 'false', + '--launcherdaemon', 'false', + '--restartstalled', 'true', + '--autoupdateoverride', 'False', + ].join(' '), + ); + } + + private preparePythonEnvironment(props: SecretsManagementIdentityRegistrationProps) { + // The script must run as ec2-user because Repository.configureClientInstance(...) used above will store the + // credentials in a per-user credential store that is only available to "ec2-user". + props.deploymentInstance.userData.addCommands( + 'sudo -u ec2-user python3 -m pip install --user boto3', + ); + } +} diff --git a/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet.ts b/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet.ts index 2eccaea20..e28bc4a4c 100644 --- a/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet.ts +++ b/packages/aws-rfdk/lib/deadline/lib/spot-event-plugin-fleet.ts @@ -303,6 +303,12 @@ export class SpotEventPluginFleet extends Construct implements ISpotEventPluginF */ public readonly connections: Connections; + /** + * Indicates whether the subnets are the defaults. If `props.vpcSubnets` was passed in, this + * will be false. + */ + public readonly defaultSubnets: boolean; + /** * The principal to grant permissions to. Granting permissions to this principal will grant * those permissions to the spot instance role. @@ -415,6 +421,8 @@ export class SpotEventPluginFleet extends Construct implements ISpotEventPluginF constructor(scope: Construct, id: string, props: SpotEventPluginFleetProps) { super(scope, id); + this.defaultSubnets = !props.vpcSubnets; + this.deadlineGroups = props.deadlineGroups.map(group => group.toLocaleLowerCase()); this.deadlinePools = props.deadlinePools?.map(pool => pool.toLocaleLowerCase()); this.validateProps(props); diff --git a/packages/aws-rfdk/lib/deadline/lib/stage.ts b/packages/aws-rfdk/lib/deadline/lib/stage.ts index 3cd327c67..3fdbe77db 100644 --- a/packages/aws-rfdk/lib/deadline/lib/stage.ts +++ b/packages/aws-rfdk/lib/deadline/lib/stage.ts @@ -58,7 +58,7 @@ export interface DeadlineDockerRecipes { /** * A mapping of name to recipe */ - readonly [name: string]: Recipe | undefined; + readonly [name: string]: Recipe; } /** @@ -154,6 +154,7 @@ export class Stage { } const version = rawManifest.version; + /* istanbul ignore else */ if (version === undefined) { throw new Error('Manifest contains no "version" key'); } else if (typeof version !== 'string') { @@ -228,4 +229,39 @@ export class Stage { const versionComponents = fullVersion.split('.'); return `${versionComponents[0]}.${versionComponents[1]}.${versionComponents[2]}`; } + + public get clientInstallerPath(): string { + const INSTALLER_FILENAME_RE = /^DeadlineClient-(?.+)-linux-x64-installer\.run$/; + + const listing = fs.readdirSync( + path.join( + this.dirPath, + 'bin', + ), + ).filter(filename => INSTALLER_FILENAME_RE.test(filename)); + + /* istanbul ignore else */ + if (listing.length === 1) { + const filename = listing[0]; + const match = INSTALLER_FILENAME_RE.exec(filename); + const version = match!.groups!.version; + const recipes = Array.from(Object.values(this.manifest.recipes)); + const aRecipeHasMatchingDlVersion = recipes.some((recipe) => { + return recipe.buildArgs?.DL_VERSION === version; + }); + const installerPath = path.join( + this.dirPath, + 'bin', + listing[0], + ); + if (!aRecipeHasMatchingDlVersion) { + throw new Error(`No stage recipes refer to the Deadline Client installer found (${installerPath})`); + } + return installerPath; + } else if (listing.length === 0) { + throw new Error(`No matching Client installer found in "${this.dirPath}"`); + } else { + throw new Error(`Multiple Client installers found: ${listing.join(',')}`); + } + } } diff --git a/packages/aws-rfdk/lib/deadline/lib/thinkbox-docker-recipes.ts b/packages/aws-rfdk/lib/deadline/lib/thinkbox-docker-recipes.ts index 74df7dc5e..a19cfa4f4 100644 --- a/packages/aws-rfdk/lib/deadline/lib/thinkbox-docker-recipes.ts +++ b/packages/aws-rfdk/lib/deadline/lib/thinkbox-docker-recipes.ts @@ -5,13 +5,16 @@ import { DockerImageAsset } from '@aws-cdk/aws-ecr-assets'; import { ContainerImage } from '@aws-cdk/aws-ecs'; +import { Asset } from '@aws-cdk/aws-s3-assets'; import { Construct } from '@aws-cdk/core'; import { + Installer, IVersion, RenderQueueImages, Stage, UsageBasedLicensingImages, + Version, } from '.'; /** @@ -139,8 +142,42 @@ export class ThinkboxDockerRecipes extends Construct { public get version(): IVersion { if (!this.versionInstance) { - this.versionInstance = this.stage.getVersion(this, 'Version'); + const version = Version.parse(this.stage.manifest.version); + + const self = this; + + this.versionInstance = { + isLessThan: (other) => version.isLessThan(other), + linuxFullVersionString: () => this.stage.manifest.version, + linuxInstallers: { + get client(): Installer { + let assetNode = self.node.tryFindChild('ClientInstallerAsset'); + let asset: Asset; + /* istanbul ignore else */ + if (!assetNode) { + asset = new Asset(self, 'ClientInstallerAsset', { + path: self.stage.clientInstallerPath, + }); + } else if (assetNode instanceof Asset) { + asset = assetNode as Asset; + } else { + throw new Error(`Node "${assetNode?.node.path}" is not an S3 Asset`); + } + return { + objectKey: asset.s3ObjectKey, + s3Bucket: asset.bucket, + }; + }, + repository: this.stage.getVersion(this, 'VersionQuery').linuxInstallers.repository, + patchVersion: version.patchVersion, + }, + majorVersion: version.majorVersion, + minorVersion: version.minorVersion, + releaseVersion: version.releaseVersion, + versionString: version.versionString, + }; } + return this.versionInstance; } } diff --git a/packages/aws-rfdk/lib/deadline/lib/usage-based-licensing.ts b/packages/aws-rfdk/lib/deadline/lib/usage-based-licensing.ts index 80405ac88..0dbd9f38b 100644 --- a/packages/aws-rfdk/lib/deadline/lib/usage-based-licensing.ts +++ b/packages/aws-rfdk/lib/deadline/lib/usage-based-licensing.ts @@ -19,6 +19,7 @@ import { SubnetType, } from '@aws-cdk/aws-ec2'; import { + CfnService, Cluster, Compatibility, ContainerImage, @@ -35,9 +36,14 @@ import { } from '@aws-cdk/aws-iam'; import { ISecret } from '@aws-cdk/aws-secretsmanager'; import { + Annotations, Construct, } from '@aws-cdk/core'; +import { + SecretsManagementRegistrationStatus, + SecretsManagementRole, +} from '.'; import { LogGroupFactory, LogGroupFactoryProps, @@ -511,8 +517,16 @@ export class UsageBasedLicensing extends Construct implements IGrantable { this.cluster = new Cluster(this, 'Cluster', { vpc: props.vpc }); + if (!props.vpcSubnets && props.renderQueue.repository.secretsManagementSettings.enabled) { + Annotations.of(this).addWarning( + 'Deadline Secrets Management is enabled on the Repository and VPC subnets have not been supplied. Using dedicated subnets is recommended. See https://github.com/aws/aws-rfdk/blobs/release/packages/aws-rfdk/lib/deadline/README.md#using-dedicated-subnets-for-deadline-components', + ); + } + + const vpcSubnets = props.vpcSubnets ?? { subnetType: SubnetType.PRIVATE }; + this.asg = this.cluster.addCapacity('ASG', { - vpcSubnets: props.vpcSubnets ?? { subnetType: SubnetType.PRIVATE }, + vpcSubnets, instanceType: props.instanceType ? props.instanceType : InstanceType.of(InstanceClass.C5, InstanceSize.LARGE), minCapacity: props.desiredCount ?? 1, maxCapacity: props.desiredCount ?? 1, @@ -593,6 +607,16 @@ export class UsageBasedLicensing extends Construct implements IGrantable { this.node.defaultChild = this.service; + if (props.renderQueue.repository.secretsManagementSettings.enabled) { + props.renderQueue.configureSecretsManagementAutoRegistration({ + dependent: this.service.node.defaultChild as CfnService, + registrationStatus: SecretsManagementRegistrationStatus.REGISTERED, + role: SecretsManagementRole.CLIENT, + vpc: props.vpc, + vpcSubnets, + }); + } + // Tag deployed resources with RFDK meta-data tagConstruct(this); } diff --git a/packages/aws-rfdk/lib/deadline/lib/version-query.ts b/packages/aws-rfdk/lib/deadline/lib/version-query.ts index 5ff619255..d7e282d71 100644 --- a/packages/aws-rfdk/lib/deadline/lib/version-query.ts +++ b/packages/aws-rfdk/lib/deadline/lib/version-query.ts @@ -200,11 +200,17 @@ export class VersionQuery extends VersionQueryBase { customResourceAttribute: 'ReleaseVersion', }); + const installerBucket = Bucket.fromBucketName(scope, 'InstallerBucket', this.deadlineResource.getAttString('S3Bucket')); + this.linuxInstallers = { patchVersion: Token.asNumber(this.deadlineResource.getAtt('LinuxPatchVersion')), repository: { objectKey: this.deadlineResource.getAttString('LinuxRepositoryInstaller'), - s3Bucket: Bucket.fromBucketName(scope, 'InstallerBucket', this.deadlineResource.getAttString('S3Bucket')), + s3Bucket: installerBucket, + }, + client: { + objectKey: this.deadlineResource.getAttString('LinuxClientInstaller'), + s3Bucket: installerBucket, }, }; } diff --git a/packages/aws-rfdk/lib/deadline/lib/version-ref.ts b/packages/aws-rfdk/lib/deadline/lib/version-ref.ts index c3ddc2a39..064944f80 100644 --- a/packages/aws-rfdk/lib/deadline/lib/version-ref.ts +++ b/packages/aws-rfdk/lib/deadline/lib/version-ref.ts @@ -40,6 +40,15 @@ export interface PlatformInstallers { * - DeadlineRepository-10.1.8.5-windows-installer.exe */ readonly repository: Installer; + + /** + * The Deadline Client installer for this platform, as extracted from the bundle on the Thinkbox download site. + * For example: + * + * - DeadlineClient-10.1.8.5-linux-x64-installer.run + * - DeadlineClient-10.1.8.5-windows-installer.run + */ + readonly client: Installer; } /** diff --git a/packages/aws-rfdk/lib/deadline/lib/worker-fleet.ts b/packages/aws-rfdk/lib/deadline/lib/worker-fleet.ts index 04b9ba352..99f009471 100644 --- a/packages/aws-rfdk/lib/deadline/lib/worker-fleet.ts +++ b/packages/aws-rfdk/lib/deadline/lib/worker-fleet.ts @@ -56,6 +56,10 @@ import { import { IRenderQueue, } from './render-queue'; +import { + SecretsManagementRegistrationStatus, + SecretsManagementRole, +} from './secrets-management-ref'; import { Version } from './version'; import { IInstanceUserDataProvider, @@ -456,15 +460,17 @@ export class WorkerInstanceFleet extends WorkerInstanceFleetBase { Annotations.of(this).addWarning('Deploying with 0 minimum capacity. If there is an error in the EC2 UserData for this fleet, then your stack deployment will not fail. Watch for errors in your CloudWatch logs.'); } + const vpcSubnets = props.vpcSubnets ? props.vpcSubnets : { + subnetType: SubnetType.PRIVATE, + }; + // Launching the fleet with deadline workers. this.fleet = new AutoScalingGroup(this, 'Default', { vpc: props.vpc, instanceType: (props.instanceType ? props.instanceType : InstanceType.of(InstanceClass.T2, InstanceSize.LARGE)), machineImage: props.workerMachineImage, keyName: props.keyName, - vpcSubnets: props.vpcSubnets ? props.vpcSubnets : { - subnetType: SubnetType.PRIVATE, - }, + vpcSubnets, securityGroup: props.securityGroup, minCapacity, maxCapacity: props.maxCapacity, @@ -524,6 +530,21 @@ export class WorkerInstanceFleet extends WorkerInstanceFleetBase { workerConfig.listenerPort + WorkerInstanceFleet.MAX_WORKERS_PER_HOST, ); + if (props.renderQueue.repository.secretsManagementSettings.enabled) { + if (!props.vpcSubnets) { + Annotations.of(this).addWarning( + 'Deadline Secrets Management is enabled on the Repository and VPC subnets have not been supplied. Using dedicated subnets is recommended. See https://github.com/aws/aws-rfdk/blobs/release/packages/aws-rfdk/lib/deadline/README.md#using-dedicated-subnets-for-deadline-components', + ); + } + props.renderQueue.configureSecretsManagementAutoRegistration({ + vpc: props.vpc, + vpcSubnets, + role: SecretsManagementRole.CLIENT, + registrationStatus: SecretsManagementRegistrationStatus.REGISTERED, + dependent: this.fleet, + }); + } + // Updating the user data with successful cfn-signal commands. if (signals) { this.fleet.userData.addSignalOnExitCommand(this.fleet); diff --git a/packages/aws-rfdk/lib/deadline/scripts/python/configure_identity_registration_settings.py b/packages/aws-rfdk/lib/deadline/scripts/python/configure_identity_registration_settings.py new file mode 100644 index 000000000..ee9b2d530 --- /dev/null +++ b/packages/aws-rfdk/lib/deadline/scripts/python/configure_identity_registration_settings.py @@ -0,0 +1,612 @@ +#!/usr/bin/env python3 +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Configures Deadline Secrets Management identity registration settings +""" + +import argparse +import base64 +import io +import ipaddress +import json +import os +import re +import shlex +import subprocess +import sys + +from typing import Dict, Iterable, List, NamedTuple, Match + +import boto3 + +# Regex's for validating and splitting arguments +SECRET_ARN_RE = re.compile(r''' + ^ + arn + : + (aws[a-zA-Z-]*)? + : + secretsmanager + : + (?P + [a-z]{2} + ( + (-gov)|(-iso(b?)) + )? + - + [a-z]+-\d{1} + ) + : + \d{12} + : + secret + : + [a-zA-Z0-9-_/+=.@]+ + $ +''', re.VERBOSE) +SOURCE_SUBNET_RE = re.compile(r""" + ^ + (?P[^,]+?) + , + (?PServer|Client) + , + (?PPending|Registered|Revoked) + $ +""", re.VERBOSE) + +# Regex for converting CamelCase to snake_case +RE_CAMEL_HUMP_SUB = re.compile(r'[A-Z]?[a-z]+|[A-Z]{2,}(?=[A-Z][a-z]|\d|\W|$)|\d+|[A-Z]{2,}|[A-Z]$') + +# Constants for the naming convention of RFDK-managed identity registration settings +RFDK_IDENTITY_REGISTRATION_SETTING_NAME_PREFIX = f'RfdkSubnet' +RFDK_IDENTITY_REGISTRATION_SETTING_NAME_SEP = '|' +RFDK_IDENTITY_REGISTRATION_SETTING_NAME_RE = re.compile(rf""" + ^ + {re.escape(RFDK_IDENTITY_REGISTRATION_SETTING_NAME_PREFIX)} + {re.escape(RFDK_IDENTITY_REGISTRATION_SETTING_NAME_SEP)} + (?P[^{RFDK_IDENTITY_REGISTRATION_SETTING_NAME_SEP}]+?) + {re.escape(RFDK_IDENTITY_REGISTRATION_SETTING_NAME_SEP)} + (?P[^{RFDK_IDENTITY_REGISTRATION_SETTING_NAME_SEP}]+?) + $ +""", re.VERBOSE) + +# Bitmask for all bits in a single byte +BYTE_MASK = 0xFF + +# Constants for determining the Deadline path from the environment script installed by the Deadline Client installer +# on Linux +DL_ENV_SCRIPT_PATH_RE = re.compile(r'DEADLINEBIN="(?P.*)"$', re.VERBOSE | re.MULTILINE) +DL_ENV_SCRIPT_PATH_LINUX = '/etc/profile.d/deadlineclient.sh' +DL_PATH_FILE_MACOS = '/Users/Shared/Thinkbox/DEADLINE_PATH' + + +##################################### +# DATA STRUCTURES # +##################################### + + +class AwsSecret(NamedTuple): + arn: str + region: str + + +def _camel_to_snake_case(camel_str: str): + words = re.findall(RE_CAMEL_HUMP_SUB, camel_str) + return '_'.join(map(str.lower, words)) + + +class LoadBalancerIdentityRegistrationSetting(NamedTuple): + connection_ip_filter_type: str + connection_ip_filter_value: str + source_ip_filter_type: str + source_ip_filter_value: str + settings_id: str + settings_name: str + is_enabled: bool + default_status: str + default_role: str + + @classmethod + def from_json(cls, json_data: Dict) -> 'LoadBalancerIdentityRegistrationSetting': + kwargs = { + _camel_to_snake_case(key): value + for key, value in json_data.items() + } + + return LoadBalancerIdentityRegistrationSetting(**kwargs) + + +class SourceSubnet(NamedTuple): + subnet_id: str + role: str + registration_status: str + + +############################################## +# PROGRAM ARGUMENT HANDLING # +############################################## + + +def parse_args(args): + """ + Parses all command line arguments and convert them into named tuples + + :param args: A list of command line arguments + :return: A configuration object containing the parsed arguments + """ + + def _secret(value): + """ + A type function for converting args that represent secrets into a named Tuple + + :param value: The string representing the argument + :return: AwsSecret based on the value + :exception argparse.ArgumentTypeError: if the argument cannot be converted properly. + """ + + match = SECRET_ARN_RE.match(value) + if match: + named_groups = match.groupdict() + return AwsSecret(arn=value,region=named_groups["Region"]) + + raise argparse.ArgumentTypeError('Given argument "%s" is not a valid secret' % value) + + def _source_subnet(value): + """ + A type function for converting args that represent source subnets + """ + match = SOURCE_SUBNET_RE.match(value) + if match: + named_groups = match.groupdict() + subnet_id = named_groups['SubnetID'] + role = named_groups['Role'] + registration_status = named_groups['RegistrationStatus'] + return SourceSubnet( + role = role, + registration_status = registration_status, + subnet_id = subnet_id + ) + + raise argparse.ArgumentTypeError('Given argument "%s" is not a valid source subnet' % value) + + + parser = argparse.ArgumentParser(description="Configures Deadline Secrets Management identity registration settings") + parser.add_argument( + '--credentials', + type = _secret, + required = True, + help = 'Specifies Deadline Secrets Management admin credentials. This must be an AWS Secrets Manager ' \ + 'secret arn', + ) + parser.add_argument( + '--region', + required = True, + help = 'The region where the Repository, Render Queue, and Clients reside', + ) + parser.add_argument( + '--connection-subnet', + action = 'append', + help = 'Specifies one or more subnet IDs that the Render Queue\'s load balancer will connect from', + ) + parser.add_argument( + '--source-subnet', + type = _source_subnet, + action = 'append', + help = 'Specifies one or more source subnets that Deadline Clients will connect from and their role and ' \ + 'registration status to be applied. This should be a comma-separated string where the first two ' \ + 'elements are the role and status respectively and additional elements are subnet IDs' + ) + + return parser.parse_args(args) + + +def validate_config(config): + source_subnets = config.source_subnet # type: List[SourceSubnet] + + # Validate that source subnets are unique + observed_subnets = set() + for source_subnet in source_subnets: + subnet_id = source_subnet.subnet_id + if subnet_id not in observed_subnets: + observed_subnets.add(subnet_id) + else: + raise ValueError(f"Subnet \"{subnet_id}\" is not unique") + + if not getattr(config, 'connection_subnet', None): + raise ValueError('no --connection-subnet specified') + + +#################################### +# AWS INTERACTION # +#################################### + + +def fetch_secret(secret, binary=False): + """ + Fetch a secret from AWS + + :return: returns the contents of the secret + """ + if isinstance(secret, AwsSecret): + secrets_client = boto3.client('secretsmanager', region_name=secret.region) + secret_value = secrets_client.get_secret_value(SecretId=secret.arn) + if binary: + return base64.b64decode(secret_value.get('SecretBinary')) + else: + return secret_value.get('SecretString') + else: + raise TypeError('Unknown Secret type.') + + +def get_subnet_cidrs(region: str, subnet_ids: Iterable[str]) -> Dict[str, str]: + ec2 = boto3.resource('ec2', region_name=region) + + return { + subnet.subnet_id: subnet.cidr_block + for subnet in ec2.subnets.filter(SubnetIds=list(subnet_ids)) + } + + +############################################################ +# DEADLINE SECRETS MANAGEMENT INTERACTION # +############################################################ + + +class DeadlineSecretsCommandClient(object): + _PW_ENV_VAR_NAME = 'DL_SM_PW' + + def __init__(self, username, password): + self._username = username + self._password = password + + self._deadline_command_path = self._get_deadline_command_path() + + def _transform_args(self, args: List[str]) -> List[str]: + return ( + # Use JSON output if specified + # Secrets top-level command + ['secrets'] + # Use the sub-command from the arguments + + list(args[:1]) + # Inject the credentials + + [ + self._username, + # Password is sourced from the env var + '--password', 'env:%s' % DeadlineSecretsCommandClient._PW_ENV_VAR_NAME + ] + # Append the rest of the supplied arguments + + list(args[1:]) + ) + + def _call_deadline_command(self, args: List[str]) -> str: + try: + os.environ[DeadlineSecretsCommandClient._PW_ENV_VAR_NAME] = self._password + return self._call_deadline_command_raw(args) + finally: + del os.environ[DeadlineSecretsCommandClient._PW_ENV_VAR_NAME] + + def run_str(self, *args: Iterable[str]): + transformed_args = self._transform_args(args) + + return self._call_deadline_command(transformed_args) + + def run_json(self, *args: Iterable[str]): + transformed_args = self._transform_args(args) + + # Prepend the arguments with the json command-line flag + transformed_args = ['--json'] + transformed_args + + return json.loads(self._call_deadline_command(transformed_args)) + + def dry_run(self, *args: Iterable[str]): + transformed_args = self._transform_args(args) + transformed_args = ['deadlinecommand'] + transformed_args + print(' '.join(shlex.quote(arg) for arg in transformed_args)) + + @staticmethod + def _get_deadline_command_path(): + """ + Find the Deadline executable on the current machine + + :return: the string path to the Deadline executable. + """ + + deadline_bin = os.environ.get('DEADLINE_PATH', '') + + # On Linux, the Deadline Client installer creates a system-wide script to set the DEADLINE_PATH environment + # variable. Cloud-init does not load system environment variables. Cherry-pick the + # environment variable installed by the Deadline Client installer. + if not deadline_bin and os.path.exists(DL_ENV_SCRIPT_PATH_LINUX): + print(f'using environement script at "{DL_ENV_SCRIPT_PATH_LINUX}"...') + with io.open(DL_ENV_SCRIPT_PATH_LINUX, 'r', encoding='utf8') as env_script: + env_script_contents = env_script.read() + dl_path_match = DL_ENV_SCRIPT_PATH_RE.search(env_script_contents) + if dl_path_match: + deadline_bin = dl_path_match.group('DeadlineDir') + + # On OSX, we look for the DEADLINE_PATH file if the environment variable does not exist. + if deadline_bin == "" and os.path.exists(DL_PATH_FILE_MACOS): + print(f'using MacOS Deadline path file at "{DL_PATH_FILE_MACOS}"...') + with io.open(DL_PATH_FILE_MACOS, 'r', encoding='utf8') as f: + deadline_bin = f.read().strip() + + if not deadline_bin: + raise ValueError('Could not determine deadline path') + + deadline_command = os.path.join(deadline_bin, "deadlinecommand") + + return deadline_command + + def _call_deadline_command_raw(self, arguments): + """ + Executes a deadline command and return the output + + :param arguments: the list of arguments to be passed to Deadline. + """ + # make a copy so we don't mutate the caller's reference + arguments = list(arguments) + arguments.insert(0, self._deadline_command_path) + try: + proc = subprocess.Popen( + arguments, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + except: + raise Exception('Failed to call Deadline.') + + output, errors = proc.communicate() + if proc.returncode != 0: + raise ValueError('DeadlineCommandError: \n%s\n%s' % (output, errors)) + return output.decode('utf8') + + +def subnet_to_setting_name(connection_subnet_id:str, source_subnet_id: str) -> str: + return RFDK_IDENTITY_REGISTRATION_SETTING_NAME_SEP.join(( + RFDK_IDENTITY_REGISTRATION_SETTING_NAME_PREFIX, + connection_subnet_id, + source_subnet_id, + )) + + +def is_rfdk_setting(setting: LoadBalancerIdentityRegistrationSetting) -> bool: + return bool(RFDK_IDENTITY_REGISTRATION_SETTING_NAME_RE.search(setting.settings_name)) + + +def get_rfdk_registration_settings(dl_secrets: DeadlineSecretsCommandClient) -> List[LoadBalancerIdentityRegistrationSetting]: + all_registration_settings = [ + LoadBalancerIdentityRegistrationSetting.from_json(registration_setting) + for registration_setting in dl_secrets.run_json('GetLoadBalancerIdentityRegistrationSettings') + ] + + print('all registration settings = ', end='') + print(json.dumps([setting.settings_name for setting in all_registration_settings], indent=4)) + + rfdk_registration_settings = [ + registration_setting + for registration_setting in all_registration_settings + if is_rfdk_setting(registration_setting) + ] + + print('RFDK-managed settings = ', end='') + print(json.dumps([setting.settings_name for setting in rfdk_registration_settings], indent=4)) + + return rfdk_registration_settings + + +def delete_setting(dl_secrets: DeadlineSecretsCommandClient, setting: LoadBalancerIdentityRegistrationSetting) -> None: + print(dl_secrets.run_str( + 'DeleteLoadBalancerIdentityRegistrationSetting', + setting.settings_id + )) + + +def cidr_to_ipv4_match(cidr: str) -> str: + network = ipaddress.ip_network(cidr) + if not isinstance(network, ipaddress.IPv4Network): + raise TypeError(f'"{cidr}" is not an IPv4 network') + + # Get the network address, net mask, and host mask as byte arrays + nw_address = network.network_address.packed + netmask = network.netmask.packed + hostmask = network.hostmask.packed + + ipv4_match_octets: List[str] = [] + + + for byte_netmask, byte_hostmask, byte_nw_address in zip(netmask, hostmask, nw_address): + if byte_netmask == BYTE_MASK: + # If all bits in the byte are part of the netmask, just set + # the byte as defined in the network address + ipv4_match_octets.append(str(byte_nw_address)) + elif byte_netmask == 0: + # If none of the bits of the byte are part of the netmask, the + # byte is free and we can match any IP + ipv4_match_octets.append('*') + else: + # In this case, the byte is partially fixed and we need to find the + # range + byte_min = byte_netmask & byte_nw_address + byte_max = byte_min + byte_hostmask + ipv4_match_octets.append(f'{byte_min}-{byte_max}') + + return '.'.join(ipv4_match_octets) + + +def prepare_desired_setting( + connection_subnet_id: str, + source_subnet: SourceSubnet, + subnet_to_cidr: Dict[str, str] +) -> LoadBalancerIdentityRegistrationSetting: + connection_subnet_cidr = subnet_to_cidr[connection_subnet_id] + source_subnet_cidr = subnet_to_cidr[source_subnet.subnet_id] + connection_subnet_ipv4_match = cidr_to_ipv4_match(connection_subnet_cidr) + source_subnet_ipv4_match = cidr_to_ipv4_match(source_subnet_cidr) + + return LoadBalancerIdentityRegistrationSetting( + # This is left blank since the downstream code does not use it + settings_id='', + settings_name=subnet_to_setting_name(connection_subnet_id, source_subnet.subnet_id), + connection_ip_filter_type='IPv4Match', + connection_ip_filter_value=connection_subnet_ipv4_match, + source_ip_filter_type='IPv4Match', + source_ip_filter_value=source_subnet_ipv4_match, + default_role=source_subnet.role, + default_status=source_subnet.registration_status, + is_enabled=True, + ) + + +def delete_removed_settings( + dl_secrets: DeadlineSecretsCommandClient, + prior_rfdk_settings: List[LoadBalancerIdentityRegistrationSetting], + connection_subnet_ids: List[str], + source_subnets: List[SourceSubnet] +) -> None: + desired_source_subnet_ids = set(source_subnet.subnet_id for source_subnet in source_subnets) + desired_connection_subnet_ids = set(connection_subnet_ids) + + for prior_rfdk_setting in prior_rfdk_settings: + match = RFDK_IDENTITY_REGISTRATION_SETTING_NAME_RE.search(prior_rfdk_setting.settings_name) + if not match: + raise ValueError('Recevied non-RFDK load balancer identity registration setting %s' % prior_rfdk_setting._asdict()) + source_subnet_id = match.group('SourceSubnetID') + connection_subnet_id = match.group('ConnectionSubnetID') + if source_subnet_id not in desired_source_subnet_ids or connection_subnet_id not in desired_connection_subnet_ids: + print(f'Rule "{prior_rfdk_setting.settings_name}" removed from RFDK. Deleting setting from Deadline...') + delete_setting(dl_secrets, prior_rfdk_setting) + + +def create_and_update_settings( + dl_secrets: DeadlineSecretsCommandClient, + prior_rfdk_settings: List[LoadBalancerIdentityRegistrationSetting], + connection_subnet_ids: List[str], + source_subnets: List[SourceSubnet], + subnet_to_cidr: Dict[str, str], +) -> None: + prior_settings_by_name = { + prior_rfdk_setting.settings_name: prior_rfdk_setting + for prior_rfdk_setting in prior_rfdk_settings + } + + for connection_subnet_id in connection_subnet_ids: + for source_subnet in source_subnets: + setting_name = subnet_to_setting_name(connection_subnet_id, source_subnet.subnet_id) + prior_rfdk_setting = prior_settings_by_name.get(setting_name, None) + desired_rfdk_setting = prepare_desired_setting( + connection_subnet_id, + source_subnet, + subnet_to_cidr + ) + if prior_rfdk_setting: + # Create a new desired setting with the "settings_id" field set to match the existing setting + desired_rfdk_setting_fields = desired_rfdk_setting._asdict() + desired_rfdk_setting_fields.update(settings_id=prior_rfdk_setting.settings_id) + desired_rfdk_setting = LoadBalancerIdentityRegistrationSetting(**desired_rfdk_setting_fields) + + if setting_differs(prior_rfdk_setting, desired_rfdk_setting): + update_setting(dl_secrets, desired_rfdk_setting) + else: + print(f'setting "{setting_name}" exists and is up-to-date, skipping') + else: + create_setting(dl_secrets, desired_rfdk_setting) + + +def setting_differs( + setting_a: LoadBalancerIdentityRegistrationSetting, + setting_b: LoadBalancerIdentityRegistrationSetting, +) -> bool: + return setting_a != setting_b + + +def update_setting( + dl_secrets: DeadlineSecretsCommandClient, + setting: LoadBalancerIdentityRegistrationSetting, +) -> None: + print(json.dumps( + { + "action": "update", + "setting": setting._asdict(), + }, + indent=2, + )) + print(dl_secrets.run_str( + "UpdateLoadBalancerIdentityRegistrationSetting", + setting.settings_id, + setting.settings_name, + setting.connection_ip_filter_type, + setting.connection_ip_filter_value, + setting.source_ip_filter_type, + setting.source_ip_filter_value, + setting.default_role, + setting.default_status, + str(setting.is_enabled), + )) + + +def create_setting( + dl_secrets: DeadlineSecretsCommandClient, + setting: LoadBalancerIdentityRegistrationSetting, +) -> None: + print(json.dumps( + { + "action": "create", + "setting": setting._asdict(), + }, + indent=2, + )) + print(dl_secrets.run_str( + "CreateLoadBalancerIdentityRegistrationSetting", + setting.settings_name, + setting.connection_ip_filter_type, + setting.connection_ip_filter_value, + setting.source_ip_filter_type, + setting.source_ip_filter_value, + setting.default_role, + setting.default_status, + str(setting.is_enabled), + )) + + +def apply_registration_settings(config): + connection_subnets = config.connection_subnet # type: List[str] + credentials = json.loads(fetch_secret(config.credentials)) + source_subnets = config.source_subnet # type: List[SourceSubnet] + + # Get the CIDR ranges of all subnets involved in the desired registration setting rules + all_subnets = set(connection_subnets).union( + source_subnet.subnet_id for source_subnet in source_subnets + ) + subnet_to_cidr = get_subnet_cidrs(config.region, all_subnets) + print('subnet_to_cidr = ' + json.dumps(subnet_to_cidr, indent=4)) + + dl_secrets = DeadlineSecretsCommandClient(credentials['username'], credentials['password']) + + prior_rfdk_settings = get_rfdk_registration_settings(dl_secrets) + + delete_removed_settings(dl_secrets, prior_rfdk_settings, connection_subnets, source_subnets) + create_and_update_settings(dl_secrets, prior_rfdk_settings, connection_subnets, source_subnets, subnet_to_cidr) + + +################################ +# ENTRY POINT # +################################ + + +def __main__(*args): + """Main entrypoint function + + This function is named to be compatible with "deadlinecommand ExecuteScriptNoGui ..." which expects the python + module to contain this function. + """ + config = parse_args(args) + validate_config(config) + apply_registration_settings(config) + + +# If the function is called directly from a python interpreter, call the entrypoint with the arguments +if __name__ == '__main__': + __main__(*sys.argv[1:]) diff --git a/packages/aws-rfdk/lib/deadline/test/assets/bin/DeadlineClient-10.1.9.2-linux-x64-installer.run b/packages/aws-rfdk/lib/deadline/test/assets/bin/DeadlineClient-10.1.9.2-linux-x64-installer.run new file mode 100644 index 000000000..e69de29bb diff --git a/packages/aws-rfdk/lib/deadline/test/configure-spot-event-plugin.test.ts b/packages/aws-rfdk/lib/deadline/test/configure-spot-event-plugin.test.ts index bd7a6b536..4ae3f4ef1 100644 --- a/packages/aws-rfdk/lib/deadline/test/configure-spot-event-plugin.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/configure-spot-event-plugin.test.ts @@ -20,6 +20,7 @@ import { InstanceClass, InstanceSize, InstanceType, + SubnetType, Vpc, } from '@aws-cdk/aws-ec2'; import { @@ -968,4 +969,92 @@ describe('ConfigureSpotEventPlugin', () => { // THEN expect(createConfigureSpotEventPlugin).not.toThrow(); }); + + describe('secrets management enabled', () => { + beforeEach(() => { + region = 'us-east-1'; + app = new App(); + stack = new Stack(app, 'stack', { + env: { + region, + }, + }); + vpc = new Vpc(stack, 'Vpc'); + + version = new VersionQuery(stack, 'Version'); + + renderQueue = new RenderQueue(stack, 'RQ', { + vpc, + images: { remoteConnectionServer: ContainerImage.fromAsset(__dirname) }, + repository: new Repository(stack, 'Repository', { + vpc, + version, + }), + version, + }); + + groupName = 'group_name1'; + }); + + test('a fleet without vpcSubnets specified => warns about dedicated subnets', () => { + // GIVEN + fleet = new SpotEventPluginFleet(stack, 'SpotFleet', { + vpc, + renderQueue: renderQueue, + deadlineGroups: [ + groupName, + ], + instanceTypes: [ + InstanceType.of(InstanceClass.T2, InstanceSize.SMALL), + ], + workerMachineImage, + maxCapacity: 1, + }); + + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + renderQueue, + vpc, + spotFleets: [fleet], + }); + + // THEN + expect(fleet.node.metadataEntry).toContainEqual(expect.objectContaining({ + type: 'aws:cdk:warning', + data: 'Deadline Secrets Management is enabled on the Repository and VPC subnets have not been supplied. Using dedicated subnets is recommended. See https://github.com/aws/aws-rfdk/blobs/release/packages/aws-rfdk/lib/deadline/README.md#using-dedicated-subnets-for-deadline-components', + })); + }); + + test('a fleet with vpcSubnets specified => does not warn about dedicated subnets', () => { + // GIVEN + fleet = new SpotEventPluginFleet(stack, 'SpotFleetWithSubnets', { + vpc, + vpcSubnets: { + subnetType: SubnetType.PRIVATE, + }, + renderQueue: renderQueue, + deadlineGroups: [ + groupName, + ], + instanceTypes: [ + InstanceType.of(InstanceClass.T2, InstanceSize.SMALL), + ], + workerMachineImage, + maxCapacity: 1, + }); + + // WHEN + new ConfigureSpotEventPlugin(stack, 'ConfigureSpotEventPlugin', { + renderQueue, + vpc, + spotFleets: [fleet], + }); + + // THEN + expect(fleet.node.metadataEntry).not.toContainEqual(expect.objectContaining({ + type: 'aws:cdk:warning', + data: expect.stringMatching(/dedicated subnet/i), + })); + }); + }); }); 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 893637e2c..59e92a84e 100644 --- a/packages/aws-rfdk/lib/deadline/test/render-queue.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/render-queue.test.ts @@ -17,11 +17,15 @@ import { ResourcePart, SynthUtils, } from '@aws-cdk/assert'; +import { + CfnLaunchConfiguration, +} from '@aws-cdk/aws-autoscaling'; import { Certificate, } from '@aws-cdk/aws-certificatemanager'; import { AmazonLinuxGeneration, + AmazonLinuxImage, Instance, InstanceClass, InstanceSize, @@ -30,6 +34,8 @@ import { Port, SecurityGroup, Subnet, + SubnetSelection, + SubnetType, Vpc, WindowsVersion, } from '@aws-cdk/aws-ec2'; @@ -76,8 +82,11 @@ import { RenderQueueProps, RenderQueueSecurityGroups, Repository, + SecretsManagementRegistrationStatus, + SecretsManagementRole, VersionQuery, } from '../lib'; +import { SecretsManagementIdentityRegistration } from '../lib/secrets-management'; import { RQ_CONNECTION_ASSET, } from './asset-constants'; @@ -2893,5 +2902,75 @@ describe('RenderQueue', () => { }), })); }); + + describe('client calls .configureSecretsManagementAutoRegistration()', () => { + let callParams: any; + let clientInstance: Instance; + let identityRegistrationSettings: SecretsManagementIdentityRegistration; + let launchConfiguration: CfnLaunchConfiguration; + let rqVpcSubnets: SubnetSelection; + const RQ_SUBNET_IDS = ['SubnetID1', 'SubnetID2']; + + beforeEach(() => { + // GIVEN + const subnets = [ + Subnet.fromSubnetAttributes(dependencyStack, 'Subnet1', { + subnetId: RQ_SUBNET_IDS[0], + availabilityZone: 'us-west-2a', + }), + Subnet.fromSubnetAttributes(dependencyStack, 'Subnet2', { + subnetId: RQ_SUBNET_IDS[1], + availabilityZone: 'us-west-2b', + }), + ]; + rqVpcSubnets = { + subnets, + }; + const rq = new RenderQueue(stack, 'SecretsManagementRenderQueue', { + ...rqSecretsManagementProps, + vpcSubnets: rqVpcSubnets, + }); + + clientInstance = new Instance(stack, 'ClientInstance', { + instanceType: new InstanceType('t3.micro'), + machineImage: new AmazonLinuxImage(), + vpc, + }); + callParams = { + dependent: clientInstance, + registrationStatus: SecretsManagementRegistrationStatus.REGISTERED, + role: SecretsManagementRole.CLIENT, + vpc, + vpcSubnets: { subnetType: SubnetType.PRIVATE }, + }; + launchConfiguration = ( + // @ts-ignore + rq.deploymentInstance + .node.findChild('ASG') + .node.findChild('LaunchConfig') + ) as CfnLaunchConfiguration; + // @ts-ignore + identityRegistrationSettings = rq.identityRegistrationSettings; + jest.spyOn(identityRegistrationSettings, 'addSubnetIdentityRegistrationSetting'); + + // WHEN + rq.configureSecretsManagementAutoRegistration(callParams); + }); + + test('registration is delegated to SecretsManagementIdentityRegistration', () => { + // THEN + expect(identityRegistrationSettings.addSubnetIdentityRegistrationSetting).toHaveBeenCalledWith(callParams); + }); + + test('deployment instance is created using specified subnets', () => { + // THEN + expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + LaunchConfigurationName: stack.resolve(launchConfiguration.ref), + VPCZoneIdentifier: arrayWith( + ...RQ_SUBNET_IDS, + ), + })); + }); + }); }); }); diff --git a/packages/aws-rfdk/lib/deadline/test/repository.test.ts b/packages/aws-rfdk/lib/deadline/test/repository.test.ts index d6bea5864..9e94e8e40 100644 --- a/packages/aws-rfdk/lib/deadline/test/repository.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/repository.test.ts @@ -34,6 +34,7 @@ import { CfnFileSystem, FileSystem as EfsFileSystem, } from '@aws-cdk/aws-efs'; +import { CfnRole } from '@aws-cdk/aws-iam'; import { Bucket } from '@aws-cdk/aws-s3'; import { Asset } from '@aws-cdk/aws-s3-assets'; import { Secret } from '@aws-cdk/aws-secretsmanager'; @@ -61,6 +62,7 @@ import { Repository, VersionQuery, Version, + PlatformInstallers, } from '../lib'; import { REPO_DC_ASSET, @@ -99,13 +101,17 @@ beforeEach(() => { }); class MockVersion extends Version implements IVersion { - readonly linuxInstallers = { + readonly linuxInstallers: PlatformInstallers = { patchVersion: 0, repository: { objectKey: 'testInstaller', s3Bucket: new Bucket(stack, 'LinuxInstallerBucket'), }, - } + client: { + objectKey: 'testClientInstaller', + s3Bucket: new Bucket(stack, 'LinuxClientInstallerBucket'), + }, + }; public linuxFullVersionString() { return this.toString(); @@ -931,6 +937,15 @@ test('repository configure client instance', () => { instanceType: new InstanceType('t3.small'), machineImage: MachineImage.latestAmazonLinux({ generation: AmazonLinuxGeneration.AMAZON_LINUX_2 }), }); + const instanceRole = ( + instance + .node.findChild('InstanceRole') + .node.defaultChild + ) as CfnRole; + const db = ( + repo + .node.findChild('DocumentDatabase') + ) as DatabaseCluster; // WHEN repo.configureClientInstance({ @@ -949,6 +964,23 @@ test('repository configure client instance', () => { // Make sure we call the configureRepositoryDirectConnect script with appropriate argument. const regex = new RegExp(escapeTokenRegex('\'/tmp/${Token[TOKEN.\\d+]}${Token[TOKEN.\\d+]}\' \\"/mnt/repository/DeadlineRepository\\"')); expect(userData).toMatch(regex); + + // Assert the IAM instance profile is given read access to the database credentials secret + expectCDK(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith({ + Action: [ + 'secretsmanager:GetSecretValue', + 'secretsmanager:DescribeSecret', + ], + Effect: 'Allow', + Resource: stack.resolve(db.secret!.secretArn), + }), + }, + Roles: [ + stack.resolve(instanceRole.ref), + ], + })); }); test('configureClientInstance uses singleton for repo config script', () => { diff --git a/packages/aws-rfdk/lib/deadline/test/secrets-management.test.ts b/packages/aws-rfdk/lib/deadline/test/secrets-management.test.ts new file mode 100644 index 000000000..c6f0c3eb2 --- /dev/null +++ b/packages/aws-rfdk/lib/deadline/test/secrets-management.test.ts @@ -0,0 +1,667 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; + +import { + arrayWith, + expect as expectCDK, + haveResourceLike, + SynthUtils, +} from '@aws-cdk/assert'; +import { + ExecuteFileOptions, + IVpc, + S3DownloadOptions, + SelectedSubnets, + SubnetSelection, + SubnetType, + UserData, + Vpc, +} from '@aws-cdk/aws-ec2'; +import { CfnRole } from '@aws-cdk/aws-iam'; +import { Asset } from '@aws-cdk/aws-s3-assets'; +import { + App, + Construct, + Fn, + Resource, + Stack, +} from '@aws-cdk/core'; + +import { DeploymentInstance, DeploymentInstanceProps } from '../../core/lib/deployment-instance'; + +import { + InstanceDirectConnectProps, + IRepository, + IVersion, + Repository, + RepositoryProps, + SecretsManagementProps, + SecretsManagementRegistrationStatus, + SecretsManagementRole, + VersionQuery, +} from '../lib'; +import { SecretsManagementIdentityRegistration } from '../lib/secrets-management'; + + +class MockUserData extends UserData { + readonly addCommands: jest.Mock; + readonly addOnExitCommands: jest.Mock; + readonly render: jest.Mock; + readonly addExecuteFileCommand: jest.Mock; + readonly addS3DownloadCommand: jest.Mock; + readonly addSignalOnExitCommand: jest.Mock; + + constructor() { + super(); + this.addCommands = jest.fn(); + this.addCommands = jest.fn(); + this.addOnExitCommands = jest.fn(); + this.render = jest.fn(); + this.addExecuteFileCommand = jest.fn(); + this.addS3DownloadCommand = jest.fn(); + this.addSignalOnExitCommand = jest.fn(); + } +} + +class MockDeploymentInstance extends DeploymentInstance { + private readonly mockUserData: MockUserData; + + constructor(scope: Construct, id: string, props: DeploymentInstanceProps) { + super(scope, id, props); + this.mockUserData = new MockUserData(); + } + + public get userData(): MockUserData { + return this.mockUserData; + } +} + +function writeSynthedTemplate(stack: Stack, outputFile: string) { + const template = SynthUtils.synthesize(stack).template; + fs.writeFileSync(outputFile, JSON.stringify(template, null, 2), { encoding: 'utf8' }); +} + +const DEADLINE_CLIENT_SUBNET_NAME = 'DeadlineClient'; +const RENDER_QUEUE_ALB_SUBNET_NAME = 'RenderQueueALB'; + +describe('SecretsManagementIdentityRegistration', () => { + let app: App; + let dependencyStack: Stack; + let deploymentInstanceStack: Stack; + let stack: Stack; + let vpc: IVpc; + let version: IVersion; + let repository: IRepository; + let deploymentInstance: MockDeploymentInstance; + let deploymentInstanceRole: CfnRole; + let renderQueueSubnets: SelectedSubnets; + let target: SecretsManagementIdentityRegistration; + + // @ts-ignore + function writeSynthedTemplates() { + writeSynthedTemplate(deploymentInstanceStack, 'deployment-instance-stack.json'); + writeSynthedTemplate(stack, 'secrets-management-stack.json'); + } + + beforeEach(() => { + app = new App(); + dependencyStack = new Stack(app, 'DependencyStack'); + deploymentInstanceStack = new Stack(app, 'DeploymentInstanceStack'); + stack = new Stack(app, 'Stack'); + vpc = new Vpc(dependencyStack, 'Vpc', { + subnetConfiguration: [ + { + name: RENDER_QUEUE_ALB_SUBNET_NAME, + subnetType: SubnetType.PRIVATE, + cidrMask: 28, + }, + { + name: 'Public', + subnetType: SubnetType.PUBLIC, + cidrMask: 28, + }, + { + name: DEADLINE_CLIENT_SUBNET_NAME, + subnetType: SubnetType.PUBLIC, + cidrMask: 28, + }, + ], + }); + version = new VersionQuery(dependencyStack, 'Version'); + deploymentInstance = new MockDeploymentInstance(deploymentInstanceStack, 'DeploymentInstance', { + vpc, + }); + renderQueueSubnets = vpc.selectSubnets({ subnetGroupName: RENDER_QUEUE_ALB_SUBNET_NAME }); + }); + + describe('when Repository uses secrets management', () => { + beforeEach(() => { + // GIVEN + repository = new Repository(dependencyStack, 'Repository', { + version, + vpc, + secretsManagementSettings: { + enabled: true, + }, + }); + jest.spyOn(repository, 'configureClientInstance'); + // Get a reference to the DeploymentInstance's IAM role L1 resource + deploymentInstanceRole = ( + deploymentInstance + .node.findChild('ASG') + .node.findChild('InstanceRole') + .node.defaultChild + ) as CfnRole; + }); + + function createTarget() { + target = new SecretsManagementIdentityRegistration(stack, 'IdentityRegistration', { + deploymentInstance, + renderQueueSubnets, + repository, + version, + }); + } + + describe('Deadline Client installer', () => { + test('grant S3 read to client installer', () => { + // WHEN + createTarget(); + + // THEN + expectCDK(deploymentInstanceStack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: [ + 's3:GetObject*', + 's3:GetBucket*', + 's3:List*', + ], + Effect: 'Allow', + Resource: arrayWith(...deploymentInstanceStack.resolve([ + version.linuxInstallers.client.s3Bucket.bucketArn, + version.linuxInstallers.client.s3Bucket.arnForObjects(version.linuxInstallers.client.objectKey), + ])), + }, + ), + }, + Roles: [ + deploymentInstanceStack.resolve(deploymentInstanceRole.ref), + ], + })); + }); + + test('downloads and executes Client installer', () => { + // GIVEN + const clientInstallerLocalFilename = 'clientInstallerLocalFilename'; + const userData = deploymentInstance.userData; + userData.addS3DownloadCommand.mockReturnValueOnce(clientInstallerLocalFilename); + + // WHEN + createTarget(); + + // THEN + expect(userData.addS3DownloadCommand).toHaveBeenCalledWith<[S3DownloadOptions]>({ + bucket: version.linuxInstallers.client.s3Bucket, + bucketKey: version.linuxInstallers.client.objectKey, + }); + expect(userData.addCommands).toHaveBeenCalledWith(`chmod +x "${clientInstallerLocalFilename}"`); + expect(userData.addCommands).toHaveBeenCalledWith([ + // This is required b/c USER and HOME environment variables are not defined when running + // user-data + 'sudo', '--login', + + // Run the Deadline Client installer + `"${clientInstallerLocalFilename}"`, + '--mode', 'unattended', + '--connectiontype', 'Remote', + '--proxyrootdir', '127.0.0.1:8080', + '--noguimode', 'true', + '--slavestartup', 'false', + '--launcherdaemon', 'false', + '--restartstalled', 'true', + '--autoupdateoverride', 'False', + ].join(' ')); + }); + }); + + test('grants DeploymentInstance role permissions to describe subnets', () => { + // WHEN + createTarget(); + + // THEN + expectCDK(deploymentInstanceStack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: 'ec2:DescribeSubnets', + Effect: 'Allow', + Resource: '*', + }, + ), + }, + Roles: [stack.resolve(deploymentInstanceRole.ref)], + })); + }); + + test('sets up Python environment', () => { + // WHEN + createTarget(); + + // THEN + // The script requires boto3 to query the subnets CIDR. This script runs + // as the ec2-user so we install this into the user's package directory + expect(deploymentInstance.userData.addCommands) + .toHaveBeenCalledWith('sudo -u ec2-user python3 -m pip install --user boto3'); + }); + + test('configures direct repository connection', () => { + // WHEN + createTarget(); + + // THEN + expect(repository.configureClientInstance).toHaveBeenCalledWith<[InstanceDirectConnectProps]>({ + host: deploymentInstance, + mountPoint: expect.any(String), + }); + }); + + test('grants DeploymentInstance read access to the Deadline Secrets Management admin credentials secret', () => { + // WHEN + createTarget(); + + // THEN + expectCDK(deploymentInstanceStack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: [ + 'secretsmanager:GetSecretValue', + 'secretsmanager:DescribeSecret', + ], + Effect: 'Allow', + Resource: deploymentInstanceStack.resolve(repository.secretsManagementSettings.credentials!.secretArn), + }, + ), + }, + Roles: [ + deploymentInstanceStack.resolve(deploymentInstanceRole.ref), + ], + })); + }); + + describe('Identity registration settings script', () => { + function getIdentityRegistrationSettingsScript() { + return target.node.findChild('ConfigureIdentityRegistrationSettingScript') as Asset; + } + + test('DeploymentInstance granted S3 read access', () => { + // WHEN + createTarget(); + const identityRegistrationSettingsScript = getIdentityRegistrationSettingsScript(); + + // THEN + expectCDK(deploymentInstanceStack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith( + { + Action: [ + 's3:GetObject*', + 's3:GetBucket*', + 's3:List*', + ], + Effect: 'Allow', + Resource: deploymentInstanceStack.resolve([ + identityRegistrationSettingsScript.bucket.bucketArn, + identityRegistrationSettingsScript.bucket.arnForObjects('*'), + ]), + }, + ), + }, + Roles: [deploymentInstanceStack.resolve(deploymentInstanceRole.ref)], + })); + }); + + test('DeploymentInstance downloads script', () => { + // GIVEN + const identityRegistrationSettingsScriptLocalPath = 'identityRegistrationSettingsScriptLocalPath'; + deploymentInstance.userData.addS3DownloadCommand.mockReturnValueOnce('deadlineClientLocalPath'); + deploymentInstance.userData.addS3DownloadCommand.mockReturnValueOnce(identityRegistrationSettingsScriptLocalPath); + + // WHEN + createTarget(); + const identityRegistrationSettingsScript = getIdentityRegistrationSettingsScript(); + + // THEN + expect(deploymentInstance.userData.addS3DownloadCommand).toHaveBeenCalledWith<[S3DownloadOptions]>({ + bucket: identityRegistrationSettingsScript.bucket, + bucketKey: identityRegistrationSettingsScript.s3ObjectKey, + localFile: expect.stringMatching(/^\/home\/ec2-user\//), + }); + }); + + test('DeploymentInstance sets ownership and executable permissions for ec2-user', () => { + // GIVEN + const identityRegistrationSettingsScriptLocalPath = 'identityRegistrationSettingsScriptLocalPath'; + ( + deploymentInstance.userData.addS3DownloadCommand + .mockReturnValueOnce('deadlineClientInstallerLocalPath') + .mockReturnValueOnce('efsMountScriptLocalPath') + .mockReturnValueOnce('directRepoConnectionConfigScriptLocalPath') + .mockReturnValueOnce(identityRegistrationSettingsScriptLocalPath) + ); + + // WHEN + createTarget(); + + // THEN + expect(deploymentInstance.userData.addCommands).toHaveBeenCalledWith( + `chmod +x ${identityRegistrationSettingsScriptLocalPath}`, + `chown ec2-user:ec2-user ${identityRegistrationSettingsScriptLocalPath}`, + ); + }); + }); + + describe('.addSubnetIdentityRegistrationSetting(...)', () => { + describe.each<[SecretsManagementRole]>([ + [SecretsManagementRole.SERVER], + [SecretsManagementRole.CLIENT], + ])('identityRole=%s', (identityRole) => { + describe.each<[SecretsManagementRegistrationStatus]>([ + [SecretsManagementRegistrationStatus.PENDING], + [SecretsManagementRegistrationStatus.REGISTERED], + [SecretsManagementRegistrationStatus.REVOKED], + ])('registrationStatus=%s', (registrationStatus) => { + test('executes identity registration settings configuration script with proper arguments', () => { + // GIVEN + const identityRegistrationSettingsScriptLocalPath = 'identityRegistrationSettingsScriptLocalPath'; + ( + deploymentInstance.userData.addS3DownloadCommand + .mockReturnValueOnce('deadlineClientInstallerLocalPath') + .mockReturnValueOnce('efsMountScriptLocalPath') + .mockReturnValueOnce('directRepoConnectionConfigScriptLocalPath') + .mockReturnValueOnce(identityRegistrationSettingsScriptLocalPath) + ); + const clientStack = new Stack(app, 'ClientStack'); + createTarget(); + + // WHEN + target.addSubnetIdentityRegistrationSetting({ + dependent: new Construct(clientStack, 'DeadlineClient'), + registrationStatus, + role: identityRole, + vpc, + vpcSubnets: { subnetGroupName: DEADLINE_CLIENT_SUBNET_NAME }, + }); + + // THEN + const resolvedCalls = deploymentInstance.userData.addCommands.mock.calls.map(call => { + return deploymentInstanceStack.resolve(call); + }); + const expectedCall = [{ + 'Fn::Join': [ + '', + [ + // Command is run as "ec2-user" which has the database credentials stored + `sudo --login -u ec2-user ${identityRegistrationSettingsScriptLocalPath} `, + stack.resolve(Fn.join( + ' ', + [ + '--region', + stack.region, + // The Deadline Secrets Management admin credentials secret ARN is passed + '--credentials', + `"${repository.secretsManagementSettings.credentials!.secretArn}"`, + // The Render Queue's ALB subnets are passed as --connection-subnet args + ...(vpc.selectSubnets({ subnetGroupName: RENDER_QUEUE_ALB_SUBNET_NAME }) + .subnetIds.map(subnetID => `--connection-subnet "${subnetID}"`) + ), + // The Deadline Client's subnets, desired role, and registration status are passed as --source-subnet args + ...(vpc.selectSubnets({ subnetGroupName: DEADLINE_CLIENT_SUBNET_NAME }) + .subnetIds.map(subnetID => { + return `--source-subnet "${subnetID},${identityRole},${registrationStatus}"`; + }) + ), + ], + )), + ], + ], + }]; + expect(resolvedCalls).toContainEqual(expectedCall); + }); + }); + }); + + test('throws execption when using Administrator role', () => { + // GIVEN + createTarget(); + + // WHEN + function when() { + target.addSubnetIdentityRegistrationSetting({ + dependent: new Construct(stack, 'Dependent'), + registrationStatus: SecretsManagementRegistrationStatus.REGISTERED, + role: SecretsManagementRole.ADMINISTRATOR, + vpc, + vpcSubnets: { subnetGroupName: DEADLINE_CLIENT_SUBNET_NAME }, + }); + } + + // THEN + expect(when) + .toThrowError('The Administrator role cannot be set using a Deadline identity registration setting'); + }); + + test('throws when two rules for same source subnet with different roles', () => { + // GIVEN + const client1 = new Construct(stack, 'client1'); + const client2 = new Construct(stack, 'client2'); + const existingRole = SecretsManagementRole.SERVER; + const newRole = SecretsManagementRole.CLIENT; + createTarget(); + target.addSubnetIdentityRegistrationSetting({ + dependent: client1, + registrationStatus: SecretsManagementRegistrationStatus.REGISTERED, + role: existingRole, + vpc, + vpcSubnets: { subnetGroupName: DEADLINE_CLIENT_SUBNET_NAME }, + }); + + // WHEN + function when() { + target.addSubnetIdentityRegistrationSetting({ + dependent: client2, + registrationStatus: SecretsManagementRegistrationStatus.REGISTERED, + role: newRole, + vpc, + vpcSubnets: { subnetGroupName: DEADLINE_CLIENT_SUBNET_NAME }, + }); + } + + // THEN + expect(when) + .toThrowError(`Subnet is already registered with role "${existingRole}" but another caller requested "${newRole}"`); + }); + + test('throws when two rules for same source subnet with different registration statuses', () => { + // GIVEN + const client1 = new Construct(stack, 'client1'); + const client2 = new Construct(stack, 'client2'); + const role = SecretsManagementRole.CLIENT; + const existingStatus = SecretsManagementRegistrationStatus.REGISTERED; + const newStatus = SecretsManagementRegistrationStatus.PENDING; + createTarget(); + target.addSubnetIdentityRegistrationSetting({ + dependent: client1, + registrationStatus: existingStatus, + role, + vpc, + vpcSubnets: { subnetGroupName: DEADLINE_CLIENT_SUBNET_NAME }, + }); + + // WHEN + function when() { + target.addSubnetIdentityRegistrationSetting({ + dependent: client2, + registrationStatus: newStatus, + role, + vpc, + vpcSubnets: { subnetGroupName: DEADLINE_CLIENT_SUBNET_NAME }, + }); + } + + // THEN + expect(when) + .toThrowError(`Subnet is already registered with registrationStatus "${existingStatus}" but another caller requested "${newStatus}"`); + }); + + test('de-duplicates subnets', () => { + // GIVEN + const identityRegistrationSettingsScriptLocalPath = 'identityRegistrationSettingsScriptLocalPath'; + ( + deploymentInstance.userData.addS3DownloadCommand + .mockReturnValueOnce('deadlineClientInstallerLocalPath') + .mockReturnValueOnce('efsMountScriptLocalPath') + .mockReturnValueOnce('directRepoConnectionConfigScriptLocalPath') + .mockReturnValueOnce(identityRegistrationSettingsScriptLocalPath) + ); + const clientStack = new Stack(app, 'ClientStack'); + const client1 = new Construct(clientStack, 'client1'); + const client2 = new Construct(clientStack, 'client2'); + createTarget(); + const baseProps = { + registrationStatus: SecretsManagementRegistrationStatus.REGISTERED, + role: SecretsManagementRole.CLIENT, + vpc, + vpcSubnets: { subnetGroupName: DEADLINE_CLIENT_SUBNET_NAME }, + }; + target.addSubnetIdentityRegistrationSetting({ + ...baseProps, + dependent: client1, + }); + + // WHEN + target.addSubnetIdentityRegistrationSetting({ + ...baseProps, + dependent: client2, + }); + + // THEN + const resolvedCalls = deploymentInstance.userData.addCommands.mock.calls.map(call => { + return deploymentInstanceStack.resolve(call); + }); + const expectedCall = [{ + 'Fn::Join': [ + '', + [ + // Command is run as "ec2-user" which has the database credentials stored + `sudo --login -u ec2-user ${identityRegistrationSettingsScriptLocalPath} `, + stack.resolve(Fn.join( + ' ', + [ + '--region', + stack.region, + // The Deadline Secrets Management admin credentials secret ARN is passed + '--credentials', + `"${repository.secretsManagementSettings.credentials!.secretArn}"`, + // The Render Queue's ALB subnets are passed as --connection-subnet args + ...(vpc.selectSubnets({ subnetGroupName: RENDER_QUEUE_ALB_SUBNET_NAME }) + .subnetIds.map(subnetID => `--connection-subnet "${subnetID}"`) + ), + // The Deadline Client's subnets, desired role, and registration status are passed as --source-subnet args + ...(vpc.selectSubnets({ subnetGroupName: DEADLINE_CLIENT_SUBNET_NAME }) + .subnetIds.map(subnetID => { + return `--source-subnet "${subnetID},${baseProps.role},${baseProps.registrationStatus}"`; + }) + ), + ], + )), + ], + ], + }]; + expect(resolvedCalls).toContainEqual(expectedCall); + }); + + test('warns about dedicated subnets when render queue ALB and source subnets match', () => { + // GIVEN + createTarget(); + const dependent = new Construct(stack, 'Dependent'); + const registrationStatus = SecretsManagementRegistrationStatus.REGISTERED; + const role = SecretsManagementRole.CLIENT; + const vpcSubnets: SubnetSelection = { + subnetGroupName: RENDER_QUEUE_ALB_SUBNET_NAME, + }; + + // WHEN + target.addSubnetIdentityRegistrationSetting({ + dependent, + registrationStatus, + role, + vpc, + vpcSubnets, + }); + + expect(dependent.node.metadataEntry).toContainEqual(expect.objectContaining({ + type: 'aws:cdk:warning', + data: `Deadline Secrets Management is enabled on the Repository and VPC subnets of the Render Queue match the subnets of ${dependent.node.path}. Using dedicated subnets is recommended. See https://github.com/aws/aws-rfdk/blobs/release/packages/aws-rfdk/lib/deadline/README.md#using-dedicated-subnets-for-deadline-components`, + })); + }); + }); + + test('Repository with no admin credentials throws an error', () => { + // GIVEN + class RepositoryNoCredentials extends Repository { + public readonly secretsManagementSettings: SecretsManagementProps; + + constructor(scope: Construct, id: string, props: RepositoryProps) { + super(scope, id, props); + this.secretsManagementSettings = { + enabled: true, + credentials: undefined, + }; + } + } + repository = new RepositoryNoCredentials(dependencyStack, 'RepoNoCreds', { + version, + vpc, + }); + + // WHEN + const when = createTarget; + + // THEN + expect(when).toThrowError('Repository does not contain secrets management credentials'); + }); + }); + + test('when Repository disables secrets management throws an exception', () => { + // GIVEN + repository = new Repository(stack, 'Repository', { + version, + vpc, + secretsManagementSettings: { + enabled: false, + }, + }); + + // WHEN + function when() { + new SecretsManagementIdentityRegistration(stack, 'IdentityRegistrationSettings', { + deploymentInstance, + renderQueueSubnets: vpc.selectSubnets({ + subnetGroupName: 'RenderQueueALB', + }), + repository, + version, + }); + } + + // THEN + expect(when).toThrow(); + }); +}); diff --git a/packages/aws-rfdk/lib/deadline/test/spot-event-plugin-fleet.test.ts b/packages/aws-rfdk/lib/deadline/test/spot-event-plugin-fleet.test.ts index 78c52989a..f4d2fb2a3 100644 --- a/packages/aws-rfdk/lib/deadline/test/spot-event-plugin-fleet.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/spot-event-plugin-fleet.test.ts @@ -346,6 +346,21 @@ describe('SpotEventPluginFleet', () => { // THEN expect(fleet.keyName).toBeUndefined(); }); + + test('.defaultSubnets is true', () => { + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + }); + + // THEN + expect(fleet.defaultSubnets).toBeTruthy(); + }); }); describe('created with custom values', () => { @@ -603,6 +618,27 @@ describe('SpotEventPluginFleet', () => { expect(stack.resolve(fleet.subnets.subnetIds)).toContainEqual(expectedSubnetId); }); + test('.defaultSubnets is false when subnets provided', () => { + // GIVEN + const privateSubnets: SubnetSelection = { + subnetType: SubnetType.PRIVATE, + }; + + // WHEN + const fleet = new SpotEventPluginFleet(spotFleetStack, 'SpotFleet', { + vpc, + renderQueue, + deadlineGroups, + instanceTypes, + workerMachineImage, + maxCapacity, + vpcSubnets: privateSubnets, + }); + + // THEN + expect(fleet.defaultSubnets).toBeFalsy(); + }); + test('uses provided allocation strategy', () => { // GIVEN const allocationStartegy = SpotFleetAllocationStrategy.CAPACITY_OPTIMIZED; diff --git a/packages/aws-rfdk/lib/deadline/test/thinkbox-docker-recipes.test.ts b/packages/aws-rfdk/lib/deadline/test/thinkbox-docker-recipes.test.ts index b3476599a..781dfd03e 100644 --- a/packages/aws-rfdk/lib/deadline/test/thinkbox-docker-recipes.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/thinkbox-docker-recipes.test.ts @@ -12,6 +12,7 @@ import { stringLike, } from '@aws-cdk/assert'; import { DockerImageAsset } from '@aws-cdk/aws-ecr-assets'; +import { Asset } from '@aws-cdk/aws-s3-assets'; import { App, Stack, @@ -24,6 +25,7 @@ import { Stage, StageProps, ThinkboxDockerRecipes, + Version, } from '../lib'; describe('ThinkboxDockerRecipes', () => { @@ -33,11 +35,20 @@ describe('ThinkboxDockerRecipes', () => { // GIVEN const STAGE_PATH = path.join(__dirname, 'assets'); + + const MAJOR_VERSION = 10; + const MINOR_VERSION = 1; + const RELEASE_VERSION = 9; + const PATCH_VERSION = 2; + const RELEASE_VERSION_STRING = `${MAJOR_VERSION}.${MINOR_VERSION}.${RELEASE_VERSION}`; + const FULL_VERSION_STRING = `${RELEASE_VERSION_STRING}.${PATCH_VERSION}`; + const RCS_RECIPE_NAME = 'rcs'; const RCS_RECIPE: Recipe = { description: 'rcs', title: 'rcs', buildArgs: { + DL_VERSION: FULL_VERSION_STRING, a: 'a', b: 'b', }, @@ -49,19 +60,13 @@ describe('ThinkboxDockerRecipes', () => { title: 'license-forwarder', description: 'license-forwarder', buildArgs: { + DL_VERSION: FULL_VERSION_STRING, c: 'c', d: 'd', }, target: 'lf', }; - const MAJOR_VERSION = 10; - const MINOR_VERSION = 1; - const RELEASE_VERSION = 9; - const PATCH_VERSION = 2; - const RELEASE_VERSION_STRING = `${MAJOR_VERSION}.${MINOR_VERSION}.${RELEASE_VERSION}`; - const FULL_VERSION_STRING = `${RELEASE_VERSION_STRING}.${PATCH_VERSION}`; - beforeEach(() => { app = new App(); @@ -159,6 +164,86 @@ describe('ThinkboxDockerRecipes', () => { expectCDK(stack).notTo(haveResource('Custom::RFDK_DEADLINE_INSTALLERS')); }); + + test('.linuxInstallers.client creates an Asset using the client installer', () => { + // GIVEN + const recipes = new ThinkboxDockerRecipes(stack, 'Recipes', { + stage, + }); + + // WHEN + const clientInstaller = recipes.version.linuxInstallers.client; + + // THEN + const asset = recipes.node.findChild('ClientInstallerAsset') as Asset; + expect(clientInstaller.s3Bucket).toEqual(asset.bucket); + expect(clientInstaller.objectKey).toEqual(asset.s3ObjectKey); + }); + + test('.linuxInstallers.client successive accesses return the same bucket/key', () => { + // GIVEN + const recipes = new ThinkboxDockerRecipes(stack, 'Recipes', { + stage, + }); + + // WHEN + const firstClientInstaller = recipes.version.linuxInstallers.client; + const secondClientInstaller = recipes.version.linuxInstallers.client; + + // THEN + expect(firstClientInstaller.objectKey).toBe(secondClientInstaller.objectKey); + expect(firstClientInstaller.s3Bucket).toBe(secondClientInstaller.s3Bucket); + }); + + describe('.isLessThan()', () => { + let recipes: ThinkboxDockerRecipes; + beforeEach(() => { + // GIVEN + recipes = new ThinkboxDockerRecipes(stack, 'Recipes', { + stage, + }); + }); + + test.each<[{ majorOffset?: number, minorOffset?: number, releaseOffset?: number }, boolean]>([ + [{ majorOffset: -1 }, false], + [{ minorOffset: -1 }, false], + [{ releaseOffset: -1 }, false], + [{}, false], + [{ majorOffset: 1 }, true], + [{ minorOffset: 1 }, true], + [{ releaseOffset: 1 }, true], + ])('%s = %s', ({majorOffset, minorOffset, releaseOffset}, expectedResult) => { + // GIVEN + majorOffset = majorOffset ?? 0; + minorOffset = minorOffset ?? 0; + releaseOffset = releaseOffset ?? 0; + const other = new Version([ + MAJOR_VERSION + majorOffset, + MINOR_VERSION + minorOffset, + RELEASE_VERSION + releaseOffset, + 0, + ]); + + // WHEN + const result = recipes.version.isLessThan(other); + + // THEN + expect(result).toEqual(expectedResult); + }); + }); + + test('.linuxfullVersionString matches the stage manifest version', () => { + // GIVEN + const recipes = new ThinkboxDockerRecipes(stack, 'Recipes', { + stage, + }); + + // WHEN + const linuxFullVersionString = recipes.version.linuxFullVersionString(); + + // THEN + expect(linuxFullVersionString).toEqual(FULL_VERSION_STRING); + }); }); test.each([ diff --git a/packages/aws-rfdk/lib/deadline/test/usage-based-licensing.test.ts b/packages/aws-rfdk/lib/deadline/test/usage-based-licensing.test.ts index 46319afd4..11ad06c4f 100644 --- a/packages/aws-rfdk/lib/deadline/test/usage-based-licensing.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/usage-based-licensing.test.ts @@ -14,6 +14,7 @@ import { GenericWindowsImage, IVpc, SecurityGroup, + SubnetSelection, SubnetType, Vpc, } from '@aws-cdk/aws-ec2'; @@ -21,6 +22,7 @@ import { DockerImageAsset, } from '@aws-cdk/aws-ecr-assets'; import { + CfnService, ContainerImage, } from '@aws-cdk/aws-ecs'; import { @@ -44,9 +46,13 @@ import { IWorkerFleet, RenderQueue, Repository, + SecretsManagementRegistrationStatus, + SecretsManagementRole, + SubnetIdentityRegistrationSettingsProps, UsageBasedLicense, UsageBasedLicensing, UsageBasedLicensingImages, + UsageBasedLicensingProps, VersionQuery, WorkerInstanceFleet, } from '../lib'; @@ -67,6 +73,8 @@ let stack: Stack; let vpc: IVpc; let workerFleet: IWorkerFleet; +const DEFAULT_CONSTRUCT_ID = 'UBL'; + describe('UsageBasedLicensing', () => { beforeEach(() => { // GIVEN @@ -86,11 +94,10 @@ describe('UsageBasedLicensing', () => { repository: new Repository(dependencyStack, 'RepositoryNonDefault', { vpc, version: versionedInstallers, - secretsManagementSettings: { enabled: false }, }), - trafficEncryption: { externalTLS: { enabled: false } }, version: versionedInstallers, }); + jest.spyOn(renderQueue, 'configureSecretsManagementAutoRegistration'); stack = new Stack(app, 'Stack', { env }); certificateSecret = Secret.fromSecretArn(stack, 'CertSecret', 'arn:aws:secretsmanager:us-west-2:675872700355:secret:CertSecret-j1kiFz'); @@ -103,15 +110,92 @@ describe('UsageBasedLicensing', () => { licenses = [UsageBasedLicense.forMaya()]; }); - test('creates an ECS cluster', () => { - // WHEN - new UsageBasedLicensing(stack, 'UBL', { + function createUbl(props?: Partial): UsageBasedLicensing { + return new UsageBasedLicensing(stack, DEFAULT_CONSTRUCT_ID, { certificateSecret, images, licenses, renderQueue, vpc, + ...props, }); + } + + test('vpcSubnets specified => does not emit warnings', () => { + // GIVEN + const vpcSubnets: SubnetSelection = { + subnetType: SubnetType.PRIVATE, + }; + + // WHEN + const ubl = createUbl({ + vpcSubnets, + }); + + // THEN + expect(ubl.node.metadataEntry).not.toContainEqual(expect.objectContaining({ + type: 'aws:cdk:warning', + data: expect.stringMatching(/dedicated subnet/i), + })); + }); + + test('vpcSubnets not specified => emits warning about dedicated subnets', () => { + // WHEN + const ubl = createUbl(); + + // THEN + expect(ubl.node.metadataEntry).toContainEqual(expect.objectContaining({ + type: 'aws:cdk:warning', + data: 'Deadline Secrets Management is enabled on the Repository and VPC subnets have not been supplied. Using dedicated subnets is recommended. See https://github.com/aws/aws-rfdk/blobs/release/packages/aws-rfdk/lib/deadline/README.md#using-dedicated-subnets-for-deadline-components', + })); + }); + + describe('configures auto registration', () => { + test('default to private subnets', () => { + // WHEN + const ubl = createUbl(); + + // THEN + const expectedCall: SubnetIdentityRegistrationSettingsProps = { + dependent: ubl.service.node.defaultChild as CfnService, + registrationStatus: SecretsManagementRegistrationStatus.REGISTERED, + role: SecretsManagementRole.CLIENT, + vpc, + vpcSubnets: { subnetType: SubnetType.PRIVATE }, + }; + + // THEN + expect(renderQueue.configureSecretsManagementAutoRegistration).toHaveBeenCalledWith(expectedCall); + }); + + test.each<[SubnetSelection]>([ + [{ + subnetType: SubnetType.PUBLIC, + }], + ])('%s', (vpcSubnets) => { + // WHEN + const ubl = createUbl({ + vpcSubnets, + }); + + // THEN + const expectedCall: SubnetIdentityRegistrationSettingsProps = { + dependent: ubl.service.node.defaultChild as CfnService, + registrationStatus: SecretsManagementRegistrationStatus.REGISTERED, + role: SecretsManagementRole.CLIENT, + vpc, + vpcSubnets, + }; + + // THEN + expect(renderQueue.configureSecretsManagementAutoRegistration).toHaveBeenCalledWith(expectedCall); + }); + }); + + test('creates an ECS cluster', () => { + // WHEN + createUbl(); + // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::Cluster')); }); @@ -119,13 +203,7 @@ describe('UsageBasedLicensing', () => { describe('creates an ASG', () => { test('defaults', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { @@ -144,13 +222,8 @@ describe('UsageBasedLicensing', () => { test('capacity can be specified', () => { // WHEN - new UsageBasedLicensing(stack, 'licenseForwarder', { - certificateSecret, + createUbl({ desiredCount: 2, - images, - licenses, - renderQueue, - vpc, }); // THEN @@ -162,16 +235,10 @@ describe('UsageBasedLicensing', () => { test('gives write access to log group', () => { // GIVEN - const ubl = new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + const ubl = createUbl(); // WHEN - const logGroup = ubl.node.findChild('UBLLogGroup') as ILogGroup; + const logGroup = ubl.node.findChild(`${DEFAULT_CONSTRUCT_ID}LogGroup`) as ILogGroup; const asgRoleLogicalId = Stack.of(ubl).getLogicalId(ubl.asg.role.node.defaultChild as CfnElement); // THEN @@ -196,18 +263,14 @@ describe('UsageBasedLicensing', () => { }); test('uses the supplied security group', () => { + // GIVEN const securityGroup = new SecurityGroup(stack, 'UblSecurityGroup', { vpc, }); + // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - securityGroup, - }); + createUbl({ securityGroup }); + // THEN expectCDK(stack).to(haveResourceLike('AWS::AutoScaling::LaunchConfiguration', { SecurityGroups: arrayWith(stack.resolve(securityGroup.securityGroupId)), @@ -218,13 +281,7 @@ describe('UsageBasedLicensing', () => { describe('creates an ECS service', () => { test('associated with the cluster', () => { // WHEN - const ubl = new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + const ubl = createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::Service', { @@ -235,13 +292,7 @@ describe('UsageBasedLicensing', () => { describe('DesiredCount', () => { test('defaults to 1', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::Service', { @@ -254,14 +305,7 @@ describe('UsageBasedLicensing', () => { const desiredCount = 2; // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - desiredCount, - }); + createUbl({ desiredCount }); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::Service', { @@ -272,13 +316,7 @@ describe('UsageBasedLicensing', () => { test('sets launch type to EC2', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::Service', { @@ -288,13 +326,7 @@ describe('UsageBasedLicensing', () => { test('sets distinct instance placement constraint', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::Service', { @@ -306,13 +338,7 @@ describe('UsageBasedLicensing', () => { test('uses the task definition', () => { // WHEN - const ubl = new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + const ubl = createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::Service', { @@ -322,13 +348,7 @@ describe('UsageBasedLicensing', () => { test('with the correct deployment configuration', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::Service', { @@ -343,13 +363,7 @@ describe('UsageBasedLicensing', () => { describe('creates a task definition', () => { test('container name is LicenseForwarderContainer', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::TaskDefinition', { @@ -363,13 +377,7 @@ describe('UsageBasedLicensing', () => { test('container is marked essential', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::TaskDefinition', { @@ -383,13 +391,7 @@ describe('UsageBasedLicensing', () => { test('with increased ulimits', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::TaskDefinition', { @@ -414,13 +416,7 @@ describe('UsageBasedLicensing', () => { test('with awslogs log driver', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::TaskDefinition', { @@ -441,13 +437,7 @@ describe('UsageBasedLicensing', () => { test('configures UBL certificates', () => { // GIVEN - const ubl = new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + const ubl = createUbl(); // WHEN const taskRoleLogicalId = Stack.of(ubl).getLogicalId(ubl.service.taskDefinition.taskRole.node.defaultChild as CfnElement); @@ -474,7 +464,7 @@ describe('UsageBasedLicensing', () => { expectCDK(stack).to(haveResourceLike('AWS::IAM::Policy', { PolicyDocument: { - Statement: [ + Statement: arrayWith( { Action: [ 'secretsmanager:GetSecretValue', @@ -483,7 +473,7 @@ describe('UsageBasedLicensing', () => { Effect: 'Allow', Resource: certificateSecret.secretArn, }, - ], + ), Version: '2012-10-17', }, Roles: [ @@ -494,13 +484,7 @@ describe('UsageBasedLicensing', () => { test('uses host networking', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::TaskDefinition', { @@ -510,13 +494,7 @@ describe('UsageBasedLicensing', () => { test('is marked EC2 compatible only', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::TaskDefinition', { @@ -535,11 +513,7 @@ describe('UsageBasedLicensing', () => { }); // WHEN - new UsageBasedLicensing(stack, 'licenseForwarder', { - certificateSecret, - images, - licenses, - renderQueue, + createUbl({ vpc: vpcFromAttributes, vpcSubnets: { subnetType: SubnetType.PUBLIC }, }); @@ -555,15 +529,10 @@ describe('UsageBasedLicensing', () => { '', ])('License Forwarder is created with correct LogGroup prefix %s', (testPrefix: string) => { // GIVEN - const id = 'licenseForwarder'; + const id = DEFAULT_CONSTRUCT_ID; // WHEN - new UsageBasedLicensing(stack, id, { - certificateSecret, - images, - licenses, - renderQueue, - vpc, + createUbl({ logGroupProps: { logGroupPrefix: testPrefix, }, @@ -578,11 +547,7 @@ describe('UsageBasedLicensing', () => { describe('license limits', () => { test('multiple licenses with limits', () => { // WHEN - new UsageBasedLicensing(stack, 'licenseForwarder', { - vpc, - images, - certificateSecret, - renderQueue, + createUbl({ licenses: [ UsageBasedLicense.forMaya(10), UsageBasedLicense.forVray(10), @@ -623,13 +588,7 @@ describe('UsageBasedLicensing', () => { ['Yeti', UsageBasedLicense.forYeti(10), [5053, 7053]], ])('Test open port for license type %s', (_licenseName: string, license: UsageBasedLicense, ports: number[]) => { // GIVEN - const ubl = new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + const ubl = createUbl(); const workerStack = new Stack(app, 'WorkerStack', { env }); workerFleet = new WorkerInstanceFleet(workerStack, 'workerFleet', { vpc, @@ -661,13 +620,7 @@ describe('UsageBasedLicensing', () => { test('requires one usage based license', () => { // Without any licenses expect(() => { - new UsageBasedLicensing(stack, 'licenseForwarder', { - vpc, - images, - certificateSecret: certificateSecret, - licenses: [], - renderQueue, - }); + createUbl({ licenses: [] }); }).toThrowError('Should be specified at least one license with defined limit.'); }); }); @@ -675,18 +628,12 @@ describe('UsageBasedLicensing', () => { describe('configures render queue', () => { test('adds ingress rule for asg', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); expectCDK(stack).to(haveResourceLike('AWS::EC2::SecurityGroupIngress', { IpProtocol: 'tcp', - FromPort: 8080, - ToPort: 8080, + FromPort: 4433, + ToPort: 4433, GroupId: { 'Fn::ImportValue': stringLike(`${Stack.of(renderQueue).stackName}:ExportsOutputFnGetAttRQNonDefaultPortLBSecurityGroup*`), }, @@ -701,13 +648,7 @@ describe('UsageBasedLicensing', () => { test('sets RENDER_QUEUE_URI environment variable', () => { // WHEN - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); // THEN expectCDK(stack).to(haveResourceLike('AWS::ECS::TaskDefinition', { @@ -716,18 +657,7 @@ describe('UsageBasedLicensing', () => { Environment: arrayWith( { Name: 'RENDER_QUEUE_URI', - Value: { - 'Fn::Join': [ - '', - [ - 'http://', - { - 'Fn::ImportValue': stringLike(`${Stack.of(renderQueue).stackName}:ExportsOutputFnGetAttRQNonDefaultPortLB*`), - }, - ':8080', - ], - ], - }, + Value: stack.resolve(`${renderQueue.endpoint.applicationProtocol.toLowerCase()}://${renderQueue.endpoint.socketAddress}`), }, ), }, @@ -740,13 +670,7 @@ describe('UsageBasedLicensing', () => { testConstructTags({ constructName: 'UsageBasedLicensing', createConstruct: () => { - new UsageBasedLicensing(stack, 'UBL', { - certificateSecret, - images, - licenses, - renderQueue, - vpc, - }); + createUbl(); return stack; }, resourceTypeCounts: { diff --git a/packages/aws-rfdk/lib/deadline/test/version-query.test.ts b/packages/aws-rfdk/lib/deadline/test/version-query.test.ts index 3be13f53e..59f53506a 100644 --- a/packages/aws-rfdk/lib/deadline/test/version-query.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/version-query.test.ts @@ -11,10 +11,12 @@ import { } from '@aws-cdk/assert'; import { App, + CustomResource, Stack, } from '@aws-cdk/core'; import { + Installer, VersionQuery, } from '../lib'; @@ -91,3 +93,59 @@ test.each([ forceRun: stringLike('*'), })); }); + +describe('VersionQuery.linuxInstallers', () => { + let customResource: CustomResource; + let versionQuery: VersionQuery; + let stack: Stack; + + beforeAll(() => { + // GIVEN + const app = new App(); + stack = new Stack(app, 'Stack'); + versionQuery = new VersionQuery(stack, 'VersionQuery'); + customResource = versionQuery.node.findChild('DeadlineResource') as CustomResource; + }); + + describe('.repository', () => { + let repoInstaller: Installer; + + beforeAll(() => { + // WHEN + repoInstaller = versionQuery.linuxInstallers.repository; + }); + + test('S3 bucket from Custom::RFDK_DEADLINE_INSTALLERS "S3Bucket" attribute', () => { + // THEN + expect(stack.resolve(repoInstaller.s3Bucket.bucketName)) + .toEqual(stack.resolve(customResource.getAtt('S3Bucket'))); + }); + + test('S3 object key from Custom::RFDK_DEADLINE_INSTALLERS "LinuxRepositoryInstaller" attribute', () => { + // THEN + expect(stack.resolve(repoInstaller.objectKey)) + .toEqual(stack.resolve(customResource.getAtt('LinuxRepositoryInstaller'))); + }); + }); + + describe('.client', () => { + let clientInstaller: Installer; + + beforeAll(() => { + // WHEN + clientInstaller = versionQuery.linuxInstallers.client; + }); + + test('S3 bucket from Custom::RFDK_DEADLINE_INSTALLERS "S3Bucket" attribute', () => { + // THEN + expect(stack.resolve(clientInstaller.s3Bucket.bucketName)) + .toEqual(stack.resolve(customResource.getAtt('S3Bucket'))); + }); + + test('S3 object key from Custom::RFDK_DEADLINE_INSTALLERS "LinuxClientInstaller" attribute', () => { + // THEN + expect(stack.resolve(clientInstaller.objectKey)) + .toEqual(stack.resolve(customResource.getAtt('LinuxClientInstaller'))); + }); + }); +}); diff --git a/packages/aws-rfdk/lib/deadline/test/worker-fleet.test.ts b/packages/aws-rfdk/lib/deadline/test/worker-fleet.test.ts index fe74e17d5..5f52be894 100644 --- a/packages/aws-rfdk/lib/deadline/test/worker-fleet.test.ts +++ b/packages/aws-rfdk/lib/deadline/test/worker-fleet.test.ts @@ -58,6 +58,7 @@ import { VersionQuery, WorkerInstanceConfiguration, WorkerInstanceFleet, + WorkerInstanceFleetProps, } from '../lib'; import { CONFIG_WORKER_ASSET_LINUX, @@ -1943,3 +1944,58 @@ test('worker fleet does not signal when zero minCapacity', () => { expect(fleet.node.metadataEntry[1].type).toMatch(ArtifactMetadataEntryType.WARN); expect(fleet.node.metadataEntry[1].data).toMatch(/Deploying with 0 minimum capacity./); }); + +describe('secrets management enabled', () => { + let props: WorkerInstanceFleetProps; + + // GIVEN + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack'); + vpc = new Vpc(stack, 'VPC'); + rcsImage = ContainerImage.fromAsset(__dirname); + const version = new VersionQuery(stack, 'VersionQuery'); + renderQueue = new RenderQueue(stack, 'RQ', { + vpc, + images: { remoteConnectionServer: rcsImage }, + repository: new Repository(stack, 'Repository', { + vpc, + version, + }), + version, + }); + wfstack = new Stack(app, 'workerFleetStack'); + props = { + renderQueue, + vpc, + workerMachineImage: new GenericWindowsImage({}), + }; + }); + + test('vpc subnets not specified => warns about dedicated subnets', () => { + // WHEN + const workerInstanceFleet = new WorkerInstanceFleet(wfstack, 'WorkerInstanceFleet', props); + + // THEN + expect(workerInstanceFleet.node.metadataEntry).toContainEqual(expect.objectContaining({ + type: 'aws:cdk:warning', + data: 'Deadline Secrets Management is enabled on the Repository and VPC subnets have not been supplied. Using dedicated subnets is recommended. See https://github.com/aws/aws-rfdk/blobs/release/packages/aws-rfdk/lib/deadline/README.md#using-dedicated-subnets-for-deadline-components', + })); + }); + + test('vpc subnets specified => does not emit dedicated subnets warning', () => { + // WHEN + const workerInstanceFleet = new WorkerInstanceFleet(wfstack, 'WorkerInstanceFleet', { + ...props, + vpcSubnets: { + subnetType: SubnetType.PRIVATE, + }, + }); + + // THEN + expect(workerInstanceFleet.node.metadataEntry).not.toContainEqual(expect.objectContaining({ + type: 'aws:cdk:warning', + data: expect.stringMatching(/dedicated subnet/i), + })); + }); +}); diff --git a/packages/aws-rfdk/lib/lambdas/nodejs/version-provider/handler.ts b/packages/aws-rfdk/lib/lambdas/nodejs/version-provider/handler.ts index b9e0b9383..38b6b356b 100644 --- a/packages/aws-rfdk/lib/lambdas/nodejs/version-provider/handler.ts +++ b/packages/aws-rfdk/lib/lambdas/nodejs/version-provider/handler.ts @@ -64,6 +64,11 @@ export interface FlatVersionedUriOutput { * The object key of the Deadline repository installer for Linux. */ readonly LinuxRepositoryInstaller: string; + + /** + * The object key of the Deadline client installer for Linux. + */ + readonly LinuxClientInstaller: string; } /** @@ -101,6 +106,7 @@ export class VersionProviderResource extends SimpleCustomResource { const s3Bucket = this.parseS3BucketName(deadlineLinuxUris.bundle); const linuxRepoObjectKey = this.parseS3ObjectKey(deadlineLinuxUris.repositoryInstaller!); + const linuxClientObjectKey = this.parseS3ObjectKey(deadlineLinuxUris.clientInstaller!); return { S3Bucket: s3Bucket, @@ -109,6 +115,7 @@ export class VersionProviderResource extends SimpleCustomResource { ReleaseVersion: deadlineLinux.ReleaseVersion, LinuxPatchVersion: deadlineLinux.PatchVersion, LinuxRepositoryInstaller: linuxRepoObjectKey, + LinuxClientInstaller: linuxClientObjectKey, }; }