diff --git a/.github/workflows/deploy-integ-appstream-egress.yml b/.github/workflows/deploy-integ-appstream-egress.yml new file mode 100644 index 0000000000..de89b648b1 --- /dev/null +++ b/.github/workflows/deploy-integ-appstream-egress.yml @@ -0,0 +1,130 @@ +# +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# + +name: Deploy & Integration Test (AppStream and Egress Enabled) +on: + push: + branches: + - feat-secure-workspace-egress +jobs: + pre-deployment-check: + name: Pre deployment check + runs-on: ubuntu-18.04 + timeout-minutes: 10 + steps: + - name: "Block Concurrent Deployments" + uses: softprops/turnstyle@v1 + with: + poll-interval-seconds: 10 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + deploy: + name: Deploy to AppStream Dev + runs-on: ubuntu-18.04 + needs: pre-deployment-check + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: 12 + - uses: actions/setup-go@v2 + with: + go-version: 1.13 + - name: Install pnpm + run: npm install -g pnpm + - name: Install dependencies + run: ./scripts/install.sh + - name: Build all packages + run: ./scripts/build-all-packages.sh + - name: Deploy + env: + STAGE_NAME: tre + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_APPSTREAM_EGRESS}} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_APPSTREAM_EGRESS }} + run: | + cp ./main/end-to-end-tests/e2eGitHubConfig.AppStreamEgress.yml ./main/config/settings/${STAGE_NAME}.yml + ./scripts/environment-deploy.sh ${STAGE_NAME} + infrastructure-test: + name: Infrastructure test + runs-on: ubuntu-18.04 + needs: deploy + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: 12 + - name: Install pnpm and system libraries + run: npm install -g pnpm + - name: Install dependencies + run: pnpm install + working-directory: main/infrastructure-tests + - name: Run infrastructure tests + run: pnpm run testAppStreamEgressEnabled -- --stage=github + working-directory: ./main/infrastructure-tests + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_APPSTREAM_EGRESS }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_APPSTREAM_EGRESS }} + INFRA_TESTS_HOSTING_ACCOUNT_ID: ${{ secrets.INFRA_TESTS_HOSTING_ACCOUNT_ID }} + INFRA_TESTS_HOSTING_ACCOUNT_STACK_NAME: ${{ secrets.INFRA_TESTS_HOSTING_ACCOUNT_STACK_NAME }} + integration-test: + name: Integration test + runs-on: ubuntu-18.04 + needs: infrastructure-test + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: 12 + - name: Install pnpm and system libraries + run: | + npm install -g pnpm + sudo apt-get install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb + - name: Install dependencies + run: pnpm install + working-directory: main/integration-tests + - name: Run integration tests + run: ./scripts/run-integration-tests.sh ${STAGE_NAME} AppStreamEgress + working-directory: ./ + env: + DEPLOYMENT_BUCKET: ${{ secrets.DEPLOYMENT_BUCKET_APPSTREAM_EGRESS}} + STAGE_NAME: tre + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_APPSTREAM_EGRESS }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_APPSTREAM_EGRESS }} + aws-region: us-east-1 + cypress-test: + name: Cypress test + runs-on: ubuntu-18.04 + needs: integration-test + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: 12 + - name: Install pnpm and system libraries + run: | + npm install -g pnpm + sudo apt-get install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb + - name: Install dependencies + run: pnpm install + working-directory: main/end-to-end-tests + - name: Run cypress test + run: pnpm run cypress:run-tests:github:appstream-egress-enabled + working-directory: main/end-to-end-tests + env: + # Env parameters for cypress tests need header 'CYPRESS_' or 'cypress_' + # Cypress will strip the header and pass it to the tests + CYPRESS_BASE_URL: ${{ secrets.CYPRESS_BASE_URL_APPSTREAM_EGRESS}} + CYPRESS_researcherEmail: ${{ secrets.CYPRESS_RESEARCHER_EMAIL_APPSTREAM_EGRESS}} + CYPRESS_researcherPassword: ${{ secrets.CYPRESS_RESEARCHER_PASSWORD_APPSTREAM_EGRESS}} + CYPRESS_adminEmail: ${{ secrets.CYPRESS_ADMIN_EMAIL_APPSTREAM_EGRESS}} + CYPRESS_adminPassword: ${{ secrets.CYPRESS_ADMIN_PASSWORD_APPSTREAM_EGRESS}} diff --git a/.gitignore b/.gitignore index 2d049dc485..d8e1847e0e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ docs/build */*/*/config/settings/*.yml !*/*/*/config/settings/.defaults.yml !*/*/*/config/settings/example*.yml +!main/infrastructure-tests/config/settings/github.yml # rStudio specific source/ServiceWorkbenchOnAWS/main/solution/machine-images/config/infra/files/rstudio/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 447fcfe2b6..6cfed69e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -360,4 +360,4 @@ We recommend to apply this patch as soon as possible ### Added -- Initial launch! :rocket: +- Initial launch! :rocket: \ No newline at end of file diff --git a/addons/addon-base-post-deployment/packages/base-post-deployment/package.json b/addons/addon-base-post-deployment/packages/base-post-deployment/package.json index 04542fc979..a3be739101 100644 --- a/addons/addon-base-post-deployment/packages/base-post-deployment/package.json +++ b/addons/addon-base-post-deployment/packages/base-post-deployment/package.json @@ -2,14 +2,14 @@ "name": "@aws-ee/base-post-deployment", "version": "1.0.0", "private": true, - "description": "A library containing base set of post-deployment steps to be run with solutions based on addons", + "description": "A library containing base set of post-deployment steps", "author": "Amazon Web Services", "license": "Apache-2.0", "dependencies": { "@aws-ee/base-api-services": "workspace:*", "@aws-ee/base-services": "workspace:*", "@aws-ee/base-services-container": "workspace:*", - "aws-sdk": "^2.647.0", + "aws-sdk": "^2.1000.0", "generate-password": "^1.5.0", "lodash": "^4.17.21" }, diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/.eslintrc.json b/addons/addon-base-pre-deployment/packages/base-pre-deployment/.eslintrc.json new file mode 100644 index 0000000000..a9e56eda24 --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"], + "plugins": ["jest", "prettier"], + "rules": { + "prettier/prettier": ["error"], + "no-unused-vars": [ + "error", + { + "varsIgnorePattern": "^_.+", + "argsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "args": "after-used", + "ignoreRestSiblings": true + } + ], + "prefer-destructuring": 0, + "no-underscore-dangle": 0, + "no-param-reassign": 0, + "class-methods-use-this": 0, + "no-use-before-define": 0 + }, + "env": { + "jest/globals": true + } +} diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/.gitignore b/addons/addon-base-pre-deployment/packages/base-pre-deployment/.gitignore new file mode 100644 index 0000000000..a6b6ec72ae --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/.gitignore @@ -0,0 +1,23 @@ +**/.class +**/.DS_Store +**/coverage +**/node_modules + +**/npm-debug.log +**/pnpm-debug.log + +# Serverless directories +.serverless + +# Webpack generated directories +.webpack + +# VisualStudioCode.gitignore +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# IntelliJ Idea Module Files +*.iml diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/.prettierrc.json b/addons/addon-base-pre-deployment/packages/base-pre-deployment/.prettierrc.json new file mode 100644 index 0000000000..a333103711 --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "tabWidth": 2, + "printWidth": 120, + "singleQuote": true, + "quoteProps": "consistent", + "trailingComma": "all" +} diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/README.md b/addons/addon-base-pre-deployment/packages/base-pre-deployment/README.md new file mode 100644 index 0000000000..b782ab0a3a --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/README.md @@ -0,0 +1,13 @@ +## To invoke pre deployment locally + +After you run + +``` +$ pnpx sls deploy -s +``` + +You can invoke lambda locally + +``` +$ pnpx sls invoke local -f preDeployment -s +``` diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/jest.config.js b/addons/addon-base-pre-deployment/packages/base-pre-deployment/jest.config.js new file mode 100644 index 0000000000..3f7ffc8068 --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/jest.config.js @@ -0,0 +1,26 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +// jest.config.js +module.exports = { + // verbose: true, + notify: false, + testEnvironment: 'node', + // testPathIgnorePatterns: ['service.test.js'], + + // Configure JUnit reporter as CodeBuild currently only supports JUnit or Cucumber reports + // See https://docs.aws.amazon.com/codebuild/latest/userguide/test-reporting.html + reporters: ['default', ['jest-junit', { suiteName: 'jest tests', outputDirectory: './.build/test' }]], +}; diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/jsconfig.json b/addons/addon-base-pre-deployment/packages/base-pre-deployment/jsconfig.json new file mode 100644 index 0000000000..780d3afae6 --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/jsconfig.json @@ -0,0 +1,6 @@ +{ + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} \ No newline at end of file diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/deployment-store-service.js b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/deployment-store-service.js new file mode 100644 index 0000000000..a1934d94bd --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/deployment-store-service.js @@ -0,0 +1,88 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const Service = require('@aws-ee/base-services-container/lib/service'); +const { runAndCatch } = require('@aws-ee/base-services/lib/helpers/utils'); + +const createOrUpdateSchema = require('./schema/deployment-item'); + +const settingKeys = { + tableName: 'dbDeploymentStore', +}; + +class DeploymentStoreService extends Service { + constructor() { + super(); + this.dependency(['jsonSchemaValidationService', 'dbService']); + } + + async init() { + await super.init(); + const [dbService] = await this.service(['dbService']); + const table = this.settings.get(settingKeys.tableName); + + this._getter = () => dbService.helper.getter().table(table); + this._updater = () => dbService.helper.updater().table(table); + this._query = () => dbService.helper.query().table(table); + this._deleter = () => dbService.helper.deleter().table(table); + } + + async find({ type, id, fields = [] }) { + return this._getter() + .key({ type, id }) + .projection(fields) + .get(); + } + + async mustFind({ type, id, fields = [] }) { + const result = await this.find({ type, id, fields }); + if (!result) throw this.boom.notFound(`deployment item of type "${type}" and id "${id}" does not exist`, true); + return result; + } + + async createOrUpdate(rawData) { + const [validationService] = await this.service(['jsonSchemaValidationService']); + + // Validate input + await validationService.ensureValid(rawData, createOrUpdateSchema); + + const { type, id } = rawData; + + // Time to save the the db object + return this._updater() + .key({ type, id }) + .item(rawData) + .update(); + } + + async delete({ type, id }) { + // Lets now remove the item from the database + const result = await runAndCatch( + async () => { + return this._deleter() + .condition('attribute_exists(type) and attribute_exists(id)') // yes we need this + .key({ type, id }) + .delete(); + }, + async () => { + throw this.boom.notFound(`deployment item of type "${type}" and id "${id}" does not exist`, true); + }, + ); + + return result; + } +} + +module.exports = DeploymentStoreService; diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/plugins/services-plugin.js b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/plugins/services-plugin.js new file mode 100644 index 0000000000..b38e4f0c87 --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/plugins/services-plugin.js @@ -0,0 +1,109 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const AwsService = require('@aws-ee/base-services/lib/aws/aws-service'); +const S3Service = require('@aws-ee/base-services/lib/s3-service'); +const IamService = require('@aws-ee/base-services/lib/iam/iam-service'); +const DbService = require('@aws-ee/base-services/lib/db-service'); +const JsonSchemaValidationService = require('@aws-ee/base-services/lib/json-schema-validation-service'); +const InputManifestValidationService = require('@aws-ee/base-services/lib/input-manifest/input-manifest-validation-service'); +const LockService = require('@aws-ee/base-services/lib/lock/lock-service'); +const PluginRegistryService = require('@aws-ee/base-services/lib/plugin-registry/plugin-registry-service'); +const AuditWriterService = require('@aws-ee/base-services/lib/audit/audit-writer-service'); +const AuthorizationService = require('@aws-ee/base-services/lib/authorization/authorization-service'); +const UserAuthzService = require('@aws-ee/base-services/lib/user/user-authz-service'); +const UserService = require('@aws-ee/base-services/lib/user/user-service'); +const DbPasswordService = require('@aws-ee/base-services/lib/db-password/db-password-service'); +const AuthenticationProviderTypeService = require('@aws-ee/base-api-services/lib/authentication-providers/authentication-provider-type-service'); +const AuthenticationProviderConfigService = require('@aws-ee/base-api-services/lib/authentication-providers/authentication-provider-config-service'); +const registerBuiltInAuthProvisioners = require('@aws-ee/base-api-services/lib/authentication-providers/register-built-in-provisioner-services.js'); + +const DeploymentStoreService = require('../deployment-store-service'); + +const settingKeys = { + tablePrefix: 'dbPrefix', +}; + +/** + * A function that registers base services required by the base addon for pre-deployment lambda handler + * @param container An instance of ServicesContainer to register services to + * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point. + * + * @returns {Promise} + */ +// eslint-disable-next-line no-unused-vars +async function registerServices(container, pluginRegistry) { + container.register('aws', new AwsService(), { lazy: false }); + container.register('jsonSchemaValidationService', new JsonSchemaValidationService()); + container.register('authenticationProviderTypeService', new AuthenticationProviderTypeService()); + container.register('authenticationProviderConfigService', new AuthenticationProviderConfigService()); + container.register('lockService', new LockService()); + container.register('s3Service', new S3Service()); + container.register('iamService', new IamService()); + container.register('dbService', new DbService(), { lazy: false }); + container.register('deploymentStoreService', new DeploymentStoreService()); + container.register('dbPasswordService', new DbPasswordService()); + container.register('userService', new UserService()); + container.register('inputManifestValidationService', new InputManifestValidationService()); + container.register('auditWriterService', new AuditWriterService()); + container.register('pluginRegistryService', new PluginRegistryService(pluginRegistry), { lazy: false }); + + // Authorization Services from base addon + container.register('authorizationService', new AuthorizationService()); + container.register('userAuthzService', new UserAuthzService()); + + registerBuiltInAuthProvisioners(container); +} + +/** + * A function that registers base static settings required by the base addon for api handler lambda function + * @param existingStaticSettings An existing static settings plain javascript object containing settings as key/value contributed by other plugins + * @param settings Default instance of settings service that resolves settings from environment variables + * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point + * + * @returns {Promise<*>} A promise that resolves to static settings object + */ +// eslint-disable-next-line no-unused-vars +function getStaticSettings(existingStaticSettings, settings, pluginRegistry) { + const staticSettings = { + ...existingStaticSettings, + }; + + // Register all dynamodb table names used by the base rest api addon + const tablePrefix = settings.get(settingKeys.tablePrefix); + const table = (key, suffix) => { + staticSettings[key] = `${tablePrefix}-${suffix}`; + }; + table('dbAuthenticationProviderTypes', 'AuthenticationProviderTypes'); + table('dbAuthenticationProviderConfigs', 'AuthenticationProviderConfigs'); + table('dbPasswords', 'Passwords'); + table('dbUserApiKeys', 'UserApiKeys'); + table('dbRevokedTokens', 'RevokedTokens'); + table('dbUsers', 'Users'); + table('dbLocks', 'Locks'); + + return staticSettings; +} + +const plugin = { + getStaticSettings, + // getLoggingContext, // not implemented, the default behavior provided by addon-base is sufficient + // getLoggingContext, // not implemented, the default behavior provided by addon-base is sufficient + // registerSettingsService, // not implemented, the default behavior provided by addon-base is sufficient + // registerLoggerService, // not implemented, the default behavior provided by addon-base is sufficient + registerServices, +}; + +module.exports = plugin; diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/plugins/steps-plugin.js b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/plugins/steps-plugin.js new file mode 100644 index 0000000000..4cb0a6c41e --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/plugins/steps-plugin.js @@ -0,0 +1,37 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const ValidateByobStudyService = require('../steps/validate-byob-study-service'); + +/** + * Returns a map of pre deployment steps + * + * @param existingStepsMap Map of existing pre deployment steps + * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point. + * + * @returns {Promise<*>} + */ +// eslint-disable-next-line no-unused-vars +async function getSteps(existingStepsMap, pluginRegistry) { + const stepsMap = new Map([...existingStepsMap, ['validateByobStudyService', new ValidateByobStudyService()]]); + + return stepsMap; +} + +const plugin = { + getSteps, +}; + +module.exports = plugin; diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/schema/deployment-item.json b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/schema/deployment-item.json new file mode 100644 index 0000000000..8ccee66fd6 --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/schema/deployment-item.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": ["type", "id"] +} diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/steps-registration-util.js b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/steps-registration-util.js new file mode 100644 index 0000000000..5e57f8499d --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/steps-registration-util.js @@ -0,0 +1,52 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const _ = require('lodash'); + +/** + * Utility function to register pre-deployment steps by calling each pre-deployment step registration plugin in order. + * + * @param {*} container An instance of ServicesContainer + * @param {getPlugins} pluginRegistry A registry that provides plugins registered by various addons for the specified extension point. + * Each 'preDeploymentStep' plugin in the returned array is an object containing "getSteps" method. + * + * @returns {Promise>} + */ +async function registerSteps(container, pluginRegistry) { + const plugins = await pluginRegistry.getPlugins('preDeploymentStep'); + + // 1. Collect steps from all plugins + // + // Ask each plugin to return their steps. Each plugin is passed a Map containing the pre deployment steps collected + // so far from other plugins. The plugins are called in the same order as returned by the registry. + // Each plugin gets a chance to add, remove, update, or delete steps by mutating the provided stepsMap object. + // This stepsMap is a Map that has step service names as keys and an instance of step implementation service containing + // "execute" method as value. + // + const stepsMap = await _.reduce( + plugins, + async (stepsSoFarPromise, plugin) => plugin.getSteps(await stepsSoFarPromise, pluginRegistry), + Promise.resolve(new Map()), + ); + + // 2. Register all steps to the container + stepsMap.forEach((stepService, stepName) => { + container.register(stepName, stepService); + }); + + return stepsMap; +} + +module.exports = { registerSteps }; diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/steps/validate-byob-study-service.js b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/steps/validate-byob-study-service.js new file mode 100644 index 0000000000..373a55c9ae --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/lib/steps/validate-byob-study-service.js @@ -0,0 +1,120 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const _ = require('lodash'); +const Service = require('@aws-ee/base-services-container/lib/service'); + +const { getSystemRequestContext } = require('@aws-ee/base-services/lib/helpers/system-context'); + +const settingKeys = { + enableEgressStore: 'enableEgressStore', + backendStackName: 'backendStackName', +}; + +/* eslint max-classes-per-file: ["error", 2] */ +class ValidationError extends Error { + constructor(code = '', message = '') { + // Pass remaining arguments (including vendor specific ones) to parent constructor + super(); + this.name = 'ValidationError'; + // Custom debugging information + this.code = code; + this.message = message; + this.date = new Date(); + } +} +class ValidateByobStudyService extends Service { + constructor() { + super(); + this.dependency(['dataSourceAccountService', 'studyService', 'aws']); + } + + async validateByobStudy() { + const [dataSourceAccountService, studyService] = await this.service(['dataSourceAccountService', 'studyService']); + + // try { + const enableEgressStore = this.settings.getBoolean(settingKeys.enableEgressStore); + if (!enableEgressStore) { + this.log.info('Egress feature is not enabled, no need to validate BYOB Study access types.'); + return; + } + // check if backendstack exists + const backendStackDetail = await this.getBackendStack(); + + if (backendStackDetail.length > 1) { + // multiple backend stack found, which is not right, throw error + throw new Error('Multiple backend stack found'); + } else if (backendStackDetail.length === 1) { + // proceed if there is existing backend stack + const requestContext = getSystemRequestContext(); + const accountList = await dataSourceAccountService.list(requestContext); + const accountIdList = _.map(accountList, 'id'); + /* eslint-disable no-await-in-loop */ + /* eslint-disable no-restricted-syntax */ + for (const accountId of accountIdList) { + const studyInfoList = await studyService.listStudiesForAccount(requestContext, { accountId }); + this.log.info(studyInfoList); + _.forEach(studyInfoList, studyInfo => { + if (studyInfo.accessType !== 'readonly') { + throw new ValidationError( + 'InvalidAccessTypeFound', + `Readwrite access type is not supported with egress feature enabled. StudyId: ${studyInfo.id} has readwrite access which is not allowed. Please remove the study: ${studyInfo.id} and redeploy the solution`, + ); + } + }); + } + } + } + + async getAWS() { + const aws = await this.service('aws'); + return aws; + } + + async getCfn() { + const aws = await this.getAWS(); + return new aws.sdk.CloudFormation(); + } + + async getBackendStack() { + let result = {}; + const backendStackName = this.settings.get(settingKeys.backendStackName); + const params = { + StackName: backendStackName, + }; + try { + const cfnClient = await this.getCfn(); + result = await cfnClient.describeStacks(params).promise(); + } catch (err) { + if (err.code === 'ValidationError' && err.statusCode === 400) { + this.log.info( + 'This is First time deployment, backend stack does not exist yet, no need to validate BYOB Studies', + ); + return []; + } + throw new Error( + `Error in pre-deployment validate BYOB study, can not describe backend stack: ${backendStackName}, message: ${err.message}`, + ); + } + + return result.Stacks; + } + + async execute() { + return this.validateByobStudy(); + } +} + +module.exports = ValidateByobStudyService; diff --git a/addons/addon-base-pre-deployment/packages/base-pre-deployment/package.json b/addons/addon-base-pre-deployment/packages/base-pre-deployment/package.json new file mode 100644 index 0000000000..53fd090688 --- /dev/null +++ b/addons/addon-base-pre-deployment/packages/base-pre-deployment/package.json @@ -0,0 +1,46 @@ +{ + "name": "@aws-ee/base-pre-deployment", + "version": "1.0.0", + "private": true, + "description": "A library containing base set of pre-deployment steps", + "author": "Amazon Web Services", + "license": "Apache-2.0", + "dependencies": { + "@aws-ee/base-api-services": "workspace:*", + "@aws-ee/base-services": "workspace:*", + "@aws-ee/base-services-container": "workspace:*", + "aws-sdk": "^2.1000.0", + "generate-password": "^1.5.0", + "lodash": "^4.17.21" + }, + "devDependencies": { + "eslint": "^6.8.0", + "eslint-config-airbnb-base": "^14.0.0", + "eslint-config-prettier": "^6.10.0", + "eslint-import-resolver-node": "^0.3.3", + "eslint-plugin-import": "^2.20.1", + "eslint-plugin-jest": "^22.21.0", + "eslint-plugin-prettier": "^3.1.2", + "husky": "^3.1.0", + "jest": "^24.9.0", + "jest-junit": "^10.0.0", + "prettier": "^1.19.1", + "source-map-support": "^0.5.16" + }, + "scripts": { + "coverage": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --verbose --collectCoverage --watchAll=false --coverage && codecov --disable=gcov", + "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests", + "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll", + "lint": "pnpm run lint:eslint && pnpm run lint:prettier", + "lint:eslint": "eslint --ignore-path .gitignore . ", + "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' ", + "format": "pnpm run format:eslint && pnpm run format:prettier", + "format:eslint": "eslint --fix --ignore-path .gitignore . ", + "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' " + }, + "husky": { + "hooks": { + "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'" + } + } +} diff --git a/addons/addon-base-raas-appstream/README.md b/addons/addon-base-raas-appstream/README.md new file mode 100644 index 0000000000..0515da342f --- /dev/null +++ b/addons/addon-base-raas-appstream/README.md @@ -0,0 +1,3 @@ +# @aws-ee/base-raas-appstream-cfn-templates, @aws-ee/base-raas-appstream-rest-api and @aws-ee/base-raas-appstream-services + +These packages implement the server side changes to RaaS to secure workspaces using AppStream. AppStream is deployed in the on-boarded research account. Rather than returning the workspace connection URL, these changes return the url to an AppStream session (per user-environment pair) that will provide access to the workspace. diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/.eslintrc.json b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/.eslintrc.json new file mode 100644 index 0000000000..c700cf03a8 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"], + "plugins": ["jest", "prettier"], + "rules": { + "prettier/prettier": ["error"], + "no-unused-vars": [ + "error", + { + "varsIgnorePattern": "^_.+", + "argsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "args": "after-used", + "ignoreRestSiblings": true + } + ], + "prefer-destructuring": 0, + "no-underscore-dangle": 0, + "no-param-reassign": 0, + "class-methods-use-this": 0, + "no-use-before-define": 0, + "import/no-unresolved": 0 + }, + "env": { + "jest/globals": true + } +} diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/.gitignore b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/.gitignore new file mode 100644 index 0000000000..659959de8f --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/.gitignore @@ -0,0 +1,16 @@ +**/.class +**/.DS_Store +**/node_modules + +**/npm-debug.log +**/pnpm-debug.log + +# Serverless directories +.serverless + +# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/.prettierrc.json b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/.prettierrc.json new file mode 100644 index 0000000000..d3846d96f3 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/.prettierrc.json @@ -0,0 +1,16 @@ +{ + "tabWidth": 2, + "printWidth": 120, + "singleQuote": true, + "quoteProps": "consistent", + "trailingComma": "all", + "overrides": [ + { + "files": ["*.yml", "*.yaml"], + "options": { + "singleQuote": false + } + } + ] +} + diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/jest.config.js b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/jest.config.js new file mode 100644 index 0000000000..2ae25b0067 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/jest.config.js @@ -0,0 +1,21 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +// jest.config.js +module.exports = { + // verbose: true, + notify: false, + testEnvironment: 'node', + // testPathIgnorePatterns: ['service.test.js'], +}; diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/jsconfig.json b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/jsconfig.json new file mode 100644 index 0000000000..780d3afae6 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/jsconfig.json @@ -0,0 +1,6 @@ +{ + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} \ No newline at end of file diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/lib/plugins/services-plugin.js b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/lib/plugins/services-plugin.js new file mode 100644 index 0000000000..ac48104de9 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/lib/plugins/services-plugin.js @@ -0,0 +1,36 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const AppStreamScEnvConfigVarsService = require('@aws-ee/base-raas-appstream-services/lib/appstream/appstream-sc-env-config-vars-service'); +const AppStreamScService = require('@aws-ee/base-raas-appstream-services/lib/appstream/appstream-sc-service'); + +/** + * Registers the services needed by the workflow loop runner lambda function + * @param container An instance of ServicesContainer to register services to + * @param pluginRegistry A registry that provides plugins registered by various addons for the specified extension point. + * + * @returns {Promise} + */ +// eslint-disable-next-line no-unused-vars +async function registerServices(container, _pluginRegistry) { + container.register('appStreamScEnvConfigVarsService', new AppStreamScEnvConfigVarsService()); + container.register('appStreamScService', new AppStreamScService()); +} + +const plugin = { + registerServices, +}; + +module.exports = plugin; diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/package.json b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/package.json new file mode 100644 index 0000000000..6d771c5052 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-rest-api/package.json @@ -0,0 +1,45 @@ +{ + "name": "@aws-ee/base-raas-appstream-rest-api", + "private": true, + "version": "1.0.0", + "description": "A library containing a set of base RaaS AppStream related controllers", + "author": "Amazon Web Services", + "license": "Apache-2.0", + "dependencies": { + "@aws-ee/base-controllers": "workspace:*", + "@aws-ee/base-raas-appstream-services": "workspace:*", + "@aws-ee/base-raas-services": "workspace:*", + "@aws-ee/base-services": "workspace:*", + "lodash": "^4.17.21" + }, + "devDependencies": { + "eslint": "^6.8.0", + "eslint-config-airbnb-base": "^14.1.0", + "eslint-config-prettier": "^6.10.0", + "eslint-import-resolver-node": "^0.3.3", + "eslint-plugin-import": "^2.20.1", + "eslint-plugin-jest": "^22.21.0", + "eslint-plugin-prettier": "^3.1.2", + "husky": "^3.1.0", + "jest": "^24.9.0", + "jest-junit": "^10.0.0", + "prettier": "^1.19.1", + "pretty-quick": "^1.11.1", + "source-map-support": "^0.5.16" + }, + "scripts": { + "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests", + "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll", + "lint": "pnpm run lint:eslint && pnpm run lint:prettier", + "lint:eslint": "eslint --quiet --ignore-path .gitignore . || true", + "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' || true", + "format": "pnpm run format:eslint; yarn run format:prettier", + "format:eslint": "eslint --fix --ignore-path .gitignore . || true", + "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' || true" + }, + "husky": { + "hooks": { + "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'" + } + } +} diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/.eslintrc.json b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/.eslintrc.json new file mode 100644 index 0000000000..a9e56eda24 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["plugin:jest/recommended", "airbnb-base", "prettier"], + "plugins": ["jest", "prettier"], + "rules": { + "prettier/prettier": ["error"], + "no-unused-vars": [ + "error", + { + "varsIgnorePattern": "^_.+", + "argsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "args": "after-used", + "ignoreRestSiblings": true + } + ], + "prefer-destructuring": 0, + "no-underscore-dangle": 0, + "no-param-reassign": 0, + "class-methods-use-this": 0, + "no-use-before-define": 0 + }, + "env": { + "jest/globals": true + } +} diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/.gitignore b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/.gitignore new file mode 100644 index 0000000000..05bd7c6845 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/.gitignore @@ -0,0 +1,20 @@ +**/.class +**/.DS_Store +**/node_modules + +**/npm-debug.log +**/pnpm-debug.log +**/.webpack + +# Serverless directories +.serverless + +# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +/coverage/ +.build diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/.prettierrc.json b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/.prettierrc.json new file mode 100644 index 0000000000..a333103711 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "tabWidth": 2, + "printWidth": 120, + "singleQuote": true, + "quoteProps": "consistent", + "trailingComma": "all" +} diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/jest.config.js b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/jest.config.js new file mode 100644 index 0000000000..c65c8e495f --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/jest.config.js @@ -0,0 +1,24 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +// jest.config.js +module.exports = { + // verbose: true, + notify: false, + testEnvironment: 'node', + // testPathIgnorePatterns: ['service.test.js'], + // Configure JUnit reporter as CodeBuild currently only supports JUnit or Cucumber reports + // See https://docs.aws.amazon.com/codebuild/latest/userguide/test-reporting.html + reporters: ['default', ['jest-junit', { suiteName: 'jest tests', outputDirectory: './.build/test' }]], +}; diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/jsconfig.json b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/jsconfig.json new file mode 100644 index 0000000000..780d3afae6 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/jsconfig.json @@ -0,0 +1,6 @@ +{ + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} \ No newline at end of file diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/appstream/appstream-sc-env-config-vars-service.js b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/appstream/appstream-sc-env-config-vars-service.js new file mode 100644 index 0000000000..71ab090cbd --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/appstream/appstream-sc-env-config-vars-service.js @@ -0,0 +1,63 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const Service = require('@aws-ee/base-services-container/lib/service'); +/** + * Creation of Environment Type Configuration requires specifying mapping between AWS CloudFormation Input Parameters + * and predefined values. Many times, the values are not available at the time of creating this mapping. In such cases, + * a variable expression in the form of ${variableName} can be specified in place of the value. + * The Environment Type Configuration Variables denote all such variables that can be referenced in the variable + * expressions. + * + * This service provides list of AppStream Addon Specific Environment Type Configuration Variables + */ +class AppStreamScEnvConfigVarsService extends Service { + constructor() { + super(); + this.dependency(['environmentScService', 'indexesService', 'awsAccountsService']); + } + + // eslint-disable-next-line no-unused-vars + async list(requestContext) { + return [ + { + name: 'appStreamSecurityGroupId', + desc: + 'Security Group ID of the AppStream Fleet. If this workspace needs to be streamed via AppStream then you need to allow inbound traffic to your workspace from this Security Group.', + }, + ]; + } + + // eslint-disable-next-line no-unused-vars + async resolveEnvConfigVars(requestContext, { envId }) { + const [environmentScService, indexesService, awsAccountsService] = await this.service([ + 'environmentScService', + 'indexesService', + 'awsAccountsService', + ]); + + const environment = await environmentScService.mustFind(requestContext, { id: envId }); + + const { indexId } = environment; + + // Get the aws account information + const { awsAccountId } = await indexesService.mustFind(requestContext, { id: indexId }); + + const { appStreamSecurityGroupId } = await awsAccountsService.mustFind(requestContext, { id: awsAccountId }); + + return { appStreamSecurityGroupId }; + } +} +module.exports = AppStreamScEnvConfigVarsService; diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/appstream/appstream-sc-service.js b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/appstream/appstream-sc-service.js new file mode 100644 index 0000000000..413ab30db1 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/appstream/appstream-sc-service.js @@ -0,0 +1,217 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const _ = require('lodash'); +const Service = require('@aws-ee/base-services-container/lib/service'); + +class AppStreamScService extends Service { + constructor() { + super(); + this.dependency([ + 'auditWriterService', + 'aws', + 'awsAccountsService', + 'environmentScKeypairService', + 'environmentScService', + 'indexesService', + ]); + } + + async init() { + await super.init(); + } + + async shareAppStreamImageWithAccount(requestContext, accountId, appStreamImageName) { + const appStream = await this.getAppStream(); + const result = await appStream + .updateImagePermissions({ + ImagePermissions: { + allowFleet: true, + allowImageBuilder: false, + }, + Name: appStreamImageName, + SharedAccountId: accountId, + }) + .promise(); + + // Write audit event + await this.audit(requestContext, { action: 'share-appstream-image-with-account', body: { accountId } }); + + return result; + } + + async getStackAndFleet(requestContext, { environmentId, indexId }) { + const [environmentScService, awsAccountsService, indexesService] = await this.service([ + 'environmentScService', + 'awsAccountsService', + 'indexesService', + ]); + + // Find stack + const { awsAccountId } = await indexesService.mustFind(requestContext, { id: indexId }); + const { + appStreamStackName: stackName, + accountId, + appStreamFleetName: fleetName, + } = await awsAccountsService.mustFind(requestContext, { + id: awsAccountId, + }); + + if (!stackName) { + throw this.boom.badRequest(`No AppStream stack is associated with the account ${accountId}`, true); + } + + // Verify fleet is associated to appstream stack + const appStream = await environmentScService.getClientSdkWithEnvMgmtRole( + requestContext, + { id: environmentId }, + { clientName: 'AppStream', options: { signatureVersion: 'v4' } }, + ); + const { Names: fleetNames } = await appStream.listAssociatedFleets({ StackName: stackName }).promise(); + + if (!_.includes(fleetNames, fleetName)) { + throw this.boom.badRequest( + `AppStream Fleet ${fleetName} is not associated with the AppStream stack ${stackName}`, + true, + ); + } + + return { stackName, fleetName }; + } + + generateSessionSuffix(environment) { + // Generate a unique session suffix for the environment as a 6 character alphanumeric string + // This is random looking but string but is deterministic so can be derived from the environment + return (new Date(environment.createdAt).getTime() % 36 ** 6).toString(36); + } + + generateUserId(requestContext, environment) { + // UserId must match [\w+=,.@-]* with max length 32 + // Don't let the username be too long (otherwise the user won't be able to open multiple sessions) + const uid = _.get(requestContext, 'principalIdentifier.uid'); + // Append a unique session suffix to the user id, this user id is used for creating unique AppStream session + // appending suffix to make sure a unique session is created per environment per user + const sessionSuffix = this.generateSessionSuffix(environment); + return `${uid}-${sessionSuffix}`.replace(/[^\w+=,.@-]+/g, '').slice(0, 32); + } + + async getStreamingUrl(requestContext, { environmentId, applicationId }) { + const environmentScService = await this.service('environmentScService'); + + const appStream = await environmentScService.getClientSdkWithEnvMgmtRole( + requestContext, + { id: environmentId }, + { clientName: 'AppStream', options: { signatureVersion: 'v4' } }, + ); + + const environment = await environmentScService.mustFind(requestContext, { id: environmentId }); + + const { stackName, fleetName } = await this.getStackAndFleet(requestContext, { + environmentId, + indexId: environment.indexId, + }); + + let result = {}; + + try { + result = await appStream + .createStreamingURL({ + FleetName: fleetName, + StackName: stackName, + UserId: this.generateUserId(requestContext, environment), + ApplicationId: applicationId, + }) + .promise(); + } catch (err) { + throw this.boom.badRequest('There was an error generating AppStream URL', true); + } + + // Write audit event + await this.audit(requestContext, { action: 'appstream-firefox-app-url-requested', body: { environmentId } }); + + return result.StreamingURL; + } + + async urlForRemoteDesktop(requestContext, { environmentId, instanceId }) { + const environmentScService = await this.service('environmentScService'); + const environment = await environmentScService.mustFind(requestContext, { id: environmentId }); + + // Get stack and fleet + const { stackName, fleetName } = await this.getStackAndFleet(requestContext, { + environmentId, + indexId: environment.indexId, + }); + + // Generate AppStream URL + const appStream = await environmentScService.getClientSdkWithEnvMgmtRole( + requestContext, + { id: environmentId }, + { clientName: 'AppStream', options: { signatureVersion: 'v4' } }, + ); + const ec2 = await environmentScService.getClientSdkWithEnvMgmtRole( + requestContext, + { id: environmentId }, + { clientName: 'EC2', options: { apiVersion: '2016-11-15' } }, + ); + const data = await ec2.describeInstances({ InstanceIds: [instanceId] }).promise(); + const instanceInfo = _.get(data, 'Reservations[0].Instances[0]'); + const networkInterfaces = _.get(instanceInfo, 'NetworkInterfaces') || []; + const privateIp = _.get(networkInterfaces[0], 'PrivateIpAddress'); + + const userId = this.generateUserId(requestContext, environment); + this.log.info({ msg: `Creating AppStream URL`, appStreamSessionUid: userId }); + + let result = {}; + try { + result = await appStream + .createStreamingURL({ + FleetName: fleetName, + StackName: stackName, + UserId: userId, + ApplicationId: 'MicrosoftRemoteDesktop', + SessionContext: privateIp, + }) + .promise(); + } catch (err) { + throw this.boom.badRequest('There was an error generating AppStream URL', true); + } + + // Write audit event + await this.audit(requestContext, { action: 'appstream-remote-desktop-app-url-requested', body: { environmentId } }); + + return result.StreamingURL; + } + + async audit(requestContext, auditEvent) { + const auditWriterService = await this.service('auditWriterService'); + // Calling "writeAndForget" instead of "write" to allow main call to continue without waiting for audit logging + // and not fail main call if audit writing fails for some reason + // If the main call also needs to fail in case writing to any audit destination fails then switch to "write" method as follows + // return auditWriterService.write(requestContext, auditEvent); + return auditWriterService.writeAndForget(requestContext, auditEvent); + } + + async getAWS() { + const aws = await this.service('aws'); + return aws; + } + + async getAppStream() { + const aws = await this.getAWS(); + return new aws.sdk.AppStream(); + } +} + +module.exports = AppStreamScService; diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/appstream/tests/appstream-sc-service.test.js b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/appstream/tests/appstream-sc-service.test.js new file mode 100644 index 0000000000..6d1453a5f7 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/appstream/tests/appstream-sc-service.test.js @@ -0,0 +1,373 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const ServicesContainer = require('@aws-ee/base-services-container/lib/services-container'); +const AwsService = require('@aws-ee/base-services/lib/aws/aws-service'); + +const AWSMock = require('aws-sdk-mock'); + +jest.mock('@aws-ee/base-raas-services/lib/aws-accounts/aws-accounts-service'); +const AwsAccountsServiceMock = require('@aws-ee/base-raas-services/lib/aws-accounts/aws-accounts-service'); + +jest.mock('@aws-ee/base-raas-services/lib/indexes/indexes-service'); +const IndexesServiceMock = require('@aws-ee/base-raas-services/lib/indexes/indexes-service'); + +jest.mock('@aws-ee/base-services/lib/audit/audit-writer-service'); +const AuditServiceMock = require('@aws-ee/base-services/lib/audit/audit-writer-service'); + +jest.mock('@aws-ee/base-raas-services/lib/environment/service-catalog/environment-sc-service'); +const EnvironmentScServiceMock = require('@aws-ee/base-raas-services/lib/environment/service-catalog/environment-sc-service'); + +jest.mock('@aws-ee/base-raas-services/lib/environment/service-catalog/environment-sc-keypair-service'); +const EnvironmentScKeyPairServiceMock = require('@aws-ee/base-raas-services/lib/environment/service-catalog/environment-sc-keypair-service'); + +jest.mock('@aws-ee/base-services/lib/settings/env-settings-service'); +const SettingsServiceMock = require('@aws-ee/base-services/lib/settings/env-settings-service'); + +jest.mock('@aws-ee/base-services/lib/logger/logger-service'); +const Logger = require('@aws-ee/base-services/lib/logger/logger-service'); + +const AppStreamScService = require('../appstream-sc-service'); + +describe('AppStreamScService', () => { + let service = null; + let environmentScService = null; + let indexesService = null; + let awsAccountsService = null; + let settings = null; + + beforeAll(async () => { + const container = new ServicesContainer(); + container.register('log', new Logger()); + container.register('aws', new AwsService()); + container.register('settings', new SettingsServiceMock()); + container.register('auditWriterService', new AuditServiceMock()); + container.register('environmentScService', new EnvironmentScServiceMock()); + container.register('environmentScKeypairService', new EnvironmentScKeyPairServiceMock()); + container.register('appStreamScService', new AppStreamScService()); + container.register('awsAccountsService', new AwsAccountsServiceMock()); + container.register('indexesService', new IndexesServiceMock()); + await container.initServices(); + + // suppress expected console errors + jest.spyOn(console, 'error').mockImplementation(); + + // Get instance of the service we are testing + service = await container.find('appStreamScService'); + environmentScService = await container.find('environmentScService'); + awsAccountsService = await container.find('awsAccountsService'); + indexesService = await container.find('indexesService'); + settings = await container.find('settings'); + }); + + beforeEach(async () => { + const aws = await service.service('aws'); + AWSMock.setSDKInstance(aws.sdk); + }); + + afterEach(async () => { + AWSMock.restore(); + }); + + describe('appstreamScService functions', () => { + it('should return empty body when sharing image with account', async () => { + // BUILD + const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; + const accountId = '999999999999'; + const appStreamMock = { + updateImagePermissions: jest.fn(() => { + return { + promise: () => { + return {}; + }, + }; + }), + }; + service.getAppStream = jest.fn(() => { + return appStreamMock; + }); + settings.get = jest.fn(input => input); + + // OPERATE + const retVal = await service.shareAppStreamImageWithAccount(requestContext, accountId, 'appStreamImageName'); + + // ASSERT + expect(retVal).toEqual({}); + expect(appStreamMock.updateImagePermissions).toHaveBeenCalledTimes(1); + expect(appStreamMock.updateImagePermissions).toHaveBeenCalledWith({ + ImagePermissions: { + allowFleet: true, + allowImageBuilder: false, + }, + Name: 'appStreamImageName', + SharedAccountId: accountId, + }); + }); + + it('should return AppStream stack and fleet names', async () => { + // BUILD + const params = { + environmentId: 'exampleEnvId', + indexId: 'exampleIndexId', + }; + const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; + const appStreamMock = { + listAssociatedFleets: jest.fn(() => { + return { + promise: () => { + return { + Names: ['exampleFleetName'], + }; + }, + }; + }), + }; + indexesService.mustFind = jest.fn(() => { + return { awsAccountId: 'abcd-1234-example-account-id' }; + }); + awsAccountsService.mustFind = jest.fn(() => { + return { + appStreamStackName: 'exampleStackName', + accountId: '999999999999', + appStreamFleetName: 'exampleFleetName', + }; + }); + environmentScService.getClientSdkWithEnvMgmtRole = jest.fn(() => { + return appStreamMock; + }); + + // OPERATE + const retVal = await service.getStackAndFleet(requestContext, params); + + // ASSERT + expect(retVal).toEqual({ stackName: 'exampleStackName', fleetName: 'exampleFleetName' }); + expect(indexesService.mustFind).toHaveBeenCalledWith(requestContext, { id: 'exampleIndexId' }); + expect(awsAccountsService.mustFind).toHaveBeenCalledWith(requestContext, { id: 'abcd-1234-example-account-id' }); + expect(appStreamMock.listAssociatedFleets).toHaveBeenCalledTimes(1); + expect(appStreamMock.listAssociatedFleets).toHaveBeenCalledWith({ + StackName: 'exampleStackName', + }); + }); + + it('should throw error when AppStream stack not associated', async () => { + // BUILD + const params = { + environmentId: 'exampleEnvId', + indexId: 'exampleIndexId', + }; + const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; + const appStreamMock = { + listAssociatedFleets: jest.fn(() => { + return { + promise: () => { + return { + Names: ['exampleFleetName'], + }; + }, + }; + }), + }; + indexesService.mustFind = jest.fn(() => { + return { awsAccountId: 'abcd-1234-example-account-id' }; + }); + awsAccountsService.mustFind = jest.fn(() => { + return { + // No appStreamStackName returned back + accountId: '999999999999', + appStreamFleetName: 'exampleFleetName', + }; + }); + environmentScService.getClientSdkWithEnvMgmtRole = jest.fn(() => { + return appStreamMock; + }); + + try { + // OPERATE + await service.getStackAndFleet(requestContext, params); + } catch (err) { + // CHECK + expect(err.message).toEqual('No AppStream stack is associated with the account 999999999999'); + } + }); + + it('should throw error when AppStream fleet not associated', async () => { + // BUILD + const params = { + environmentId: 'exampleEnvId', + indexId: 'exampleIndexId', + }; + const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; + const appStreamMock = { + listAssociatedFleets: jest.fn(() => { + return { + promise: () => { + return { + Names: ['NotTheSameFleet'], + }; + }, + }; + }), + }; + indexesService.mustFind = jest.fn(() => { + return { awsAccountId: 'abcd-1234-example-account-id' }; + }); + awsAccountsService.mustFind = jest.fn(() => { + return { + appStreamStackName: 'exampleStackName', + accountId: '999999999999', + appStreamFleetName: 'exampleFleetName', + }; + }); + environmentScService.getClientSdkWithEnvMgmtRole = jest.fn(() => { + return appStreamMock; + }); + + try { + // OPERATE + await service.getStackAndFleet(requestContext, params); + } catch (err) { + // CHECK + expect(err.message).toEqual( + 'AppStream Fleet exampleFleetName is not associated with the AppStream stack exampleStackName', + ); + } + }); + + it('should return AppStream url for Windows environments', async () => { + // BUILD + const params = { + environmentId: 'env1', + instanceId: 'instance1', + }; + const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; + const ec2Mock = {}; + ec2Mock.describeInstances = jest.fn(() => { + return { + promise: () => { + return { + Reservations: [ + { + Instances: [ + { + NetworkInterfaces: [ + { + PrivateIpAddress: '10.0.78.193', + }, + ], + }, + ], + }, + ], + }; + }, + }; + }); + const appStreamMock = { + createStreamingURL: jest.fn(() => { + return { + promise: () => { + return { + StreamingURL: 'testurl', + }; + }, + }; + }), + }; + environmentScService.mustFind = jest.fn(() => { + return { indexId: 'exampleIndexId', createdAt: '2021-07-14T03:33:21.234Z' }; + }); + service.getStackAndFleet = jest.fn(() => { + return { stackName: 'testStack', fleetName: 'testFleet' }; + }); + environmentScService.getClientSdkWithEnvMgmtRole = jest.fn((context, envId, client) => { + if (client.clientName === 'EC2') { + return ec2Mock; + } + if (client.clientName === 'AppStream') { + return appStreamMock; + } + throw Error('unexpected clientName'); + }); + + // OPERATE + const returnedUrl = await service.urlForRemoteDesktop(requestContext, params); + + // ASSERT + expect(returnedUrl).toEqual('testurl'); + expect(environmentScService.mustFind).toHaveBeenCalledWith(requestContext, { id: 'env1' }); + expect(service.getStackAndFleet).toHaveBeenCalledWith(requestContext, { + environmentId: 'env1', + indexId: 'exampleIndexId', + }); + expect(ec2Mock.describeInstances).toHaveBeenCalledTimes(1); + expect(ec2Mock.describeInstances).toHaveBeenCalledWith({ InstanceIds: ['instance1'] }); + expect(appStreamMock.createStreamingURL).toHaveBeenCalledTimes(1); + expect(appStreamMock.createStreamingURL).toHaveBeenCalledWith({ + FleetName: 'testFleet', + StackName: 'testStack', + UserId: 'u-testuser-2xhxhu', + ApplicationId: 'MicrosoftRemoteDesktop', + SessionContext: '10.0.78.193', + }); + }); + + it('should return AppStream url for SageMaker and Linux environments', async () => { + // BUILD + const params = { + environmentId: 'env1', + applicationId: 'dummyApplication', + }; + const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; + const appStreamMock = { + createStreamingURL: jest.fn(() => { + return { + promise: () => { + return { + StreamingURL: 'testurl', + }; + }, + }; + }), + }; + environmentScService.mustFind = jest.fn(() => { + return { indexId: 'exampleIndexId', createdAt: '2021-07-14T03:33:21.234Z' }; + }); + service.getStackAndFleet = jest.fn(() => { + return { stackName: 'testStack', fleetName: 'testFleet' }; + }); + environmentScService.getClientSdkWithEnvMgmtRole = jest.fn(() => { + return appStreamMock; + }); + + // OPERATE + const returnedUrl = await service.getStreamingUrl(requestContext, params); + + // ASSERT + expect(returnedUrl).toEqual('testurl'); + expect(environmentScService.mustFind).toHaveBeenCalledWith(requestContext, { id: 'env1' }); + expect(service.getStackAndFleet).toHaveBeenCalledWith(requestContext, { + environmentId: 'env1', + indexId: 'exampleIndexId', + }); + expect(appStreamMock.createStreamingURL).toHaveBeenCalledTimes(1); + expect(appStreamMock.createStreamingURL).toHaveBeenCalledWith({ + FleetName: 'testFleet', + StackName: 'testStack', + UserId: 'u-testuser-2xhxhu', + ApplicationId: 'dummyApplication', + }); + }); + }); +}); diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/__tests__/aws-account-mgmt-plugin.test.js b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/__tests__/aws-account-mgmt-plugin.test.js new file mode 100644 index 0000000000..6ff9d41544 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/__tests__/aws-account-mgmt-plugin.test.js @@ -0,0 +1,165 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const ServicesContainer = require('@aws-ee/base-services-container/lib/services-container'); + +// Mocked dependencies +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/settings/env-settings-service'); +const SettingsServiceMock = require('@aws-ee/base-services/lib/settings/env-settings-service'); + +jest.mock('@aws-ee/base-raas-services/lib/environment/service-catalog/environment-sc-service'); +const EnvironmentScServiceMock = require('@aws-ee/base-raas-services/lib/environment/service-catalog/environment-sc-service'); + +jest.mock('@aws-ee/base-raas-services/lib/indexes/indexes-service'); +const IndexesServiceMock = require('@aws-ee/base-raas-services/lib/indexes/indexes-service'); + +const plugin = require('../aws-account-mgmt-plugin'); + +// CHECKed Functions: getActiveNonAppStreamEnvs +describe('awsAccountMgmtPlugin', () => { + let container; + let settings; + let environmentScService; + let indexesService; + beforeEach(async () => { + // Initialize services container and register dependencies + container = new ServicesContainer(); + container.register('pluginRegistryService', new PluginRegistryService()); + container.register('settings', new SettingsServiceMock()); + container.register('environmentScService', new EnvironmentScServiceMock()); + container.register('indexesService', new IndexesServiceMock()); + + await container.initServices(); + settings = await container.find('settings'); + environmentScService = await container.find('environmentScService'); + indexesService = await container.find('indexesService'); + }); + + describe('getActiveNonAppStreamEnvs', () => { + const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; + it('should return empty list if AppStream is disabled', async () => { + // BUILD + const awsAccountId = 'sampleAwsAccountId'; + settings.getBoolean = jest.fn(() => { + return false; + }); + const expected = []; + + // OPERATE + const retVal = await plugin.getActiveNonAppStreamEnvs({ awsAccountId, requestContext, container }); + + // CHECK + expect(retVal).toEqual(expected); + }); + + it('should return a list of active non-AppStream environments for an account if AppStream is enabled', async () => { + // BUILD + const awsAccountId = 'sampleAwsAccountId'; + settings.getBoolean = jest.fn(() => { + return true; + }); + const scEnvs = [ + { id: 'env1', indexId: 'index1', isAppStreamConfigured: true, status: 'COMPLETED' }, + { id: 'env2', indexId: 'index1', isAppStreamConfigured: false, status: 'COMPLETED' }, // This will be returned + { id: 'env3', indexId: 'index1', isAppStreamConfigured: false, status: 'FAILED' }, + { id: 'env4', indexId: 'index1', isAppStreamConfigured: false, status: 'TERMINATED' }, + { id: 'env5', indexId: 'index1', isAppStreamConfigured: false, status: 'UNKNOWN' }, + ]; + const indexes = [ + { id: 'index1', awsAccountId }, + { id: 'index2', awsAccountId: 'someOtherAccount' }, + ]; + environmentScService.list = jest.fn(() => { + return scEnvs; + }); + indexesService.list = jest.fn(() => { + return indexes; + }); + + const expected = ['env2']; + + // OPERATE + const retVal = await plugin.getActiveNonAppStreamEnvs({ awsAccountId, requestContext, container }); + + // CHECK + expect(retVal).toEqual(expected); + }); + + it('should return an empty list if no active non-AppStream environments for an account are found', async () => { + // BUILD + const awsAccountId = 'sampleAwsAccountId'; + settings.getBoolean = jest.fn(() => { + return true; + }); + const scEnvs = [ + { id: 'env1', indexId: 'index1', isAppStreamConfigured: true, status: 'COMPLETED' }, + { id: 'env2', indexId: 'index1', isAppStreamConfigured: false, status: 'TERMINATED' }, + { id: 'env3', indexId: 'index1', isAppStreamConfigured: false, status: 'FAILED' }, + { id: 'env4', indexId: 'index1', isAppStreamConfigured: false, status: 'UNKNOWN' }, + ]; + const indexes = [ + { id: 'index1', awsAccountId }, + { id: 'index2', awsAccountId: 'someOtherAccount' }, + ]; + environmentScService.list = jest.fn(() => { + return scEnvs; + }); + indexesService.list = jest.fn(() => { + return indexes; + }); + + const expected = []; + + // OPERATE + const retVal = await plugin.getActiveNonAppStreamEnvs({ awsAccountId, requestContext, container }); + + // CHECK + expect(retVal).toEqual(expected); + }); + + it('should return an empty list if active non-AppStream environments exist but for a different account', async () => { + // BUILD + const awsAccountId = 'sampleAwsAccountId'; + settings.getBoolean = jest.fn(() => { + return true; + }); + const scEnvs = [ + { id: 'env1', indexId: 'index1', isAppStreamConfigured: true, status: 'COMPLETED' }, + { id: 'env2', indexId: 'index1', isAppStreamConfigured: false, status: 'STOPPED' }, + ]; + const indexes = [ + { id: 'index1', awsAccountId: 'someOtherAccount' }, + { id: 'index2', awsAccountId }, + ]; + environmentScService.list = jest.fn(() => { + return scEnvs; + }); + indexesService.list = jest.fn(() => { + return indexes; + }); + + const expected = []; + + // OPERATE + const retVal = await plugin.getActiveNonAppStreamEnvs({ awsAccountId, requestContext, container }); + + // CHECK + expect(retVal).toEqual(expected); + }); + }); +}); diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/__tests__/env-sc-connection-url-plugin.test.js b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/__tests__/env-sc-connection-url-plugin.test.js new file mode 100644 index 0000000000..729cefd071 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/__tests__/env-sc-connection-url-plugin.test.js @@ -0,0 +1,247 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const ServicesContainer = require('@aws-ee/base-services-container/lib/services-container'); + +// Mocked dependencies +jest.mock('@aws-ee/base-services/lib/logger/logger-service'); +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/settings/env-settings-service'); +const SettingsServiceMock = require('@aws-ee/base-services/lib/settings/env-settings-service'); + +jest.mock('@aws-ee/base-raas-services/lib/environment/service-catalog/environment-sc-connection-service'); +const EnvironmentScConnectionServiceMock = require('@aws-ee/base-raas-services/lib/environment/service-catalog/environment-sc-connection-service'); + +jest.mock('../../appstream/appstream-sc-service'); +const AppStreamScService = require('../../appstream/appstream-sc-service'); + +const plugin = require('../env-sc-connection-url-plugin'); + +// Tested Functions: createConnectionUrl +describe('envScConnectionUrlPlugin', () => { + let container; + let appStreamScService; + let settings; + let environmentScConnectionService; + beforeEach(async () => { + // Initialize services container and register dependencies + container = new ServicesContainer(); + container.register('pluginRegistryService', new PluginRegistryService()); + container.register('appStreamScService', new AppStreamScService()); + container.register('settings', new SettingsServiceMock()); + container.register('log', new Logger()); + container.register('environmentScConnectionService', new EnvironmentScConnectionServiceMock()); + + await container.initServices(); + settings = await container.find('settings'); + appStreamScService = await container.find('appStreamScService'); + environmentScConnectionService = await container.find('environmentScConnectionService'); + }); + + describe('createConnectionUrl', () => { + const requestContext = { principalIdentifier: { uid: 'u-testuser' } }; + it('should return original connection info if AppStream is disabled', async () => { + // BUILD + const connection = { scheme: 'http', operation: 'create' }; + const envId = 'sampleEnvId'; + settings.getBoolean = jest.fn(() => { + return false; + }); + + // OPERATE + const retVal = await plugin.createConnectionUrl({ envId, connection }, { requestContext, container }); + + // CHECK + expect(retVal).toEqual({ envId, connection }); + }); + + it('should return original connection info if list operation calls plugin', async () => { + // BUILD + const connection = { scheme: 'http', operation: 'list' }; + const envId = 'sampleEnvId'; + settings.getBoolean = jest.fn(() => { + return true; + }); + + // OPERATE + const retVal = await plugin.createConnectionUrl({ envId, connection }, { requestContext, container }); + + // CHECK + expect(retVal).toEqual({ envId, connection }); + }); + + it('should return original connection info if connection scheme is unknown', async () => { + // BUILD + const connection = { scheme: 'random', operation: 'create' }; + const envId = 'sampleEnvId'; + settings.getBoolean = jest.fn(() => { + return true; + }); + appStreamScService.getStreamingUrl = jest.fn(); + appStreamScService.urlForRemoteDesktop = jest.fn(); + + // OPERATE + const retVal = await plugin.createConnectionUrl({ envId, connection }, { requestContext, container }); + + // CHECK + expect(retVal).toEqual({ envId, connection }); + expect(appStreamScService.getStreamingUrl).toHaveBeenCalledTimes(0); + expect(appStreamScService.urlForRemoteDesktop).toHaveBeenCalledTimes(0); + }); + + it('should return AppStream URL with private SageMaker URL when connection type is SageMaker', async () => { + // BUILD + const destinationUrl = 'originalPublicDestinationUrl'; + let connection = { scheme: 'http', operation: 'create', url: destinationUrl, type: 'SageMaker' }; + const envId = 'sampleEnvId'; + settings.getBoolean = jest.fn(() => { + return true; + }); + environmentScConnectionService.createPrivateSageMakerUrl = jest.fn(() => { + return 'newPrivateUrl'; + }); + const streamingUrl = 'sampleAppStreamUrl'; + appStreamScService.getStreamingUrl = jest.fn(() => { + return streamingUrl; + }); + + // OPERATE + const retVal = await plugin.createConnectionUrl({ envId, connection }, { requestContext, container }); + + connection = { + scheme: 'http', + operation: 'create', + url: streamingUrl, + type: 'SageMaker', + appstreamDestinationUrl: 'newPrivateUrl', + }; + + // CHECK + expect(retVal).toStrictEqual({ envId, connection }); + expect(environmentScConnectionService.createPrivateSageMakerUrl).toHaveBeenCalledTimes(1); + expect(environmentScConnectionService.createPrivateSageMakerUrl).toHaveBeenCalledWith( + requestContext, + envId, + connection, + ); + expect(appStreamScService.getStreamingUrl).toHaveBeenCalledTimes(1); + expect(appStreamScService.getStreamingUrl).toHaveBeenCalledWith(requestContext, { + environmentId: envId, + applicationId: 'Firefox', + }); + }); + + it('should return AppStream URL with connection info for HTTP create', async () => { + // BUILD + const destinationUrl = 'destinationUrl'; + let connection = { scheme: 'http', operation: 'create', url: destinationUrl }; + const envId = 'sampleEnvId'; + settings.getBoolean = jest.fn(() => { + return true; + }); + const streamingUrl = 'sampleAppStreamUrl'; + appStreamScService.getStreamingUrl = jest.fn(() => { + return streamingUrl; + }); + + // OPERATE + const retVal = await plugin.createConnectionUrl({ envId, connection }, { requestContext, container }); + + connection = { + scheme: 'http', + operation: 'create', + url: streamingUrl, + appstreamDestinationUrl: destinationUrl, + }; + + // CHECK + expect(retVal).toStrictEqual({ envId, connection }); + expect(environmentScConnectionService.createPrivateSageMakerUrl).not.toHaveBeenCalled(); + expect(appStreamScService.getStreamingUrl).toHaveBeenCalledTimes(1); + expect(appStreamScService.getStreamingUrl).toHaveBeenCalledWith(requestContext, { + environmentId: envId, + applicationId: 'Firefox', + }); + }); + + it('should return AppStream URL with connection info for SSH create', async () => { + // BUILD + const destinationUrl = 'destinationUrl'; + let connection = { scheme: 'ssh', operation: 'create', url: destinationUrl }; + const envId = 'sampleEnvId'; + settings.getBoolean = jest.fn(() => { + return true; + }); + const streamingUrl = 'sampleAppStreamUrl'; + appStreamScService.getStreamingUrl = jest.fn(() => { + return streamingUrl; + }); + + // OPERATE + const retVal = await plugin.createConnectionUrl({ envId, connection }, { requestContext, container }); + + connection = { + scheme: 'ssh', + operation: 'create', + url: streamingUrl, + appstreamDestinationUrl: destinationUrl, + }; + + // CHECK + expect(retVal).toStrictEqual({ envId, connection }); + expect(appStreamScService.getStreamingUrl).toHaveBeenCalledTimes(1); + expect(appStreamScService.getStreamingUrl).toHaveBeenCalledWith(requestContext, { + environmentId: envId, + applicationId: 'EC2Linux', + }); + }); + + it('should return AppStream URL with connection info for RDP create', async () => { + // BUILD + let connection = { scheme: 'rdp', operation: 'create', instanceId: 'sampleInstanceId' }; + const envId = 'sampleEnvId'; + settings.getBoolean = jest.fn(() => { + return true; + }); + const streamingUrl = 'sampleAppStreamUrl'; + appStreamScService.urlForRemoteDesktop = jest.fn(() => { + return streamingUrl; + }); + + // OPERATE + const retVal = await plugin.createConnectionUrl({ envId, connection }, { requestContext, container }); + + connection = { + scheme: 'rdp', + operation: 'create', + url: streamingUrl, + appstreamDestinationUrl: undefined, + instanceId: 'sampleInstanceId', + }; + + // CHECK + expect(retVal).toStrictEqual({ envId, connection }); + expect(appStreamScService.urlForRemoteDesktop).toHaveBeenCalledTimes(1); + expect(appStreamScService.urlForRemoteDesktop).toHaveBeenCalledWith(requestContext, { + environmentId: envId, + instanceId: connection.instanceId, + }); + }); + }); +}); diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/aws-account-mgmt-plugin.js b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/aws-account-mgmt-plugin.js new file mode 100644 index 0000000000..5fc27b7e4c --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/aws-account-mgmt-plugin.js @@ -0,0 +1,63 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const _ = require('lodash'); + +const settingKeys = { + isAppStreamEnabled: 'isAppStreamEnabled', +}; + +/** + * Returns a list of active non-AppStream environments linked to a given AWS Account ID + * This check is only performed when the deployment has AppStream enabled, + * and is triggered if the user attempts to update the AWS account using SWB APIs. + * A similar check is performed on the UI components (AccountUtils) as well. + */ +async function getActiveNonAppStreamEnvs(payload) { + const { awsAccountId, requestContext, container } = payload; + const settings = await container.find('settings'); + const isAppStreamEnabled = settings.getBoolean(settingKeys.isAppStreamEnabled); + if (!isAppStreamEnabled) return []; + + const nonActiveStates = ['FAILED', 'TERMINATED', 'UNKNOWN']; + const environmentScService = await container.find('environmentScService'); + const indexesService = await container.find('indexesService'); + + const indexes = await indexesService.list(requestContext); + const indexesOfInterest = _.filter(indexes, index => index.awsAccountId === awsAccountId); + if (_.isEmpty(indexesOfInterest)) return []; + const indexesIdsOfInterest = _.map(indexesOfInterest, index => { + return index.id; + }); + + const scEnvs = await environmentScService.list(requestContext); + const scEnvsOfInterest = _.filter( + scEnvs, + scEnv => + _.includes(indexesIdsOfInterest, scEnv.indexId) && + !scEnv.isAppStreamConfigured && + !_.includes(nonActiveStates, scEnv.status), + ); + if (_.isEmpty(scEnvsOfInterest)) return []; + const retVal = _.map(scEnvsOfInterest, scEnv => { + return scEnv.id; + }); + + return retVal; +} + +const plugin = { getActiveNonAppStreamEnvs }; + +module.exports = plugin; diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/env-sc-connection-url-plugin.js b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/env-sc-connection-url-plugin.js new file mode 100644 index 0000000000..398d453288 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/env-sc-connection-url-plugin.js @@ -0,0 +1,89 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +const _ = require('lodash'); + +const settingKeys = { + isAppStreamEnabled: 'isAppStreamEnabled', +}; + +async function createConnectionUrl({ envId, connection }, { requestContext, container }) { + const log = await container.find('log'); + // Only wraps web urls via app stream (i.e., scheme = 'http' or 'https' or no scheme) + const isHttp = connection.scheme === 'http' || connection.scheme === 'https' || _.isEmpty(connection.scheme); + const isSsh = connection.scheme === 'ssh'; + const isRdp = connection.scheme === 'rdp'; + const appStreamScService = await container.find('appStreamScService'); + const environmentScConnectionService = await container.find('environmentScConnectionService'); + const settings = await container.find('settings'); + const isAppStreamEnabled = settings.getBoolean(settingKeys.isAppStreamEnabled); + + // This plugin will only contribute to URL creation when AppStream is enabled + // Since this plugin is also called upon during listConnections cycle + // it will only be triggered during the URL creation API call + if (!isAppStreamEnabled || connection.operation === 'list') { + return { envId, connection }; + } + + if (_.toLower(_.get(connection, 'type', '')) === 'sagemaker') { + connection.url = await environmentScConnectionService.createPrivateSageMakerUrl(requestContext, envId, connection); + } + + // Only wrap via AppStream if the connection.url exists + let appStreamUrl; + if (isHttp && connection.url) { + log.debug({ + msg: `Target connection URL ${connection.url} will be accessible via AppStream URL`, + connection, + }); + appStreamUrl = await appStreamScService.getStreamingUrl(requestContext, { + environmentId: envId, + applicationId: 'Firefox', + }); + } else if (isSsh) { + log.debug({ + msg: `Target instance ${connection.instanceId} will be available for SSH connection via AppStream URL`, + connection, + }); + appStreamUrl = await appStreamScService.getStreamingUrl(requestContext, { + environmentId: envId, + applicationId: 'EC2Linux', + }); + } else if (isRdp) { + log.debug({ + msg: `Will stream target RDP connection for instance ${connection.instanceId} via AppStream`, + connection, + }); + appStreamUrl = await appStreamScService.urlForRemoteDesktop(requestContext, { + environmentId: envId, + instanceId: connection.instanceId, + }); + } + + if (appStreamUrl) { + // Retain the original destination URL so we don't have to trigger another API call + connection.appstreamDestinationUrl = connection.url; + + // Now rewrite connection.url to the AppStream streaming URL so it can be opened in a new tab + connection.url = appStreamUrl; + log.debug({ msg: `Modified connection to use AppStream streaming URL ${connection.url}`, connection }); + } + + return { envId, connection }; +} + +const plugin = { createConnectionUrl }; + +module.exports = plugin; diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/env-sc-provisioning-plugin.js b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/env-sc-provisioning-plugin.js new file mode 100644 index 0000000000..0e6004a141 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/lib/plugins/env-sc-provisioning-plugin.js @@ -0,0 +1,71 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +/** + * A plugin method to contribute to the list of available variables for usage in variable expressions in + * Environment Type Configurations. This plugin method just provides metadata about the variables such as list of + * variable names and descriptions. The plugin must provide values for all the variables it claims to resolve. i.e., the + * plugin must provide values for all the variables via the "resolve" method it claims to provide in this "list" method. + * + * @param requestContext The request context object containing principal (caller) information. + * The principal's identifier object is expected to be available as "requestContext.principalIdentifier" + * @param container Services container instance + * @param vars An array of available variables accumulated from other plugins so far + * @returns {Promise<{container: *, requestContext: *, vars: [{name: string, desc: string}]}>} + */ +// See addons/addon-environment-sc-api/README.md +// Called by "addons/addon-environment-sc-api/packages/environment-type-mgmt-services/lib/environment-type/env-type-config-var-service.js" +async function list({ requestContext, container, vars }) { + const appStreamScEnvConfigVarsService = await container.find('appStreamScEnvConfigVarsService'); + const appStreamScEnvConfigVars = await appStreamScEnvConfigVarsService.list(requestContext); + // add AppStream Addon specific variables to the list + return { requestContext, container, vars: [...vars, ...appStreamScEnvConfigVars] }; +} + +/** + * A plugin method to participate in providing the values for the list of available variables for usage in variable expressions in + * Environment Type Configurations. The plugin must provide values for all the variables it claims to resolve. i.e., the + * plugin must provide values for all the variables via this "resolve" method it claims to provide in the "list" method. + * + * @param requestContext The request context object containing principal (caller) information. + * The principal's identifier object is expected to be available as "requestContext.principalIdentifier" + * @param container Services container instance* @param resolvedVars + * @param resolvedVars A plain javascript object containing values for variables accumulated from other plugins so far + * @returns {Promise<{container: *, requestContext: *, resolvedVars: *}>} + */ +// Called by the environment provisioning workflow for service catalog based envs from "read-environment-info" workflow step +// See "addons/addon-environment-sc-api/packages/environment-sc-workflow-steps/lib/steps/read-environment-info/read-environment-info.js" +async function resolve({ requestContext, container, resolvedVars }) { + const appStreamScEnvConfigVarsService = await container.find('appStreamScEnvConfigVarsService'); + const appStreamScEnvConfigVars = await appStreamScEnvConfigVarsService.resolveEnvConfigVars( + requestContext, + resolvedVars, + ); + return { + requestContext, + container, + resolvedVars: { + ...(resolvedVars || {}), + ...appStreamScEnvConfigVars, + }, + }; +} + +const plugin = { + list, + resolve, +}; + +module.exports = plugin; diff --git a/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/package.json b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/package.json new file mode 100644 index 0000000000..8ffc463804 --- /dev/null +++ b/addons/addon-base-raas-appstream/packages/base-raas-appstream-services/package.json @@ -0,0 +1,47 @@ +{ + "name": "@aws-ee/base-raas-appstream-services", + "private": true, + "version": "1.0.0", + "description": "A library containing a set of base RaaS AppStream related services", + "author": "Amazon Web Services", + "license": "Apache-2.0", + "dependencies": { + "@aws-ee/base-raas-services": "workspace:*", + "@aws-ee/base-services": "workspace:*", + "@aws-ee/base-services-container": "workspace:*", + "lodash": "^4.17.21", + "uuid": "^3.4.0" + }, + "devDependencies": { + "aws-sdk-mock": "^5.1.0", + "eslint": "^6.8.0", + "eslint-config-airbnb-base": "^14.1.0", + "eslint-config-prettier": "^6.10.0", + "eslint-import-resolver-node": "^0.3.3", + "eslint-plugin-import": "^2.20.1", + "eslint-plugin-jest": "^22.21.0", + "eslint-plugin-prettier": "^3.1.2", + "husky": "^3.1.0", + "jest": "^24.9.0", + "jest-junit": "^10.0.0", + "prettier": "^1.19.1", + "pretty-quick": "^1.11.1", + "source-map-support": "^0.5.16" + }, + "scripts": { + "coverage": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --verbose --collectCoverage --watchAll=false --coverage && codecov --disable=gcov", + "test": "NODE_ENV=test jest --config jest.config.js --passWithNoTests", + "test:watch": "NODE_ENV=test jest --config jest.config.js --passWithNoTests --watchAll", + "lint": "pnpm run lint:eslint && pnpm run lint:prettier", + "lint:eslint": "eslint --quiet --ignore-path .gitignore . || true", + "lint:prettier": "prettier --check --ignore-path .gitignore '**/*.{js,jsx}' || true", + "format": "pnpm run format:eslint; yarn run format:prettier", + "format:eslint": "eslint --fix --ignore-path .gitignore . || true", + "format:prettier": "prettier --write --ignore-path .gitignore '**/*.{js,jsx}' || true" + }, + "husky": { + "hooks": { + "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx)'" + } + } +} diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/package.json b/addons/addon-base-raas-ui/packages/base-raas-ui/package.json index fcdb36a93b..62fc3080e9 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/package.json +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/package.json @@ -9,7 +9,7 @@ "@aws-ee/base-ui": "workspace:*", "@aws-ee/base-services": "workspace:*", "@aws-ee/key-pair-mgmt-ui": "workspace:*", - "aws-sdk": "^2.713.0", + "aws-sdk": "^2.1000.0", "chart.js": "^2.9.4", "classnames": "^2.2.6", "crypto-browserify": "^3.12.0", diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/helpers/api.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/helpers/api.js index 10a163799d..e102ae8e27 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/helpers/api.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/helpers/api.js @@ -56,6 +56,23 @@ function createAwsAccount(awsAccount) { return httpApiPost('api/aws-accounts/provision', { data: awsAccount }); } +// Note the accountUUID used here is the 'id' column in dbAwsAccounts table and 'id' attribute in AwsAccount.js, not AWS account id +function updateAwsAccount(accountUUID, data) { + return httpApiPut(`api/aws-accounts/${accountUUID}`, { data }); +} + +function getAccountsPermissionsStatus(accountUUID) { + return httpApiGet(`api/aws-accounts/${accountUUID}/permissions`); +} + +function getAllAccountsPermissionStatus() { + return httpApiGet(`api/aws-accounts/permissions`); +} + +function getAccountOnboardCfnTemplate(accountUUID) { + return httpApiGet(`api/aws-accounts/${accountUUID}/get-template`); +} + function addIndex(index) { return httpApiPost('api/indexes', { data: index }); } @@ -338,6 +355,20 @@ function updateRegisteredAccount(accountId, data) { }); } +function deleteEgressStore(id) { + return httpApiDelete(`api/data-egress/${id}`); +} + +function egressNotifySns(id) { + return httpApiPost(`api/data-egress/notify`, { + data: { id }, + }); +} + +function getEgressStore(id) { + return httpApiGet(`api/data-egress/${id}`); +} + export { addIndex, addUsers, @@ -358,6 +389,10 @@ export { updateStudyPermissions, addAwsAccount, createAwsAccount, + updateAwsAccount, + getAccountsPermissionsStatus, + getAllAccountsPermissionStatus, + getAccountOnboardCfnTemplate, getStepTemplates, getEnvironments, getEnvironment, @@ -408,4 +443,7 @@ export { registerStudy, generateAccountCfnTemplate, updateRegisteredAccount, + deleteEgressStore, + egressNotifySns, + getEgressStore, }; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/helpers/settings.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/helpers/settings.js index 1d75cb2482..ad508c2e10 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/helpers/settings.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/helpers/settings.js @@ -1,4 +1,20 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ /* eslint-disable import/prefer-default-export */ const enableBuiltInWorkspaces = process.env.REACT_APP_ENABLE_BUILT_IN_WORKSPACES === 'true'; +const enableEgressStore = process.env.REACT_APP_ENABLE_EGRESS_STORE === 'true'; +const isAppStreamEnabled = process.env.REACT_APP_IS_APP_STREAM_ENABLED === 'true'; -export { enableBuiltInWorkspaces }; +export { enableBuiltInWorkspaces, enableEgressStore, isAppStreamEnabled }; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsAccount.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsAccount.js index ed22588f05..23fb07eadc 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsAccount.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsAccount.js @@ -13,9 +13,55 @@ * permissions and limitations under the License. */ +import _ from 'lodash'; import { types } from 'mobx-state-tree'; import Budget from './Budget'; +import { AwsStackInfo } from './AwsStackInfo'; +const states = [ + { + key: 'CURRENT', + display: 'Up-to-Date', + color: 'green', + tip: 'IAM Role permissions are up-to-date.', + spinner: false, + }, + { + key: 'NEEDS_UPDATE', + display: 'Needs Update', + color: 'orange', + tip: 'This account needs updated IAM Role permissions. Some functionalities may not work until update.', + spinner: false, + }, + { + key: 'NEEDS_ONBOARD', + display: 'Needs Onboarding', + color: 'purple', + tip: 'This account needs to be onboarded to SWB before it can be used.', + spinner: false, + }, + { + key: 'ERRORED', + display: 'Error', + color: 'red', + tip: 'The account encountered an error while checking IAM role permissions.', + spinner: false, + }, + { + key: 'PENDING', + display: 'Pending', + color: 'yellow', + tip: 'The account is being modified. Please wait a moment.', + spinner: true, + }, + { + key: 'UNKNOWN', + display: 'Unknown', + color: 'grey', + tip: 'Something went wrong.', + spinner: false, + }, +]; // ================================================================== // AwsAccounts // ================================================================== @@ -27,15 +73,24 @@ const AwsAccount = types description: '', accountId: '', externalId: '', + permissionStatus: '', + cfnStackName: '', + cfnStackId: '', roleArn: '', vpcId: '', subnetId: '', encryptionKeyArn: '', + onboardStatusRoleArn: '', createdAt: '', createdBy: '', updatedAt: '', updatedBy: '', budget: types.optional(Budget, {}), + stackInfo: types.optional(AwsStackInfo, {}), + isAppStreamConfigured: false, + appStreamStackName: types.maybe(types.string), + appStreamFleetName: types.maybe(types.string), + appStreamSecurityGroupId: types.maybe(types.string), }) .actions(self => ({ setAwsAccounts(rawAwsAccounts) { @@ -45,21 +100,98 @@ const AwsAccount = types self.description = rawAwsAccounts.description || self.description; self.accountId = rawAwsAccounts.accountId || rawAwsAccounts.accountId; self.externalId = rawAwsAccounts.externalId || self.externalId; + self.permissionStatus = rawAwsAccounts.permissionStatus || self.permissionStatus || 'UNKNOWN'; + self.cfnStackName = rawAwsAccounts.cfnStackName || self.cfnStackName; + self.cfnStackId = rawAwsAccounts.cfnStackId || self.cfnStackId; self.roleArn = rawAwsAccounts.roleArn || self.roleArn; self.vpcId = rawAwsAccounts.vpcId || self.vpcId; self.subnetId = rawAwsAccounts.subnetId || self.subnetId; self.encryptionKeyArn = rawAwsAccounts.encryptionKeyArn || self.encryptionKeyArn; + self.onboardStatusRoleArn = rawAwsAccounts.onboardStatusRoleArn || self.onboardStatusRoleArn; self.createdAt = rawAwsAccounts.createdAt || self.createdAt; self.updatedAt = rawAwsAccounts.updatedAt || self.updatedAt; self.createdBy = rawAwsAccounts.createdBy || self.createdBy; self.updatedBy = rawAwsAccounts.updatedBy || self.updatedBy; + self.appStreamStackName = rawAwsAccounts.appStreamStackName; + self.appStreamFleetName = rawAwsAccounts.appStreamFleetName; + self.appStreamSecurityGroupId = rawAwsAccounts.appStreamSecurityGroupId; + self.isAppStreamConfigured = + !_.isUndefined(rawAwsAccounts.appStreamStackName) && + !_.isUndefined(rawAwsAccounts.appStreamFleetName) && + !_.isUndefined(rawAwsAccounts.appStreamSecurityGroupId); + self.rev = rawAwsAccounts.rev || 0; + + // Can't use || for needsPermissionUpdate because the value is a Boolean // we don't update the other fields because they are being populated by a separate store }, + + setStackInfo(stackInfo) { + self.stackInfo.setStackInfo(stackInfo); + }, })) // eslint-disable-next-line no-unused-vars .views(self => ({ // add view methods here + get permissionStatusDetail() { + // We need to clone the entry so that we don't impact the existing states object + const entry = _.cloneDeep(_.find(states, ['key', self.permissionStatus]) || _.find(states, ['key', 'UNKNOWN'])); + + return entry; + }, + + get emailCommonSection() { + const lines = [ + 'Dear AWS Account Admin,', + '', + `We are attempting to update your onboarded AWS account #${self.accountId} in AWS Service Workbench.`, + 'This update requires administrator access to the AWS Management Console.', + ]; + lines.push(''); + lines.push( + 'For your convenience, you can follow these steps to configure the account for the requested access:\n', + ); + + return lines; + }, + + get updateStackEmailTemplate() { + const { accountId, region, stackInfo = {} } = self; + const { cfnConsoleUrl, updateStackUrl, urlExpiry } = stackInfo; + const lines = _.slice(self.emailCommonSection); + + lines.push( + `1 - Log in to the aws console using the correct account. Please ensure that you are using the correct account #${accountId} and region ${region}\n`, + ); + lines.push(`2 - Go to the AWS CloudFormation console ${cfnConsoleUrl}\n`); + lines.push(` You need to visit the AWS CloudFormation console page before you can follow the next link\n`); + lines.push(`3 - Click on the following link\n`); + lines.push(` ${updateStackUrl}\n`); + lines.push( + ' The link takes you to the CloudFormation console where you can review the stack information and provision it.\n', + ); + lines.push(` Note: the link expires at ${new Date(urlExpiry).toISOString()}`); + lines.push(`\n\nRegards,\nService Workbench admin`); + return lines.join('\n'); + }, + + get createStackEmailTemplate() { + const { accountId, region, stackInfo = {} } = self; + const { createStackUrl, urlExpiry } = stackInfo; + const lines = _.slice(self.emailCommonSection); + + lines.push( + `1 - Log in to the aws console using the correct account. Please ensure that you are using the correct account #${accountId} and region ${region}\n`, + ); + lines.push(`2 - Click on the following link\n`); + lines.push(` ${createStackUrl}\n`); + lines.push( + ' The link takes you to the CloudFormation console where you can review the stack information and provision it.\n', + ); + lines.push(` Note: the link expires at ${new Date(urlExpiry).toISOString()}`); + lines.push(`\n\nRegards,\nService Workbench admin`); + return lines.join('\n'); + }, })); // eslint-disable-next-line import/prefer-default-export diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsAccountStore.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsAccountStore.js new file mode 100644 index 0000000000..692d8ef98e --- /dev/null +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsAccountStore.js @@ -0,0 +1,69 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { getParent } from 'mobx-state-tree'; +import { BaseStore } from '@aws-ee/base-ui/dist/models/BaseStore'; + +import { getAccountOnboardCfnTemplate } from '../../helpers/api'; + +// ================================================================== +// AwsAccountStore +// ================================================================== +const AwsAccountStore = BaseStore.named('AwsAccountStore') + .props({ + accountId: '', + tickPeriod: 2 * 60 * 1000, // 2 minutes + }) + + .actions(self => { + // save the base implementation of cleanup + const superCleanup = self.cleanup; + + return { + async doLoad() { + const account = self.account; + const stackInfo = await getAccountOnboardCfnTemplate(self.accountId); + account.setStackInfo(stackInfo); + }, + + getOnboardTemplate: async awsAccountUUID => { + const template = await getAccountOnboardCfnTemplate(awsAccountUUID); + return template; + }, + + cleanup: () => { + self.accountId = ''; + superCleanup(); + }, + }; + }) + + .views(self => ({ + get account() { + const parent = getParent(self, 2); + const a = parent.getAwsAccount(self.accountId); + return a; + }, + + get stackInfo() { + const account = self.account; + return account.stackInfo; + }, + })); + +// Note: Do NOT register this in the global context, if you want to gain access to an instance +// use AwsAccountsStore.getAwsAccountStore() +// eslint-disable-next-line import/prefer-default-export +export { AwsAccountStore }; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsAccountsStore.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsAccountsStore.js index 2544299f61..7c68068154 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsAccountsStore.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsAccountsStore.js @@ -13,20 +13,47 @@ * permissions and limitations under the License. */ +import _ from 'lodash'; import { types } from 'mobx-state-tree'; import { BaseStore } from '@aws-ee/base-ui/dist/models/BaseStore'; -import { getAwsAccounts, addAwsAccount, createAwsAccount } from '../../helpers/api'; +import { + getAwsAccounts, + addAwsAccount, + createAwsAccount, + updateAwsAccount, + getAllAccountsPermissionStatus, +} from '../../helpers/api'; import { AwsAccount } from './AwsAccount'; +import { AwsAccountStore } from './AwsAccountStore'; import { BudgetStore } from './BudgetStore'; import Budget from './Budget'; +const filterNames = { + ALL: 'All', + CURRENT: 'Up-to-Date', + UPDATEME: 'Needs Update', + NEW: 'Needs Onboarding', + ERRORED: 'Errored', +}; + +// A map, with the key being the filter name and the value being the function that will be used to filter the workspace +// cfnStackName is an empty string if the account hasn't been onboarded yet +const filters = { + [filterNames.ALL]: () => true, + [filterNames.CURRENT]: account => account.permissionStatus === 'CURRENT', + [filterNames.UPDATEME]: account => account.permissionStatus === 'NEEDS_UPDATE', + [filterNames.NEW]: account => account.permissionStatus === 'NEEDS_ONBOARD', + [filterNames.ERRORED]: account => account.permissionStatus === 'ERRORED', +}; + // ================================================================== // AwsAccountsStore // ================================================================== const AwsAccountsStore = BaseStore.named('AwsAccountsStore') .props({ awsAccounts: types.optional(types.map(AwsAccount), {}), + awsAccountStores: types.optional(types.map(AwsAccountStore), {}), budgetStores: types.optional(types.map(BudgetStore), {}), tickPeriod: 10 * 1000, // 10 sec }) @@ -42,6 +69,7 @@ const AwsAccountsStore = BaseStore.named('AwsAccountsStore') // We could have used self.accounts.replace(), but it will do clear() then merge() self.runInAction(() => { awsAccounts.forEach(awsAccount => { + awsAccount = { ...awsAccount }; const awsAccountsModel = AwsAccount.create(awsAccount); const previous = self.awsAccounts.get(awsAccountsModel.id); if (!previous) { @@ -64,12 +92,17 @@ const AwsAccountsStore = BaseStore.named('AwsAccountsStore') const addedAwsAccountModel = AwsAccount.create(addedAwsAccount); self.awsAccounts.set(addedAwsAccountModel.id, addedAwsAccountModel); }); + return addedAwsAccount; }, createAwsAccount: async awsAccount => { await createAwsAccount(awsAccount); }, + updateAwsAccount: async (awsAccountUUID, updatedAcctInfo) => { + await updateAwsAccount(awsAccountUUID, updatedAcctInfo); + }, + getBudgetStore: awsAccountUUID => { let entry = self.budgetStores.get(awsAccountUUID); if (!entry) { @@ -84,6 +117,25 @@ const AwsAccountsStore = BaseStore.named('AwsAccountsStore') const account = self.awsAccounts.get(awsAccountUUID); account.budget = Budget.create(rawBudget); }, + + forceCheckAccountPermissions: async () => { + await getAllAccountsPermissionStatus(); + }, + + hasPendingAccounts: () => { + return !_.isEmpty(_.filter(self.awsAccounts, acct => acct.permissionStatus === 'PENDING')); + }, + + getAwsAccountStore(accountId) { + let entry = self.awsAccountStores.get(accountId); + if (!entry) { + // Lazily create the store + self.awsAccountStores.set(accountId, AwsAccountStore.create({ accountId })); + entry = self.awsAccountStores.get(accountId); + } + + return entry; + }, }; }) @@ -101,7 +153,20 @@ const AwsAccountsStore = BaseStore.named('AwsAccountsStore') res.externalId = awsAccount.externalId; res.vpcId = awsAccount.vpcId; res.subnetId = awsAccount.subnetId; + res.permissionStatus = awsAccount.permissionStatus; res.encryptionKeyArn = awsAccount.encryptionKeyArn; + res.onboardStatusRoleArn = awsAccount.onboardStatusRoleArn; + res.cfnStackName = awsAccount.cfnStackName; + res.cfnStackId = awsAccount.cfnStackId; + res.updatedAt = awsAccount.updatedAt; + res.appStreamStackName = awsAccount.appStreamStackName; + res.appStreamFleetName = awsAccount.appStreamFleetName; + res.appStreamSecurityGroupId = awsAccount.appStreamSecurityGroupId; + res.isAppStreamConfigured = + !_.isUndefined(awsAccount.appStreamStackName) && + !_.isUndefined(awsAccount.appStreamFleetName) && + !_.isUndefined(awsAccount.appStreamSecurityGroupId); + res.rev = awsAccount.rev; result.push(res); }); return result; @@ -135,10 +200,19 @@ const AwsAccountsStore = BaseStore.named('AwsAccountsStore') getAwsAccount(id) { return self.awsAccounts.get(id); }, + + filtered(filterName) { + const filter = filters[filterName] || (() => true); + const result = []; + self.list.forEach(awsAccount => { + if (filter(awsAccount)) result.push(awsAccount); + }); + return _.orderBy(result, [account => account.name.toLowerCase()], ['asc']); + }, })); function registerContextItems(appContext) { appContext.awsAccountsStore = AwsAccountsStore.create({}, appContext); } -export { AwsAccountsStore, registerContextItems }; +export { AwsAccountsStore, filterNames, registerContextItems }; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsStackInfo.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsStackInfo.js new file mode 100644 index 0000000000..dce951028d --- /dev/null +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/AwsStackInfo.js @@ -0,0 +1,62 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import _ from 'lodash'; +import { types } from 'mobx-state-tree'; + +// ================================================================== +// StackInfo +// ================================================================== +const AwsStackInfo = types + .model('StackInfo', { + id: '', + name: '', + region: '', + accountId: '', + stackId: '', + template: types.optional(types.frozen(), {}), + signedUrl: '', + createStackUrl: '', + updateStackUrl: '', + cfnConsoleUrl: '', + urlExpiry: 0, + }) + .actions(self => ({ + setStackInfo(raw = {}) { + _.forEach(raw, (value, key) => { + self[key] = value; + }); + }, + })) + + // eslint-disable-next-line no-unused-vars + .views(self => ({ + get formattedTemplate() { + // Unlike BYOB, the onboarding template is a YAML + return self.template; + }, + + get hasUpdateStackUrl() { + return !_.isEmpty(self.updateStackUrl); + }, + + get expired() { + const now = Date.now(); + + return self.urlExpiry < now + 1000 * 60; // lets buffer 1 minute + }, + })); + +export { AwsStackInfo }; // eslint-disable-line import/prefer-default-export diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/__tests__/AwsAccountsStore.test.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/__tests__/AwsAccountsStore.test.js index 87a0be38a0..ca5ae4ee87 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/__tests__/AwsAccountsStore.test.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/aws-accounts/__tests__/AwsAccountsStore.test.js @@ -13,7 +13,7 @@ * permissions and limitations under the License. */ -import { getAwsAccounts, addAwsAccount } from '../../../helpers/api'; +import { getAwsAccounts, addAwsAccount, updateAwsAccount, getAllAccountsPermissionStatus } from '../../../helpers/api'; import { registerContextItems as registerAwsAccountsStore } from '../AwsAccountsStore'; jest.mock('../../../helpers/api'); @@ -34,7 +34,12 @@ describe('AwsAccountsStore', () => { encryptionKeyArn: 'AndHeresThePartThatHurtsTheMost', createdAt: 'humans cannot ride a ghost :(', updatedAt: 'Bye bye, Lil Sebastian', + permissionStatus: 'CURRENT', + cfnStackName: 'testCfnName', + cfnStackId: '', + onboardStatusRoleArn: 'placeholder-arn', }; + const permRetVal = { newStatus: { mouserat: 'CURRENT' } }; beforeEach(async () => { await registerAwsAccountsStore(appContext); @@ -42,25 +47,58 @@ describe('AwsAccountsStore', () => { }); describe('addAwsAccount', () => { - it('should add a new Aws Account successfully', async () => { + it('should successfully add a new Aws Account without AppStream configured', async () => { // BUILD getAwsAccounts.mockResolvedValue([]); addAwsAccount.mockResolvedValue(newAwsAccount); + getAllAccountsPermissionStatus.mockResolvedValue(permRetVal); await store.load(); // OPERATE await store.addAwsAccount(newAwsAccount); + const expectedAwsAccount = { + ...newAwsAccount, + isAppStreamConfigured: false, + appStreamFleetName: undefined, + appStreamSecurityGroupId: undefined, + appStreamStackName: undefined, + }; + delete expectedAwsAccount.createdAt; // CHECK - expect(newAwsAccount).toMatchObject(store.list[0]); - // some properties are dropped when added, so this makes sure store.list[0] - // is a subset of newAwsAccount + expect(store.list[0]).toMatchObject(expectedAwsAccount); + }); + + it('should successfully add a new Aws Account with AppStream configured', async () => { + // BUILD + getAwsAccounts.mockResolvedValue([]); + const appStreamFleetName = 'fleet1'; + const appStreamSecurityGroupId = 'sg1'; + const appStreamStackName = 'stack1'; + const appStreamConfiguredAwsAccount = { + ...newAwsAccount, + appStreamFleetName, + appStreamSecurityGroupId, + appStreamStackName, + }; + addAwsAccount.mockResolvedValue(appStreamConfiguredAwsAccount); + getAllAccountsPermissionStatus.mockResolvedValue(permRetVal); + await store.load(); + + // OPERATE + await store.addAwsAccount(appStreamConfiguredAwsAccount); + + // CHECK + const expectedAwsAccount = { ...appStreamConfiguredAwsAccount, isAppStreamConfigured: true }; + delete expectedAwsAccount.createdAt; + expect(store.list[0]).toMatchObject(expectedAwsAccount); }); it('should not add an Aws Account', async () => { // BUILD getAwsAccounts.mockResolvedValue([newAwsAccount]); addAwsAccount.mockResolvedValue(newAwsAccount); + getAllAccountsPermissionStatus.mockResolvedValue(permRetVal); await store.load(); // OPERATE @@ -70,4 +108,56 @@ describe('AwsAccountsStore', () => { expect(store.list.length).toBe(1); }); }); + + describe('filteredList', () => { + it('should return the whole list if the filter does not exist', async () => { + // BUILD + getAwsAccounts.mockResolvedValue([newAwsAccount]); + getAllAccountsPermissionStatus.mockResolvedValue(permRetVal); + await store.load(); + + // OPERATE + const retVal = store.filtered('randomfiltername'); + + // CHECK + expect(retVal).toMatchObject(store.list); + }); + }); + + describe('AWS Account-specific tests', () => { + it('should generate the email template for a given AWS account', async () => { + // BUILD + // getAwsAccounts.mockResolvedValue([newAwsAccount]); + // getAllAccountsPermissionStatus.mockResolvedValue(permRetVal); + await store.load(); + await store.addAwsAccount(newAwsAccount); + + // OPERATE + const account = store.getAwsAccount(newAwsAccount.id); + + const commonSectionChunk = `We are attempting to update your onboarded AWS account #${account.accountId} in AWS Service Workbench.`; + const createChunk = '2 - Click on the following link'; + const updateChunk = '3 - Click on the following link'; + + const createString = account.createStackEmailTemplate; + const updateString = account.updateStackEmailTemplate; + + expect(createString).toContain(commonSectionChunk); + expect(createString).toContain(createChunk); + expect(updateString).toContain(commonSectionChunk); + expect(updateString).toContain(updateChunk); + }); + }); + + describe('updateAccount', () => { + it('should try to update the account with updated permissions', async () => { + const erroredAcct = { id: 'testid', permissionsStatus: 'CURRENT' }; + const newPermRetVal = { newStatus: { testid: 'NEEDS_UPDATE' } }; + getAllAccountsPermissionStatus.mockResolvedValue(newPermRetVal); + await store.load(); + await store.updateAwsAccount(erroredAcct.id, erroredAcct); + + expect(updateAwsAccount).toHaveBeenCalledWith(erroredAcct.id, erroredAcct); + }); + }); }); diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvConnectionStore.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvConnectionStore.js index 0b5d9f07bc..a383c3a5b7 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvConnectionStore.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvConnectionStore.js @@ -1,3 +1,18 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + import _ from 'lodash'; import { getParent } from 'mobx-state-tree'; import { BaseStore } from '@aws-ee/base-ui/dist/models/BaseStore'; @@ -31,7 +46,7 @@ const ScEnvConnectionStore = BaseStore.named('ScEnvConnectionStore') async createConnectionUrl(connectionId) { const urlObj = await createScEnvironmentConnectionUrl(self.envId, connectionId); - return _.get(urlObj, 'url'); + return urlObj; }, async sendSshKey(connectionId, keyPairId) { diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironment.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironment.js index 553cd3acaa..12fadfa2a1 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironment.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironment.js @@ -1,3 +1,18 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + /* eslint-disable import/prefer-default-export */ import _ from 'lodash'; import { types, applySnapshot } from 'mobx-state-tree'; @@ -153,6 +168,7 @@ const ScEnvironment = types studyIds: types.frozen([]), cidr: types.frozen([]), outputs: types.frozen([]), + isAppStreamConfigured: types.optional(types.boolean, false), }) .actions(self => ({ setScEnvironment(rawEnvironment) { diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCost.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCost.js index 63bf7b2f48..36d28f0896 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCost.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCost.js @@ -1,3 +1,18 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + /* eslint-disable import/prefer-default-export */ import _ from 'lodash'; import { types, applySnapshot } from 'mobx-state-tree'; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCostStore.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCostStore.js index f64b11632a..443cc1932b 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCostStore.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCostStore.js @@ -1,3 +1,18 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + import _ from 'lodash'; import { getParent } from 'mobx-state-tree'; import { BaseStore } from '@aws-ee/base-ui/dist/models/BaseStore'; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCostsStore.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCostsStore.js index 6d08885d9c..94b5f9559a 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCostsStore.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentCostsStore.js @@ -1,3 +1,18 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + import _ from 'lodash'; import { getEnv, types } from 'mobx-state-tree'; import { consolidateToMap } from '@aws-ee/base-ui/dist/helpers/utils'; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentEgressStoreDetailStore.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentEgressStoreDetailStore.js new file mode 100644 index 0000000000..e34ee78d07 --- /dev/null +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentEgressStoreDetailStore.js @@ -0,0 +1,81 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +import { BaseStore } from '@aws-ee/base-ui/dist/models/BaseStore'; +import { getParent, types } from 'mobx-state-tree'; +import { egressNotifySns, getEgressStore } from '../../helpers/api'; +import { enableEgressStore } from '../../helpers/settings'; + +const S3Object = types.model('S3Object', { + ETag: '', + Key: '', + LastModified: '', + Size: '', + StorageClass: '', + projectId: '', + workspaceId: '', +}); + +const egressStoreInfo = types.model('EgressStoreInfo', { + objectList: types.optional(types.array(S3Object), []), + isAbleToSubmitEgressRequest: types.optional(types.boolean, false), +}); +// ================================================================== +// ScEnvironmentEgressStoreDetailStore +// ================================================================== +const ScEnvironmentEgressStoreDetailStore = BaseStore.named('ScEnvironmentEgressStoreDetailStore') + .props({ + envId: '', + tickPeriod: 30 * 1000, // 30 seconds, + egressStoreDetails: types.optional(types.maybe(egressStoreInfo), {}), + }) + + .actions(self => { + const superCleanup = self.cleanup; + + return { + async doLoad() { + const raw = await getEgressStore(self.envId); + self.runInAction(() => { + self.egressStoreDetails = raw; + }); + }, + + async egressNotifySns(id) { + if (enableEgressStore) { + await egressNotifySns(id); + } + }, + + cleanup: () => { + superCleanup(); + }, + }; + }) + .views(self => ({ + get scEnvironment() { + const parent = getParent(self, 2); + const w = parent.getScEnvironment(self.envId); + return w; + }, + get list() { + return self.egressStoreDetails.objectList; + }, + get isAbleToSubmitEgressRequest() { + return self.egressStoreDetails.isAbleToSubmitEgressRequest; + }, + })); + +// eslint-disable-next-line import/prefer-default-export +export { ScEnvironmentEgressStoreDetailStore }; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentStore.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentStore.js index 0be1a601ab..c53fda1e81 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentStore.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentStore.js @@ -1,3 +1,18 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + import { getParent } from 'mobx-state-tree'; import { BaseStore } from '@aws-ee/base-ui/dist/models/BaseStore'; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentsStore.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentsStore.js index d9ec09da2a..6e38db208e 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentsStore.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/environments-sc/ScEnvironmentsStore.js @@ -1,3 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ import _ from 'lodash'; import { values } from 'mobx'; import { getEnv, types } from 'mobx-state-tree'; @@ -11,10 +25,14 @@ import { startScEnvironment, stopScEnvironment, updateScEnvironmentCidrs, + deleteEgressStore, } from '../../helpers/api'; + import { ScEnvironment } from './ScEnvironment'; import { ScEnvironmentStore } from './ScEnvironmentStore'; import { ScEnvConnectionStore } from './ScEnvConnectionStore'; +import { ScEnvironmentEgressStoreDetailStore } from './ScEnvironmentEgressStoreDetailStore'; +import { enableEgressStore } from '../../helpers/settings'; const filterNames = { ALL: 'all', @@ -48,6 +66,7 @@ const ScEnvironmentsStore = BaseStore.named('ScEnvironmentsStore') environments: types.optional(types.map(ScEnvironment), {}), environmentStores: types.optional(types.map(ScEnvironmentStore), {}), connectionStores: types.optional(types.map(ScEnvConnectionStore), {}), + egressStoreDetailStore: types.optional(types.map(ScEnvironmentEgressStoreDetailStore), {}), tickPeriod: 30 * 1000, // 30 seconds }) @@ -90,6 +109,9 @@ const ScEnvironmentsStore = BaseStore.named('ScEnvironmentsStore') }, async terminateScEnvironment(id) { + if (enableEgressStore) { + await deleteEgressStore(id); + } await deleteScEnvironment(id); const env = self.getScEnvironment(id); if (!env) return; @@ -132,10 +154,21 @@ const ScEnvironmentsStore = BaseStore.named('ScEnvironmentsStore') return entry; }, + getScEnvironmentEgressStoreDetailStore(envId) { + let entry = self.egressStoreDetailStore.get(envId); + if (!entry) { + // Lazily create the store + self.egressStoreDetailStore.set(envId, ScEnvironmentEgressStoreDetailStore.create({ envId })); + entry = self.egressStoreDetailStore.get(envId); + } + return entry; + }, + cleanup: () => { self.environments.clear(); self.environmentStores.clear(); self.connectionStores.clear(); + self.egressStoreDetailStore.clear(); superCleanup(); }, }; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/files/__tests__/FileUploadGroup.test.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/files/__tests__/FileUploadGroup.test.js index 0e2dd1e404..74ea07125b 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/files/__tests__/FileUploadGroup.test.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/files/__tests__/FileUploadGroup.test.js @@ -1,3 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ import FileUploadGroup from '../FileUploadGroup'; describe('FileUploadGroup', () => { diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/AddAwsAccountForm.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/AddAwsAccountForm.js deleted file mode 100644 index a9a588b66e..0000000000 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/AddAwsAccountForm.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import { createForm } from '../../helpers/form'; - -const addAwsAccountFormFields = { - name: { - label: 'Account Name', - placeholder: 'Type the name of this account', - rules: 'required|string|between:1,100', - }, - accountId: { - label: 'AWS Account ID', - placeholder: 'Type the 12-digit AWS account ID', - rules: 'required|string|size:12', - }, - roleArn: { - label: 'Role Arn', - placeholder: 'Type Role ARN for launching resources into this AWS account', - rules: 'required|string|between:10,300', - }, - xAccEnvMgmtRoleArn: { - label: 'AWS Service Catalog Role Arn', - placeholder: 'Type Role ARN for launching resources into this AWS account using AWS Service Catalog', - rules: 'required|string|between:10,300', - }, - externalId: { - label: 'External ID', - placeholder: 'Type external ID for this AWS account', - rules: 'required|string|between:1,300', - }, - description: { - label: 'Description', - placeholder: 'Type description for this AWS account', - rules: 'required|string', - }, - vpcId: { - label: 'VPC ID', - placeholder: 'Type the ID of the VPC where EMR clusters will be launched', - rules: 'required|string|min:12|max:21', - }, - subnetId: { - label: 'Subnet ID', - placeholder: 'Type the ID of the subnet where the EMR clusters will be launched', - rules: 'required|string|min:15|max:24', - }, - encryptionKeyArn: { - label: 'KMS Encryption Key ARN', - placeholder: 'Type the KMS Encryption Key ARN to use for this AWS account', - rules: 'required|string|between:1,100', - }, -}; - -function getAddAwsAccountFormFields() { - return addAwsAccountFormFields; -} - -function getAddAwsAccountForm() { - return createForm(addAwsAccountFormFields); -} - -export { getAddAwsAccountFormFields, getAddAwsAccountForm }; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/AddLocalUserForm.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/AddLocalUserForm.js index 5e4af10087..1d765493fb 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/AddLocalUserForm.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/AddLocalUserForm.js @@ -1,3 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ /* eslint-disable import/prefer-default-export */ import { createForm } from '../../helpers/form'; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/AddUpdateAwsAccountForm.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/AddUpdateAwsAccountForm.js new file mode 100644 index 0000000000..ab294826d8 --- /dev/null +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/AddUpdateAwsAccountForm.js @@ -0,0 +1,94 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { createForm } from '../../helpers/form'; + +const addUpdateBaseAwsAccountFormFields = { + name: { + label: 'Account Name', + placeholder: 'Type the name of this account', + rules: 'required|string|between:1,100', + }, + accountId: { + label: 'AWS Account ID', + placeholder: 'Type the 12-digit AWS account ID', + rules: 'required|string|size:12', + }, + description: { + label: 'Description', + placeholder: 'Type description for this AWS account', + rules: 'required|string', + }, + externalId: { + label: 'External ID', + placeholder: 'Type external ID for this AWS account', + rules: 'required|string|between:1,300', + }, +}; + +const addUpdateAwsAccountAppStreamFormFields = { + appStreamFleetDesiredInstances: { + label: 'AppStream Fleet Desired Instance', + placeholder: + 'Maximum number of concurrently running AppStream sessions. Each researcher uses one AppStream session when viewing a workspace', + rules: 'required|integer', + }, + appStreamDisconnectTimeoutSeconds: { + label: 'AppStreamDisconnectTimeoutSeconds', + placeholder: 'The amount of time that a streaming session remains active after users disconnect. (Minimum of 60)', + rules: ['required', 'integer', 'min:60'], + }, + appStreamIdleDisconnectTimeoutSeconds: { + label: 'AppStreamIdleDisconnectTimeoutSeconds', + placeholder: + 'The amount of time that users can be idle (inactive) before they are disconnected from their streaming session', + rules: 'required|integer', + }, + appStreamMaxUserDurationSeconds: { + label: 'AppStreamMaxUserDurationSeconds', + placeholder: 'The maximum amount of time that a streaming session can remain active, in seconds', + rules: 'required|integer', + }, + appStreamImageName: { + label: 'AppStreamImageName', + placeholder: 'The name of the image used to create the fleet', + rules: 'required|string', + }, + appStreamInstanceType: { + label: 'AppStreamInstanceType', + placeholder: + 'The instance type to use when launching fleet instances. List of images available at https://aws.amazon.com/appstream2/pricing/', + rules: 'required|string', + }, + appStreamFleetType: { + label: 'AppStreamFleetType', + placeholder: 'The fleet type. Should be either ALWAYS_ON or ON_DEMAND', + rules: ['required', 'regex:/^ALWAYS_ON|ON_DEMAND$/'], + }, +}; + +function getBaseAddUpdateAwsAccountFormFields() { + return addUpdateBaseAwsAccountFormFields; +} + +function getAddUpdateAwsAccountAppStreamFormFields() { + return addUpdateAwsAccountAppStreamFormFields; +} + +function getAddUpdateAwsAccountForm(fields) { + return createForm(fields); +} + +export { getBaseAddUpdateAwsAccountFormFields, getAddUpdateAwsAccountAppStreamFormFields, getAddUpdateAwsAccountForm }; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/CreateAwsAccountForm.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/CreateAwsAccountForm.js index 3a10d1feae..eadca3d2c7 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/CreateAwsAccountForm.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/CreateAwsAccountForm.js @@ -15,7 +15,7 @@ import { createForm } from '../../helpers/form'; -const createAwsAccountFormFields = { +const createBaseAwsAccountFormFields = { accountName: { label: 'Account Name', placeholder: 'Type the name of this account', @@ -43,12 +43,57 @@ const createAwsAccountFormFields = { }, }; -function getCreateAwsAccountFormFields() { - return createAwsAccountFormFields; +const createAwsAccountAppStreamFormFields = { + appStreamFleetDesiredInstances: { + label: 'AppStream Fleet Desired Instance', + placeholder: + 'Maximum number of concurrently running AppStream sessions. Each researcher uses one AppStream session when viewing a workspace', + rules: 'required|integer', + }, + appStreamDisconnectTimeoutSeconds: { + label: 'AppStreamDisconnectTimeoutSeconds', + placeholder: 'The amount of time that a streaming session remains active after users disconnect. (Minimum of 60)', + rules: ['required', 'integer', 'min:60'], + }, + appStreamIdleDisconnectTimeoutSeconds: { + label: 'AppStreamIdleDisconnectTimeoutSeconds', + placeholder: + 'The amount of time that users can be idle (inactive) before they are disconnected from their streaming session', + rules: 'required|integer', + }, + appStreamMaxUserDurationSeconds: { + label: 'AppStreamMaxUserDurationSeconds', + placeholder: 'The maximum amount of time that a streaming session can remain active, in seconds', + rules: 'required|integer', + }, + appStreamImageName: { + label: 'AppStreamImageName', + placeholder: 'The name of the image used to create the fleet', + rules: 'required|string', + }, + appStreamInstanceType: { + label: 'AppStreamInstanceType', + placeholder: + 'The instance type to use when launching fleet instances. List of images available at https://aws.amazon.com/appstream2/pricing/', + rules: 'required|string', + }, + appStreamFleetType: { + label: 'AppStreamFleetType', + placeholder: 'The fleet type. Should be either ALWAYS_ON or ON_DEMAND', + rules: ['required', 'regex:/^ALWAYS_ON|ON_DEMAND$/'], + }, +}; + +function getCreateBaseAwsAccountFormFields() { + return createBaseAwsAccountFormFields; +} + +function getCreateAwsAccountAppStreamFormFields() { + return createAwsAccountAppStreamFormFields; } -function getCreateAwsAccountForm() { - return createForm(createAwsAccountFormFields); +function getCreateAwsAccountForm(fields) { + return createForm(fields); } -export { getCreateAwsAccountFormFields, getCreateAwsAccountForm }; +export { getCreateBaseAwsAccountFormFields, getCreateAwsAccountForm, getCreateAwsAccountAppStreamFormFields }; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/CreateInternalEnvForm.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/CreateInternalEnvForm.js index 3f0ebeaffc..845cd16ccf 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/CreateInternalEnvForm.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/forms/CreateInternalEnvForm.js @@ -1,3 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ import _ from 'lodash'; import { createForm } from '../../helpers/form'; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/projects/Project.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/projects/Project.js index ebbc05edf3..cf79a9f174 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/projects/Project.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/projects/Project.js @@ -30,6 +30,7 @@ const Project = types updatedAt: '', updatedBy: '', projectAdmins: types.optional(types.array(types.string), []), + isAppStreamConfigured: types.optional(types.boolean, false), }) .actions(self => ({ setProject(rawProject) { diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/users/__tests__/User.test.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/users/__tests__/User.test.js index 68a1f28f6e..e804b870c6 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/users/__tests__/User.test.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/models/users/__tests__/User.test.js @@ -1,3 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ import { User } from '../User'; describe('User', () => { diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AccountCard.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AccountCard.js new file mode 100644 index 0000000000..42a08cf23d --- /dev/null +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AccountCard.js @@ -0,0 +1,320 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import React from 'react'; +import { decorate, action, computed, runInAction, observable } from 'mobx'; +import { observer, inject } from 'mobx-react'; +import { withRouter } from 'react-router-dom'; +import { Header, Segment, Accordion, Icon, Label, Table, Button } from 'semantic-ui-react'; +import c from 'classnames'; +import { createLink } from '@aws-ee/base-ui/dist/helpers/routing'; +import { displayWarning } from '@aws-ee/base-ui/dist/helpers/notification'; +import { isAppStreamEnabled } from '../../helpers/settings'; + +const { getAccountIdsOfActiveEnvironments } = require('./AccountUtils'); + +const statusDisplay = { + CURRENT: { color: 'green', display: 'Up-to-Date', spinner: false }, + NEEDS_UPDATE: { color: 'orange', display: 'Needs Update', spinner: false }, + NEEDS_ONBOARD: { color: 'purple', display: 'Needs Onboarding', spinner: false }, + ERRORED: { color: 'red', display: 'Error', spinner: false }, + PENDING: { color: 'yellow', display: 'Pending', spinner: true }, + UNKNOWN: { color: 'grey', display: 'Unknown', spinner: false }, +}; + +// expected props +// - id (via props) +// - account (via props) +// - permissionStatus (via props) +// - isSelectable (via props) (currently unused) +class AccountCard extends React.Component { + constructor(props) { + super(props); + runInAction(() => { + this.detailsExpanded = false; + this.isSelected = false; + this.permButtonLoading = false; + }); + } + + get account() { + return this.props.account; + } + + get appStreamStatusMismatch() { + return isAppStreamEnabled && !this.account.isAppStreamConfigured; + } + + get awsAccountsStore() { + return this.props.awsAccountsStore; + } + + get isSelectable() { + return this.props.isSelectable; + } + + get permissionStatus() { + // Possible Values: CURRENT, NEEDS_UPDATE, NEEDS_ONBOARD, ERRORED + return this.account.permissionStatus; + } + + goto(pathname) { + const location = this.props.location; + const link = createLink({ location, pathname }); + this.props.history.push(link); + } + + handleDetailsExpanded = () => { + this.detailsExpanded = !this.detailsExpanded; + }; + + handleSelected = () => { + this.isSelected = !this.isSelected; + }; + + handleBudgetButton = () => { + const awsAccountUUID = this.account.id; + this.goto(`/aws-accounts/budget/${awsAccountUUID}`); + }; + + handleUpdatePermission() { + const awsAccountUUID = this.account.id; + // If the account needs to be upgraded to support AppStream we need to Update the account with AppStream specific settings, for example: AppStreamImageName + if (this.appStreamStatusMismatch) { + this.goto(`/aws-accounts/update/${awsAccountUUID}/rev/${this.account.rev}`); + } else { + this.goto(`/aws-accounts/onboard/${awsAccountUUID}`); + } + } + + render() { + const isSelectable = this.isSelectable; // Internal and external guests can't select studies + const account = this.account; + const attrs = {}; + const onClickAttr = {}; + const permissionStatus = this.permissionStatus; + + if (this.isSelected) attrs.color = 'blue'; + if (isSelectable) onClickAttr.onClick = () => this.handleSelected(); + + return ( + +
+
+ {this.renderStatus(permissionStatus)} + {this.renderBudgetButton()} + {this.renderHeader(account)} + {this.renderDescription(account)} + {permissionStatus !== 'CURRENT' && this.renderUpdatePermsButton()} + {!(permissionStatus === 'NEEDS_ONBOARD' || permissionStatus === 'PENDING') && + this.renderDetailsAccordion(account)} + {(permissionStatus === 'NEEDS_ONBOARD' || permissionStatus === 'PENDING') && + this.renderPendingDetailsAccordion(account)} +
+
+
+ ); + } + + renderHeader(account) { + const isSelectable = this.isSelectable; + const onClickAttr = {}; + const idReadable = account.accountId.replace(/(.{4})(.{4})/g, '$1-$2-'); + if (isSelectable) onClickAttr.onClick = () => this.handleSelected(); + return ( +
+
+ {account.name} + + AWS Account #{idReadable} + +
+
+ ); + } + + renderDescription(account) { + return
{account.description}
; + } + + renderStatus(status) { + const state = statusDisplay[status] || statusDisplay.UNKNOWN; + return ( + + ); + } + + renderPendingDetailsAccordion(account) { + const isExpanded = this.detailsExpanded; + const shouldShowOnboardMessage = account.permissionStatus === 'NEEDS_ONBOARD'; + return ( + + + + Details + + + {isExpanded && ( +
+ {shouldShowOnboardMessage + ? "This account needs to be onboarded. Click 'Onboard Account' to finish setting up." + : `Service Workbench is waiting for the CFN stack to complete. + Please wait a few minutes for provisioning to complete. + If you did not create a CFN stack for this account, click + "Re-Onboard Account" to return to the account onboarding page.`} +
+ )} +
+
+ ); + } + + renderDetailsAccordion(account) { + const isExpanded = this.detailsExpanded; + const errored = account.permissionStatus === 'ERRORED'; + const rowKeyVal = { + roleArn: 'Role ARN', + externalId: 'External ID', + vpcId: 'VPC ID', + subnetId: 'Subnet ID', + encryptionKeyArn: 'Encryption Key ARN', + cfnStackId: 'CloudFormation Stack ID', + }; + + return ( + + + + Details + + + {isExpanded && errored && ( +
+ Something went wrong while trying to check the CFN stack associated with this account. Please check the + CFN stack in the AWS Management Console for more information. +
+ )} + {isExpanded && ( +
+ <> + + + {Object.keys(rowKeyVal).map(entry => ( + + {rowKeyVal[entry]} + {account[entry]} + + ))} + +
+ +
+ )} +
+
+ ); + } + + renderBudgetButton() { + return ( + + ); + } + + async checkForActiveAccounts() { + runInAction(() => { + this.permButtonLoading = true; + }); + const scEnvironmentStore = this.props.scEnvironmentsStore; + const indexesStore = this.props.indexesStore; + const projectsStore = this.props.projectsStore; + + await Promise.all([scEnvironmentStore.doLoad(), indexesStore.doLoad(), projectsStore.doLoad()]); + const scEnvs = scEnvironmentStore.list; + const indexes = indexesStore.list; + const projects = projectsStore.list; + + const accountHasActiveEnv = getAccountIdsOfActiveEnvironments(scEnvs, projects, indexes).includes( + this.props.account.id, + ); + runInAction(() => { + this.permButtonLoading = false; + }); + + if (accountHasActiveEnv) { + displayWarning('Please terminate all workspaces in this account before upgrading the account'); + } else { + this.handleUpdatePermission(); + } + } + + renderUpdatePermsButton() { + const permissionStatus = this.permissionStatus; + let buttonArgs; + if (permissionStatus === 'NEEDS_UPDATE' || permissionStatus === 'ERRORED') + buttonArgs = { + message: 'Update Permissions', + color: 'orange', + }; + else if (permissionStatus === 'PENDING' || permissionStatus === 'UNKNOWN') + buttonArgs = { + message: 'Re-Onboard Account', + color: 'red', + }; + else + buttonArgs = { + message: 'Onboard Account', + color: 'purple', + }; + + buttonArgs.onClick = async () => { + if (this.appStreamStatusMismatch) { + await this.checkForActiveAccounts(); + } else { + this.handleUpdatePermission(); + } + }; + + return ( + + ); + } +} + +decorate(AccountCard, { + handleDetailsExpanded: action, + handleSelected: action, + handleBudgetButton: action, + account: computed, + detailsExpanded: observable, + isSelectable: computed, + isSelected: observable, + permissionStatus: computed, + permButtonLoading: observable, +}); + +export default inject( + 'awsAccountsStore', + 'scEnvironmentsStore', + 'indexesStore', + 'projectsStore', +)(withRouter(observer(AccountCard))); diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AccountUtils.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AccountUtils.js new file mode 100644 index 0000000000..da1d0ccbcf --- /dev/null +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AccountUtils.js @@ -0,0 +1,27 @@ +import _ from 'lodash'; + +function getAccountIdsOfActiveEnvironments(scEnvs, projects, indexes) { + const nonActiveStates = ['FAILED', 'TERMINATED', 'UNKNOWN']; + const activeEnvs = scEnvs.filter(env => { + return !nonActiveStates.includes(env.status); + }); + const projectToActiveEnvs = _.groupBy(activeEnvs, 'projectId'); + + const indexIdToAwsAccountId = {}; + indexes.forEach(index => { + indexIdToAwsAccountId[index.id] = index.awsAccountId; + }); + + const projectIdToAwsAccountId = {}; + projects.forEach(project => { + projectIdToAwsAccountId[project.id] = indexIdToAwsAccountId[project.indexId]; + }); + + return Object.keys(projectToActiveEnvs).map(projectId => { + return projectIdToAwsAccountId[projectId]; + }); +} + +module.exports = { + getAccountIdsOfActiveEnvironments, +}; diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AccountsFilterButtons.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AccountsFilterButtons.js new file mode 100644 index 0000000000..4755caa9eb --- /dev/null +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AccountsFilterButtons.js @@ -0,0 +1,67 @@ +import _ from 'lodash'; +import React from 'react'; +import { decorate, computed, action } from 'mobx'; +import { observer } from 'mobx-react'; +import { Button } from 'semantic-ui-react'; +import c from 'classnames'; + +import { filterNames } from '../../models/aws-accounts/AwsAccountsStore'; + +const filterColorMap = { + [filterNames.ALL]: 'blue', + [filterNames.CURRENT]: 'green', + [filterNames.UPDATEME]: 'orange', + [filterNames.NEW]: 'purple', + [filterNames.ERRORED]: 'red', +}; + +// expected props +// - selectedFilter (via prop) the filter name of the currently selected filter +// - onSelectedFilter (via prop) a fn to be called when a button is selected +// - className (via prop) optional +class AccountsFilterButtons extends React.Component { + get selectedFilter() { + return this.props.selectedFilter; + } + + get onSelectedFilter() { + const fn = this.props.onSelectedFilter; + return _.isFunction(fn) ? fn : () => {}; + } + + handleSelected = name => + action(() => { + this.onSelectedFilter(name); + }); + + render() { + const selectedFilter = this.selectedFilter; + const getAttrs = name => { + const color = _.get(filterColorMap, name, 'grey'); + const selected = name === selectedFilter; + const attrs = { active: selected, basic: !selected, color, style: { cursor: selected ? 'default' : 'pointer' } }; + if (!selected) attrs.onClick = this.handleSelected(name); + return attrs; + }; + + return ( +
+ + {_.map(_.keys(filterColorMap), name => ( + + ))} + +
+ ); + } +} + +// see https://medium.com/@mweststrate/mobx-4-better-simpler-faster-smaller-c1fbc08008da +decorate(AccountsFilterButtons, { + selectedFilter: computed, + onSelectedFilter: computed, +}); + +export default observer(AccountsFilterButtons); diff --git a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AddAwsAccount.js b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AddUpdateAwsAccount.js similarity index 76% rename from addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AddAwsAccount.js rename to addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AddUpdateAwsAccount.js index a7b1fbe116..b80d6d77e9 100644 --- a/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AddAwsAccount.js +++ b/addons/addon-base-raas-ui/packages/base-raas-ui/src/parts/accounts/AddUpdateAwsAccount.js @@ -24,9 +24,17 @@ import { displayError } from '@aws-ee/base-ui/dist/helpers/notification'; import { createLink } from '@aws-ee/base-ui/dist/helpers/routing'; import validate from '@aws-ee/base-ui/dist/models/forms/Validate'; -import { getAddAwsAccountForm, getAddAwsAccountFormFields } from '../../models/forms/AddAwsAccountForm'; +import { + getBaseAddUpdateAwsAccountFormFields, + getAddUpdateAwsAccountAppStreamFormFields, + getAddUpdateAwsAccountForm, +} from '../../models/forms/AddUpdateAwsAccountForm'; + +class AddUpdateAwsAccount extends React.Component { + PAGE_TYPE_UPDATE = 'UPDATE'; + + PAGE_TYPE_ADD = 'ADD'; -class AddAwsAccount extends React.Component { constructor(props) { super(props); this.state = { @@ -40,16 +48,27 @@ class AddAwsAccount extends React.Component { this.formProcessing = false; this.validationErrors = new Map(); this.awsAccount = {}; + this.awsAccountUUID = _.get(this.props, 'match.params.id', undefined); + this.rev = _.get(this.props, 'match.params.rev', undefined); + this.pageType = this.awsAccountUUID ? this.PAGE_TYPE_UPDATE : this.PAGE_TYPE_ADD; }); - this.form = getAddAwsAccountForm(); - this.addAwsAccountFormFields = getAddAwsAccountFormFields(); + + let fields = {}; + if (this.pageType === this.PAGE_TYPE_ADD) { + fields = getBaseAddUpdateAwsAccountFormFields(); + } + if (process.env.REACT_APP_IS_APP_STREAM_ENABLED === 'true') { + fields = { ...fields, ...getAddUpdateAwsAccountAppStreamFormFields() }; + } + this.form = getAddUpdateAwsAccountForm(fields); + this.addUpdateAwsAccountFormFields = fields; } render() { return (
- Add AWS Account + {this.pageType === this.PAGE_TYPE_ADD ? 'Add' : 'Update'} AWS Account
{this.renderAddAwsAccountForm()}
@@ -64,7 +83,7 @@ class AddAwsAccount extends React.Component { renderAddAwsAccountForm() { const processing = this.formProcessing; - const fields = this.addAwsAccountFormFields; + const fields = this.addUpdateAwsAccountFormFields; const toEditableInput = (attributeName, type = 'text') => { const handleChange = action(event => { event.preventDefault(); @@ -103,7 +122,7 @@ class AddAwsAccount extends React.Component { return (
+ + + ); + } + + renderMain() { + const { accountId } = this.account; + const form = this.form; + const field = form.$('managed'); + + return ( + <> + {this.renderCfnTemplate()} +
+ +
+
+ AWS Account # {accountId} +
+ +
+ +
+ {this.renderSteps()} + + ); + } + + renderCfnTemplate() { + const stackInfo = this.stackInfo; + const { name, formattedTemplate } = stackInfo; + + return ( +
+
+ CloudFormation Stack Name +
+
+
+ +
+
+ +
+
+
+
+