diff options
Diffstat (limited to 'app/assets/javascripts/create_cluster/eks_cluster')
13 files changed, 640 insertions, 213 deletions
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue index 3c6da43c4c4..e6893c14cda 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue @@ -2,14 +2,19 @@ import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; +import { GlIcon } from '@gitlab/ui'; -const findItem = (items, valueProp, value) => items.find(item => item[valueProp] === value); +const toArray = value => [].concat(value); +const itemsProp = (items, prop) => items.map(item => item[prop]); +const defaultSearchFn = (searchQuery, labelProp) => item => + item[labelProp].toLowerCase().indexOf(searchQuery) > -1; export default { components: { DropdownButton, DropdownSearchInput, DropdownHiddenInput, + GlIcon, }, props: { fieldName: { @@ -28,7 +33,7 @@ export default { default: '', }, value: { - type: [Object, String], + type: [Object, Array, String], required: false, default: () => null, }, @@ -72,6 +77,11 @@ export default { required: false, default: false, }, + multiple: { + type: Boolean, + required: false, + default: false, + }, errorMessage: { type: String, required: false, @@ -90,12 +100,11 @@ export default { searchFn: { type: Function, required: false, - default: searchQuery => item => item.name.toLowerCase().indexOf(searchQuery) > -1, + default: defaultSearchFn, }, }, data() { return { - selectedItem: findItem(this.items, this.value), searchQuery: '', }; }, @@ -109,36 +118,52 @@ export default { return this.disabledText; } - if (!this.selectedItem) { + if (!this.selectedItems.length) { return this.placeholder; } - return this.selectedItemLabel; + return this.selectedItemsLabels; }, results() { - if (!this.items) { - return []; - } - - return this.items.filter(this.searchFn(this.searchQuery)); + return this.getItemsOrEmptyList().filter(this.searchFn(this.searchQuery, this.labelProperty)); }, - selectedItemLabel() { - return this.selectedItem && this.selectedItem[this.labelProperty]; + selectedItems() { + const valueProp = this.valueProperty; + const valueList = toArray(this.value); + const items = this.getItemsOrEmptyList(); + + return items.filter(item => valueList.some(value => item[valueProp] === value)); }, - selectedItemValue() { - return (this.selectedItem && this.selectedItem[this.valueProperty]) || ''; + selectedItemsLabels() { + return itemsProp(this.selectedItems, this.labelProperty).join(', '); }, - }, - watch: { - value(value) { - this.selectedItem = findItem(this.items, this.valueProperty, value); + selectedItemsValues() { + return itemsProp(this.selectedItems, this.valueProperty).join(', '); }, }, methods: { - select(item) { - this.selectedItem = item; + getItemsOrEmptyList() { + return this.items || []; + }, + selectSingle(item) { this.$emit('input', item[this.valueProperty]); }, + selectMultiple(item) { + const value = toArray(this.value); + const itemValue = item[this.valueProperty]; + const itemValueIndex = value.indexOf(itemValue); + + if (itemValueIndex > -1) { + value.splice(itemValueIndex, 1); + } else { + value.push(itemValue); + } + + this.$emit('input', value); + }, + isSelected(item) { + return this.selectedItems.includes(item); + }, }, }; </script> @@ -146,7 +171,7 @@ export default { <template> <div> <div class="js-gcp-machine-type-dropdown dropdown"> - <dropdown-hidden-input :name="fieldName" :value="selectedItemValue" /> + <dropdown-hidden-input :name="fieldName" :value="selectedItemsValues" /> <dropdown-button :class="{ 'border-danger': hasErrors }" :is-disabled="disabled" @@ -158,15 +183,28 @@ export default { <div class="dropdown-content"> <ul> <li v-if="!results.length"> - <span class="js-empty-text menu-item"> - {{ emptyText }} - </span> + <span class="js-empty-text menu-item">{{ emptyText }}</span> </li> <li v-for="item in results" :key="item.id"> - <button class="js-dropdown-item" type="button" @click.prevent="select(item)"> - <slot name="item" :item="item"> - {{ item.name }} - </slot> + <button + v-if="multiple" + class="js-dropdown-item d-flex align-items-center" + type="button" + @click.stop.prevent="selectMultiple(item)" + > + <gl-icon + :class="[{ invisible: !isSelected(item) }, 'mr-1']" + name="mobile-issue-close" + /> + <slot name="item" :item="item">{{ item.name }}</slot> + </button> + <button + v-else + class="js-dropdown-item" + type="button" + @click.prevent="selectSingle(item)" + > + <slot name="item" :item="item">{{ item.name }}</slot> </button> </li> </ul> @@ -182,8 +220,7 @@ export default { 'text-muted': !hasErrors, }, ]" + >{{ errorMessage }}</span > - {{ errorMessage }} - </span> </div> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue index 22ee368b8e0..3f7c2204b9f 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue @@ -1,4 +1,5 @@ <script> +import { mapState } from 'vuex'; import ServiceCredentialsForm from './service_credentials_form.vue'; import EksClusterConfigurationForm from './eks_cluster_configuration_form.vue'; @@ -16,14 +17,37 @@ export default { type: String, required: true, }, + accountAndExternalIdsHelpPath: { + type: String, + required: true, + }, + createRoleArnHelpPath: { + type: String, + required: true, + }, + externalLinkIcon: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['hasCredentials']), }, }; </script> <template> <div class="js-create-eks-cluster"> <eks-cluster-configuration-form + v-if="hasCredentials" :gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath" :kubernetes-integration-help-path="kubernetesIntegrationHelpPath" + :external-link-icon="externalLinkIcon" + /> + <service-credentials-form + v-else + :create-role-arn-help-path="createRoleArnHelpPath" + :account-and-external-ids-help-path="accountAndExternalIdsHelpPath" + :external-link-icon="externalLinkIcon" /> </div> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index 1188cf08850..57d5f4f541b 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -4,8 +4,8 @@ import { sprintf, s__ } from '~/locale'; import _ from 'underscore'; import { GlFormInput, GlFormCheckbox } from '@gitlab/ui'; import ClusterFormDropdown from './cluster_form_dropdown.vue'; -import RegionDropdown from './region_dropdown.vue'; import { KUBERNETES_VERSIONS } from '../constants'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles'); const { mapState: mapRegionsState, mapActions: mapRegionsActions } = createNamespacedHelpers( @@ -22,13 +22,17 @@ const { mapState: mapSecurityGroupsState, mapActions: mapSecurityGroupsActions, } = createNamespacedHelpers('securityGroups'); +const { + mapState: mapInstanceTypesState, + mapActions: mapInstanceTypesActions, +} = createNamespacedHelpers('instanceTypes'); export default { components: { ClusterFormDropdown, - RegionDropdown, GlFormInput, GlFormCheckbox, + LoadingButton, }, props: { gitlabManagedClusterHelpPath: { @@ -39,6 +43,10 @@ export default { type: String, required: true, }, + externalLinkIcon: { + type: String, + required: true, + }, }, computed: { ...mapState([ @@ -51,7 +59,10 @@ export default { 'selectedSubnet', 'selectedRole', 'selectedSecurityGroup', + 'selectedInstanceType', + 'nodeCount', 'gitlabManagedCluster', + 'isCreatingCluster', ]), ...mapRolesState({ roles: 'items', @@ -83,6 +94,11 @@ export default { isLoadingSecurityGroups: 'isLoadingItems', loadingSecurityGroupsError: 'loadingItemsError', }), + ...mapInstanceTypesState({ + instanceTypes: 'items', + isLoadingInstanceTypes: 'isLoadingItems', + loadingInstanceTypesError: 'loadingItemsError', + }), kubernetesVersions() { return KUBERNETES_VERSIONS; }, @@ -98,6 +114,27 @@ export default { securityGroupDropdownDisabled() { return !this.selectedVpc; }, + createClusterButtonDisabled() { + return ( + !this.clusterName || + !this.environmentScope || + !this.kubernetesVersion || + !this.selectedRegion || + !this.selectedKeyPair || + !this.selectedVpc || + !this.selectedSubnet || + !this.selectedRole || + !this.selectedSecurityGroup || + !this.selectedInstanceType || + !this.nodeCount || + this.isCreatingCluster + ); + }, + createClusterButtonLabel() { + return this.isCreatingCluster + ? s__('ClusterIntegration|Creating Kubernetes cluster') + : s__('ClusterIntegration|Create Kubernetes cluster'); + }, kubernetesIntegrationHelpText() { const escapedUrl = _.escape(this.kubernetesIntegrationHelpPath); @@ -115,11 +152,26 @@ export default { roleDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services%{endLink}.', + 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', + ), + { + startLink: + '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#role-create" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + regionsDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}.', ), { startLink: - '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -128,11 +180,12 @@ export default { keyPairDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services%{endLink}.', + 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', ), { startLink: '<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -141,11 +194,12 @@ export default { vpcDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services%{endLink}.', + 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', ), { startLink: - '<a href="https://console.aws.amazon.com/vpc/home?#vpc" target="_blank" rel="noopener noreferrer">', + '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -154,11 +208,12 @@ export default { subnetDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Choose the %{startLink}subnets%{endLink} in your VPC where your worker nodes will run.', + 'ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run.', ), { startLink: '<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -167,11 +222,26 @@ export default { securityGroupDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Choose the %{startLink}security groups%{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', + 'ClusterIntegration|Choose the %{startLink}security group %{externalLinkIcon} %{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', ), { startLink: '<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + instanceTypesDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}.', + ), + { + startLink: + '<a href="https://aws.amazon.com/ec2/instance-types" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -195,9 +265,12 @@ export default { mounted() { this.fetchRegions(); this.fetchRoles(); + this.fetchInstanceTypes(); }, methods: { ...mapActions([ + 'createCluster', + 'signOut', 'setClusterName', 'setEnvironmentScope', 'setKubernetesVersion', @@ -207,6 +280,8 @@ export default { 'setRole', 'setKeyPair', 'setSecurityGroup', + 'setInstanceType', + 'setNodeCount', 'setGitlabManagedCluster', ]), ...mapRegionsActions({ fetchRegions: 'fetchItems' }), @@ -215,15 +290,22 @@ export default { ...mapRolesActions({ fetchRoles: 'fetchItems' }), ...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }), ...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }), + ...mapInstanceTypesActions({ fetchInstanceTypes: 'fetchItems' }), setRegionAndFetchVpcsAndKeyPairs(region) { this.setRegion({ region }); + this.setVpc({ vpc: null }); + this.setKeyPair({ keyPair: null }); + this.setSubnet({ subnet: null }); + this.setSecurityGroup({ securityGroup: null }); this.fetchVpcs({ region }); this.fetchKeyPairs({ region }); }, setVpcAndFetchSubnets(vpc) { this.setVpc({ vpc }); - this.fetchSubnets({ vpc }); - this.fetchSecurityGroups({ vpc }); + this.setSubnet({ subnet: null }); + this.setSecurityGroup({ securityGroup: null }); + this.fetchSubnets({ vpc, region: this.selectedRegion }); + this.fetchSecurityGroups({ vpc, region: this.selectedRegion }); }, }, }; @@ -233,7 +315,12 @@ export default { <h2> {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }} </h2> - <p v-html="kubernetesIntegrationHelpText"></p> + <div class="mb-3" v-html="kubernetesIntegrationHelpText"></div> + <div class="mb-3"> + <button class="btn btn-link js-sign-out" @click.prevent="signOut()"> + {{ s__('ClusterIntegration|Select a different AWS role') }} + </button> + </div> <div class="form-group"> <label class="label-bold" for="eks-cluster-name">{{ s__('ClusterIntegration|Kubernetes cluster name') @@ -273,7 +360,7 @@ export default { <cluster-form-dropdown field-id="eks-role" field-name="eks-role" - :input="selectedRole" + :value="selectedRole" :items="roles" :loading="isLoadingRoles" :loading-text="s__('ClusterIntegration|Loading IAM Roles')" @@ -288,13 +375,21 @@ export default { </div> <div class="form-group"> <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label> - <region-dropdown + <cluster-form-dropdown + field-id="eks-region" + field-name="eks-region" :value="selectedRegion" - :regions="regions" - :error="loadingRegionsError" + :items="regions" :loading="isLoadingRegions" + :loading-text="s__('ClusterIntegration|Loading Regions')" + :placeholder="s__('ClusterIntergation|Select a region')" + :search-field-placeholder="s__('ClusterIntegration|Search regions')" + :empty-text="s__('ClusterIntegration|No region found')" + :has-errors="Boolean(loadingRegionsError)" + :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" @input="setRegionAndFetchVpcsAndKeyPairs($event)" /> + <p class="form-text text-muted" v-html="regionsDropdownHelpText"></p> </div> <div class="form-group"> <label class="label-bold" for="eks-key-pair">{{ @@ -303,7 +398,7 @@ export default { <cluster-form-dropdown field-id="eks-key-pair" field-name="eks-key-pair" - :input="selectedKeyPair" + :value="selectedKeyPair" :items="keyPairs" :disabled="keyPairDropdownDisabled" :disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')" @@ -323,7 +418,7 @@ export default { <cluster-form-dropdown field-id="eks-vpc" field-name="eks-vpc" - :input="selectedVpc" + :value="selectedVpc" :items="vpcs" :loading="isLoadingVpcs" :disabled="vpcDropdownDisabled" @@ -339,11 +434,12 @@ export default { <p class="form-text text-muted" v-html="vpcDropdownHelpText"></p> </div> <div class="form-group"> - <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnet') }}</label> + <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnets') }}</label> <cluster-form-dropdown field-id="eks-subnet" field-name="eks-subnet" - :input="selectedSubnet" + multiple + :value="selectedSubnet" :items="subnets" :loading="isLoadingSubnets" :disabled="subnetDropdownDisabled" @@ -360,12 +456,12 @@ export default { </div> <div class="form-group"> <label class="label-bold" for="eks-security-group">{{ - s__('ClusterIntegration|Security groups') + s__('ClusterIntegration|Security group') }}</label> <cluster-form-dropdown field-id="eks-security-group" field-name="eks-security-group" - :input="selectedSecurityGroup" + :value="selectedSecurityGroup" :items="securityGroups" :loading="isLoadingSecurityGroups" :disabled="securityGroupDropdownDisabled" @@ -383,6 +479,39 @@ export default { <p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p> </div> <div class="form-group"> + <label class="label-bold" for="eks-instance-type">{{ + s__('ClusterIntegration|Instance type') + }}</label> + <cluster-form-dropdown + field-id="eks-instance-type" + field-name="eks-instance-type" + :value="selectedInstanceType" + :items="instanceTypes" + :loading="isLoadingInstanceTypes" + :loading-text="s__('ClusterIntegration|Loading instance types')" + :placeholder="s__('ClusterIntergation|Select an instance type')" + :search-field-placeholder="s__('ClusterIntegration|Search instance types')" + :empty-text="s__('ClusterIntegration|No instance type found')" + :has-errors="Boolean(loadingInstanceTypesError)" + :error-message="s__('ClusterIntegration|Could not load instance types')" + @input="setInstanceType({ instanceType: $event })" + /> + <p class="form-text text-muted" v-html="instanceTypesDropdownHelpText"></p> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-node-count">{{ + s__('ClusterIntegration|Number of nodes') + }}</label> + <gl-form-input + id="eks-node-count" + type="number" + min="1" + step="1" + :value="nodeCount" + @input="setNodeCount({ nodeCount: $event })" + /> + </div> + <div class="form-group"> <gl-form-checkbox :checked="gitlabManagedCluster" @input="setGitlabManagedCluster({ gitlabManagedCluster: $event })" @@ -390,5 +519,14 @@ export default { > <p class="form-text text-muted" v-html="gitlabManagedHelpText"></p> </div> + <div class="form-group"> + <loading-button + class="js-create-cluster btn-success" + :disabled="createClusterButtonDisabled" + :loading="isCreatingCluster" + :label="createClusterButtonLabel" + @click="createCluster()" + /> + </div> </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue deleted file mode 100644 index 765955305c8..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { sprintf, s__ } from '~/locale'; - -import ClusterFormDropdown from './cluster_form_dropdown.vue'; - -export default { - components: { - ClusterFormDropdown, - }, - props: { - regions: { - type: Array, - required: false, - default: () => [], - }, - loading: { - type: Boolean, - required: false, - default: false, - }, - error: { - type: Object, - required: false, - default: null, - }, - }, - computed: { - hasErrors() { - return Boolean(this.error); - }, - helpText() { - return sprintf( - s__('ClusterIntegration|Learn more about %{startLink}Regions%{endLink}.'), - { - startLink: - '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">', - endLink: '</a>', - }, - false, - ); - }, - }, -}; -</script> -<template> - <div> - <cluster-form-dropdown - field-id="eks-region" - field-name="eks-region" - :items="regions" - :loading="loading" - :loading-text="s__('ClusterIntegration|Loading Regions')" - :placeholder="s__('ClusterIntergation|Select a region')" - :search-field-placeholder="s__('ClusterIntegration|Search regions')" - :empty-text="s__('ClusterIntegration|No region found')" - :has-errors="hasErrors" - :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" - v-bind="$attrs" - v-on="$listeners" - /> - <p class="form-text text-muted" v-html="helpText"></p> - </div> -</template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue index 79029b8cfa8..ab33e9fbc95 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue @@ -1,3 +1,141 @@ +<script> +import { GlFormInput } from '@gitlab/ui'; +import { sprintf, s__, __ } from '~/locale'; +import _ from 'underscore'; +import { mapState, mapActions } from 'vuex'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; + +export default { + components: { + GlFormInput, + LoadingButton, + ClipboardButton, + }, + props: { + accountAndExternalIdsHelpPath: { + type: String, + required: true, + }, + createRoleArnHelpPath: { + type: String, + required: true, + }, + externalLinkIcon: { + type: String, + required: true, + }, + }, + data() { + return { + roleArn: '', + }; + }, + computed: { + ...mapState(['accountId', 'externalId', 'isCreatingRole', 'createRoleError']), + submitButtonDisabled() { + return this.isCreatingRole || !this.roleArn; + }, + submitButtonLabel() { + return this.isCreatingRole + ? __('Authenticating') + : s__('ClusterIntegration|Authenticate with AWS'); + }, + accountAndExternalIdsHelpText() { + const escapedUrl = _.escape(this.accountAndExternalIdsHelpPath); + + return sprintf( + s__( + 'ClusterIntegration|Create a provision role on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the account and external ID above. %{startMoreInfoLink}More information%{endLink}', + ), + { + startAwsLink: + '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + provisionRoleArnHelpText() { + const escapedUrl = _.escape(this.createRoleArnHelpPath); + + return sprintf( + s__( + 'ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provision role, first create one on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the above account and external IDs. %{startMoreInfoLink}More information%{endLink}', + ), + { + startAwsLink: + '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + }, + methods: { + ...mapActions(['createRole']), + }, +}; +</script> <template> - <form name="service-credentials-form"></form> + <form name="service-credentials-form" @submit.prevent="createRole({ roleArn, externalId })"> + <h2>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h2> + <p> + {{ + s__( + 'ClusterIntegration|You must grant access to your organization’s AWS resources in order to create a new EKS cluster. To grant access, create a provision role using the account and external ID below and provide us the ARN.', + ) + }} + </p> + <div v-if="createRoleError" class="js-invalid-credentials bs-callout bs-callout-danger"> + {{ createRoleError }} + </div> + <div class="form-row"> + <div class="form-group col-md-6"> + <label for="gitlab-account-id">{{ __('Account ID') }}</label> + <div class="input-group"> + <gl-form-input id="gitlab-account-id" type="text" readonly :value="accountId" /> + <div class="input-group-append"> + <clipboard-button + :text="accountId" + :title="__('Copy Account ID to clipboard')" + class="input-group-text js-copy-account-id-button" + /> + </div> + </div> + </div> + <div class="form-group col-md-6"> + <label for="eks-external-id">{{ __('External ID') }}</label> + <div class="input-group"> + <gl-form-input id="eks-external-id" type="text" readonly :value="externalId" /> + <div class="input-group-append"> + <clipboard-button + :text="externalId" + :title="__('Copy External ID to clipboard')" + class="input-group-text js-copy-external-id-button" + /> + </div> + </div> + </div> + <div class="col-12 mb-3 mt-n3"> + <p class="form-text text-muted" v-html="accountAndExternalIdsHelpText"></p> + </div> + </div> + <div class="form-group"> + <label for="eks-provision-role-arn">{{ s__('ClusterIntegration|Provision Role ARN') }}</label> + <gl-form-input id="eks-provision-role-arn" v-model="roleArn" /> + <p class="form-text text-muted" v-html="provisionRoleArnHelpText"></p> + </div> + <loading-button + class="js-submit-service-credentials btn-success" + type="submit" + :disabled="submitButtonDisabled" + :loading="isCreatingRole" + :label="submitButtonLabel" + /> + </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js index 339642f991e..a850ba89818 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js @@ -1,7 +1,2 @@ // eslint-disable-next-line import/prefer-default-export -export const KUBERNETES_VERSIONS = [ - { name: '1.14', value: '1.14' }, - { name: '1.13', value: '1.13' }, - { name: '1.12', value: '1.12' }, - { name: '1.11', value: '1.11' }, -]; +export const KUBERNETES_VERSIONS = [{ name: '1.14', value: '1.14' }]; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js index 1f595e9b2df..27f859d8972 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js @@ -1,16 +1,54 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; import CreateEksCluster from './components/create_eks_cluster.vue'; import createStore from './store'; Vue.use(Vuex); export default el => { - const { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath } = el.dataset; + const { + gitlabManagedClusterHelpPath, + kubernetesIntegrationHelpPath, + accountAndExternalIdsHelpPath, + createRoleArnHelpPath, + getRolesPath, + getRegionsPath, + getKeyPairsPath, + getVpcsPath, + getSubnetsPath, + getSecurityGroupsPath, + getInstanceTypesPath, + externalId, + accountId, + hasCredentials, + createRolePath, + createClusterPath, + signOutPath, + externalLinkIcon, + } = el.dataset; return new Vue({ el, - store: createStore(), + store: createStore({ + initialState: { + hasCredentials: parseBoolean(hasCredentials), + externalId, + accountId, + createRolePath, + createClusterPath, + signOutPath, + }, + apiPaths: { + getRolesPath, + getRegionsPath, + getKeyPairsPath, + getVpcsPath, + getSubnetsPath, + getSecurityGroupsPath, + getInstanceTypesPath, + }, + }), components: { CreateEksCluster, }, @@ -19,6 +57,9 @@ export default el => { props: { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath, + accountAndExternalIdsHelpPath, + createRoleArnHelpPath, + externalLinkIcon, }, }); }, diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js index d982e4db4c1..21b87d525cf 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js @@ -1,84 +1,58 @@ -import EC2 from 'aws-sdk/clients/ec2'; -import IAM from 'aws-sdk/clients/iam'; - -export const fetchRoles = () => { - const iam = new IAM(); - - return iam - .listRoles() - .promise() - .then(({ Roles: roles }) => roles.map(({ RoleName: name }) => ({ name }))); -}; - -export const fetchKeyPairs = () => { - const ec2 = new EC2(); - - return ec2 - .describeKeyPairs() - .promise() - .then(({ KeyPairs: keyPairs }) => keyPairs.map(({ RegionName: name }) => ({ name }))); -}; - -export const fetchRegions = () => { - const ec2 = new EC2(); - - return ec2 - .describeRegions() - .promise() - .then(({ Regions: regions }) => - regions.map(({ RegionName: name }) => ({ - name, - value: name, +import axios from '~/lib/utils/axios_utils'; + +export default apiPaths => ({ + fetchRoles() { + return axios + .get(apiPaths.getRolesPath) + .then(({ data: { roles } }) => + roles.map(({ role_name: name, arn: value }) => ({ name, value })), + ); + }, + fetchKeyPairs({ region }) { + return axios + .get(apiPaths.getKeyPairsPath, { params: { region } }) + .then(({ data: { key_pairs: keyPairs } }) => + keyPairs.map(({ key_name }) => ({ name: key_name, value: key_name })), + ); + }, + fetchRegions() { + return axios.get(apiPaths.getRegionsPath).then(({ data: { regions } }) => + regions.map(({ region_name }) => ({ + name: region_name, + value: region_name, })), ); -}; - -export const fetchVpcs = () => { - const ec2 = new EC2(); - - return ec2 - .describeVpcs() - .promise() - .then(({ Vpcs: vpcs }) => - vpcs.map(({ VpcId: id }) => ({ - value: id, - name: id, + }, + fetchVpcs({ region }) { + return axios.get(apiPaths.getVpcsPath, { params: { region } }).then(({ data: { vpcs } }) => + vpcs.map(({ vpc_id }) => ({ + value: vpc_id, + name: vpc_id, })), ); -}; - -export const fetchSubnets = ({ vpc }) => { - const ec2 = new EC2(); - - return ec2 - .describeSubnets({ - Filters: [ - { - Name: 'vpc-id', - Values: [vpc], - }, - ], - }) - .promise() - .then(({ Subnets: subnets }) => subnets.map(({ SubnetId: id }) => ({ id, name: id }))); -}; - -export const fetchSecurityGroups = ({ vpc }) => { - const ec2 = new EC2(); - - return ec2 - .describeSecurityGroups({ - Filters: [ - { - Name: 'vpc-id', - Values: [vpc], - }, - ], - }) - .promise() - .then(({ SecurityGroups: securityGroups }) => - securityGroups.map(({ GroupName: name, GroupId: value }) => ({ name, value })), - ); -}; - -export default () => {}; + }, + fetchSubnets({ vpc, region }) { + return axios + .get(apiPaths.getSubnetsPath, { params: { vpc_id: vpc, region } }) + .then(({ data: { subnets } }) => + subnets.map(({ subnet_id }) => ({ name: subnet_id, value: subnet_id })), + ); + }, + fetchSecurityGroups({ vpc, region }) { + return axios + .get(apiPaths.getSecurityGroupsPath, { params: { vpc_id: vpc, region } }) + .then(({ data: { security_groups: securityGroups } }) => + securityGroups.map(({ group_name: name, group_id: value }) => ({ name, value })), + ); + }, + fetchInstanceTypes() { + return axios + .get(apiPaths.getInstanceTypesPath) + .then(({ data: { instance_types: instanceTypes } }) => + instanceTypes.map(({ instance_type_name }) => ({ + name: instance_type_name, + value: instance_type_name, + })), + ); + }, +}); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index 917c8da6c3e..72f15263a8f 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -1,4 +1,12 @@ import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; + +const getErrorMessage = data => { + const errorKey = Object.keys(data)[0]; + + return data[errorKey][0]; +}; export const setClusterName = ({ commit }, payload) => { commit(types.SET_CLUSTER_NAME, payload); @@ -12,6 +20,68 @@ export const setKubernetesVersion = ({ commit }, payload) => { commit(types.SET_KUBERNETES_VERSION, payload); }; +export const createRole = ({ dispatch, state: { createRolePath } }, payload) => { + dispatch('requestCreateRole'); + + return axios + .post(createRolePath, { + role_arn: payload.roleArn, + role_external_id: payload.externalId, + }) + .then(() => dispatch('createRoleSuccess')) + .catch(error => dispatch('createRoleError', { error })); +}; + +export const requestCreateRole = ({ commit }) => { + commit(types.REQUEST_CREATE_ROLE); +}; + +export const createRoleSuccess = ({ commit }) => { + commit(types.CREATE_ROLE_SUCCESS); +}; + +export const createRoleError = ({ commit }, payload) => { + commit(types.CREATE_ROLE_ERROR, payload); +}; + +export const createCluster = ({ dispatch, state }) => { + dispatch('requestCreateCluster'); + + return axios + .post(state.createClusterPath, { + name: state.clusterName, + environment_scope: state.environmentScope, + managed: state.gitlabManagedCluster, + provider_aws_attributes: { + region: state.selectedRegion, + vpc_id: state.selectedVpc, + subnet_ids: state.selectedSubnet, + role_arn: state.selectedRole, + key_name: state.selectedKeyPair, + security_group_id: state.selectedSecurityGroup, + instance_type: state.selectedInstanceType, + num_nodes: state.nodeCount, + }, + }) + .then(({ headers: { location } }) => dispatch('createClusterSuccess', location)) + .catch(({ response: { data } }) => { + dispatch('createClusterError', data); + }); +}; + +export const requestCreateCluster = ({ commit }) => { + commit(types.REQUEST_CREATE_CLUSTER); +}; + +export const createClusterSuccess = (_, location) => { + window.location.assign(location); +}; + +export const createClusterError = ({ commit }, error) => { + commit(types.CREATE_CLUSTER_ERROR, error); + createFlash(getErrorMessage(error)); +}; + export const setRegion = ({ commit }, payload) => { commit(types.SET_REGION, payload); }; @@ -40,4 +110,16 @@ export const setGitlabManagedCluster = ({ commit }, payload) => { commit(types.SET_GITLAB_MANAGED_CLUSTER, payload); }; -export default () => {}; +export const setInstanceType = ({ commit }, payload) => { + commit(types.SET_INSTANCE_TYPE, payload); +}; + +export const setNodeCount = ({ commit }, payload) => { + commit(types.SET_NODE_COUNT, payload); +}; + +export const signOut = ({ commit, state: { signOutPath } }) => + axios + .delete(signOutPath) + .then(() => commit(types.SIGN_OUT)) + .catch(({ response: { data } }) => createFlash(getErrorMessage(data))); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js index d575deafd19..5982fc8a2fd 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js @@ -6,14 +6,16 @@ import state from './state'; import clusterDropdownStore from './cluster_dropdown'; -import * as awsServices from '../services/aws_services_facade'; +import awsServicesFactory from '../services/aws_services_facade'; -const createStore = () => - new Vuex.Store({ +const createStore = ({ initialState, apiPaths }) => { + const awsServices = awsServicesFactory(apiPaths); + + return new Vuex.Store({ actions, getters, mutations, - state: state(), + state: Object.assign(state(), initialState), modules: { roles: { namespaced: true, @@ -39,7 +41,12 @@ const createStore = () => namespaced: true, ...clusterDropdownStore(awsServices.fetchSecurityGroups), }, + instanceTypes: { + namespaced: true, + ...clusterDropdownStore(awsServices.fetchInstanceTypes), + }, }, }); +}; export default createStore; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js index 82eb512ac07..f9204cc2207 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js @@ -7,4 +7,13 @@ export const SET_KEY_PAIR = 'SET_KEY_PAIR'; export const SET_SUBNET = 'SET_SUBNET'; export const SET_ROLE = 'SET_ROLE'; export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP'; +export const SET_INSTANCE_TYPE = 'SET_INSTANCE_TYPE'; +export const SET_NODE_COUNT = 'SET_NODE_COUNT'; export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER'; +export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE'; +export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS'; +export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR'; +export const SIGN_OUT = 'SIGN_OUT'; +export const REQUEST_CREATE_CLUSTER = 'REQUEST_CREATE_CLUSTER'; +export const CREATE_CLUSTER_SUCCESS = 'CREATE_CLUSTER_SUCCESS'; +export const CREATE_CLUSTER_ERROR = 'CREATE_CLUSTER_ERROR'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js index 79950ac7dce..aa04c8f7079 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js @@ -28,7 +28,39 @@ export default { [types.SET_SECURITY_GROUP](state, { securityGroup }) { state.selectedSecurityGroup = securityGroup; }, + [types.SET_INSTANCE_TYPE](state, { instanceType }) { + state.selectedInstanceType = instanceType; + }, + [types.SET_NODE_COUNT](state, { nodeCount }) { + state.nodeCount = nodeCount; + }, [types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) { state.gitlabManagedCluster = gitlabManagedCluster; }, + [types.REQUEST_CREATE_ROLE](state) { + state.isCreatingRole = true; + state.createRoleError = null; + state.hasCredentials = false; + }, + [types.CREATE_ROLE_SUCCESS](state) { + state.isCreatingRole = false; + state.createRoleError = null; + state.hasCredentials = true; + }, + [types.CREATE_ROLE_ERROR](state, { error }) { + state.isCreatingRole = false; + state.createRoleError = error; + state.hasCredentials = false; + }, + [types.REQUEST_CREATE_CLUSTER](state) { + state.isCreatingCluster = true; + state.createClusterError = null; + }, + [types.CREATE_CLUSTER_ERROR](state, { error }) { + state.isCreatingCluster = false; + state.createClusterError = error; + }, + [types.SIGN_OUT](state) { + state.hasCredentials = false; + }, }; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js index bf74213bdce..2e3a05a9187 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js @@ -1,18 +1,31 @@ import { KUBERNETES_VERSIONS } from '../constants'; +const [{ value: kubernetesVersion }] = KUBERNETES_VERSIONS; + export default () => ({ - isValidatingCredentials: false, - validCredentials: false, + createRolePath: null, + + isCreatingRole: false, + roleCreated: false, + createRoleError: false, + + accountId: '', + externalId: '', clusterName: '', environmentScope: '*', - kubernetesVersion: [KUBERNETES_VERSIONS].value, + kubernetesVersion, selectedRegion: '', selectedRole: '', selectedKeyPair: '', selectedVpc: '', selectedSubnet: '', selectedSecurityGroup: '', + selectedInstanceType: 'm5.large', + nodeCount: '3', + + isCreatingCluster: false, + createClusterError: false, gitlabManagedCluster: true, }); |