constructor()

in packages/aws-rfdk/lib/deadline/lib/render-queue.ts [383:652]


  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)) {
      // Deadline versions earlier than 10.1.10 do not support horizontal scaling behind a load-balancer, so we limit to at most one instance
      if ((this.renderQueueSize.min ?? 0) > 1) {
        throw new Error(`renderQueueSize.min for Deadline version less than ${RenderQueue.MINIMUM_LOAD_BALANCING_VERSION.toString()} cannot be greater than 1 - got ${this.renderQueueSize.min}`);
      }
      if ((this.renderQueueSize.desired ?? 0) > 1) {
        throw new Error(`renderQueueSize.desired for Deadline version less than ${RenderQueue.MINIMUM_LOAD_BALANCING_VERSION.toString()} cannot be greater than 1 - got ${this.renderQueueSize.desired}`);
      }
      if ((this.renderQueueSize.max ?? 0) > 1) {
        throw new Error(`renderQueueSize.max for Deadline version less than ${RenderQueue.MINIMUM_LOAD_BALANCING_VERSION.toString()} cannot be greater than 1 - got ${this.renderQueueSize.max}`);
      }
    }

    this.version = props?.version;

    const externalProtocol = props.trafficEncryption?.externalTLS?.enabled === false ? ApplicationProtocol.HTTP : ApplicationProtocol.HTTPS;
    let loadBalancerFQDN: string | undefined;
    let domainZone: IHostedZone | undefined;

    if ( externalProtocol ===  ApplicationProtocol.HTTPS ) {
      const tlsInfo = this.getOrCreateTlsInfo(props);

      this.certChain = tlsInfo.certChain;
      this.clientCert = tlsInfo.serverCert;
      loadBalancerFQDN = tlsInfo.fullyQualifiedDomainName;
      domainZone = tlsInfo.domainZone;
    } else {
      if (props.hostname) {
        loadBalancerFQDN = this.generateFullyQualifiedDomainName(props.hostname.zone, props.hostname.hostname);
        domainZone = props.hostname.zone;
      }
    }

    this.version = props.version;

    const internalProtocol = props.trafficEncryption?.internalProtocol ?? ApplicationProtocol.HTTPS;

    this.cluster = new Cluster(this, 'Cluster', {
      vpc: props.vpc,
    });

    const minCapacity = props.renderQueueSize?.min ?? 1;
    if (minCapacity < 1) {
      throw new Error(`renderQueueSize.min capacity must be at least 1: got ${minCapacity}`);
    }
    const maxCapacity = this.renderQueueSize.max ?? this.renderQueueSize?.desired;
    if (this.renderQueueSize?.desired && maxCapacity && this.renderQueueSize?.desired > maxCapacity) {
      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 ?? RenderQueue.DEFAULT_VPC_SUBNETS_OTHER,
      instanceType: props.instanceType ?? new InstanceType('c5.large'),
      minCapacity,
      desiredCapacity: this.renderQueueSize?.desired,
      maxCapacity,
      blockDevices: [{
        deviceName: '/dev/xvda',
        // See: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-ami-storage-config.html
        // We want the volume to be encrypted. The default AMI size is 30-GiB.
        volume: BlockDeviceVolume.ebs(30, { encrypted: true }),
      }],
      ...{ updateType: undefined }, // Workaround -- See: https://github.com/aws/aws-cdk/issues/11581
      updatePolicy: UpdatePolicy.rollingUpdate(),
      // addCapacity doesn't specifically take a securityGroup, but it passes on its properties to the ASG it creates,
      // so this security group will get applied there
      // @ts-ignore
      securityGroup: props.securityGroups?.backend,
      machineImage: EcsOptimizedImage.amazonLinux2023(),
    });

    this.backendConnections = this.asg.connections;

    /**
     * The ECS-optimized AMI that is defaulted to when adding capacity to a cluster does not include the awscli or unzip
     * packages as is the case with the standard Amazon Linux AMI. These are required by RFDK scripts to configure the
     * direct connection on the host container instances.
     */
    this.asg.userData.addCommands(
      'yum install -yq awscli unzip',
    );
    if (props.enableLocalFileCaching ?? false) {
      // Has to be done before any filesystems mount.
      this.enableFilecaching(this.asg);
    }

    const externalPortNumber = RenderQueue.RCS_PROTO_PORTS[externalProtocol];
    const internalPortNumber = RenderQueue.RCS_PROTO_PORTS[internalProtocol];

    this.logGroup = LogGroupFactory.createOrFetch(this, 'LogGroupWrapper', id, {
      logGroupPrefix: '/renderfarm/',
      ...props.logGroupProps,
    });
    this.logGroup.grantWrite(this.asg);

    if (props.repository.secretsManagementSettings.enabled) {
      const errors = [];
      if (props.version.isLessThan(Version.MINIMUM_SECRETS_MANAGEMENT_VERSION)) {
        errors.push(`The supplied Deadline version (${props.version.versionString}) does not support Deadline Secrets Management in RFDK. Either upgrade Deadline to the minimum required version (${Version.MINIMUM_SECRETS_MANAGEMENT_VERSION.versionString}) or disable the feature in the Repository's construct properties.`);
      }
      if (props.repository.secretsManagementSettings.credentials === undefined) {
        errors.push('The Repository does not have Secrets Management credentials');
      }
      if (internalProtocol !== ApplicationProtocol.HTTPS) {
        errors.push('The internal protocol on the Render Queue is not HTTPS.');
      }
      if (externalProtocol !== ApplicationProtocol.HTTPS) {
        errors.push('External TLS on the Render Queue is not enabled.');
      }
      if (errors.length > 0) {
        throw new Error(`Deadline Secrets Management is enabled on the supplied Repository but cannot be enabled on the Render Queue for the following reasons:\n${errors.join('\n')}`);
      }
    }
    const taskDefinition = this.createTaskDefinition({
      image: props.images.remoteConnectionServer,
      portNumber: internalPortNumber,
      protocol: internalProtocol,
      repository: props.repository,
      runAsUser: RenderQueue.RCS_USER,
      secretsManagementOptions: props.repository.secretsManagementSettings.enabled ? {
        credentials: props.repository.secretsManagementSettings.credentials!,
        posixUsername: RenderQueue.RCS_USER.username,
      } : undefined,
    });
    this.taskDefinition = taskDefinition;

    // The fully-qualified domain name to use for the ALB

    const loadBalancer = new ApplicationLoadBalancer(this, 'LB', {
      vpc: this.cluster.vpc,
      vpcSubnets: props.vpcSubnetsAlb ?? RenderQueue.DEFAULT_VPC_SUBNETS_ALB,
      internetFacing: false,
      deletionProtection: props.deletionProtection ?? true,
      securityGroup: props.securityGroups?.frontend,
    });

    this.pattern = new ApplicationLoadBalancedEc2Service(this, 'AlbEc2ServicePattern', {
      certificate: this.clientCert,
      cluster: this.cluster,
      desiredCount: this.renderQueueSize?.desired ?? minCapacity,
      domainZone,
      domainName: loadBalancerFQDN,
      listenerPort: externalPortNumber,
      loadBalancer,
      protocol: externalProtocol,
      taskDefinition,
      // This is required to right-size our host capacity and not have the ECS service block on updates. We set a memory
      // reservation, but no memory limit on the container. This allows the container's memory usage to grow unbounded.
      // We want 1:1 container to container instances to not over-spend, but this comes at the price of down-time during
      // cloudformation updates.
      minHealthyPercent: 0,
      maxHealthyPercent: 100,
      // This is required to ensure that the ALB listener's security group does not allow any ingress by default.
      openListener: false,
    });

    // An explicit dependency is required from the Service to the Client certificate
    // Otherwise cloud formation will try to remove the cert before the ALB using it is disposed.
    if (this.clientCert) {
      this.pattern.node.addDependency(this.clientCert);
    }

    // An explicit dependency is required from the service to the ASG providing its capacity.
    // See: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-attribute-dependson.html
    this.pattern.service.node.addDependency(this.asg);

    this.loadBalancer = this.pattern.loadBalancer;
    // Enabling dropping of invalid HTTP header fields on the load balancer to prevent http smuggling attacks.
    this.loadBalancer.setAttribute('routing.http.drop_invalid_header_fields.enabled', 'true');

    if (props.accessLogs) {
      const accessLogsBucket = props.accessLogs.destinationBucket;

      // Policies are applied according to
      // https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-access-logs.html
      accessLogsBucket.addToResourcePolicy( new PolicyStatement({
        actions: ['s3:PutObject'],
        principals: [new ServicePrincipal('delivery.logs.amazonaws.com')],
        resources: [`${accessLogsBucket.bucketArn}/*`],
        conditions: {
          StringEquals: {
            's3:x-amz-acl': 'bucket-owner-full-control',
          },
        },
      }));
      accessLogsBucket.addToResourcePolicy(new PolicyStatement({
        actions: [ 's3:GetBucketAcl' ],
        principals: [ new ServicePrincipal('delivery.logs.amazonaws.com')],
        resources: [ accessLogsBucket.bucketArn ],
      }));

      this.loadBalancer.logAccessLogs(
        accessLogsBucket,
        props.accessLogs.prefix);
    }

    // Ensure tasks are run on separate container instances
    this.pattern.service.addPlacementConstraints(PlacementConstraint.distinctInstances());

    /**
     * Uses an escape-hatch to set the target group protocol to HTTPS. We cannot configure server certificate
     * validation, but at least traffic is encrypted and terminated at the application layer.
     */
    const listener = this.loadBalancer.node.findChild('PublicListener');
    this.listener = listener as ApplicationListener;
    const targetGroup = listener.node.findChild('ECSGroup') as ApplicationTargetGroup;
    const targetGroupResource = targetGroup.node.defaultChild as CfnTargetGroup;
    targetGroupResource.protocol = ApplicationProtocol[internalProtocol];
    targetGroupResource.port = internalPortNumber;

    this.grantPrincipal = taskDefinition.taskRole;

    this.connections = new Connections({
      defaultPort: Port.tcp(externalPortNumber),
      securityGroups: this.pattern.loadBalancer.connections.securityGroups,
    });

    this.endpoint = new ConnectableApplicationEndpoint({
      address: loadBalancerFQDN ?? this.pattern.loadBalancer.loadBalancerDnsName,
      port: externalPortNumber,
      connections: this.connections,
      protocol: externalProtocol,
    });

    if ( externalProtocol === ApplicationProtocol.HTTP ) {
      this.rqConnection = RenderQueueConnection.forHttp({
        endpoint: this.endpoint,
      });
    } else {
      this.rqConnection = RenderQueueConnection.forHttps({
        endpoint: this.endpoint,
        caCert: this.certChain!,
      });
    }

    props.repository.secretsManagementSettings.credentials?.grantRead(this);

    this.ecsServiceStabilized = new WaitForStableService(this, 'WaitForStableService', {
      service: this.pattern.service,
    });

    this.node.defaultChild = taskDefinition;

    // Tag deployed resources with RFDK meta-data
    tagConstruct(this);

    const thisConstruct = this;
    this.node.addValidation({
      validate(): string[] {
        const validationErrors = [];

        // Using the output of VersionQuery across stacks can cause issues. CloudFormation stack outputs cannot change if
        // a resource in another stack is referencing it.
        if (thisConstruct.version instanceof VersionQuery) {
          const versionStack = Stack.of(thisConstruct.version);
          const thisStack = Stack.of(thisConstruct);
          if (versionStack != thisStack) {
            validationErrors.push('A VersionQuery can not be supplied from a different stack');
          }
        }

        return validationErrors;
      },
    });
  }