diff options
48 files changed, 2130 insertions, 427 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 6bcae6ab536..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 @@ -41,6 +41,7 @@ export default { v-if="hasCredentials" :gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath" :kubernetes-integration-help-path="kubernetesIntegrationHelpPath" + :external-link-icon="externalLinkIcon" /> <service-credentials-form v-else 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 185fecba2d8..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 @@ -131,7 +131,7 @@ export default { <p class="form-text text-muted" v-html="provisionRoleArnHelpText"></p> </div> <loading-button - class="js-submit-service-credentials" + class="js-submit-service-credentials btn-success" type="submit" :disabled="submitButtonDisabled" :loading="isCreatingRole" 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 e634a743d1d..27f859d8972 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js @@ -12,10 +12,19 @@ export default el => { kubernetesIntegrationHelpPath, accountAndExternalIdsHelpPath, createRoleArnHelpPath, + getRolesPath, + getRegionsPath, + getKeyPairsPath, + getVpcsPath, + getSubnetsPath, + getSecurityGroupsPath, + getInstanceTypesPath, externalId, accountId, hasCredentials, createRolePath, + createClusterPath, + signOutPath, externalLinkIcon, } = el.dataset; @@ -27,6 +36,17 @@ export default el => { externalId, accountId, createRolePath, + createClusterPath, + signOutPath, + }, + apiPaths: { + getRolesPath, + getRegionsPath, + getKeyPairsPath, + getVpcsPath, + getSubnetsPath, + getSecurityGroupsPath, + getInstanceTypesPath, }, }), components: { 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 16a7547957e..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,5 +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); @@ -37,6 +44,44 @@ 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); }; @@ -64,3 +109,17 @@ export const setSecurityGroup = ({ commit }, payload) => { export const setGitlabManagedCluster = ({ commit }, payload) => { commit(types.SET_GITLAB_MANAGED_CLUSTER, payload); }; + +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 22cca5b816e..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,10 +6,12 @@ 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 = ({ initialState }) => - new Vuex.Store({ +const createStore = ({ initialState, apiPaths }) => { + const awsServices = awsServicesFactory(apiPaths); + + return new Vuex.Store({ actions, getters, mutations, @@ -39,7 +41,12 @@ const createStore = ({ initialState }) => 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 398b48d725f..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,7 +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 f7752a23574..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,6 +28,12 @@ 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; }, @@ -46,4 +52,15 @@ export default { 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 b69ae5b51e5..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,5 +1,7 @@ import { KUBERNETES_VERSIONS } from '../constants'; +const [{ value: kubernetesVersion }] = KUBERNETES_VERSIONS; + export default () => ({ createRolePath: null, @@ -12,13 +14,18 @@ export default () => ({ clusterName: '', environmentScope: '*', - kubernetesVersion: [KUBERNETES_VERSIONS].value, + kubernetesVersion, selectedRegion: '', selectedRole: '', selectedKeyPair: '', selectedVpc: '', selectedSubnet: '', selectedSecurityGroup: '', + selectedInstanceType: 'm5.large', + nodeCount: '3', + + isCreatingCluster: false, + createClusterError: false, gitlabManagedCluster: true, }); diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 6b27db23315..cfad274f91d 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -94,8 +94,7 @@ - else = f.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") = f.text_field :id, readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' } - - if experiment_enabled?(:signup_flow) - = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { prompt: _('Select your role') }, class: 'input-md' + = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { prompt: _('Select your role') }, required: true, class: 'input-md' = render_if_exists 'profiles/email_settings', form: f = f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username") diff --git a/changelogs/unreleased/18986-allow-to-use-commit-sha-in-cache-key.yml b/changelogs/unreleased/18986-allow-to-use-commit-sha-in-cache-key.yml new file mode 100644 index 00000000000..3e08b80282a --- /dev/null +++ b/changelogs/unreleased/18986-allow-to-use-commit-sha-in-cache-key.yml @@ -0,0 +1,5 @@ +--- +title: Build CI cache key from commit SHAs that changed given files +merge_request: 19392 +author: +type: added diff --git a/changelogs/unreleased/22392-eks-create-cluster-fe.yml b/changelogs/unreleased/22392-eks-create-cluster-fe.yml new file mode 100644 index 00000000000..133154de03f --- /dev/null +++ b/changelogs/unreleased/22392-eks-create-cluster-fe.yml @@ -0,0 +1,5 @@ +--- +title: Create AWS EKS cluster +merge_request: 19578 +author: +type: added diff --git a/changelogs/unreleased/growth-91-when-users-update-their-profile-do-not-let-them-complete-the-up.yml b/changelogs/unreleased/growth-91-when-users-update-their-profile-do-not-let-them-complete-the-up.yml new file mode 100644 index 00000000000..739d865b516 --- /dev/null +++ b/changelogs/unreleased/growth-91-when-users-update-their-profile-do-not-let-them-complete-the-up.yml @@ -0,0 +1,5 @@ +--- +title: Make role required when editing profile +merge_request: 19636 +author: +type: changed diff --git a/doc/api/packages.md b/doc/api/packages.md index 52cc1d5c97e..bab3f91bc40 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -61,7 +61,7 @@ GET /groups/:id/packages | `exclude_subgroups` | boolean | false | If the param is included as true, packages from projects from subgroups are not listed. Default is `false`. | ```bash -curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/group/:id/packages?exclude_subgroups=true +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/:id/packages?exclude_subgroups=true ``` Example response: diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index ea67617ff5c..27ced0eecf5 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1535,6 +1535,50 @@ cache: - binaries/ ``` +##### `cache:key:files` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/18986) in GitLab v12.5. + +If `cache:key:files` is added, the cache `key` will use the SHA of the most recent commit +that changed either of the given files. If neither file was changed in any commits, the key will be `default`. +A maximum of two files are allowed. + +```yaml +cache: + key: + files: + - Gemfile.lock + - package.json + paths: + - vendor/ruby + - node_modules +``` + +##### `cache:key:prefix` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/18986) in GitLab v12.5. + +The `prefix` parameter adds extra functionality to `key:files` by allowing the key to +be composed of the given `prefix` combined with the SHA of the most recent commit +that changed either of the files. For example, adding a `prefix` of `rspec`, will +cause keys to look like: `rspec-feef9576d21ee9b6a32e30c5c79d0a0ceb68d1e5`. If neither +file was changed in any commits, the prefix is added to `default`, so the key in the +example would be `rspec-default`. + +`prefix` follows the same restrictions as `key`, so it can use any of the +[predefined variables](../variables/README.md). Similarly, the `/` character or the +equivalent URI-encoded `%2F`, or a value made only of `.` or `%2E`, is not allowed. + +```yaml +cache: + key: + files: + - Gemfile.lock + prefix: ${CI_JOB_NAME} + paths: + - vendor/ruby +``` + #### `cache:untracked` Set `untracked: true` to cache all files that are untracked in your Git diff --git a/doc/development/feature_flags/development.md b/doc/development/feature_flags/development.md index de8098434c2..1ce9525d74e 100644 --- a/doc/development/feature_flags/development.md +++ b/doc/development/feature_flags/development.md @@ -52,20 +52,16 @@ isn't gated by a License or Plan. [namespace-fa]: https://gitlab.com/gitlab-org/gitlab/blob/4cc1c62918aa4c31750cb21dfb1a6c3492d71080/ee/app/models/ee/namespace.rb#L71-85 [license-fa]: https://gitlab.com/gitlab-org/gitlab/blob/4cc1c62918aa4c31750cb21dfb1a6c3492d71080/ee/app/models/license.rb#L293-300 -An important side-effect of the implicit feature flags mentioned above is that +**An important side-effect of the implicit feature flags mentioned above is that unless the feature is explicitly disabled or limited to a percentage of users, -the feature flag check will default to `true`. +the feature flag check will default to `true`.** As an example, if you were to ship the backend half of a feature behind a flag, you'd want to explicitly disable that flag until the frontend half is also ready -to be shipped. [You can do this via Chatops](controls.md): - -``` -/chatops run feature set some_feature 0 -``` - -Note that you can do this at any time, even before the merge request using the -flag has been merged! +to be shipped. To make sure this feature is disabled for both GitLab.com and +self-managed instances you'd need to explicitly call `Feature.enabled?` method +before the `feature_available` method. This ensures the feature_flag is defaulting +to `true`. ## Feature groups diff --git a/lib/gitlab/ci/config/entry/files.rb b/lib/gitlab/ci/config/entry/files.rb new file mode 100644 index 00000000000..d0d6a36d754 --- /dev/null +++ b/lib/gitlab/ci/config/entry/files.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents an array of file paths. + # + class Files < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, array_of_strings: true + validates :config, length: { + minimum: 1, + maximum: 2, + too_short: 'requires at least %{count} item', + too_long: 'has too many items (maximum is %{count})' + } + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/key.rb b/lib/gitlab/ci/config/entry/key.rb index 0c10967e629..f12f0919348 100644 --- a/lib/gitlab/ci/config/entry/key.rb +++ b/lib/gitlab/ci/config/entry/key.rb @@ -7,11 +7,48 @@ module Gitlab ## # Entry that represents a key. # - class Key < ::Gitlab::Config::Entry::Node - include ::Gitlab::Config::Entry::Validatable + class Key < ::Gitlab::Config::Entry::Simplifiable + strategy :SimpleKey, if: -> (config) { config.is_a?(String) || config.is_a?(Symbol) } + strategy :ComplexKey, if: -> (config) { config.is_a?(Hash) } - validations do - validates :config, key: true + class SimpleKey < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, key: true + end + + def self.default + 'default' + end + + def value + super.to_s + end + end + + class ComplexKey < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Attributable + include ::Gitlab::Config::Entry::Configurable + + ALLOWED_KEYS = %i[files prefix].freeze + REQUIRED_KEYS = %i[files].freeze + + validations do + validates :config, allowed_keys: ALLOWED_KEYS + validates :config, required_keys: REQUIRED_KEYS + end + + entry :files, Entry::Files, + description: 'Files that should be used to build the key' + entry :prefix, Entry::Prefix, + description: 'Prefix that is added to the final cache key' + end + + class UnknownStrategy < ::Gitlab::Config::Entry::Node + def errors + ["#{location} should be a hash, a string or a symbol"] + end end def self.default diff --git a/lib/gitlab/ci/config/entry/prefix.rb b/lib/gitlab/ci/config/entry/prefix.rb new file mode 100644 index 00000000000..3244ad6d611 --- /dev/null +++ b/lib/gitlab/ci/config/entry/prefix.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a key prefix. + # + class Prefix < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, key: true + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/seed/build.rb b/lib/gitlab/ci/pipeline/seed/build.rb index fc9c540088b..1d698a32ba8 100644 --- a/lib/gitlab/ci/pipeline/seed/build.rb +++ b/lib/gitlab/ci/pipeline/seed/build.rb @@ -29,6 +29,8 @@ module Gitlab .fabricate(attributes.delete(:except)) @rules = Gitlab::Ci::Build::Rules .new(attributes.delete(:rules)) + @cache = Seed::Build::Cache + .new(pipeline, attributes.delete(:cache)) end def name @@ -59,6 +61,7 @@ module Gitlab @seed_attributes .deep_merge(pipeline_attributes) .deep_merge(rules_attributes) + .deep_merge(cache_attributes) end def bridge? @@ -150,6 +153,12 @@ module Gitlab @using_rules ? @rules.evaluate(@pipeline, self).build_attributes : {} end end + + def cache_attributes + strong_memoize(:cache_attributes) do + @cache.build_attributes + end + end end end end diff --git a/lib/gitlab/ci/pipeline/seed/build/cache.rb b/lib/gitlab/ci/pipeline/seed/build/cache.rb new file mode 100644 index 00000000000..7671035b896 --- /dev/null +++ b/lib/gitlab/ci/pipeline/seed/build/cache.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Seed + class Build + class Cache + def initialize(pipeline, cache) + @pipeline = pipeline + local_cache = cache.to_h.deep_dup + @key = local_cache.delete(:key) + @paths = local_cache.delete(:paths) + @policy = local_cache.delete(:policy) + @untracked = local_cache.delete(:untracked) + + raise ArgumentError, "unknown cache keys: #{local_cache.keys}" if local_cache.any? + end + + def build_attributes + { + options: { + cache: { + key: key_string, + paths: @paths, + policy: @policy, + untracked: @untracked + }.compact.presence + }.compact + } + end + + private + + def key_string + key_from_string || key_from_files + end + + def key_from_string + @key.to_s if @key.is_a?(String) || @key.is_a?(Symbol) + end + + def key_from_files + return unless @key.is_a?(Hash) + + [@key[:prefix], files_digest].select(&:present?).join('-') + end + + def files_digest + hash_of_the_latest_changes || 'default' + end + + def hash_of_the_latest_changes + return unless Feature.enabled?(:ci_file_based_cache, @pipeline.project, default_enabled: true) + + ids = files.map { |path| last_commit_id_for_path(path) } + ids = ids.compact.sort.uniq + + Digest::SHA1.hexdigest(ids.join('-')) if ids.any? + end + + def files + @key[:files] + .to_a + .select(&:present?) + .uniq + end + + def last_commit_id_for_path(path) + @pipeline.project.repository.last_commit_id_for_path(@pipeline.sha, path) + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/yaml_processor.rb b/lib/gitlab/ci/yaml_processor.rb index c2a55fa8b1b..f48ffa9c1f7 100644 --- a/lib/gitlab/ci/yaml_processor.rb +++ b/lib/gitlab/ci/yaml_processor.rb @@ -43,11 +43,11 @@ module Gitlab needs_attributes: job.dig(:needs, :job), interruptible: job[:interruptible], rules: job[:rules], + cache: job[:cache], options: { image: job[:image], services: job[:services], artifacts: job[:artifacts], - cache: job[:cache], dependencies: job[:dependencies], job_timeout: job[:timeout], before_script: job[:before_script], diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1e5a9ecf360..93bde33b8f9 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3547,10 +3547,13 @@ msgstr "" msgid "ClusterIntegration|Choose a prefix to be used for your namespaces. Defaults to your project path." msgstr "" -msgid "ClusterIntegration|Choose the %{startLink}security groups%{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets." +msgid "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." msgstr "" -msgid "ClusterIntegration|Choose the %{startLink}subnets%{endLink} in your VPC where your worker nodes will run." +msgid "ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run." +msgstr "" + +msgid "ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}." msgstr "" msgid "ClusterIntegration|Choose which applications to install on your Kubernetes cluster. Helm Tiller is required to install any of the following applications." @@ -3607,6 +3610,9 @@ msgstr "" msgid "ClusterIntegration|Could not load VPCs for the selected region" msgstr "" +msgid "ClusterIntegration|Could not load instance types" +msgstr "" + msgid "ClusterIntegration|Could not load regions from your AWS account" msgstr "" @@ -3634,6 +3640,9 @@ msgstr "" msgid "ClusterIntegration|Create new Cluster on GKE" msgstr "" +msgid "ClusterIntegration|Creating Kubernetes cluster" +msgstr "" + msgid "ClusterIntegration|Did you know?" msgstr "" @@ -3742,6 +3751,9 @@ msgstr "" msgid "ClusterIntegration|Instance cluster" msgstr "" +msgid "ClusterIntegration|Instance type" +msgstr "" + msgid "ClusterIntegration|Integrate Kubernetes cluster automation" msgstr "" @@ -3817,7 +3829,7 @@ msgstr "" msgid "ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}." msgstr "" -msgid "ClusterIntegration|Learn more about %{startLink}Regions%{endLink}." +msgid "ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}." msgstr "" msgid "ClusterIntegration|Learn more about Kubernetes" @@ -3844,6 +3856,9 @@ msgstr "" msgid "ClusterIntegration|Loading VPCs" msgstr "" +msgid "ClusterIntegration|Loading instance types" +msgstr "" + msgid "ClusterIntegration|Loading security groups" msgstr "" @@ -3868,6 +3883,9 @@ msgstr "" msgid "ClusterIntegration|No VPCs found" msgstr "" +msgid "ClusterIntegration|No instance type found" +msgstr "" + msgid "ClusterIntegration|No machine types matched your search" msgstr "" @@ -3964,6 +3982,9 @@ msgstr "" msgid "ClusterIntegration|Search VPCs" msgstr "" +msgid "ClusterIntegration|Search instance types" +msgstr "" + msgid "ClusterIntegration|Search machine types" msgstr "" @@ -3982,7 +4003,7 @@ msgstr "" msgid "ClusterIntegration|Search zones" msgstr "" -msgid "ClusterIntegration|Security groups" +msgid "ClusterIntegration|Security group" msgstr "" msgid "ClusterIntegration|See and edit the details for your Kubernetes cluster" @@ -3994,7 +4015,10 @@ msgstr "" msgid "ClusterIntegration|Select a VPC to choose a subnet" msgstr "" -msgid "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}." +msgid "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}." +msgstr "" + +msgid "ClusterIntegration|Select a different AWS role" msgstr "" msgid "ClusterIntegration|Select a region to choose a Key Pair" @@ -4015,10 +4039,10 @@ msgstr "" msgid "ClusterIntegration|Select project to choose zone" msgstr "" -msgid "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}." +msgid "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}." msgstr "" -msgid "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}." +msgid "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}." msgstr "" msgid "ClusterIntegration|Select zone" @@ -4054,7 +4078,7 @@ msgstr "" msgid "ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain." msgstr "" -msgid "ClusterIntegration|Subnet" +msgid "ClusterIntegration|Subnets" msgstr "" msgid "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}" @@ -4174,6 +4198,9 @@ msgstr "" msgid "ClusterIntergation|Select a subnet" msgstr "" +msgid "ClusterIntergation|Select an instance type" +msgstr "" + msgid "ClusterIntergation|Select key pair" msgstr "" diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index 23f660d111a..9839b3d6c80 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -23,6 +23,7 @@ describe 'User edit profile' do fill_in 'user_location', with: 'Ukraine' fill_in 'user_bio', with: 'I <3 GitLab' fill_in 'user_organization', with: 'GitLab' + select 'Data Analyst', from: 'user_role' submit_settings expect(user.reload).to have_attributes( @@ -31,7 +32,8 @@ describe 'User edit profile' do twitter: 'testtwitter', website_url: 'testurl', bio: 'I <3 GitLab', - organization: 'GitLab' + organization: 'GitLab', + role: 'data_analyst' ) expect(find('#user_location').value).to eq 'Ukraine' @@ -66,34 +68,6 @@ describe 'User edit profile' do end end - describe 'when I change my role' do - context 'experiment enabled' do - before do - stub_experiment_for_user(signup_flow: true) - visit(profile_path) - end - - it 'changes my role' do - expect(page).to have_content 'Role' - select 'Data Analyst', from: 'user_role' - submit_settings - user.reload - expect(user.role).to eq 'data_analyst' - end - end - - context 'experiment disabled' do - before do - stub_experiment_for_user(signup_flow: false) - visit(profile_path) - end - - it 'does not show the role picker' do - expect(page).not_to have_content 'Role' - end - end - end - context 'user avatar' do before do attach_file(:user_avatar, Rails.root.join('spec', 'fixtures', 'banana_sample.gif')) diff --git a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js index 366c2fc7b26..efbe2635fcc 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import ClusterFormDropdown from '~/create_cluster/eks_cluster/components/cluster_form_dropdown.vue'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; -import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; +import { GlIcon } from '@gitlab/ui'; describe('ClusterFormDropdown', () => { let vm; @@ -41,24 +41,50 @@ describe('ClusterFormDropdown', () => { .trigger('click'); }); - it('displays selected item label', () => { - expect(vm.find(DropdownButton).props('toggleText')).toEqual(secondItem.name); + it('emits input event with selected item', () => { + expect(vm.emitted('input')[0]).toEqual([secondItem.value]); + }); + }); + + describe('when multiple items are selected', () => { + const value = [1]; + + beforeEach(() => { + vm.setProps({ items, multiple: true, value }); + vm.findAll('.js-dropdown-item') + .at(0) + .trigger('click'); + vm.findAll('.js-dropdown-item') + .at(1) + .trigger('click'); + }); + + it('emits input event with an array of selected items', () => { + expect(vm.emitted('input')[1]).toEqual([[firstItem.value, secondItem.value]]); + }); + }); + + describe('when multiple items can be selected', () => { + beforeEach(() => { + vm.setProps({ items, multiple: true, value: firstItem.value }); }); - it('sets selected value to dropdown hidden input', () => { - expect(vm.find(DropdownHiddenInput).props('value')).toEqual(secondItem.value); + it('displays a checked GlIcon next to the item', () => { + expect(vm.find(GlIcon).is('.invisible')).toBe(false); + expect(vm.find(GlIcon).props('name')).toBe('mobile-issue-close'); }); }); describe('when an item is selected and has a custom label property', () => { it('displays selected item custom label', () => { const labelProperty = 'customLabel'; - const selectedItem = { [labelProperty]: 'Name' }; + const label = 'Name'; + const currentValue = 1; + const customLabelItems = [{ [labelProperty]: label, value: currentValue }]; - vm.setProps({ labelProperty }); - vm.setData({ selectedItem }); + vm.setProps({ labelProperty, items: customLabelItems, value: currentValue }); - expect(vm.find(DropdownButton).props('toggleText')).toEqual(selectedItem[labelProperty]); + expect(vm.find(DropdownButton).props('toggleText')).toEqual(label); }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js index 69290f6dfa9..25d613d64ed 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js @@ -4,7 +4,6 @@ import Vue from 'vue'; import { GlFormCheckbox } from '@gitlab/ui'; import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue'; -import RegionDropdown from '~/create_cluster/eks_cluster/components/region_dropdown.vue'; import eksClusterFormState from '~/create_cluster/eks_cluster/store/state'; import clusterDropdownStoreState from '~/create_cluster/eks_cluster/store/cluster_dropdown/state'; @@ -21,17 +20,21 @@ describe('EksClusterConfigurationForm', () => { let subnetsState; let keyPairsState; let securityGroupsState; + let instanceTypesState; let vpcsActions; let rolesActions; let regionsActions; let subnetsActions; let keyPairsActions; let securityGroupsActions; + let instanceTypesActions; let vm; beforeEach(() => { state = eksClusterFormState(); actions = { + signOut: jest.fn(), + createCluster: jest.fn(), setClusterName: jest.fn(), setEnvironmentScope: jest.fn(), setKubernetesVersion: jest.fn(), @@ -41,6 +44,8 @@ describe('EksClusterConfigurationForm', () => { setRole: jest.fn(), setKeyPair: jest.fn(), setSecurityGroup: jest.fn(), + setInstanceType: jest.fn(), + setNodeCount: jest.fn(), setGitlabManagedCluster: jest.fn(), }; regionsActions = { @@ -61,6 +66,9 @@ describe('EksClusterConfigurationForm', () => { securityGroupsActions = { fetchItems: jest.fn(), }; + instanceTypesActions = { + fetchItems: jest.fn(), + }; rolesState = { ...clusterDropdownStoreState(), }; @@ -79,6 +87,9 @@ describe('EksClusterConfigurationForm', () => { securityGroupsState = { ...clusterDropdownStoreState(), }; + instanceTypesState = { + ...clusterDropdownStoreState(), + }; store = new Vuex.Store({ state, actions, @@ -113,6 +124,11 @@ describe('EksClusterConfigurationForm', () => { state: securityGroupsState, actions: securityGroupsActions, }, + instanceTypes: { + namespaced: true, + state: instanceTypesState, + actions: instanceTypesActions, + }, }, }); }); @@ -124,6 +140,7 @@ describe('EksClusterConfigurationForm', () => { propsData: { gitlabManagedClusterHelpPath: '', kubernetesIntegrationHelpPath: '', + externalLinkIcon: '', }, }); }); @@ -132,15 +149,34 @@ describe('EksClusterConfigurationForm', () => { vm.destroy(); }); + const setAllConfigurationFields = () => { + store.replaceState({ + ...state, + clusterName: 'cluster name', + environmentScope: '*', + selectedRegion: 'region', + selectedRole: 'role', + selectedKeyPair: 'key pair', + selectedVpc: 'vpc', + selectedSubnet: 'subnet', + selectedSecurityGroup: 'group', + selectedInstanceType: 'small-1', + }); + }; + + const findSignOutButton = () => vm.find('.js-sign-out'); + const findCreateClusterButton = () => vm.find('.js-create-cluster'); const findClusterNameInput = () => vm.find('[id=eks-cluster-name]'); const findEnvironmentScopeInput = () => vm.find('[id=eks-environment-scope]'); const findKubernetesVersionDropdown = () => vm.find('[field-id="eks-kubernetes-version"]'); - const findRegionDropdown = () => vm.find(RegionDropdown); + const findRegionDropdown = () => vm.find('[field-id="eks-region"]'); const findKeyPairDropdown = () => vm.find('[field-id="eks-key-pair"]'); const findVpcDropdown = () => vm.find('[field-id="eks-vpc"]'); const findSubnetDropdown = () => vm.find('[field-id="eks-subnet"]'); const findRoleDropdown = () => vm.find('[field-id="eks-role"]'); const findSecurityGroupDropdown = () => vm.find('[field-id="eks-security-group"]'); + const findInstanceTypeDropdown = () => vm.find('[field-id="eks-instance-type"'); + const findNodeCountInput = () => vm.find('[id="eks-node-count"]'); const findGitlabManagedClusterCheckbox = () => vm.find(GlFormCheckbox); describe('when mounted', () => { @@ -151,6 +187,15 @@ describe('EksClusterConfigurationForm', () => { it('fetches available roles', () => { expect(rolesActions.fetchItems).toHaveBeenCalled(); }); + + it('fetches available instance types', () => { + expect(instanceTypesActions.fetchItems).toHaveBeenCalled(); + }); + }); + + it('dispatches signOut action when sign out button is clicked', () => { + findSignOutButton().trigger('click'); + expect(actions.signOut).toHaveBeenCalled(); }); it('sets isLoadingRoles to RoleDropdown loading property', () => { @@ -180,11 +225,13 @@ describe('EksClusterConfigurationForm', () => { }); it('sets regions to RegionDropdown regions property', () => { - expect(findRegionDropdown().props('regions')).toBe(regionsState.items); + expect(findRegionDropdown().props('items')).toBe(regionsState.items); }); it('sets loadingRegionsError to RegionDropdown error property', () => { - expect(findRegionDropdown().props('error')).toBe(regionsState.loadingItemsError); + regionsState.loadingItemsError = new Error(); + + expect(findRegionDropdown().props('hasErrors')).toEqual(true); }); it('disables KeyPairDropdown when no region is selected', () => { @@ -329,6 +376,34 @@ describe('EksClusterConfigurationForm', () => { undefined, ); }); + + it('cleans selected vpc', () => { + expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null }, undefined); + }); + + it('cleans selected key pair', () => { + expect(actions.setKeyPair).toHaveBeenCalledWith( + expect.anything(), + { keyPair: null }, + undefined, + ); + }); + + it('cleans selected subnet', () => { + expect(actions.setSubnet).toHaveBeenCalledWith( + expect.anything(), + { subnet: null }, + undefined, + ); + }); + + it('cleans selected security group', () => { + expect(actions.setSecurityGroup).toHaveBeenCalledWith( + expect.anything(), + { securityGroup: null }, + undefined, + ); + }); }); it('dispatches setClusterName when cluster name input changes', () => { @@ -381,8 +456,10 @@ describe('EksClusterConfigurationForm', () => { describe('when vpc is selected', () => { const vpc = { name: 'vpc-1' }; + const region = 'east-1'; beforeEach(() => { + state.selectedRegion = region; findVpcDropdown().vm.$emit('input', vpc); }); @@ -390,14 +467,34 @@ describe('EksClusterConfigurationForm', () => { expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined); }); + it('cleans selected subnet', () => { + expect(actions.setSubnet).toHaveBeenCalledWith( + expect.anything(), + { subnet: null }, + undefined, + ); + }); + + it('cleans selected security group', () => { + expect(actions.setSecurityGroup).toHaveBeenCalledWith( + expect.anything(), + { securityGroup: null }, + undefined, + ); + }); + it('dispatches fetchSubnets action', () => { - expect(subnetsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined); + expect(subnetsActions.fetchItems).toHaveBeenCalledWith( + expect.anything(), + { vpc, region }, + undefined, + ); }); it('dispatches fetchSecurityGroups action', () => { expect(securityGroupsActions.fetchItems).toHaveBeenCalledWith( expect.anything(), - { vpc }, + { vpc, region }, undefined, ); }); @@ -454,4 +551,76 @@ describe('EksClusterConfigurationForm', () => { ); }); }); + + describe('when instance type is selected', () => { + const instanceType = 'small-1'; + + beforeEach(() => { + findInstanceTypeDropdown().vm.$emit('input', instanceType); + }); + + it('dispatches setInstanceType action', () => { + expect(actions.setInstanceType).toHaveBeenCalledWith( + expect.anything(), + { instanceType }, + undefined, + ); + }); + }); + + it('dispatches setNodeCount when node count input changes', () => { + const nodeCount = 5; + + findNodeCountInput().vm.$emit('input', nodeCount); + + expect(actions.setNodeCount).toHaveBeenCalledWith(expect.anything(), { nodeCount }, undefined); + }); + + describe('when all cluster configuration fields are set', () => { + beforeEach(() => { + setAllConfigurationFields(); + }); + + it('enables create cluster button', () => { + expect(findCreateClusterButton().props('disabled')).toBe(false); + }); + }); + + describe('when at least one cluster configuration field is not set', () => { + beforeEach(() => { + setAllConfigurationFields(); + store.replaceState({ + ...state, + clusterName: '', + }); + }); + + it('disables create cluster button', () => { + expect(findCreateClusterButton().props('disabled')).toBe(true); + }); + }); + + describe('when isCreatingCluster', () => { + beforeEach(() => { + setAllConfigurationFields(); + store.replaceState({ + ...state, + isCreatingCluster: true, + }); + }); + + it('sets create cluster button as loading', () => { + expect(findCreateClusterButton().props('loading')).toBe(true); + }); + }); + + describe('clicking create cluster button', () => { + beforeEach(() => { + findCreateClusterButton().vm.$emit('click'); + }); + + it('dispatches createCluster action', () => { + expect(actions.createCluster).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js deleted file mode 100644 index 0ebb5026a4b..00000000000 --- a/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js +++ /dev/null @@ -1,55 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import ClusterFormDropdown from '~/create_cluster/eks_cluster/components/cluster_form_dropdown.vue'; -import RegionDropdown from '~/create_cluster/eks_cluster/components/region_dropdown.vue'; - -describe('RegionDropdown', () => { - let vm; - - const getClusterFormDropdown = () => vm.find(ClusterFormDropdown); - - beforeEach(() => { - vm = shallowMount(RegionDropdown); - }); - afterEach(() => vm.destroy()); - - it('renders a cluster-form-dropdown', () => { - expect(getClusterFormDropdown().exists()).toBe(true); - }); - - it('sets regions to cluster-form-dropdown items property', () => { - const regions = [{ name: 'basic' }]; - - vm.setProps({ regions }); - - expect(getClusterFormDropdown().props('items')).toEqual(regions); - }); - - it('sets a loading text', () => { - expect(getClusterFormDropdown().props('loadingText')).toEqual('Loading Regions'); - }); - - it('sets a placeholder', () => { - expect(getClusterFormDropdown().props('placeholder')).toEqual('Select a region'); - }); - - it('sets an empty results text', () => { - expect(getClusterFormDropdown().props('emptyText')).toEqual('No region found'); - }); - - it('sets a search field placeholder', () => { - expect(getClusterFormDropdown().props('searchFieldPlaceholder')).toEqual('Search regions'); - }); - - it('sets hasErrors property', () => { - vm.setProps({ error: {} }); - - expect(getClusterFormDropdown().props('hasErrors')).toEqual(true); - }); - - it('sets an error message', () => { - expect(getClusterFormDropdown().props('errorMessage')).toEqual( - 'Could not load regions from your AWS account', - ); - }); -}); diff --git a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js new file mode 100644 index 00000000000..25be858dcb3 --- /dev/null +++ b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js @@ -0,0 +1,152 @@ +import awsServicesFacadeFactory from '~/create_cluster/eks_cluster/services/aws_services_facade'; +import axios from '~/lib/utils/axios_utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; + +describe('awsServicesFacade', () => { + let apiPaths; + let axiosMock; + let awsServices; + let region; + let vpc; + + beforeEach(() => { + apiPaths = { + getKeyPairsPath: '/clusters/aws/api/key_pairs', + getRegionsPath: '/clusters/aws/api/regions', + getRolesPath: '/clusters/aws/api/roles', + getSecurityGroupsPath: '/clusters/aws/api/security_groups', + getSubnetsPath: '/clusters/aws/api/subnets', + getVpcsPath: '/clusters/aws/api/vpcs', + getInstanceTypesPath: '/clusters/aws/api/instance_types', + }; + region = 'west-1'; + vpc = 'vpc-2'; + awsServices = awsServicesFacadeFactory(apiPaths); + axiosMock = new AxiosMockAdapter(axios); + }); + + describe('when fetchRegions succeeds', () => { + let regions; + let regionsOutput; + + beforeEach(() => { + regions = [{ region_name: 'east-1' }, { region_name: 'west-2' }]; + regionsOutput = regions.map(({ region_name: name }) => ({ name, value: name })); + axiosMock.onGet(apiPaths.getRegionsPath).reply(200, { regions }); + }); + + it('return list of roles where each item has a name and value', () => { + expect(awsServices.fetchRegions()).resolves.toEqual(regionsOutput); + }); + }); + + describe('when fetchRoles succeeds', () => { + let roles; + let rolesOutput; + + beforeEach(() => { + roles = [ + { role_name: 'admin', arn: 'aws::admin' }, + { role_name: 'read-only', arn: 'aws::read-only' }, + ]; + rolesOutput = roles.map(({ role_name: name, arn: value }) => ({ name, value })); + axiosMock.onGet(apiPaths.getRolesPath).reply(200, { roles }); + }); + + it('return list of regions where each item has a name and value', () => { + expect(awsServices.fetchRoles()).resolves.toEqual(rolesOutput); + }); + }); + + describe('when fetchKeyPairs succeeds', () => { + let keyPairs; + let keyPairsOutput; + + beforeEach(() => { + keyPairs = [{ key_pair: 'key-pair' }, { key_pair: 'key-pair-2' }]; + keyPairsOutput = keyPairs.map(({ key_name: name }) => ({ name, value: name })); + axiosMock + .onGet(apiPaths.getKeyPairsPath, { params: { region } }) + .reply(200, { key_pairs: keyPairs }); + }); + + it('return list of key pairs where each item has a name and value', () => { + expect(awsServices.fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput); + }); + }); + + describe('when fetchVpcs succeeds', () => { + let vpcs; + let vpcsOutput; + + beforeEach(() => { + vpcs = [{ vpc_id: 'vpc-1' }, { vpc_id: 'vpc-2' }]; + vpcsOutput = vpcs.map(({ vpc_id: name }) => ({ name, value: name })); + axiosMock.onGet(apiPaths.getVpcsPath, { params: { region } }).reply(200, { vpcs }); + }); + + it('return list of vpcs where each item has a name and value', () => { + expect(awsServices.fetchVpcs({ region })).resolves.toEqual(vpcsOutput); + }); + }); + + describe('when fetchSubnets succeeds', () => { + let subnets; + let subnetsOutput; + + beforeEach(() => { + subnets = [{ subnet_id: 'vpc-1' }, { subnet_id: 'vpc-2' }]; + subnetsOutput = subnets.map(({ subnet_id }) => ({ name: subnet_id, value: subnet_id })); + axiosMock + .onGet(apiPaths.getSubnetsPath, { params: { region, vpc_id: vpc } }) + .reply(200, { subnets }); + }); + + it('return list of subnets where each item has a name and value', () => { + expect(awsServices.fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput); + }); + }); + + describe('when fetchSecurityGroups succeeds', () => { + let securityGroups; + let securityGroupsOutput; + + beforeEach(() => { + securityGroups = [ + { group_name: 'admin group', group_id: 'group-1' }, + { group_name: 'basic group', group_id: 'group-2' }, + ]; + securityGroupsOutput = securityGroups.map(({ group_id: value, group_name: name }) => ({ + name, + value, + })); + axiosMock + .onGet(apiPaths.getSecurityGroupsPath, { params: { region, vpc_id: vpc } }) + .reply(200, { security_groups: securityGroups }); + }); + + it('return list of security groups where each item has a name and value', () => { + expect(awsServices.fetchSecurityGroups({ region, vpc })).resolves.toEqual( + securityGroupsOutput, + ); + }); + }); + + describe('when fetchInstanceTypes succeeds', () => { + let instanceTypes; + let instanceTypesOutput; + + beforeEach(() => { + instanceTypes = [{ instance_type_name: 't2.small' }, { instance_type_name: 't2.medium' }]; + instanceTypesOutput = instanceTypes.map(({ instance_type_name }) => ({ + name: instance_type_name, + value: instance_type_name, + })); + axiosMock.onGet(apiPaths.getInstanceTypesPath).reply(200, { instance_types: instanceTypes }); + }); + + it('return list of instance types where each item has a name and value', () => { + expect(awsServices.fetchInstanceTypes()).resolves.toEqual(instanceTypesOutput); + }); + }); +}); diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js index 99c8cdba296..cf6c317a2df 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js @@ -13,12 +13,20 @@ import { SET_ROLE, SET_SECURITY_GROUP, SET_GITLAB_MANAGED_CLUSTER, + SET_INSTANCE_TYPE, + SET_NODE_COUNT, REQUEST_CREATE_ROLE, CREATE_ROLE_SUCCESS, CREATE_ROLE_ERROR, + REQUEST_CREATE_CLUSTER, + CREATE_CLUSTER_ERROR, + SIGN_OUT, } from '~/create_cluster/eks_cluster/store/mutation_types'; import axios from '~/lib/utils/axios_utils'; import MockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); describe('EKS Cluster Store Actions', () => { let clusterName; @@ -30,25 +38,34 @@ describe('EKS Cluster Store Actions', () => { let role; let keyPair; let securityGroup; + let instanceType; + let nodeCount; let gitlabManagedCluster; let mock; let state; + let newClusterUrl; beforeEach(() => { clusterName = 'my cluster'; environmentScope = 'production'; kubernetesVersion = '11.1'; - region = { name: 'regions-1' }; - vpc = { name: 'vpc-1' }; - subnet = { name: 'subnet-1' }; - role = { name: 'role-1' }; - keyPair = { name: 'key-pair-1' }; - securityGroup = { name: 'default group' }; + region = 'regions-1'; + vpc = 'vpc-1'; + subnet = 'subnet-1'; + role = 'role-1'; + keyPair = 'key-pair-1'; + securityGroup = 'default group'; + instanceType = 'small-1'; + nodeCount = '5'; gitlabManagedCluster = true; + newClusterUrl = '/clusters/1'; + state = { ...createState(), createRolePath: '/clusters/roles/', + signOutPath: '/aws/signout', + createClusterPath: '/clusters/', }; }); @@ -71,6 +88,8 @@ describe('EKS Cluster Store Actions', () => { ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'} ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'} ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'} + ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'} + ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'} ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'} `(`$action commits $mutation with $payloadDescription payload`, data => { const { action, mutation, payload } = data; @@ -149,4 +168,127 @@ describe('EKS Cluster Store Actions', () => { testAction(actions.createRoleError, payload, state, [{ type: CREATE_ROLE_ERROR, payload }]); }); }); + + describe('createCluster', () => { + let requestPayload; + + beforeEach(() => { + requestPayload = { + name: clusterName, + environment_scope: environmentScope, + managed: gitlabManagedCluster, + provider_aws_attributes: { + region, + vpc_id: vpc, + subnet_ids: subnet, + role_arn: role, + key_name: keyPair, + security_group_id: securityGroup, + instance_type: instanceType, + num_nodes: nodeCount, + }, + }; + state = Object.assign(createState(), { + clusterName, + environmentScope, + kubernetesVersion, + selectedRegion: region, + selectedVpc: vpc, + selectedSubnet: subnet, + selectedRole: role, + selectedKeyPair: keyPair, + selectedSecurityGroup: securityGroup, + selectedInstanceType: instanceType, + nodeCount, + gitlabManagedCluster, + }); + }); + + describe('when request succeeds', () => { + beforeEach(() => { + mock.onPost(state.createClusterPath, requestPayload).reply(201, null, { + location: '/clusters/1', + }); + }); + + it('dispatches createClusterSuccess action', () => + testAction( + actions.createCluster, + null, + state, + [], + [ + { type: 'requestCreateCluster' }, + { type: 'createClusterSuccess', payload: newClusterUrl }, + ], + )); + }); + + describe('when request fails', () => { + let response; + + beforeEach(() => { + response = 'Request failed with status code 400'; + mock.onPost(state.createClusterPath, requestPayload).reply(400, response); + }); + + it('dispatches createRoleError action', () => + testAction( + actions.createCluster, + null, + state, + [], + [{ type: 'requestCreateCluster' }, { type: 'createClusterError', payload: response }], + )); + }); + }); + + describe('requestCreateCluster', () => { + it('commits requestCreateCluster mutation', () => { + testAction(actions.requestCreateCluster, null, state, [{ type: REQUEST_CREATE_CLUSTER }]); + }); + }); + + describe('createClusterSuccess', () => { + beforeEach(() => { + jest.spyOn(window.location, 'assign').mockImplementation(() => {}); + }); + afterEach(() => { + window.location.assign.mockRestore(); + }); + + it('redirects to the new cluster URL', () => { + actions.createClusterSuccess(null, newClusterUrl); + + expect(window.location.assign).toHaveBeenCalledWith(newClusterUrl); + }); + }); + + describe('createClusterError', () => { + let payload; + + beforeEach(() => { + payload = { name: ['Create cluster failed'] }; + }); + + it('commits createClusterError mutation', () => { + testAction(actions.createClusterError, payload, state, [ + { type: CREATE_CLUSTER_ERROR, payload }, + ]); + }); + + it('creates a flash that displays the create cluster error', () => { + expect(createFlash).toHaveBeenCalledWith(payload.name[0]); + }); + }); + + describe('signOut', () => { + beforeEach(() => { + mock.onDelete(state.signOutPath).reply(200, null); + }); + + it('commits signOut mutation', () => { + testAction(actions.signOut, null, state, [{ type: SIGN_OUT }]); + }); + }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js index 2637b4822a5..0fb392f5eea 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js @@ -8,10 +8,15 @@ import { SET_SUBNET, SET_ROLE, SET_SECURITY_GROUP, + SET_INSTANCE_TYPE, + SET_NODE_COUNT, SET_GITLAB_MANAGED_CLUSTER, REQUEST_CREATE_ROLE, CREATE_ROLE_SUCCESS, CREATE_ROLE_ERROR, + REQUEST_CREATE_CLUSTER, + CREATE_CLUSTER_ERROR, + SIGN_OUT, } from '~/create_cluster/eks_cluster/store/mutation_types'; import createState from '~/create_cluster/eks_cluster/store/state'; import mutations from '~/create_cluster/eks_cluster/store/mutations'; @@ -27,6 +32,8 @@ describe('Create EKS cluster store mutations', () => { let role; let keyPair; let securityGroup; + let instanceType; + let nodeCount; let gitlabManagedCluster; beforeEach(() => { @@ -39,6 +46,8 @@ describe('Create EKS cluster store mutations', () => { role = { name: 'role-1' }; keyPair = { name: 'key pair' }; securityGroup = { name: 'default group' }; + instanceType = 'small-1'; + nodeCount = '5'; gitlabManagedCluster = false; state = createState(); @@ -53,8 +62,10 @@ describe('Create EKS cluster store mutations', () => { ${SET_REGION} | ${'selectedRegion'} | ${{ region }} | ${region} | ${'selected region payload'} ${SET_KEY_PAIR} | ${'selectedKeyPair'} | ${{ keyPair }} | ${keyPair} | ${'selected key pair payload'} ${SET_VPC} | ${'selectedVpc'} | ${{ vpc }} | ${vpc} | ${'selected vpc payload'} - ${SET_SUBNET} | ${'selectedSubnet'} | ${{ subnet }} | ${subnet} | ${'selected sybnet payload'} + ${SET_SUBNET} | ${'selectedSubnet'} | ${{ subnet }} | ${subnet} | ${'selected subnet payload'} ${SET_SECURITY_GROUP} | ${'selectedSecurityGroup'} | ${{ securityGroup }} | ${securityGroup} | ${'selected security group payload'} + ${SET_INSTANCE_TYPE} | ${'selectedInstanceType'} | ${{ instanceType }} | ${instanceType} | ${'selected instance type payload'} + ${SET_NODE_COUNT} | ${'nodeCount'} | ${{ nodeCount }} | ${nodeCount} | ${'node count payload'} ${SET_GITLAB_MANAGED_CLUSTER} | ${'gitlabManagedCluster'} | ${{ gitlabManagedCluster }} | ${gitlabManagedCluster} | ${'gitlab managed cluster'} `(`$mutation sets $mutatedProperty to $expectedValueDescription`, data => { const { mutation, mutatedProperty, payload, expectedValue } = data; @@ -118,4 +129,45 @@ describe('Create EKS cluster store mutations', () => { expect(state.hasCredentials).toBe(false); }); }); + + describe(`mutation ${REQUEST_CREATE_CLUSTER}`, () => { + beforeEach(() => { + mutations[REQUEST_CREATE_CLUSTER](state); + }); + + it('sets isCreatingCluster to true', () => { + expect(state.isCreatingCluster).toBe(true); + }); + + it('sets createClusterError to null', () => { + expect(state.createClusterError).toBe(null); + }); + }); + + describe(`mutation ${CREATE_CLUSTER_ERROR}`, () => { + const error = new Error(); + + beforeEach(() => { + mutations[CREATE_CLUSTER_ERROR](state, { error }); + }); + + it('sets isCreatingRole to false', () => { + expect(state.isCreatingCluster).toBe(false); + }); + + it('sets createRoleError to the error object', () => { + expect(state.createClusterError).toBe(error); + }); + }); + + describe(`mutation ${SIGN_OUT}`, () => { + beforeEach(() => { + state.hasCredentials = true; + mutations[SIGN_OUT](state); + }); + + it('sets hasCredentials to false', () => { + expect(state.hasCredentials).toBe(false); + }); + }); }); diff --git a/spec/lib/gitlab/ci/config/entry/cache_spec.rb b/spec/lib/gitlab/ci/config/entry/cache_spec.rb index 9aab3664e1c..4fa0a57dc82 100644 --- a/spec/lib/gitlab/ci/config/entry/cache_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/cache_spec.rb @@ -12,22 +12,53 @@ describe Gitlab::Ci::Config::Entry::Cache do context 'when entry config value is correct' do let(:policy) { nil } + let(:key) { 'some key' } let(:config) do - { key: 'some key', + { key: key, untracked: true, paths: ['some/path/'], policy: policy } end describe '#value' do - it 'returns hash value' do - expect(entry.value).to eq(key: 'some key', untracked: true, paths: ['some/path/'], policy: 'pull-push') + shared_examples 'hash key value' do + it 'returns hash value' do + expect(entry.value).to eq(key: key, untracked: true, paths: ['some/path/'], policy: 'pull-push') + end + end + + it_behaves_like 'hash key value' + + context 'with files' do + let(:key) { { files: ['a-file', 'other-file'] } } + + it_behaves_like 'hash key value' + end + + context 'with files and prefix' do + let(:key) { { files: ['a-file', 'other-file'], prefix: 'prefix-value' } } + + it_behaves_like 'hash key value' + end + + context 'with prefix' do + let(:key) { { prefix: 'prefix-value' } } + + it 'key is nil' do + expect(entry.value).to match(a_hash_including(key: nil)) + end end end describe '#valid?' do it { is_expected.to be_valid } + + context 'with files' do + let(:key) { { files: ['a-file', 'other-file'] } } + + it { is_expected.to be_valid } + end end context 'policy is pull-push' do @@ -87,10 +118,44 @@ describe Gitlab::Ci::Config::Entry::Cache do end context 'when descendants are invalid' do - let(:config) { { key: 1 } } + context 'with invalid keys' do + let(:config) { { key: 1 } } - it 'reports error with descendants' do - is_expected.to include 'key config should be a string or symbol' + it 'reports error with descendants' do + is_expected.to include 'key should be a hash, a string or a symbol' + end + end + + context 'with empty key' do + let(:config) { { key: {} } } + + it 'reports error with descendants' do + is_expected.to include 'key config missing required keys: files' + end + end + + context 'with invalid files' do + let(:config) { { key: { files: 'a-file' } } } + + it 'reports error with descendants' do + is_expected.to include 'key:files config should be an array of strings' + end + end + + context 'with prefix without files' do + let(:config) { { key: { prefix: 'a-prefix' } } } + + it 'reports error with descendants' do + is_expected.to include 'key config missing required keys: files' + end + end + + context 'when there is an unknown key present' do + let(:config) { { key: { unknown: 'a-file' } } } + + it 'reports error with descendants' do + is_expected.to include 'key config contains unknown keys: unknown' + end end end diff --git a/spec/lib/gitlab/ci/config/entry/files_spec.rb b/spec/lib/gitlab/ci/config/entry/files_spec.rb new file mode 100644 index 00000000000..2bebbd7b198 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/files_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Files do + let(:entry) { described_class.new(config) } + + describe 'validations' do + context 'when entry config value is valid' do + let(:config) { ['some/file', 'some/path/'] } + + describe '#value' do + it 'returns key value' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + describe '#errors' do + context 'when entry value is not an array' do + let(:config) { 'string' } + + it 'saves errors' do + expect(entry.errors) + .to include 'files config should be an array of strings' + end + end + + context 'when entry value is not an array of strings' do + let(:config) { [1] } + + it 'saves errors' do + expect(entry.errors) + .to include 'files config should be an array of strings' + end + end + + context 'when entry value contains more than two values' do + let(:config) { %w[file1 file2 file3] } + + it 'saves errors' do + expect(entry.errors) + .to include 'files config has too many items (maximum is 2)' + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/key_spec.rb b/spec/lib/gitlab/ci/config/entry/key_spec.rb index a7874447725..327607e2266 100644 --- a/spec/lib/gitlab/ci/config/entry/key_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/key_spec.rb @@ -6,38 +6,38 @@ describe Gitlab::Ci::Config::Entry::Key do let(:entry) { described_class.new(config) } describe 'validations' do - shared_examples 'key with slash' do - it 'is invalid' do - expect(entry).not_to be_valid - end + it_behaves_like 'key entry validations', 'simple key' - it 'reports errors with config value' do - expect(entry.errors).to include 'key config cannot contain the "/" character' - end - end + context 'when entry config value is correct' do + context 'when key is a hash' do + let(:config) { { files: ['test'], prefix: 'something' } } - shared_examples 'key with only dots' do - it 'is invalid' do - expect(entry).not_to be_valid - end + describe '#value' do + it 'returns key value' do + expect(entry.value).to match(config) + end + end - it 'reports errors with config value' do - expect(entry.errors).to include 'key config cannot be "." or ".."' + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end end - end - context 'when entry config value is correct' do - let(:config) { 'test' } + context 'when key is a symbol' do + let(:config) { :key } - describe '#value' do - it 'returns key value' do - expect(entry.value).to eq 'test' + describe '#value' do + it 'returns key value' do + expect(entry.value).to eq(config.to_s) + end end - end - describe '#valid?' do - it 'is valid' do - expect(entry).to be_valid + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end end end end @@ -47,53 +47,11 @@ describe Gitlab::Ci::Config::Entry::Key do describe '#errors' do it 'saves errors' do - expect(entry.errors) - .to include 'key config should be a string or symbol' + expect(entry.errors.first) + .to match /should be a hash, a string or a symbol/ end end end - - context 'when entry value contains slash' do - let(:config) { 'key/with/some/slashes' } - - it_behaves_like 'key with slash' - end - - context 'when entry value contains URI encoded slash (%2F)' do - let(:config) { 'key%2Fwith%2Fsome%2Fslashes' } - - it_behaves_like 'key with slash' - end - - context 'when entry value is a dot' do - let(:config) { '.' } - - it_behaves_like 'key with only dots' - end - - context 'when entry value is two dots' do - let(:config) { '..' } - - it_behaves_like 'key with only dots' - end - - context 'when entry value is a URI encoded dot (%2E)' do - let(:config) { '%2e' } - - it_behaves_like 'key with only dots' - end - - context 'when entry value is two URI encoded dots (%2E)' do - let(:config) { '%2E%2e' } - - it_behaves_like 'key with only dots' - end - - context 'when entry value is one dot and one URI encoded dot' do - let(:config) { '.%2e' } - - it_behaves_like 'key with only dots' - end end describe '.default' do diff --git a/spec/lib/gitlab/ci/config/entry/prefix_spec.rb b/spec/lib/gitlab/ci/config/entry/prefix_spec.rb new file mode 100644 index 00000000000..8132a674488 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/prefix_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Prefix do + let(:entry) { described_class.new(config) } + + describe 'validations' do + it_behaves_like 'key entry validations', :prefix + + context 'when entry value is not correct' do + let(:config) { ['incorrect'] } + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include 'prefix config should be a string or symbol' + end + end + end + end + + describe '.default' do + it 'returns default key' do + expect(described_class.default).to be_nil + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb new file mode 100644 index 00000000000..6a8b804597c --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Seed::Build::Cache do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:head_sha) { project.repository.head_commit.id } + let_it_be(:pipeline) { create(:ci_pipeline, project: project, sha: head_sha) } + + let(:processor) { described_class.new(pipeline, config) } + + describe '#build_attributes' do + subject { processor.build_attributes } + + context 'with cache:key' do + let(:config) do + { + key: 'a-key', + paths: ['vendor/ruby'] + } + end + + it { is_expected.to include(options: { cache: config }) } + end + + context 'with cache:key as a symbol' do + let(:config) do + { + key: :a_key, + paths: ['vendor/ruby'] + } + end + + it { is_expected.to include(options: { cache: config.merge(key: "a_key") }) } + end + + context 'with cache:key:files' do + shared_examples 'default key' do + let(:config) do + { key: { files: files } } + end + + it 'uses default key' do + expected = { options: { cache: { key: 'default' } } } + + is_expected.to include(expected) + end + end + + shared_examples 'version and gemfile files' do + let(:config) do + { + key: { + files: files + }, + paths: ['vendor/ruby'] + } + end + + it 'builds a string key' do + expected = { + options: { + cache: { + key: '703ecc8fef1635427a1f86a8a1a308831c122392', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + + context 'with existing files' do + let(:files) { ['VERSION', 'Gemfile.zip'] } + + it_behaves_like 'version and gemfile files' + end + + context 'with files starting with ./' do + let(:files) { ['Gemfile.zip', './VERSION'] } + + it_behaves_like 'version and gemfile files' + end + + context 'with feature flag disabled' do + let(:files) { ['VERSION', 'Gemfile.zip'] } + + before do + stub_feature_flags(ci_file_based_cache: false) + end + + it_behaves_like 'default key' + end + + context 'with files ending with /' do + let(:files) { ['Gemfile.zip/'] } + + it_behaves_like 'default key' + end + + context 'with new line in filenames' do + let(:files) { ["Gemfile.zip\nVERSION"] } + + it_behaves_like 'default key' + end + + context 'with missing files' do + let(:files) { ['project-gemfile.lock', ''] } + + it_behaves_like 'default key' + end + + context 'with directories' do + shared_examples 'foo/bar directory key' do + let(:config) do + { + key: { + files: files + } + } + end + + it 'builds a string key' do + expected = { + options: { + cache: { key: '74bf43fb1090f161bdd4e265802775dbda2f03d1' } + } + } + + is_expected.to include(expected) + end + end + + context 'with directory' do + let(:files) { ['foo/bar'] } + + it_behaves_like 'foo/bar directory key' + end + + context 'with directory ending in slash' do + let(:files) { ['foo/bar/'] } + + it_behaves_like 'foo/bar directory key' + end + + context 'with directories ending in slash star' do + let(:files) { ['foo/bar/*'] } + + it_behaves_like 'foo/bar directory key' + end + end + end + + context 'with cache:key:prefix' do + context 'without files' do + let(:config) do + { + key: { + prefix: 'a-prefix' + }, + paths: ['vendor/ruby'] + } + end + + it 'adds prefix to default key' do + expected = { + options: { + cache: { + key: 'a-prefix-default', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + + context 'with existing files' do + let(:config) do + { + key: { + files: ['VERSION', 'Gemfile.zip'], + prefix: 'a-prefix' + }, + paths: ['vendor/ruby'] + } + end + + it 'adds prefix key' do + expected = { + options: { + cache: { + key: 'a-prefix-703ecc8fef1635427a1f86a8a1a308831c122392', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + + context 'with missing files' do + let(:config) do + { + key: { + files: ['project-gemfile.lock', ''], + prefix: 'a-prefix' + }, + paths: ['vendor/ruby'] + } + end + + it 'adds prefix to default key' do + expected = { + options: { + cache: { + key: 'a-prefix-default', + paths: ['vendor/ruby'] + } + } + } + + is_expected.to include(expected) + end + end + end + + context 'with all cache option keys' do + let(:config) do + { + key: 'a-key', + paths: ['vendor/ruby'], + untracked: true, + policy: 'push' + } + end + + it { is_expected.to include(options: { cache: config }) } + end + + context 'with unknown cache option keys' do + let(:config) do + { + key: 'a-key', + unknown_key: true + } + end + + it { expect { subject }.to raise_error(ArgumentError, /unknown_key/) } + end + + context 'with empty config' do + let(:config) { {} } + + it { is_expected.to include(options: {}) } + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb index 945baf47b7b..62e5fd566f7 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -4,7 +4,8 @@ require 'spec_helper' describe Gitlab::Ci::Pipeline::Seed::Build do let(:project) { create(:project, :repository) } - let(:pipeline) { create(:ci_empty_pipeline, project: project) } + let(:head_sha) { project.repository.head_commit.id } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: head_sha) } let(:attributes) { { name: 'rspec', ref: 'master' } } let(:previous_stages) { [] } @@ -69,6 +70,101 @@ describe Gitlab::Ci::Pipeline::Seed::Build do it { is_expected.to include(when: 'never') } end end + + context 'with cache:key' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: { + key: 'a-value' + } + } + end + + it { is_expected.to include(options: { cache: { key: 'a-value' } }) } + end + + context 'with cache:key:files' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: { + key: { + files: ['VERSION'] + } + } + } + end + + it 'includes cache options' do + cache_options = { + options: { + cache: { + key: 'f155568ad0933d8358f66b846133614f76dd0ca4' + } + } + } + + is_expected.to include(cache_options) + end + end + + context 'with cache:key:prefix' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: { + key: { + prefix: 'something' + } + } + } + end + + it { is_expected.to include(options: { cache: { key: 'something-default' } }) } + end + + context 'with cache:key:files and prefix' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: { + key: { + files: ['VERSION'], + prefix: 'something' + } + } + } + end + + it 'includes cache options' do + cache_options = { + options: { + cache: { + key: 'something-f155568ad0933d8358f66b846133614f76dd0ca4' + } + } + } + + is_expected.to include(cache_options) + end + end + + context 'with empty cache' do + let(:attributes) do + { + name: 'rspec', + ref: 'master', + cache: {} + } + end + + it { is_expected.to include(options: {}) } + end end describe '#bridge?' do diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 5a173470dfe..35a4749922e 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -950,7 +950,7 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) expect(config_processor.stage_builds_attributes("test").size).to eq(1) - expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( paths: ["logs/", "binaries/"], untracked: true, key: 'key', @@ -962,7 +962,7 @@ module Gitlab config = YAML.dump( { default: { - cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'key' } + cache: { paths: ["logs/", "binaries/"], untracked: true, key: { files: ['file'] } } }, rspec: { script: "rspec" @@ -972,33 +972,79 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) expect(config_processor.stage_builds_attributes("test").size).to eq(1) - expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( paths: ["logs/", "binaries/"], untracked: true, - key: 'key', + key: { files: ['file'] }, policy: 'pull-push' ) end - it "returns cache when defined in a job" do + it 'returns cache key when defined in a job' do config = YAML.dump({ rspec: { - cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'key' }, - script: "rspec" + cache: { paths: ['logs/', 'binaries/'], untracked: true, key: 'key' }, + script: 'rspec' } }) config_processor = Gitlab::Ci::YamlProcessor.new(config) - expect(config_processor.stage_builds_attributes("test").size).to eq(1) - expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq( - paths: ["logs/", "binaries/"], + expect(config_processor.stage_builds_attributes('test').size).to eq(1) + expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( + paths: ['logs/', 'binaries/'], untracked: true, key: 'key', policy: 'pull-push' ) end + it 'returns cache files' do + config = YAML.dump( + rspec: { + cache: { + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'] } + }, + script: 'rspec' + } + ) + + config_processor = Gitlab::Ci::YamlProcessor.new(config) + + expect(config_processor.stage_builds_attributes('test').size).to eq(1) + expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'] }, + policy: 'pull-push' + ) + end + + it 'returns cache files with prefix' do + config = YAML.dump( + rspec: { + cache: { + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'], prefix: 'prefix' } + }, + script: 'rspec' + } + ) + + config_processor = Gitlab::Ci::YamlProcessor.new(config) + + expect(config_processor.stage_builds_attributes('test').size).to eq(1) + expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq( + paths: ['logs/', 'binaries/'], + untracked: true, + key: { files: ['file'], prefix: 'prefix' }, + policy: 'pull-push' + ) + end + it "overwrite cache when defined for a job and globally" do config = YAML.dump({ cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'global' }, @@ -1011,7 +1057,7 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) expect(config_processor.stage_builds_attributes("test").size).to eq(1) - expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq( paths: ["test/"], untracked: false, key: 'local', @@ -1862,14 +1908,42 @@ module Gitlab config = YAML.dump({ cache: { key: 1 }, rspec: { script: "test" } }) expect do Gitlab::Ci::YamlProcessor.new(config) - end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "cache:key config should be a string or symbol") + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "cache:key should be a hash, a string or a symbol") end it "returns errors if job cache:key is not an a string" do config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: 1 } } }) expect do Gitlab::Ci::YamlProcessor.new(config) - end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:cache:key config should be a string or symbol") + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec:cache:key should be a hash, a string or a symbol") + end + + it 'returns errors if job cache:key:files is not an array of strings' do + config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { files: [1] } } } }) + expect do + Gitlab::Ci::YamlProcessor.new(config) + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key:files config should be an array of strings') + end + + it 'returns errors if job cache:key:files is an empty array' do + config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { files: [] } } } }) + expect do + Gitlab::Ci::YamlProcessor.new(config) + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key:files config requires at least 1 item') + end + + it 'returns errors if job defines only cache:key:prefix' do + config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { prefix: 'prefix-key' } } } }) + expect do + Gitlab::Ci::YamlProcessor.new(config) + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key config missing required keys: files') + end + + it 'returns errors if job cache:key:prefix is not an a string' do + config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { prefix: 1, files: ['file'] } } } }) + expect do + Gitlab::Ci::YamlProcessor.new(config) + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:rspec:cache:key:prefix config should be a string or symbol') end it "returns errors if job cache:untracked is not an array of strings" do diff --git a/spec/lib/gitlab/external_authorization/access_spec.rb b/spec/lib/gitlab/external_authorization/access_spec.rb index 5dc2521b310..8a08b2a6275 100644 --- a/spec/lib/gitlab/external_authorization/access_spec.rb +++ b/spec/lib/gitlab/external_authorization/access_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExternalAuthorization::Access, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/external_authorization/cache_spec.rb b/spec/lib/gitlab/external_authorization/cache_spec.rb index 58e7d626707..1f217249f97 100644 --- a/spec/lib/gitlab/external_authorization/cache_spec.rb +++ b/spec/lib/gitlab/external_authorization/cache_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExternalAuthorization::Cache, :clean_gitlab_redis_cache do diff --git a/spec/lib/gitlab/external_authorization/client_spec.rb b/spec/lib/gitlab/external_authorization/client_spec.rb index a87f50b4586..a17d933e3bb 100644 --- a/spec/lib/gitlab/external_authorization/client_spec.rb +++ b/spec/lib/gitlab/external_authorization/client_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExternalAuthorization::Client do diff --git a/spec/lib/gitlab/external_authorization/logger_spec.rb b/spec/lib/gitlab/external_authorization/logger_spec.rb index 81f1b2390e6..380e765309c 100644 --- a/spec/lib/gitlab/external_authorization/logger_spec.rb +++ b/spec/lib/gitlab/external_authorization/logger_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExternalAuthorization::Logger do diff --git a/spec/lib/gitlab/external_authorization/response_spec.rb b/spec/lib/gitlab/external_authorization/response_spec.rb index 43211043eca..e1f6e9ac1fa 100644 --- a/spec/lib/gitlab/external_authorization/response_spec.rb +++ b/spec/lib/gitlab/external_authorization/response_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::ExternalAuthorization::Response do diff --git a/spec/services/ci/create_pipeline_service/cache_spec.rb b/spec/services/ci/create_pipeline_service/cache_spec.rb new file mode 100644 index 00000000000..4e0567132ff --- /dev/null +++ b/spec/services/ci/create_pipeline_service/cache_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::CreatePipelineService do + context 'cache' do + let(:user) { create(:admin) } + let(:ref) { 'refs/heads/master' } + let(:source) { :push } + let(:service) { described_class.new(project, user, { ref: ref }) } + let(:pipeline) { service.execute(source) } + let(:job) { pipeline.builds.find_by(name: 'job') } + let(:project) { create(:project, :custom_repo, files: files) } + + before do + stub_ci_pipeline_yaml_file(config) + end + + context 'with cache:key' do + let(:files) { { 'some-file' => '' } } + + let(:config) do + <<~EOY + job: + script: + - ls + cache: + key: 'a-key' + paths: ['logs/', 'binaries/'] + untracked: true + EOY + end + + it 'uses the provided key' do + expected = { + 'key' => 'a-key', + 'paths' => ['logs/', 'binaries/'], + 'policy' => 'pull-push', + 'untracked' => true + } + + expect(pipeline).to be_persisted + expect(job.cache).to match(a_collection_including(expected)) + end + end + + context 'with cache:key:files' do + let(:config) do + <<~EOY + job: + script: + - ls + cache: + paths: + - logs/ + key: + files: + - file.lock + - missing-file.lock + EOY + end + + context 'when file.lock exists' do + let(:files) { { 'file.lock' => '' } } + + it 'builds a cache key' do + expected = { + 'key' => /[a-f0-9]{40}/, + 'paths' => ['logs/'], + 'policy' => 'pull-push' + } + + expect(pipeline).to be_persisted + expect(job.cache).to match(a_collection_including(expected)) + end + end + + context 'when file.lock does not exist' do + let(:files) { { 'some-file' => '' } } + + it 'uses default cache key' do + expected = { + 'key' => /default/, + 'paths' => ['logs/'], + 'policy' => 'pull-push' + } + + expect(pipeline).to be_persisted + expect(job.cache).to match(a_collection_including(expected)) + end + end + end + + context 'with cache:key:files and prefix' do + let(:config) do + <<~EOY + job: + script: + - ls + cache: + paths: + - logs/ + key: + files: + - file.lock + prefix: '$ENV_VAR' + EOY + end + + context 'when file.lock exists' do + let(:files) { { 'file.lock' => '' } } + + it 'builds a cache key' do + expected = { + 'key' => /\$ENV_VAR-[a-f0-9]{40}/, + 'paths' => ['logs/'], + 'policy' => 'pull-push' + } + + expect(pipeline).to be_persisted + expect(job.cache).to match(a_collection_including(expected)) + end + end + + context 'when file.lock does not exist' do + let(:files) { { 'some-file' => '' } } + + it 'uses default cache key' do + expected = { + 'key' => /\$ENV_VAR-default/, + 'paths' => ['logs/'], + 'policy' => 'pull-push' + } + + expect(pipeline).to be_persisted + expect(job.cache).to match(a_collection_including(expected)) + end + end + end + + context 'with too many files' do + let(:files) { { 'some-file' => '' } } + + let(:config) do + <<~EOY + job: + script: + - ls + cache: + paths: ['logs/', 'binaries/'] + untracked: true + key: + files: + - file.lock + - other-file.lock + - extra-file.lock + prefix: 'some-prefix' + EOY + end + + it 'has errors' do + expect(pipeline).to be_persisted + expect(pipeline.yaml_errors).to eq("jobs:job:cache:key:files config has too many items (maximum is 2)") + expect(job).to be_nil + end + end + end +end diff --git a/spec/support/shared_examples/lib/gitlab/ci/config/entry/key_validations_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/ci/config/entry/key_validations_shared_examples.rb new file mode 100644 index 00000000000..b0b3e46332d --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/ci/config/entry/key_validations_shared_examples.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'key entry validations' do |config_name| + shared_examples 'key with slash' do + it 'is invalid' do + expect(entry).not_to be_valid + end + + it 'reports errors with config value' do + expect(entry.errors).to include "#{config_name} config cannot contain the \"/\" character" + end + end + + shared_examples 'key with only dots' do + it 'is invalid' do + expect(entry).not_to be_valid + end + + it 'reports errors with config value' do + expect(entry.errors).to include "#{config_name} config cannot be \".\" or \"..\"" + end + end + + context 'when entry value contains slash' do + let(:config) { 'key/with/some/slashes' } + + it_behaves_like 'key with slash' + end + + context 'when entry value contains URI encoded slash (%2F)' do + let(:config) { 'key%2Fwith%2Fsome%2Fslashes' } + + it_behaves_like 'key with slash' + end + + context 'when entry value is a dot' do + let(:config) { '.' } + + it_behaves_like 'key with only dots' + end + + context 'when entry value is two dots' do + let(:config) { '..' } + + it_behaves_like 'key with only dots' + end + + context 'when entry value is a URI encoded dot (%2E)' do + let(:config) { '%2e' } + + it_behaves_like 'key with only dots' + end + + context 'when entry value is two URI encoded dots (%2E)' do + let(:config) { '%2E%2e' } + + it_behaves_like 'key with only dots' + end + + context 'when entry value is one dot and one URI encoded dot' do + let(:config) { '.%2e' } + + it_behaves_like 'key with only dots' + end + + context 'when key is a string' do + let(:config) { 'test' } + + describe '#value' do + it 'returns key value' do + expect(entry.value).to eq 'test' + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end +end |