diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/application-load-balancer.cfn.yml b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/application-load-balancer.cfn.yml index 698c0a66b1..31c3ccc5c9 100644 --- a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/application-load-balancer.cfn.yml +++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/application-load-balancer.cfn.yml @@ -9,52 +9,163 @@ Parameters: VPC: Description: The VPC in which the ALB will reside Type: AWS::EC2::VPC::Id - Subnet1: - Description: The VPC Subnet1 in which the ALB will reside - Type: AWS::EC2::Subnet::Id - Subnet2: - Description: The VPC Subnet2 in which the ALB will reside - Type: AWS::EC2::Subnet::Id ACMSSLCertARN: Type: String Description: The ARN of the AWS Certificate Manager SSL Certificate to associate with the Load Balancer + IsAppStreamEnabled: + Type: String + AllowedValues: [true, false] + Description: Is AppStream enabled for this workspace + AppStreamSG: + Type: String + Description: AppStream Security Group ID + PublicRouteTableId: + Type: String + Description: Public Route Table ID + + # For an ALB - You must specify subnets from at least two Availability Zones. + # AWS recommends Availability Zone subnet for your load balancer to have a CIDR block with at least a /27 bitmask + # Assigning bitmask of /25 to be safe and allow 128 hosts per subnet + # Keeping non-overlapping between public/private CIDR ranges to account for failed non-AppStream env termination + + # Range from 10.0.96.0 to 10.0.96.127 + PublicSubnet1Cidr: + Type: String + Default: 10.0.96.0/25 + + # Range from 10.0.96.128 to 10.0.96.255 + PublicSubnet2Cidr: + Type: String + Default: 10.0.96.128/25 + + # Range from 10.0.97.0 to 10.0.97.127 + PrivateSubnet1Cidr: + Type: String + Default: 10.0.97.0/25 + + # Range from 10.0.97.128 to 10.0.97.255 + PrivateSubnet2Cidr: + Type: String + Default: 10.0.97.128/25 + +Conditions: + AppStreamEnabled: !Equals [!Ref IsAppStreamEnabled, 'true'] + AppStreamDisabled: !Equals [!Ref IsAppStreamEnabled, 'false'] + Resources: + PublicSubnet1: + Type: AWS::EC2::Subnet + Condition: AppStreamDisabled + Properties: + VpcId: !Ref VPC + AvailabilityZone: !Select [0, !GetAZs ] + CidrBlock: !Ref PublicSubnet1Cidr + MapPublicIpOnLaunch: true + Tags: + - Key: Name + Value: !Sub ${Namespace} ALB public subnet 1 + + PublicSubnet2: + Type: AWS::EC2::Subnet + Condition: AppStreamDisabled + Properties: + VpcId: !Ref VPC + AvailabilityZone: !Select [1, !GetAZs ] + CidrBlock: !Ref PublicSubnet2Cidr + MapPublicIpOnLaunch: true + Tags: + - Key: Name + Value: !Sub ${Namespace} ALB public subnet 2 + + PublicSubnet1RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Condition: AppStreamDisabled + Properties: + RouteTableId: !Ref PublicRouteTableId + SubnetId: !Ref PublicSubnet1 + + PublicSubnet2RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Condition: AppStreamDisabled + Properties: + RouteTableId: !Ref PublicRouteTableId + SubnetId: !Ref PublicSubnet2 + + AppStreamSecurityGroupEgress: + Type: AWS::EC2::SecurityGroupEgress + Condition: AppStreamEnabled + Properties: + GroupId: !Ref AppStreamSG + DestinationSecurityGroupId: !Ref ALBSecurityGroup + Description: 'Allow AppStream egress to ALB' + IpProtocol: '-1' + + PrivateSubnet1: + Type: AWS::EC2::Subnet + Condition: AppStreamEnabled + Properties: + VpcId: !Ref VPC + AvailabilityZone: !Select [0, !GetAZs ''] + CidrBlock: !Ref PrivateSubnet1Cidr + MapPublicIpOnLaunch: false + Tags: + - Key: Name + Value: !Sub ${Namespace} ALB private subnet 1 + + PrivateSubnet2: + Type: AWS::EC2::Subnet + Condition: AppStreamEnabled + Properties: + VpcId: !Ref VPC + AvailabilityZone: !Select [1, !GetAZs ''] + CidrBlock: !Ref PrivateSubnet2Cidr + MapPublicIpOnLaunch: false + Tags: + - Key: Name + Value: !Sub ${Namespace} ALB private subnet 2 + ALBListener: - Type: AWS::ElasticLoadBalancingV2::Listener - Properties: - DefaultActions: - - Type: fixed-response - FixedResponseConfig: - ContentType: "text/plain" - MessageBody: "Forbidden" - StatusCode: "403" - LoadBalancerArn: - Ref: ApplicationLoadBalancer - Port: 443 - Protocol: HTTPS - SslPolicy: ELBSecurityPolicy-2016-08 - Certificates: - - CertificateArn: !Ref ACMSSLCertARN + Type: AWS::ElasticLoadBalancingV2::Listener + Properties: + DefaultActions: + - Type: fixed-response + FixedResponseConfig: + ContentType: 'text/plain' + MessageBody: 'Forbidden' + StatusCode: '403' + LoadBalancerArn: + Ref: ApplicationLoadBalancer + Port: 443 + Protocol: HTTPS + SslPolicy: ELBSecurityPolicy-2016-08 + Certificates: + - CertificateArn: !Ref ACMSSLCertARN + ApplicationLoadBalancer: Type: AWS::ElasticLoadBalancingV2::LoadBalancer Properties: Name: !Ref Namespace - Scheme: internet-facing # or internal + Scheme: !If [AppStreamEnabled, 'internal', 'internet-facing'] Subnets: - - Ref: Subnet1 - - Ref: Subnet2 - SecurityGroups: - - Ref: ALBSecurityGroup + !If [AppStreamEnabled, [!Ref PrivateSubnet1, !Ref PrivateSubnet2], [!Ref PublicSubnet1, !Ref PublicSubnet2]] + SecurityGroups: + - Ref: ALBSecurityGroup + ALBSecurityGroup: Type: AWS::EC2::SecurityGroup Properties: SecurityGroupIngress: - - CidrIp: "0.0.0.0/0" - FromPort: 443 - ToPort: 443 - IpProtocol: tcp + - !If + - AppStreamEnabled + - SourceSecurityGroupId: !Ref AppStreamSG + IpProtocol: '-1' + - CidrIp: '0.0.0.0/0' + FromPort: 443 + ToPort: 443 + IpProtocol: tcp GroupDescription: ALB SecurityGroup VpcId: !Ref VPC + Outputs: LoadBalancerArn: Description: ARN of Application Load Balancer @@ -67,4 +178,4 @@ Outputs: Value: !Ref ALBListener ALBSecurityGroupId: Description: Security Group of Application Load Balancer Listener - Value: !Ref ALBSecurityGroup \ No newline at end of file + Value: !Ref ALBSecurityGroup diff --git a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/onboard-account.cfn.yml b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/onboard-account.cfn.yml index ca87aa58e6..ec57f5b9e8 100644 --- a/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/onboard-account.cfn.yml +++ b/addons/addon-base-raas/packages/base-raas-cfn-templates/src/templates/onboard-account.cfn.yml @@ -40,11 +40,6 @@ Parameters: Type: String Default: 10.0.0.0/19 - VpcPublicSubnet2Cidr: - Description: Please enter the IP range (CIDR notation) for the public subnet 2 in the 2nd Availability Zone - Type: String - Default: 10.0.32.0/19 - LaunchConstraintRolePrefix: Description: Role name prefix to use when creating a launch constraint role in the on-boarded account Type: String @@ -67,7 +62,7 @@ Parameters: Description: Please enter the IP range (CIDR notation) for the Workspace subnet. This value is only used if AppStream is enabled. Type: String Default: 10.0.64.0/19 - + AppStreamFleetDesiredInstances: Description: The desired number of streaming instances. Type: Number @@ -107,6 +102,8 @@ Parameters: Type: String Default: '' + # Note: While adding additional CIDRs/Subnets ensure they don't overlap with those used by RStudio ALB (10.0.96.0 to 10.0.97.255) + Metadata: AWS::CloudFormation::Interface: ParameterGroups: @@ -123,14 +120,14 @@ Metadata: default: Deployment Configuration Parameters: - VpcCidr - - VpcPublicSubnet1Cidr - - VpcPublicSubnet2Cidr - PublicSubnetCidr + Conditions: isAppStream: !Equals - !Ref EnableAppStream - true isNotAppStream: !Not [Condition: isAppStream] + hasCustomDomain: !Not [!Equals [!Ref "DomainName", ""]] isAppStreamAndCustomDomain: !And - !Not [!Equals [!Ref "DomainName", ""]] - !Condition isAppStream @@ -184,8 +181,8 @@ Resources: - ec2:DescribeImages - ec2:DescribeInstances - ec2:DescribeSecurityGroups - - ec2:RevokeSecurityGroupIngress - - ec2:AuthorizeSecurityGroupIngress + - ec2:RevokeSecurityGroup* + - ec2:AuthorizeSecurityGroup* - ec2-instance-connect:SendSSHPublicKey Resource: '*' - PolicyName: cfn-access @@ -416,6 +413,9 @@ Resources: Action: - ec2:DescribeInstanceStatus - ec2:DescribeInstances + - ec2:DescribeRouteTables + - ec2:AssociateRouteTable + - ec2:DisassociateRouteTable Resource: '*' # For the actions listed above IAM does not support resource-level permissions and requires all resources to be chosen - Effect: Allow Action: @@ -438,6 +438,7 @@ Resources: Action: - ec2:CreateSubnet - ec2:DeleteSubnet + - ec2:ModifySubnetAttribute - ec2:AssociateSubnetCidrBlock - ec2:DisassociateSubnetCidrBlock Resource: @@ -447,8 +448,8 @@ Resources: Action: - ec2:CreateSecurityGroup - ec2:DeleteSecurityGroup - - ec2:AuthorizeSecurityGroupIngress - - ec2:RevokeSecurityGroupIngress + - ec2:AuthorizeSecurityGroup* + - ec2:RevokeSecurityGroup* Resource: - !Sub 'arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:vpc/*' - !Sub 'arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:security-group/*' @@ -627,17 +628,6 @@ Resources: - Key: Name Value: !Sub ${Namespace} public subnet 1 - PublicSubnet2: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref VPC - AvailabilityZone: !Select [1, !GetAZs ] - CidrBlock: !Ref VpcPublicSubnet2Cidr - MapPublicIpOnLaunch: true - Tags: - - Key: Name - Value: !Sub ${Namespace} public subnet 2 - PublicRouteTable: Type: AWS::EC2::RouteTable Condition: isNotAppStream @@ -663,12 +653,6 @@ Resources: RouteTableId: !Ref PublicRouteTable SubnetId: !Ref PublicSubnet1 - PublicSubnet2RouteTableAssociation: - Type: AWS::EC2::SubnetRouteTableAssociation - Properties: - RouteTableId: !Ref PublicRouteTable - SubnetId: !Ref PublicSubnet2 - EncryptionKey: Type: AWS::KMS::Key Properties: @@ -732,6 +716,8 @@ Resources: Tags: - Key: Name Value: Private Workspace Subnet + + PrivateWorkspaceRouteTable: Type: 'AWS::EC2::RouteTable' @@ -951,7 +937,7 @@ Resources: IpProtocol: '-1' - DestinationSecurityGroupId: !Ref WorkspaceSecurityGroup IpProtocol: '-1' - + AppStreamSecurityGroupEgress: Type: AWS::EC2::SecurityGroupEgress Condition: isAppStream @@ -1047,6 +1033,10 @@ Outputs: Description: The arn of the role SWB uses to check permissions status Value: !GetAtt [CfnStatusRole, Arn] + PublicRouteTableId: + Description: The public route table assigned to the workspace VPC + Value: !Ref PublicRouteTable + #------------AppStream Output Below------- PrivateAppStreamSubnet: Description: AppStream subnet @@ -1057,7 +1047,7 @@ Outputs: Description: Workspace subnet Condition: isAppStream Value: !Ref PrivateWorkspaceSubnet - + AppStreamSecurityGroup: Description: AppStream Security Group Condition: isAppStream @@ -1069,7 +1059,7 @@ Outputs: Description: SageMaker Security Group Condition: isAppStream Value: !Ref SageMakerSecurityGroup - + AppStreamFleet: Description: AppStream Fleet Condition: isAppStream diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/alb/__tests__/alb-service.test.js b/addons/addon-base-raas/packages/base-raas-services/lib/alb/__tests__/alb-service.test.js index cee73d70a1..c7677cf1b2 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/alb/__tests__/alb-service.test.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/alb/__tests__/alb-service.test.js @@ -93,6 +93,9 @@ describe('ALBService', () => { }; service.getAlbSdk = jest.fn().mockResolvedValue(albClient); service.getEc2Sdk = jest.fn().mockResolvedValue(ec2Client); + service.checkIfAppStreamEnabled = jest.fn(() => { + return false; + }); }); afterEach(() => { @@ -253,21 +256,26 @@ describe('ALBService', () => { }); describe('getStackCreationInput', () => { - const resolvedInputParams = [{ Key: 'ACMSSLCertARN', Value: 'Value' }]; const resolvedVars = { namespace: 'namespace' }; - it('should pass and return the stack creation input with success', async () => { + + it('should pass and return the stack creation input with success for AppStream', async () => { + service.checkIfAppStreamEnabled = jest.fn(() => { + return true; + }); + const resolvedInputParams = [ + { Key: 'ACMSSLCertARN', Value: 'Value' }, + { Key: 'IsAppStreamEnabled', Value: 'true' }, + ]; service.findAwsAccountDetails = jest.fn(() => { return { subnetId: 'subnet-0a661d9f417ecff3f', vpcId: 'vpc-096b034133955abba', + appStreamSecurityGroupId: 'sampleAppStreamSgId', }; }); cfnTemplateService.getTemplate.mockImplementationOnce(() => { return ['template']; }); - jest.spyOn(service, 'findSubnet2').mockImplementationOnce(() => { - return 'test-subnet-2'; - }); const apiResponse = { StackName: resolvedVars.namespace, Parameters: [ @@ -276,12 +284,58 @@ describe('ALBService', () => { ParameterValue: 'namespace', }, { - ParameterKey: 'Subnet1', - ParameterValue: 'subnet-0a661d9f417ecff3f', + ParameterKey: 'ACMSSLCertARN', + ParameterValue: 'Value', + }, + { + ParameterKey: 'VPC', + ParameterValue: 'vpc-096b034133955abba', + }, + { + ParameterKey: 'IsAppStreamEnabled', + ParameterValue: 'true', + }, + { + ParameterKey: 'AppStreamSG', + ParameterValue: 'sampleAppStreamSgId', }, { - ParameterKey: 'Subnet2', - ParameterValue: 'test-subnet-2', + ParameterKey: 'PublicRouteTableId', + ParameterValue: 'N/A', + }, + ], + TemplateBody: ['template'], + Tags: [ + { + Key: 'Description', + Value: 'Created by SWB for the AWS account', + }, + ], + }; + const response = await service.getStackCreationInput({}, resolvedVars, resolvedInputParams, 'project_id'); + expect(response).toEqual(apiResponse); + }); + it('should pass and return the stack creation input with success', async () => { + const resolvedInputParams = [ + { Key: 'ACMSSLCertARN', Value: 'Value' }, + { Key: 'IsAppStreamEnabled', Value: 'false' }, + ]; + service.findAwsAccountDetails = jest.fn(() => { + return { + subnetId: 'subnet-0a661d9f417ecff3f', + vpcId: 'vpc-096b034133955abba', + publicRouteTableId: 'rtb-sampleRouteTableId', + }; + }); + cfnTemplateService.getTemplate.mockImplementationOnce(() => { + return ['template']; + }); + const apiResponse = { + StackName: resolvedVars.namespace, + Parameters: [ + { + ParameterKey: 'Namespace', + ParameterValue: 'namespace', }, { ParameterKey: 'ACMSSLCertARN', @@ -291,6 +345,18 @@ describe('ALBService', () => { ParameterKey: 'VPC', ParameterValue: 'vpc-096b034133955abba', }, + { + ParameterKey: 'IsAppStreamEnabled', + ParameterValue: 'false', + }, + { + ParameterKey: 'AppStreamSG', + ParameterValue: 'AppStreamNotConfigured', + }, + { + ParameterKey: 'PublicRouteTableId', + ParameterValue: 'rtb-sampleRouteTableId', + }, ], TemplateBody: ['template'], Tags: [ @@ -305,12 +371,13 @@ describe('ALBService', () => { }); it('should fail because project id is not valid', async () => { + const resolvedInputParams = [ + { Key: 'ACMSSLCertARN', Value: 'Value' }, + { Key: 'IsAppStreamEnabled', Value: 'false' }, + ]; projectService.mustFind.mockImplementationOnce(() => { throw service.boom.notFound(`project with id "test-id" does not exist`, true); }); - jest.spyOn(service, 'findSubnet2').mockImplementationOnce(() => { - return 'test-subnet'; - }); try { await service.getStackCreationInput({}, resolvedVars, resolvedInputParams, ''); } catch (err) { @@ -347,10 +414,34 @@ describe('ALBService', () => { const targetGroupArn = 'rn:aws:elasticloadbalancing:us-east-2:977461429431:targetgroup/devrgsaas-sg/f4c2a2df084e5df4'; - it('should pass if system is trying to create listener rule', async () => { + it('should pass if system is trying to create listener rule for AppStream', async () => { + service.checkIfAppStreamEnabled = jest.fn(() => { + return true; + }); service.findAwsAccountId = jest.fn(() => { return 'sampleAwsAccountId'; }); + const subdomain = 'rtsudio-test.example.com'; + const priority = 1; + const params = { + ListenerArn: JSON.parse(albDetails.value).listenerArn, + Priority: priority, + Actions: [ + { + TargetGroupArn: targetGroupArn, + Type: 'forward', + }, + ], + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: [subdomain], + }, + }, + ], + Tags: resolvedVars.tags, + }; service.updateAlbDependentWorkspaceCount = jest.fn(); albClient.describeRules = jest.fn().mockImplementation(() => { return { @@ -371,12 +462,74 @@ describe('ALBService', () => { return albDetails; }); service.getHostname = jest.fn(() => { - return 'rtsudio-test.example.com'; + return subdomain; }); jest.spyOn(service, 'calculateRulePriority').mockImplementationOnce(() => { - return 1; + return priority; }); await service.createListenerRule(prefix, requestContext, resolvedVars, targetGroupArn); + expect(albClient.createRule).toHaveBeenCalledWith(params); + expect(albClient.createRule).toHaveBeenCalled(); + }); + + it('should pass if system is trying to create listener rulefor non-AppStream', async () => { + service.findAwsAccountId = jest.fn(() => { + return 'sampleAwsAccountId'; + }); + service.updateAlbDependentWorkspaceCount = jest.fn(); + const subdomain = 'rtsudio-test.example.com'; + const priority = 1; + const params = { + ListenerArn: JSON.parse(albDetails.value).listenerArn, + Priority: priority, + Actions: [ + { + TargetGroupArn: targetGroupArn, + Type: 'forward', + }, + ], + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: [subdomain], + }, + }, + { + Field: 'source-ip', + SourceIpConfig: { + Values: [resolvedVars.cidr], + }, + }, + ], + Tags: resolvedVars.tags, + }; + albClient.describeRules = jest.fn().mockImplementation(() => { + return { + promise: () => { + return describeAPIResponse; + }, + }; + }); + albClient.createRule = jest.fn().mockImplementation(() => { + return { + promise: () => { + return createAPIResponse; + }, + }; + }); + service.getAlbSdk = jest.fn().mockResolvedValue(albClient); + service.getAlbDetails = jest.fn(() => { + return albDetails; + }); + service.getHostname = jest.fn(() => { + return subdomain; + }); + jest.spyOn(service, 'calculateRulePriority').mockImplementationOnce(() => { + return priority; + }); + await service.createListenerRule(prefix, requestContext, resolvedVars, targetGroupArn); + expect(albClient.createRule).toHaveBeenCalledWith(params); expect(albClient.createRule).toHaveBeenCalled(); }); @@ -506,7 +659,51 @@ describe('ALBService', () => { }); describe('modifyRule', () => { - it('should pass and return empty object with success', async () => { + it('should pass for AppStream', async () => { + service.checkIfAppStreamEnabled = jest.fn(() => { + return true; + }); + const resolvedVars = { + projectId: 'bio-research-vir2', + envId: '018bb1e1-6bd3-49d9-b608-051cfb180882', + prefix: 'rstudio', + ruleARN: + 'arn:aws:elasticloadbalancing:us-west-2:123456789012:listener-rule/app/my-load-balancer/50dc6c495c0c9188/f2f7dc8efc522ab2/9683b2d02a6cabee', + }; + const subdomain = 'rtsudio-test.example.com'; + const params = { + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: [subdomain], + }, + }, + ], + RuleArn: resolvedVars.ruleARN, + }; + service.getHostname = jest.fn(() => { + return subdomain; + }); + service.findAwsAccountDetails = jest.fn(() => { + return { + externalId: 'subnet-0a661d9f417ecff3f', + }; + }); + albClient.modifyRule = jest.fn().mockImplementation(() => { + return { + promise: () => { + return {}; + }, + }; + }); + service.getAlbSdk = jest.fn().mockResolvedValue(albClient); + const response = await service.modifyRule({}, resolvedVars); + expect(albClient.modifyRule).toHaveBeenCalledWith(params); + expect(response).toEqual({}); + }); + + it('should pass for non-AppStream', async () => { const resolvedVars = { projectId: 'bio-research-vir2', envId: '018bb1e1-6bd3-49d9-b608-051cfb180882', @@ -515,26 +712,26 @@ describe('ALBService', () => { ruleARN: 'arn:aws:elasticloadbalancing:us-west-2:123456789012:listener-rule/app/my-load-balancer/50dc6c495c0c9188/f2f7dc8efc522ab2/9683b2d02a6cabee', }; + const subdomain = 'rtsudio-test.example.com'; const params = { Conditions: [ { Field: 'host-header', HostHeaderConfig: { - Values: ['rtsudio-test.example.com'], + Values: [subdomain], }, }, { Field: 'source-ip', SourceIpConfig: { - Values: ['10.0.0.0/32'], + Values: resolvedVars.cidr, }, }, ], - RuleArn: - 'arn:aws:elasticloadbalancing:us-west-2:123456789012:listener-rule/app/my-load-balancer/50dc6c495c0c9188/f2f7dc8efc522ab2/9683b2d02a6cabee', + RuleArn: resolvedVars.ruleARN, }; service.getHostname = jest.fn(() => { - return 'rtsudio-test.example.com'; + return subdomain; }); service.findAwsAccountDetails = jest.fn(() => { return { @@ -604,7 +801,6 @@ describe('ALBService', () => { albClient.describeRules = jest.fn().mockImplementation(() => { throw new Error(`Error calculating rule priority. Rule describe failed with message - Rule not found`); }); - // service.getAlbSdk = jest.fn().mockResolvedValue(albClient); try { await service.calculateRulePriority({}, {}, ''); } catch (err) { @@ -622,7 +818,6 @@ describe('ALBService', () => { }, }; }); - // service.getAlbSdk = jest.fn().mockResolvedValue(albClient); const response = await service.calculateRulePriority({}, {}, ''); expect(response).toEqual(1); }); @@ -637,51 +832,23 @@ describe('ALBService', () => { }, }; }); - // service.getAlbSdk = jest.fn().mockResolvedValue(albClient); const response = await service.calculateRulePriority({}, {}, ''); expect(response).toEqual(3); }); }); - describe('findSubnet2', () => { - it('should fail when describe subnet API call throws error', async () => { - ec2Client.describeSubnets = jest.fn().mockImplementation(() => { - throw new Error(`Error describing subnet. VPC does not exist`); - }); - try { - await service.findSubnet2({}, {}, ''); - } catch (err) { - expect(err.message).toContain('Error describing subnet. VPC does not exist'); - } - }); - - it('should fail when subnet not found', async () => { - ec2Client.describeSubnets = jest.fn().mockImplementation(() => { - return { - promise: () => { - return { Subnets: [] }; - }, - }; - }); - try { - await service.findSubnet2({}, {}, 'test-vpc'); - } catch (err) { - expect(err.message).toContain( - 'Error provisioning environment. Reason: Subnet2 not found for the VPC - test-vpc', - ); - } - }); - - it('should return subnet id on success', async () => { - ec2Client.describeSubnets = jest.fn().mockImplementation(() => { + describe('getAlbHostedZoneID', () => { + it('should provide ALB hosted zone ID', async () => { + const albHostedZoneId = 'sampleAlbHostedZoneId'; + albClient.describeLoadBalancers = jest.fn().mockImplementation(() => { return { promise: () => { - return { Subnets: [{ SubnetId: 'test-subnet-id' }] }; + return { LoadBalancers: [{ CanonicalHostedZoneId: albHostedZoneId }] }; }, }; }); - const response = await service.findSubnet2({}, {}, 'test-vpc'); - expect(response).toEqual('test-subnet-id'); + const response = await service.getAlbHostedZoneID({}, {}, ''); + expect(response).toEqual(albHostedZoneId); }); }); }); diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/alb/alb-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/alb/alb-service.js index 876edd6a61..0cdae8f2f9 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/alb/alb-service.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/alb/alb-service.js @@ -18,6 +18,7 @@ const Service = require('@aws-ee/base-services-container/lib/service'); const settingKeys = { domainName: 'domainName', + isAppStreamEnabled: 'isAppStreamEnabled', }; class ALBService extends Service { @@ -84,18 +85,27 @@ class ALBService extends Service { */ async getStackCreationInput(requestContext, resolvedVars, resolvedInputParams, projectId) { const awsAccountDetails = await this.findAwsAccountDetails(requestContext, projectId); - const subnet2 = await this.findSubnet2(requestContext, resolvedVars, awsAccountDetails.vpcId); const [cfnTemplateService] = await this.service(['cfnTemplateService']); const [template] = await Promise.all([cfnTemplateService.getTemplate('application-load-balancer')]); const cfnParams = []; const certificateArn = _.find(resolvedInputParams, o => o.Key === 'ACMSSLCertARN'); + const isAppStreamEnabled = _.find(resolvedInputParams, o => o.Key === 'IsAppStreamEnabled'); const addParam = (key, v) => cfnParams.push({ ParameterKey: key, ParameterValue: v }); addParam('Namespace', resolvedVars.namespace); - addParam('Subnet1', awsAccountDetails.subnetId); - addParam('Subnet2', subnet2); addParam('ACMSSLCertARN', certificateArn.Value); addParam('VPC', awsAccountDetails.vpcId); + addParam('IsAppStreamEnabled', isAppStreamEnabled.Value); + addParam( + 'AppStreamSG', + _.isUndefined(awsAccountDetails.appStreamSecurityGroupId) + ? 'AppStreamNotConfigured' + : awsAccountDetails.appStreamSecurityGroupId, + ); + addParam( + 'PublicRouteTableId', + _.isUndefined(awsAccountDetails.appStreamSecurityGroupId) ? awsAccountDetails.publicRouteTableId : 'N/A', + ); const input = { StackName: resolvedVars.namespace, @@ -183,6 +193,10 @@ class ALBService extends Service { return deploymentItem; } + checkIfAppStreamEnabled() { + return this.settings.getBoolean(settingKeys.isAppStreamEnabled); + } + /** * Method to create listener rule. The method creates rule using the ALB SDK client. * Tags are read form the resolvedVars so the billing will happen properly @@ -194,36 +208,60 @@ class ALBService extends Service { * @returns {Promise} */ async createListenerRule(prefix, requestContext, resolvedVars, targetGroupArn) { + const isAppStreamEnabled = this.checkIfAppStreamEnabled(); const deploymentItem = await this.getAlbDetails(requestContext, resolvedVars.projectId); const albRecord = JSON.parse(deploymentItem.value); const listenerArn = albRecord.listenerArn; const priority = await this.calculateRulePriority(requestContext, resolvedVars, albRecord.listenerArn); const subdomain = this.getHostname(prefix, resolvedVars.envId); - const params = { - ListenerArn: listenerArn, - Priority: priority, - Actions: [ - { - TargetGroupArn: targetGroupArn, - Type: 'forward', - }, - ], - Conditions: [ - { - Field: 'host-header', - HostHeaderConfig: { - Values: [subdomain], + let params; + if (isAppStreamEnabled) { + params = { + ListenerArn: listenerArn, + Priority: priority, + Actions: [ + { + TargetGroupArn: targetGroupArn, + Type: 'forward', }, - }, - { - Field: 'source-ip', - SourceIpConfig: { - Values: [resolvedVars.cidr], + ], + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: [subdomain], + }, }, - }, - ], - Tags: resolvedVars.tags, - }; + ], + Tags: resolvedVars.tags, + }; + } else { + params = { + ListenerArn: listenerArn, + Priority: priority, + Actions: [ + { + TargetGroupArn: targetGroupArn, + Type: 'forward', + }, + ], + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: [subdomain], + }, + }, + { + Field: 'source-ip', + SourceIpConfig: { + Values: [resolvedVars.cidr], + }, + }, + ], + Tags: resolvedVars.tags, + }; + } const albClient = await this.getAlbSdk(requestContext, resolvedVars); let response = null; try { @@ -326,36 +364,6 @@ class ALBService extends Service { return `${prefix}-${id}.${domainName}`; } - /** - * Method to get the EC2 SDK client for the target aws account - * - * @param requestContext - * @param resolvedVars - * @param vpcId - * @returns {Promise} - */ - async findSubnet2(requestContext, resolvedVars, vpcId) { - const params = { - Filters: [ - { - Name: 'vpc-id', - Values: [vpcId], - }, - { - Name: 'tag:aws:cloudformation:logical-id', - Values: ['PublicSubnet2'], - }, - ], - }; - const ec2Client = await this.getEc2Sdk(requestContext, resolvedVars); - const response = await ec2Client.describeSubnets(params).promise(); - const subnetId = _.get(response.Subnets[0], 'SubnetId', null); - if (!subnetId) { - throw new Error(`Error provisioning environment. Reason: Subnet2 not found for the VPC - ${vpcId}`); - } - return subnetId; - } - /** * Method to get the EC2 SDK client for the target aws account * @@ -396,6 +404,20 @@ class ALBService extends Service { return albClient; } + /** + * Method to get the ALB HostedZone ID for the target aws account + * + * @param requestContext + * @param resolvedVars + * @returns {Promise<>} + */ + async getAlbHostedZoneID(requestContext, resolvedVars, albArn) { + const albClient = await this.getAlbSdk(requestContext, resolvedVars); + const params = { LoadBalancerArns: [albArn] }; + const response = await albClient.describeLoadBalancers(params).promise(); + return response.LoadBalancers[0].CanonicalHostedZoneId; + } + /** * Method to get role arn for the target aws account * @@ -427,24 +449,40 @@ class ALBService extends Service { */ async modifyRule(requestContext, resolvedVars) { const subdomain = this.getHostname(resolvedVars.prefix, resolvedVars.envId); + const isAppStreamEnabled = this.checkIfAppStreamEnabled(); try { - const params = { - Conditions: [ - { - Field: 'host-header', - HostHeaderConfig: { - Values: [subdomain], + let params; + if (isAppStreamEnabled) { + params = { + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: [subdomain], + }, }, - }, - { - Field: 'source-ip', - SourceIpConfig: { - Values: resolvedVars.cidr, + ], + RuleArn: resolvedVars.ruleARN, + }; + } else { + params = { + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: [subdomain], + }, }, - }, - ], - RuleArn: resolvedVars.ruleARN, - }; + { + Field: 'source-ip', + SourceIpConfig: { + Values: resolvedVars.cidr, + }, + }, + ], + RuleArn: resolvedVars.ruleARN, + }; + } const { externalId } = await this.findAwsAccountDetails(requestContext, resolvedVars.projectId); resolvedVars.externalId = externalId; const albClient = await this.getAlbSdk(requestContext, resolvedVars); diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/aws-accounts/__tests__/aws-cfn-service.test.js b/addons/addon-base-raas/packages/base-raas-services/lib/aws-accounts/__tests__/aws-cfn-service.test.js index 44252065c6..203d27217f 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/aws-accounts/__tests__/aws-cfn-service.test.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/aws-accounts/__tests__/aws-cfn-service.test.js @@ -100,6 +100,11 @@ describe('AwsAccountService', () => { OutputValue: 'subnet-placeholder', Description: 'A reference to the public subnet in the 1st Availability Zone', }, + { + OutputKey: 'PublicRouteTableId', + OutputValue: 'rtd-samplePublicRouteTableId', + Description: 'The public route table assigned to the workspace VPC', + }, { OutputKey: 'CrossAccountEnvMgmtRoleArn', OutputValue: 'arn:aws:iam::placeholder', @@ -481,6 +486,7 @@ describe('AwsAccountService', () => { encryptionKeyArn: 'arn:aws:kms:placeholder', permissionStatus: 'CURRENT', rev: completedAccountMock.rev, + publicRouteTableId: 'rtd-samplePublicRouteTableId', }; awsAccountsService.list.mockImplementationOnce(() => [completedAccountMock]); diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/aws-accounts/aws-cfn-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/aws-accounts/aws-cfn-service.js index 5c9796b279..6529e31f01 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/aws-accounts/aws-cfn-service.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/aws-accounts/aws-cfn-service.js @@ -476,8 +476,8 @@ class AwsCfnService extends Service { } } else { fieldsToUpdate.subnetId = findOutputValue('VpcPublicSubnet1'); + fieldsToUpdate.publicRouteTableId = findOutputValue('PublicRouteTableId'); } - await awsAccountsService.update(requestContext, fieldsToUpdate); // TODO Start AppStream fleet diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/__tests__/environment-dns-service.test.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/__tests__/environment-dns-service.test.js index 118cb5b07e..d1084c7d06 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/environment/__tests__/environment-dns-service.test.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/__tests__/environment-dns-service.test.js @@ -43,6 +43,12 @@ describe('EnvironmentDnsService', () => { service = await container.find('environmentDnsService'); environmentScService = await container.find('environmentScService'); settings = await container.find('settings'); + settings.get = jest.fn(key => { + if (key === 'domainName') { + return 'test.aws'; + } + throw Error(`${key} not found`); + }); }); describe('Test changePrivateRecordSet', () => { @@ -53,12 +59,6 @@ describe('EnvironmentDnsService', () => { environmentScService.getClientSdkWithEnvMgmtRole = jest.fn(() => { return route53Client; }); - settings.get = jest.fn(key => { - if (key === 'domainName') { - return 'test.aws'; - } - throw Error(`${key} not found`); - }); await service.changePrivateRecordSet(requestContext, 'CREATE', 'rstudio', 'test-id', '10.1.1.1', 'HOSTEDZONE123'); expect(service.changeResourceRecordSets).toHaveBeenCalledWith( route53Client, @@ -127,6 +127,56 @@ describe('EnvironmentDnsService', () => { }); }); + describe('Test createPrivateRecordForDNS', () => { + it('should call changePrivateRecordSetALB', async () => { + const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; + service.changePrivateRecordSetALB = jest.fn(); + await service.createPrivateRecordForDNS( + requestContext, + 'rstudio', + 'test-id', + 'sampleAlbHostedZoneId', + 'sampleAlbDnsName', + 'HOSTEDZONE123', + ); + expect(service.changePrivateRecordSetALB).toHaveBeenCalledTimes(1); + expect(service.changePrivateRecordSetALB).toHaveBeenCalledWith( + requestContext, + 'CREATE', + 'rstudio', + 'test-id', + 'HOSTEDZONE123', + 'sampleAlbHostedZoneId', + 'sampleAlbDnsName', + ); + }); + }); + + describe('Test deletePrivateRecordForDNS', () => { + it('should call changePrivateRecordSetALB', async () => { + const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; + service.changePrivateRecordSetALB = jest.fn(); + await service.deletePrivateRecordForDNS( + requestContext, + 'rstudio', + 'test-id', + 'sampleAlbHostedZoneId', + 'sampleAlbDnsName', + 'HOSTEDZONE123', + ); + expect(service.changePrivateRecordSetALB).toHaveBeenCalledTimes(1); + expect(service.changePrivateRecordSetALB).toHaveBeenCalledWith( + requestContext, + 'DELETE', + 'rstudio', + 'test-id', + 'HOSTEDZONE123', + 'sampleAlbHostedZoneId', + 'sampleAlbDnsName', + ); + }); + }); + describe('Test deletePrivateRecord', () => { it('should call changePrivateRecordSet', async () => { const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; @@ -143,4 +193,84 @@ describe('EnvironmentDnsService', () => { ); }); }); + + describe('Test changePrivateRecordSetALB', () => { + it('should call changeResourceRecordSetsPrivateALB', async () => { + // BUILD + const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; + environmentScService.getClientSdkWithEnvMgmtRole = jest.fn(() => { + return {}; + }); + + service.changeResourceRecordSetsPrivateALB = jest.fn(); + + // OPERATE + await service.changePrivateRecordSetALB( + requestContext, + 'ACTION', + 'rstudio', + 'test-id', + 'sampleHostedZoneId', + 'samplealbHostedZoneId', + 'sampleRecordValue', + ); + + // CHECK + expect(service.changeResourceRecordSetsPrivateALB).toHaveBeenCalledTimes(1); + expect(service.changeResourceRecordSetsPrivateALB).toHaveBeenCalledWith( + {}, + 'sampleHostedZoneId', + 'ACTION', + `rstudio-test-id.test.aws`, + 'samplealbHostedZoneId', + 'sampleRecordValue', + ); + }); + }); + + describe('Test changeResourceRecordSetsPrivateALB', () => { + it('should call changeResourceRecordSets', async () => { + // BUILD + const route53Client = jest.fn(); + route53Client.changeResourceRecordSets = jest.fn(() => { + return { + promise: jest.fn(), + }; + }); + + const params = { + HostedZoneId: 'sampleHostedZoneId', + ChangeBatch: { + Changes: [ + { + Action: 'ACTION', + ResourceRecordSet: { + Name: 'rstudio-test-id.test.aws', + Type: 'A', + AliasTarget: { + HostedZoneId: 'samplealbHostedZoneId', + DNSName: 'dualstack.sampleRecordValue', + EvaluateTargetHealth: false, + }, + }, + }, + ], + }, + }; + + // OPERATE + await service.changeResourceRecordSetsPrivateALB( + route53Client, + 'sampleHostedZoneId', + 'ACTION', + 'rstudio-test-id.test.aws', + 'samplealbHostedZoneId', + 'sampleRecordValue', + ); + + // CHECK + expect(route53Client.changeResourceRecordSets).toHaveBeenCalledTimes(1); + expect(route53Client.changeResourceRecordSets).toHaveBeenCalledWith(params); + }); + }); }); diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-dns-service.js b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-dns-service.js index 2ada1961fd..e8849d96ef 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-dns-service.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/environment/environment-dns-service.js @@ -42,6 +42,24 @@ class EnvironmentDnsService extends Service { await this.changeResourceRecordSets(route53Client, hostedZoneId, action, subdomain, 'A', privateIp); } + async changePrivateRecordSetALB(requestContext, action, prefix, id, hostedZoneId, albHostedZoneId, recordValue) { + const environmentScService = await this.service('environmentScService'); + const route53Client = await environmentScService.getClientSdkWithEnvMgmtRole( + requestContext, + { id }, + { clientName: 'Route53', options: { apiVersion: '2017-07-24' } }, + ); + const subdomain = this.getHostname(prefix, id); + await this.changeResourceRecordSetsPrivateALB( + route53Client, + hostedZoneId, + action, + subdomain, + albHostedZoneId, + recordValue, + ); + } + async changeRecordSet(action, prefix, id, publicDnsName) { const aws = await this.service('aws'); const route53Client = new aws.sdk.Route53(); @@ -70,6 +88,60 @@ class EnvironmentDnsService extends Service { await route53Client.changeResourceRecordSets(params).promise(); } + async changeResourceRecordSetsPrivateALB( + route53Client, + hostedZoneId, + action, + subdomain, + albHostedZoneId, + recordValue, + ) { + const params = { + HostedZoneId: hostedZoneId, + ChangeBatch: { + Changes: [ + { + Action: action, + ResourceRecordSet: { + Name: subdomain, + Type: 'A', + AliasTarget: { + HostedZoneId: albHostedZoneId, + DNSName: `dualstack.${recordValue}`, + EvaluateTargetHealth: false, + }, + }, + }, + ], + }, + }; + await route53Client.changeResourceRecordSets(params).promise(); + } + + async createPrivateRecordForDNS(requestContext, prefix, id, albHostedZoneId, albDnsName, hostedZoneId) { + await this.changePrivateRecordSetALB( + requestContext, + 'CREATE', + prefix, + id, + hostedZoneId, + albHostedZoneId, + albDnsName, + ); + } + + async deletePrivateRecordForDNS(requestContext, prefix, id, albHostedZoneId, albDnsName, hostedZoneId) { + await this.changePrivateRecordSetALB( + requestContext, + 'DELETE', + prefix, + id, + hostedZoneId, + albHostedZoneId, + albDnsName, + ); + } + async createPrivateRecord(requestContext, prefix, id, privateIp, hostedZoneId) { await this.changePrivateRecordSet(requestContext, 'CREATE', prefix, id, privateIp, hostedZoneId); } diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/plugins/__tests__/env-provisioning-plugin.test.js b/addons/addon-base-raas/packages/base-raas-services/lib/plugins/__tests__/env-provisioning-plugin.test.js index 5675fb1136..4981324200 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/plugins/__tests__/env-provisioning-plugin.test.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/plugins/__tests__/env-provisioning-plugin.test.js @@ -22,13 +22,22 @@ const Logger = require('@aws-ee/base-services/lib/logger/logger-service'); jest.mock('@aws-ee/base-services/lib/plugin-registry/plugin-registry-service'); const PluginRegistryService = require('@aws-ee/base-services/lib/plugin-registry/plugin-registry-service'); +jest.mock('@aws-ee/base-services/lib/lock/lock-service'); +const LockService = require('@aws-ee/base-services/lib/lock/lock-service'); + jest.mock('../../environment/service-catalog/environment-sc-service'); const SettingsServiceMock = require('@aws-ee/base-services/lib/settings/env-settings-service'); const EnvironmentScService = require('../../environment/service-catalog/environment-sc-service'); +jest.mock('../../environment/service-catalog/environment-sc-cidr-service'); +const EnvironmentScCidrService = require('../../environment/service-catalog/environment-sc-service'); + jest.mock('../../environment/environment-dns-service'); const EnvironmentDNSService = require('../../environment/environment-dns-service'); +jest.mock('../../alb/alb-service'); +const AlbService = require('../../alb/alb-service'); + jest.mock('../../environment/service-catalog/environment-sc-keypair-service'); const EnvironmentSCKeyPairService = require('../../environment/service-catalog/environment-sc-keypair-service'); @@ -41,15 +50,21 @@ describe('envProvisioningPlugin', () => { let container; const requestContext = { principal: { isAdmin: true, status: 'active' } }; let environmentScService; + let environmentScCidrService; let pluginRegistryService; let environmentDnsService; + let albService; + let lockService; let settings; beforeEach(async () => { // Initialize services container and register dependencies container = new ServicesContainer(); container.register('environmentScService', new EnvironmentScService()); + container.register('environmentScCidrService', new EnvironmentScCidrService()); container.register('pluginRegistryService', new PluginRegistryService()); container.register('environmentDnsService', new EnvironmentDNSService()); + container.register('albService', new AlbService()); + container.register('lockService', new LockService()); container.register('environmentScKeypairService', new EnvironmentSCKeyPairService()); container.register('log', new Logger()); container.register('settings', new SettingsServiceMock()); @@ -59,6 +74,10 @@ describe('envProvisioningPlugin', () => { environmentScService = await container.find('environmentScService'); pluginRegistryService = await container.find('pluginRegistryService'); environmentDnsService = await container.find('environmentDnsService'); + environmentScCidrService = await container.find('environmentScCidrService'); + albService = await container.find('albService'); + lockService = await container.find('lockService'); + lockService.tryWriteLockAndRun = jest.fn((params, callback) => callback()); }); describe('preProvisioning', () => { @@ -303,6 +322,155 @@ describe('envProvisioningPlugin', () => { expect(environmentDnsService.createRecord).toHaveBeenCalledWith('rstudio', 'env-id', 'some-dns-name'); }); + it('should create environmentDNS record if it is RStudioV2 environment', async () => { + // BUILD + environmentScService.mustFind = jest.fn().mockResolvedValueOnce({ id: 'env-id' }); + settings.getBoolean = jest.fn(key => { + if (key === 'isAppStreamEnabled') { + return false; + } + throw Error(`${key} not found`); + }); + const albDetails = { + createdAt: '2021-05-21T13:06:58.216Z', + id: 'test-id', + type: 'account-workspace-details', + updatedAt: '2021-05-31T13:32:15.503Z', + value: + '{"id":"test-id","albStackName":null,"albArn":"arn:test-arn","listenerArn":"alb-listener-arn","albDnsName":"albDNSName","albDependentWorkspacesCount":1}', + }; + albService.getAlbDetails = jest.fn(() => { + return albDetails; + }); + albService.createListenerRule = jest.fn(() => { + return 'alb-listener-rule-arn'; + }); + environmentScCidrService.authorizeIngressRuleWithSecurityGroup = jest.fn(); + // OPERATE + await plugin.onEnvProvisioningSuccess({ + requestContext, + container, + resolvedVars: { envId: 'env-id' }, + status: 'SUCCEED', + outputs: [ + { OutputKey: 'MetaConnection1Type', OutputValue: 'RStudioV2' }, + { OutputKey: 'Ec2WorkspaceDnsName', OutputValue: 'some-dns-name' }, + { OutputKey: 'TargetGroupARN', OutputValue: 'some-target-group-arn' }, + { OutputKey: 'InstanceSecurityGroupId', OutputValue: 'some-security-group-id' }, + ], + provisionedProductId: 'provisioned-product-id', + }); + // CHECK + expect(environmentScService.update).toHaveBeenCalledWith( + { + principal: { + isAdmin: true, + status: 'active', + }, + }, + { + id: 'env-id', + rev: 0, + status: 'SUCCEED', + inWorkflow: 'false', + outputs: [ + { OutputKey: 'MetaConnection1Type', OutputValue: 'RStudioV2' }, + { OutputKey: 'Ec2WorkspaceDnsName', OutputValue: 'some-dns-name' }, + { OutputKey: 'TargetGroupARN', OutputValue: 'some-target-group-arn' }, + { OutputKey: 'InstanceSecurityGroupId', OutputValue: 'some-security-group-id' }, + { + OutputKey: 'ListenerRuleARN', + Description: 'ARN of the listener rule created by code', + OutputValue: 'alb-listener-rule-arn', + }, + ], + provisionedProductId: 'provisioned-product-id', + }, + ); + expect(environmentDnsService.createRecord).toHaveBeenCalledWith('rstudio', 'env-id', 'albDNSName'); + }); + + it('should create environmentDNS record if it is RStudioV2 AppStream environment', async () => { + // BUILD + environmentScService.getMemberAccount = jest + .fn() + .mockResolvedValueOnce({ route53HostedZone: 'route53HostedZone' }); + environmentScService.mustFind = jest.fn().mockResolvedValueOnce({ id: 'env-id' }); + settings.getBoolean = jest.fn(key => { + if (key === 'isAppStreamEnabled') { + return true; + } + throw Error(`${key} not found`); + }); + const albDetails = { + createdAt: '2021-05-21T13:06:58.216Z', + id: 'test-id', + type: 'account-workspace-details', + updatedAt: '2021-05-31T13:32:15.503Z', + value: + '{"id":"test-id","albStackName":null,"albArn":"arn:test-arn","listenerArn":"alb-listener-arn","albDnsName":"albDNSName","albDependentWorkspacesCount":1}', + }; + albService.getAlbHostedZoneID = jest.fn(() => { + return 'albHostedZoneId'; + }); + albService.getAlbDetails = jest.fn(() => { + return albDetails; + }); + albService.createListenerRule = jest.fn(() => { + return 'alb-listener-rule-arn'; + }); + environmentScCidrService.authorizeIngressRuleWithSecurityGroup = jest.fn(); + // OPERATE + await plugin.onEnvProvisioningSuccess({ + requestContext, + container, + resolvedVars: { envId: 'env-id' }, + status: 'SUCCEED', + outputs: [ + { OutputKey: 'MetaConnection1Type', OutputValue: 'RStudioV2' }, + { OutputKey: 'Ec2WorkspaceDnsName', OutputValue: 'some-dns-name' }, + { OutputKey: 'TargetGroupARN', OutputValue: 'some-target-group-arn' }, + { OutputKey: 'InstanceSecurityGroupId', OutputValue: 'some-security-group-id' }, + ], + provisionedProductId: 'provisioned-product-id', + }); + // CHECK + expect(environmentScService.update).toHaveBeenCalledWith( + { + principal: { + isAdmin: true, + status: 'active', + }, + }, + { + id: 'env-id', + rev: 0, + status: 'SUCCEED', + inWorkflow: 'false', + outputs: [ + { OutputKey: 'MetaConnection1Type', OutputValue: 'RStudioV2' }, + { OutputKey: 'Ec2WorkspaceDnsName', OutputValue: 'some-dns-name' }, + { OutputKey: 'TargetGroupARN', OutputValue: 'some-target-group-arn' }, + { OutputKey: 'InstanceSecurityGroupId', OutputValue: 'some-security-group-id' }, + { + OutputKey: 'ListenerRuleARN', + Description: 'ARN of the listener rule created by code', + OutputValue: 'alb-listener-rule-arn', + }, + ], + provisionedProductId: 'provisioned-product-id', + }, + ); + expect(environmentDnsService.createPrivateRecordForDNS).toHaveBeenCalledWith( + requestContext, + 'rstudio', + 'env-id', + 'albHostedZoneId', + 'albDNSName', + 'route53HostedZone', + ); + }); + it('should create private DNS record if it is RStudio environment when AppStream enabled', async () => { // BUILD environmentScService.mustFind = jest.fn().mockResolvedValueOnce({ id: 'env-id' }); diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/plugins/env-provisioning-plugin.js b/addons/addon-base-raas/packages/base-raas-services/lib/plugins/env-provisioning-plugin.js index 6b8d416e7b..38764783e5 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/plugins/env-provisioning-plugin.js +++ b/addons/addon-base-raas/packages/base-raas-services/lib/plugins/env-provisioning-plugin.js @@ -236,15 +236,27 @@ async function updateEnvOnProvisioningSuccess({ if (!deploymentItem) { throw new Error(`Error provisioning environment. Reason: No ALB found for this AWS account`); } - const dnsName = JSON.parse(deploymentItem.value).albDnsName; + const deploymentValue = JSON.parse(deploymentItem.value); + const dnsName = deploymentValue.albDnsName; const targetGroupArn = _.find(outputs, o => o.OutputKey === 'TargetGroupARN').OutputValue; // Create DNS record for RStudio workspaces const environmentDnsService = await container.find('environmentDnsService'); const settings = await container.find('settings'); if (settings.getBoolean(settingKeys.isAppStreamEnabled)) { - const privateIp = _.find(outputs, o => o.OutputKey === 'Ec2WorkspacePrivateIp').OutputValue; const hostedZoneId = await getHostedZone(requestContext, environmentScService, existingEnvRecord); - await environmentDnsService.createPrivateRecord(requestContext, 'rstudio', envId, privateIp, hostedZoneId); + const albHostedZoneId = await albService.getAlbHostedZoneID( + requestContext, + resolvedVars, + deploymentValue.albArn, + ); + await environmentDnsService.createPrivateRecordForDNS( + requestContext, + 'rstudio', + envId, + albHostedZoneId, + dnsName, + hostedZoneId, + ); } else { await environmentDnsService.createRecord('rstudio', envId, dnsName); } diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-aws-accounts.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-aws-accounts.json index d434d80756..90cc53227c 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-aws-accounts.json +++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/create-aws-accounts.json @@ -46,6 +46,9 @@ "type": "string", "pattern": "^subnet-[a-f0-9]{8,17}$" }, + "publicRouteTableId": { + "type": "string" + }, "encryptionKeyArn": { "type": "string", "pattern": "^arn:aws:kms:.*$" diff --git a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-aws-accounts.json b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-aws-accounts.json index 3877001494..13bff13f3f 100644 --- a/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-aws-accounts.json +++ b/addons/addon-base-raas/packages/base-raas-services/lib/schema/update-aws-accounts.json @@ -67,6 +67,9 @@ "permissionStatus": { "type": "string" }, + "publicRouteTableId": { + "type": "string" + }, "appStreamStackName": { "type": "string" }, diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/__test__/provision-account.test.js b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/__test__/provision-account.test.js index 373b5f73d0..e1b93f7a4e 100644 --- a/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/__test__/provision-account.test.js +++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/__test__/provision-account.test.js @@ -307,6 +307,7 @@ describe('ProvisionAccount', () => { { OutputKey: 'CrossAccountEnvMgmtRoleArn', OutputValue: 'env-mgmt-role-arn-1' }, { OutputKey: 'EncryptionKeyArn', OutputValue: 'encryption-key-arn-1' }, { OutputKey: 'OnboardStatusRoleArn', OutputValue: 'arn-onboard-1234' }, + { OutputKey: 'PublicRouteTableId', OutputValue: 'rtb-12345' }, ], }, ], @@ -356,6 +357,7 @@ describe('ProvisionAccount', () => { vpcId: 'vpc-123', encryptionKeyArn: 'encryption-key-arn-1', subnetId: 'public-subnet-1', + publicRouteTableId: 'rtb-12345', }, ); }); diff --git a/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-account/provision-account.js b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-account/provision-account.js index db326c3693..a3e2dd20ef 100644 --- a/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-account/provision-account.js +++ b/addons/addon-base-raas/packages/base-raas-workflow-steps/lib/steps/provision-account/provision-account.js @@ -289,6 +289,7 @@ class ProvisionAccount extends StepBase { vpcId: cfnOutputs.VPC, encryptionKeyArn: cfnOutputs.EncryptionKeyArn, onboardStatusRoleArn: cfnOutputs.OnboardStatusRoleArn, + publicRouteTableId: cfnOutputs.PublicRouteTableId, cfnStackName: stackInfo.StackName, cfnStackId: stackInfo.StackId, permissionStatus: 'CURRENT', diff --git a/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/__test__/check-launch-dependency.test.js b/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/__test__/check-launch-dependency.test.js index 58b4df0407..b6b549083c 100644 --- a/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/__test__/check-launch-dependency.test.js +++ b/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/__test__/check-launch-dependency.test.js @@ -337,6 +337,7 @@ describe('CheckLaunchDependencyStep', () => { albArn: null, listenerArn: null, albDnsName: null, + albHostedZoneId: null, albSecurityGroup: null, albDependentWorkspacesCount: 0, }; @@ -344,6 +345,34 @@ describe('CheckLaunchDependencyStep', () => { expect(albService.saveAlbDetails).toHaveBeenCalledWith('test-account-id', albDetails); }); + it('should update alb details with output values for AppStream', async () => { + albService.findAwsAccountId.mockImplementationOnce(() => { + return 'test-account-id'; + }); + albService.getAlbHostedZoneId = jest.fn(() => { + return 'albHostedZoneId'; + }); + jest.spyOn(albService, 'saveAlbDetails').mockImplementationOnce(() => {}); + const output = { + LoadBalancerArn: 'test-alb-arn', + ListenerArn: 'test-listener-arn', + ALBDNSName: 'test-dns', + ALBSecurityGroupId: 'test-sg', + ALBHostedZoneId: 'albHostedZoneId', + }; + const albDetails = { + id: 'test-account-id', + albStackName: 'STACK_ID', + albArn: 'test-alb-arn', + listenerArn: 'test-listener-arn', + albDnsName: 'test-dns', + albHostedZoneId: 'albHostedZoneId', + albSecurityGroup: 'test-sg', + albDependentWorkspacesCount: 0, + }; + await step.handleStackCompletion(output); + expect(albService.saveAlbDetails).toHaveBeenCalledWith('test-account-id', albDetails); + }); it('should update alb details with output values', async () => { albService.findAwsAccountId.mockImplementationOnce(() => { return 'test-account-id'; @@ -361,6 +390,7 @@ describe('CheckLaunchDependencyStep', () => { albArn: 'test-alb-arn', listenerArn: 'test-listener-arn', albDnsName: 'test-dns', + albHostedZoneId: null, albSecurityGroup: 'test-sg', albDependentWorkspacesCount: 0, }; diff --git a/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/__test__/terminate-launch-dependency.test.js b/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/__test__/terminate-launch-dependency.test.js index ae28d8c574..30173657b1 100644 --- a/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/__test__/terminate-launch-dependency.test.js +++ b/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/__test__/terminate-launch-dependency.test.js @@ -143,6 +143,9 @@ describe('TerminateLaunchDependencyStep', () => { deleteStack: jest.fn(), }; step.getCloudFormationService = jest.fn().mockResolvedValue(cfn); + step.checkIfAppStreamEnabled = jest.fn(() => { + return false; + }); albService.findAwsAccountId = jest.fn(() => { return 'test-account-id'; }); @@ -152,7 +155,7 @@ describe('TerminateLaunchDependencyStep', () => { lockService.releaseWriteLock = jest.fn(() => { return true; }); - // Mock locking so that the putBucketPolicy actually gets called + // Mock locking so that the fn() actually gets called lockService.tryWriteLockAndRun = jest.fn((params, callback) => callback()); step.cfnOutputsArrayToObject = jest.fn(() => { return { @@ -224,7 +227,40 @@ describe('TerminateLaunchDependencyStep', () => { expect(environmentScCidrService.revokeIngressRuleWithSecurityGroup).not.toHaveBeenCalled(); }); + it('should call delete private route53 record if type is RstudioV2 and alb exists for AppStream', async () => { + step.checkIfAppStreamEnabled = jest.fn(() => { + return true; + }); + const templateOutputs = { + NeedsALB: { Description: 'Needs ALB', Value: false }, + }; + step.cfnOutputsArrayToObject = jest.fn().mockImplementationOnce(() => { + return { + MetaConnection1Type: 'rstudiov2', + ListenerRuleARN: null, + }; + }); + environmentScService.getMemberAccount = jest.fn().mockImplementationOnce(() => { + return { + route53HostedZone: 'sampleRoute53HostedZone', + }; + }); + albService.checkAlbExists.mockImplementationOnce(() => { + return true; + }); + albService.getAlbHostedZoneID = jest.fn(); + jest.spyOn(step, 'getTemplateOutputs').mockImplementationOnce(() => { + return templateOutputs; + }); + environmentDnsService.deletePrivateRecordForDNS = jest.fn(); + albService.checkAndTerminateAlb = jest.fn(); + await step.start(); + expect(environmentDnsService.deleteRecord).not.toHaveBeenCalled(); + expect(environmentDnsService.deletePrivateRecordForDNS).toHaveBeenCalled(); + }); + it('should call delete route53 record if type is RstudioV2 and alb exists', async () => { + step.setting = { getBoolean: jest.fn(() => false) }; const templateOutputs = { NeedsALB: { Description: 'Needs ALB', Value: false }, }; @@ -443,7 +479,7 @@ describe('TerminateLaunchDependencyStep', () => { throw new Error('project with id "test-project" does not exist'); }); await expect( - step.terminateStack(requestContext, 'test-project-id', 'test-external-id', 'test-stack-name'), + step.terminateStack(requestContext, 'test-project-id', 'test-external-id', { albStackName: 'test-stack-id' }), ).rejects.toThrow('project with id "test-project" does not exist'); }); @@ -454,7 +490,9 @@ describe('TerminateLaunchDependencyStep', () => { }; }); step.getCloudFormationService = jest.fn().mockResolvedValue(cfn); - await step.terminateStack(requestContext, 'test-project-id', 'test-external-id', 'test-stack-id'); + await step.terminateStack(requestContext, 'test-project-id', 'test-external-id', { + albStackName: 'test-stack-id', + }); // CHECK expect(cfn.deleteStack).toHaveBeenCalled(); expect(step.state.setKey).toHaveBeenCalledWith('STACK_ID', 'test-stack-id'); @@ -468,12 +506,9 @@ describe('TerminateLaunchDependencyStep', () => { }); step.getCloudFormationService = jest.fn().mockResolvedValue(cfn); // OPERATE - const response = await step.terminateStack( - requestContext, - 'test-project-id', - 'test-external-id', - 'test-stack-id', - ); + const response = await step.terminateStack(requestContext, 'test-project-id', 'test-external-id', { + albStackName: 'test-stack-id', + }); // CHECK expect(response).toMatchObject({ waitDecision: { diff --git a/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/check-launch-dependency/check-launch-dependency.js b/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/check-launch-dependency/check-launch-dependency.js index e73b5b0e16..9fdefa7d7d 100644 --- a/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/check-launch-dependency/check-launch-dependency.js +++ b/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/check-launch-dependency/check-launch-dependency.js @@ -39,6 +39,7 @@ const outPayloadKeys = { const settingKeys = { envMgmtRoleArn: 'envMgmtRoleArn', + isAppStreamEnabled: 'isAppStreamEnabled', }; const pluginConstants = { @@ -132,6 +133,12 @@ class CheckLaunchDependency extends StepBase { * @returns {Promise<>} */ async provisionAlb(requestContext, resolvedVars, projectId, resolvedInputParams, maxAlbWorkspacesCount) { + // Added additional check if lock exists before staring deployment + const [albLock] = await Promise.all([this.state.optionalString('ALB_LOCK')]); + if (!albLock) { + throw new Error(`Error provisioning environment. Reason: ALB lock does not exist or expired`); + } + const [albService] = await this.mustFindServices(['albService']); const count = await albService.albDependentWorkspacesCount(requestContext, projectId); const albExists = await albService.checkAlbExists(requestContext, projectId); @@ -153,14 +160,10 @@ class CheckLaunchDependency extends StepBase { projectId, ); // Create Stack - // Added additional check if lock exists before staring deployment - const [albLock] = await Promise.all([this.state.optionalString('ALB_LOCK')]); - if (albLock) { - // eslint-disable-next-line no-return-await - return await this.deployStack(requestContext, resolvedVars, stackInput); - } - throw new Error(`Error provisioning environment. Reason: ALB lock does not exist or expired`); + // eslint-disable-next-line no-return-await + return await this.deployStack(requestContext, resolvedVars, stackInput); } + return null; } @@ -215,13 +218,14 @@ class CheckLaunchDependency extends StepBase { listenerArn: _.get(stackOutputs, 'ListenerArn', null), albDnsName: _.get(stackOutputs, 'ALBDNSName', null), albSecurityGroup: _.get(stackOutputs, 'ALBSecurityGroupId', null), + albHostedZoneId: _.get(stackOutputs, 'ALBHostedZoneId', null), albDependentWorkspacesCount: 0, }; - if (albLock) { - await albService.saveAlbDetails(awsAccountId, albDetails); - } else { + if (!albLock) { throw new Error(`Error provisioning environment. Reason: ALB lock does not exist or expired`); } + await albService.saveAlbDetails(awsAccountId, albDetails); + this.print({ msg: `Dependency Details Updated Successfully`, }); @@ -336,6 +340,19 @@ class CheckLaunchDependency extends StepBase { return cfnClient; } + /** + * Method to get appstream security group ID for the target aws account + * + * @param requestContext + * @param resolvedVars + * @returns {Promise} + */ + async getAppStreamSecurityGroupId(requestContext, resolvedVars) { + const [albService] = await this.mustFindServices(['albService']); + const { appStreamSecurityGroupId } = await albService.findAwsAccountDetails(requestContext, resolvedVars.projectId); + return appStreamSecurityGroupId; + } + /** * Method to get role arn for the target aws account * diff --git a/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/launch-product/launch-product.js b/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/launch-product/launch-product.js index 328482c9ca..f7863a29d5 100644 --- a/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/launch-product/launch-product.js +++ b/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/launch-product/launch-product.js @@ -394,4 +394,4 @@ class LaunchProduct extends StepBase { } } -module.exports = LaunchProduct; \ No newline at end of file +module.exports = LaunchProduct; diff --git a/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/terminate-launch-dependency/terminate-launch-dependency.js b/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/terminate-launch-dependency/terminate-launch-dependency.js index dee5bd1c15..a8386849b6 100644 --- a/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/terminate-launch-dependency/terminate-launch-dependency.js +++ b/addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/terminate-launch-dependency/terminate-launch-dependency.js @@ -36,6 +36,7 @@ const inPayloadKeys = { const settingKeys = { envMgmtRoleArn: 'envMgmtRoleArn', + isAppStreamEnabled: 'isAppStreamEnabled', }; const pluginConstants = { @@ -84,6 +85,13 @@ class TerminateLaunchDependency extends StepBase { // Setting project id to use while polling for status this.state.setKey('PROJECT_ID', projectId); + + // creating resolvedvars object with the necessary Metadata + const resolvedVars = { + projectId, + externalId, + }; + // convert output array to object. Return {} if no outputs found const environmentOutputs = await this.cfnOutputsArrayToObject(_.get(environment, 'outputs', [])); const connectionType = _.get(environmentOutputs, 'MetaConnection1Type', ''); @@ -93,11 +101,30 @@ class TerminateLaunchDependency extends StepBase { const albExists = await albService.checkAlbExists(requestContext, projectId); const deploymentItem = await albService.getAlbDetails(requestContext, projectId); const deploymentValue = JSON.parse(deploymentItem.value); + const dnsName = deploymentValue.albDnsName; if (albExists) { try { - const dnsName = deploymentValue.albDnsName; - await environmentDnsService.deleteRecord('rstudio', envId, dnsName); + const isAppStreamEnabled = this.checkIfAppStreamEnabled(); + if (isAppStreamEnabled) { + const memberAccount = await environmentScService.getMemberAccount(requestContext, environment); + const albHostedZoneId = await albService.getAlbHostedZoneID( + requestContext, + resolvedVars, + deploymentValue.albArn, + ); + await environmentDnsService.deletePrivateRecordForDNS( + requestContext, + 'rstudio', + envId, + albHostedZoneId, + dnsName, + memberAccount.route53HostedZone, + ); + } else { + await environmentDnsService.deleteRecord('rstudio', envId, dnsName); + } + this.print({ msg: 'Route53 record deleted successfully', }); @@ -135,11 +162,6 @@ class TerminateLaunchDependency extends StepBase { // Termination should not be affected in such scenarios if (!_.isEmpty(ruleArn)) { try { - // creating resolvedvars object with the necessary Metadata - const resolvedVars = { - projectId, - externalId, - }; await lockService.tryWriteLockAndRun({ id: `alb-rule-${deploymentItem.id}` }, async () => { const listenerArn = deploymentValue.listenerArn; await albService.deleteListenerRule(requestContext, resolvedVars, ruleArn, listenerArn); @@ -178,6 +200,10 @@ class TerminateLaunchDependency extends StepBase { return await this.checkAndTerminateAlb(requestContext, projectId, externalId); } + checkIfAppStreamEnabled() { + return this.settings.getBoolean(settingKeys.isAppStreamEnabled); + } + /** * Method to check and terminate ALB if the environment is the last ALB dependent environment * @@ -200,7 +226,7 @@ class TerminateLaunchDependency extends StepBase { const [albLock] = await Promise.all([this.state.optionalString('ALB_LOCK')]); if (albLock) { // eslint-disable-next-line no-return-await - return await this.terminateStack(requestContext, projectId, externalId, albRecord.albStackName); + return await this.terminateStack(requestContext, projectId, externalId, albRecord); } throw new Error(`Error terminating environment. Reason: ALB lock does not exist or expired`); } @@ -213,10 +239,11 @@ class TerminateLaunchDependency extends StepBase { * @param requestContext * @param projectId * @param externalId - * @param stackName + * @param albDetails * @returns {Promise<>} */ - async terminateStack(requestContext, projectId, externalId, stackName) { + async terminateStack(requestContext, projectId, externalId, albDetails) { + const stackName = albDetails.albStackName; const cfn = await this.getCloudFormationService(requestContext, projectId, externalId); const params = { StackName: stackName, @@ -261,6 +288,19 @@ class TerminateLaunchDependency extends StepBase { return cfnClient; } + /** + * Method to get appstream security group ID for the target aws account + * + * @param requestContext + * @param resolvedVars + * @returns {Promise} + */ + async getAppStreamSecurityGroupId(requestContext, resolvedVars) { + const [albService] = await this.mustFindServices(['albService']); + const { appStreamSecurityGroupId } = await albService.findAwsAccountDetails(requestContext, resolvedVars.projectId); + return appStreamSecurityGroupId; + } + /** * Method to get role arn for the target aws account *