Commit 79251271 authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch...

Merge branch '38759-fetch-available-parameters-directly-from-gke-when-creating-a-cluster' into 'master'

Resolve "Fetch available parameters directly from GKE when creating a cluster"

Closes #38759

See merge request gitlab-org/gitlab-ce!17806
parents 516c0076 8d7e0bdf
import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
document.addEventListener('DOMContentLoaded', () => {
initGkeDropdowns();
});
import _ from 'underscore';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
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 store from '../store';
export default {
store,
components: {
LoadingIcon,
DropdownButton,
DropdownSearchInput,
DropdownHiddenInput,
},
props: {
fieldId: {
type: String,
required: true,
},
fieldName: {
type: String,
required: true,
},
defaultValue: {
type: String,
required: false,
default: '',
},
},
data() {
return {
isLoading: false,
hasErrors: false,
searchQuery: '',
gapiError: '',
};
},
computed: {
results() {
if (!this.items) {
return [];
}
return this.items.filter(item => item.name.toLowerCase().indexOf(this.searchQuery) > -1);
},
},
methods: {
fetchSuccessHandler() {
if (this.defaultValue) {
const itemToSelect = _.find(this.items, item => item.name === this.defaultValue);
if (itemToSelect) {
this.setItem(itemToSelect.name);
}
}
this.isLoading = false;
this.hasErrors = false;
},
fetchFailureHandler(resp) {
this.isLoading = false;
this.hasErrors = true;
if (resp.result && resp.result.error) {
this.gapiError = resp.result.error.message;
}
},
},
};
<script>
import { sprintf, s__ } from '~/locale';
import { mapState, mapGetters, mapActions } from 'vuex';
import gkeDropdownMixin from './gke_dropdown_mixin';
export default {
name: 'GkeMachineTypeDropdown',
mixins: [gkeDropdownMixin],
computed: {
...mapState([
'isValidatingProjectBilling',
'projectHasBillingEnabled',
'selectedZone',
'selectedMachineType',
]),
...mapState({ items: 'machineTypes' }),
...mapGetters(['hasZone', 'hasMachineType']),
allDropdownsSelected() {
return this.projectHasBillingEnabled && this.hasZone && this.hasMachineType;
},
isDisabled() {
return (
this.isLoading ||
this.isValidatingProjectBilling ||
!this.projectHasBillingEnabled ||
!this.hasZone
);
},
toggleText() {
if (this.isLoading) {
return s__('ClusterIntegration|Fetching machine types');
}
if (this.selectedMachineType) {
return this.selectedMachineType;
}
if (!this.projectHasBillingEnabled && !this.hasZone) {
return s__('ClusterIntegration|Select project and zone to choose machine type');
}
return !this.hasZone
? s__('ClusterIntegration|Select zone to choose machine type')
: s__('ClusterIntegration|Select machine type');
},
errorMessage() {
return sprintf(
s__(
'ClusterIntegration|An error occured while trying to fetch zone machine types: %{error}',
),
{ error: this.gapiError },
);
},
},
watch: {
selectedZone() {
this.hasErrors = false;
if (this.hasZone) {
this.isLoading = true;
this.fetchMachineTypes()
.then(this.fetchSuccessHandler)
.catch(this.fetchFailureHandler);
}
},
selectedMachineType() {
this.enableSubmit();
},
},
methods: {
...mapActions(['fetchMachineTypes']),
...mapActions({ setItem: 'setMachineType' }),
enableSubmit() {
if (this.allDropdownsSelected) {
const submitButtonEl = document.querySelector('.js-gke-cluster-creation-submit');
if (submitButtonEl) {
submitButtonEl.removeAttribute('disabled');
}
}
},
},
};
</script>
<template>
<div>
<div
class="js-gcp-machine-type-dropdown dropdown"
:class="{ 'gl-show-field-errors': hasErrors }"
>
<dropdown-hidden-input
:name="fieldName"
:value="selectedMachineType"
/>
<dropdown-button
:class="{ 'gl-field-error-outline': hasErrors }"
:is-disabled="isDisabled"
:is-loading="isLoading"
:toggle-text="toggleText"
/>
<div class="dropdown-menu dropdown-select">
<dropdown-search-input
v-model="searchQuery"
:placeholder-text="s__('ClusterIntegration|Search machine types')"
/>
<div class="dropdown-content">
<ul>
<li v-show="!results.length">
<span class="menu-item">
{{ s__('ClusterIntegration|No machine types matched your search') }}
</span>
</li>
<li
v-for="result in results"
:key="result.id"
>
<button
type="button"
@click.prevent="setItem(result.name)"
>
{{ result.name }}
</button>
</li>
</ul>
</div>
<div class="dropdown-loading">
<loading-icon />
</div>
</div>
</div>
<span
class="help-block"
:class="{ 'gl-field-error': hasErrors }"
v-if="hasErrors"
>
{{ errorMessage }}
</span>
</div>
</template>
<script>
import _ from 'underscore';
import { s__, sprintf } from '~/locale';
import { mapState, mapGetters, mapActions } from 'vuex';
import gkeDropdownMixin from './gke_dropdown_mixin';
export default {
name: 'GkeProjectIdDropdown',
mixins: [gkeDropdownMixin],
props: {
docsUrl: {
type: String,
required: true,
},
},
computed: {
...mapState(['selectedProject', 'isValidatingProjectBilling', 'projectHasBillingEnabled']),
...mapState({ items: 'projects' }),
...mapGetters(['hasProject']),
hasOneProject() {
return this.items && this.items.length === 1;
},
isDisabled() {
return (
this.isLoading || this.isValidatingProjectBilling || (this.items && this.items.length < 2)
);
},
toggleText() {
if (this.isValidatingProjectBilling) {
return s__('ClusterIntegration|Validating project billing status');
}
if (this.isLoading) {
return s__('ClusterIntegration|Fetching projects');
}
if (this.hasProject) {
return this.selectedProject.name;
}
if (!this.items) {
return s__('ClusterIntegration|No projects found');
}
return s__('ClusterIntegration|Select project');
},
helpText() {
let message;
if (this.hasErrors) {
return this.errorMessage;
}
if (!this.items) {
message =
'ClusterIntegration|We were unable to fetch any projects. Ensure that you have a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.';
}
message =
this.items && this.items.length
? 'ClusterIntegration|To use a new project, first create one on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.'
: 'ClusterIntegration|To create a cluster, first create a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.';
return sprintf(
s__(message),
{
docsLinkEnd: '&nbsp;<i class="fa fa-external-link" aria-hidden="true"></i></a>',
docsLinkStart: `<a href="${_.escape(
this.docsUrl,
)}" target="_blank" rel="noopener noreferrer">`,
},
false,
);
},
errorMessage() {
if (!this.projectHasBillingEnabled) {
if (this.gapiError) {
return s__(
'ClusterIntegration|We could not verify that one of your projects on GCP has billing enabled. Please try again.',
);
}
return sprintf(
s__(
'This project does not have billing enabled. To create a cluster, <a href=%{linkToBilling} target="_blank" rel="noopener noreferrer">enable billing <i class="fa fa-external-link" aria-hidden="true"></i></a> and try again.',
),
{
linkToBilling:
'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral',
},
false,
);
}
return sprintf(
s__('ClusterIntegration|An error occured while trying to fetch your projects: %{error}'),
{ error: this.gapiError },
);
},
},
watch: {
selectedProject() {
this.setIsValidatingProjectBilling(true);
this.validateProjectBilling()
.then(this.validateProjectBillingSuccessHandler)
.catch(this.validateProjectBillingFailureHandler);
},
},
created() {
this.isLoading = true;
this.fetchProjects()
.then(this.fetchSuccessHandler)
.catch(this.fetchFailureHandler);
},
methods: {
...mapActions(['fetchProjects', 'setIsValidatingProjectBilling', 'validateProjectBilling']),
...mapActions({ setItem: 'setProject' }),
fetchSuccessHandler() {
if (this.defaultValue) {
const projectToSelect = _.find(this.items, item => item.projectId === this.defaultValue);
if (projectToSelect) {
this.setItem(projectToSelect);
}
} else if (this.items.length === 1) {
this.setItem(this.items[0]);
}
this.isLoading = false;
this.hasErrors = false;
},
validateProjectBillingSuccessHandler() {
this.hasErrors = !this.projectHasBillingEnabled;
},
validateProjectBillingFailureHandler(resp) {
this.hasErrors = true;
this.gapiError = resp.result ? resp.result.error.message : resp;
},
},
};
</script>
<template>
<div>
<div
class="js-gcp-project-id-dropdown dropdown"
:class="{ 'gl-show-field-errors': hasErrors }"
>
<dropdown-hidden-input
:name="fieldName"
:value="selectedProject.projectId"
/>
<dropdown-button
:class="{
'gl-field-error-outline': hasErrors,
'read-only': hasOneProject
}"
:is-disabled="isDisabled"
:is-loading="isLoading"
:toggle-text="toggleText"
/>
<div class="dropdown-menu dropdown-select">
<dropdown-search-input
v-model="searchQuery"
:placeholder-text="s__('ClusterIntegration|Search projects')"
/>
<div class="dropdown-content">
<ul>
<li v-show="!results.length">
<span class="menu-item">
{{ s__('ClusterIntegration|No projects matched your search') }}
</span>
</li>
<li
v-for="result in results"
:key="result.project_number"
>
<button
type="button"
@click.prevent="setItem(result)"
>
{{ result.name }}
</button>
</li>
</ul>
</div>
<div class="dropdown-loading">
<loading-icon />
</div>
</div>
</div>
<span
class="help-block"
:class="{ 'gl-field-error': hasErrors }"
v-html="helpText"
></span>
</div>
</template>
<script>
import { sprintf, s__ } from '~/locale';
import { mapState, mapActions } from 'vuex';
import gkeDropdownMixin from './gke_dropdown_mixin';
export default {
name: 'GkeZoneDropdown',
mixins: [gkeDropdownMixin],
computed: {
...mapState([
'selectedProject',
'selectedZone',
'projects',
'isValidatingProjectBilling',
'projectHasBillingEnabled',
]),
...mapState({ items: 'zones' }),
isDisabled() {
return this.isLoading || this.isValidatingProjectBilling || !this.projectHasBillingEnabled;
},
toggleText() {
if (this.isLoading) {
return s__('ClusterIntegration|Fetching zones');
}
if (this.selectedZone) {
return this.selectedZone;
}
return !this.projectHasBillingEnabled
? s__('ClusterIntegration|Select project to choose zone')
: s__('ClusterIntegration|Select zone');
},
errorMessage() {
return sprintf(
s__('ClusterIntegration|An error occured while trying to fetch project zones: %{error}'),
{ error: this.gapiError },
);
},
},
watch: {
isValidatingProjectBilling(isValidating) {
this.hasErrors = false;
if (!isValidating && this.projectHasBillingEnabled) {
this.isLoading = true;
this.fetchZones()
.then(this.fetchSuccessHandler)
.catch(this.fetchFailureHandler);
}
},
},
methods: {
...mapActions(['fetchZones']),
...mapActions({ setItem: 'setZone' }),
},
};
</script>
<template>
<div>
<div
class="js-gcp-zone-dropdown dropdown"
:class="{ 'gl-show-field-errors': hasErrors }"
>
<dropdown-hidden-input
:name="fieldName"
:value="selectedZone"
/>
<dropdown-button
:class="{ 'gl-field-error-outline': hasErrors }"
:is-disabled="isDisabled"
:is-loading="isLoading"
:toggle-text="toggleText"
/>
<div class="dropdown-menu dropdown-select">
<dropdown-search-input
v-model="searchQuery"
:placeholder-text="s__('ClusterIntegration|Search zones')"
/>
<div class="dropdown-content">
<ul>
<li v-show="!results.length">
<span class="menu-item">
{{ s__('ClusterIntegration|No zones matched your search') }}
</span>
</li>
<li
v-for="result in results"
:key="result.id"
>
<button
type="button"
@click.prevent="setItem(result.name)"
>
{{ result.name }}
</button>
</li>
</ul>
</div>
<div class="dropdown-loading">
<loading-icon />
</div>
</div>
</div>
<span
class="help-block"
:class="{ 'gl-field-error': hasErrors }"
v-if="hasErrors"
>
{{ errorMessage }}
</span>
</div>
</template>
import { s__ } from '~/locale';
export const GCP_API_ERROR = s__(
'ClusterIntegration|An error occurred when trying to contact the Google Cloud API. Please try again later.',
);
export const GCP_API_CLOUD_BILLING_ENDPOINT =
'https://www.googleapis.com/discovery/v1/apis/cloudbilling/v1/rest';
export const GCP_API_CLOUD_RESOURCE_MANAGER_ENDPOINT =
'https://www.googleapis.com/discovery/v1/apis/cloudresourcemanager/v1/rest';
export const GCP_API_COMPUTE_ENDPOINT =
'https://www.googleapis.com/discovery/v1/apis/compute/v1/rest';
/* global gapi */
import Vue from 'vue';
import Flash from '~/flash';
import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue';
import GkeZoneDropdown from './components/gke_zone_dropdown.vue';
import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue';
import * as CONSTANTS from './constants';
const mountComponent = (entryPoint, component, componentName, extraProps = {}) => {
const el = document.querySelector(entryPoint);
if (!el) return false;
const hiddenInput = el.querySelector('input');
return new Vue({
el,
components: {
[componentName]: component,
},
render: createElement =>
createElement(componentName, {
props: {
fieldName: hiddenInput.getAttribute('name'),
fieldId: hiddenInput.getAttribute('id'),
defaultValue: hiddenInput.value,
...extraProps,
},
}),
});
};
const mountGkeProjectIdDropdown = () => {
const entryPoint = '.js-gcp-project-id-dropdown-entry-point';
const el = document.querySelector(entryPoint);
mountComponent(entryPoint, GkeProjectIdDropdown, 'gke-project-id-dropdown', {
docsUrl: el.dataset.docsurl,
});
};
const mountGkeZoneDropdown = () => {
mountComponent('.js-gcp-zone-dropdown-entry-point', GkeZoneDropdown, 'gke-zone-dropdown');
};
const mountGkeMachineTypeDropdown = () => {
mountComponent(
'.js-gcp-machine-type-dropdown-entry-point',
GkeMachineTypeDropdown,
'gke-machine-type-dropdown',
);
};
const gkeDropdownErrorHandler = () => {
Flash(CONSTANTS.GCP_API_ERROR);
};
const initializeGapiClient = () => {
const el = document.querySelector('.js-gke-cluster-creation');
if (!el) return false;
return gapi.client
.init({
discoveryDocs: [
CONSTANTS.GCP_API_CLOUD_BILLING_ENDPOINT,
CONSTANTS.GCP_API_CLOUD_RESOURCE_MANAGER_ENDPOINT,
CONSTANTS.GCP_API_COMPUTE_ENDPOINT,
],
})
.then(() => {
gapi.client.setToken({ access_token: el.dataset.token });
mountGkeProjectIdDropdown();
mountGkeZoneDropdown();
mountGkeMachineTypeDropdown();
})
.catch(gkeDropdownErrorHandler);
};
const initGkeDropdowns = () => {
if (!gapi) {
gkeDropdownErrorHandler();
return false;
}
return gapi.load('client', initializeGapiClient);
};
export default initGkeDropdowns;
/* global gapi */
import * as types from './mutation_types';
const gapiResourceListRequest = ({ resource, params, commit, mutation, payloadKey }) =>
new Promise((resolve, reject) => {
const request = resource.list(params);
return request.then(
resp => {
const { result } = resp;
commit(mutation, result[payloadKey]);
resolve();
},
resp => {
reject(resp);
},
);
});
export const setProject = ({ commit }, selectedProject) => {
commit(types.SET_PROJECT, selectedProject);
};
export const setZone = ({ commit }, selectedZone) => {
commit(types.SET_ZONE, selectedZone);
};
export const setMachineType = ({ commit }, selectedMachineType) => {
commit(types.SET_MACHINE_TYPE, selectedMachineType);
};
export const setIsValidatingProjectBilling = ({ commit }, isValidatingProjectBilling) => {
commit(types.SET_IS_VALIDATING_PROJECT_BILLING, isValidatingProjectBilling);
};
export const fetchProjects = ({ commit }) =>
gapiResourceListRequest({
resource: gapi.client.cloudresourcemanager.projects,
params: {},
commit,
mutation: types.SET_PROJECTS,
payloadKey: 'projects',
});
export const validateProjectBilling = ({ dispatch, commit, state }) =>
new Promise((resolve, reject) => {
const request = gapi.client.cloudbilling.projects.getBillingInfo({
name: `projects/${state.selectedProject.projectId}`,
});
commit(types.SET_ZONE, '');
commit(types.SET_MACHINE_TYPE, '');
return request.then(
resp => {
const { billingEnabled } = resp.result;
commit(types.SET_PROJECT_BILLING_STATUS, !!billingEnabled);
dispatch('setIsValidatingProjectBilling', false);
resolve();
},
resp => {
dispatch('setIsValidatingProjectBilling', false);
reject(resp);
},
);
});
export const fetchZones = ({ commit, state }) =>
gapiResourceListRequest({
resource: gapi.client.compute.zones,
params: {
project: state.selectedProject.projectId,
},
commit,
mutation: types.SET_ZONES,
payloadKey: 'items',
});
export const fetchMachineTypes = ({ commit, state }) =>
gapiResourceListRequest({
resource: gapi.client.compute.machineTypes,
params: {
project: state.selectedProject.projectId,
zone: state.selectedZone,
},
commit,
mutation: types.SET_MACHINE_TYPES,
payloadKey: 'items',
});
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const hasProject = state => !!state.selectedProject.projectId;
export const hasZone = state => !!state.selectedZone;
export const hasMachineType = state => !!state.selectedMachineType;
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import createState from './state';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
actions,
getters,
mutations,
state: createState(),
});
export default createStore();
export const SET_PROJECT = 'SET_PROJECT';
export const SET_PROJECT_BILLING_STATUS = 'SET_PROJECT_BILLING_STATUS';
export const SET_IS_VALIDATING_PROJECT_BILLING = 'SET_IS_VALIDATING_PROJECT_BILLING';
export const SET_ZONE = 'SET_ZONE';
export const SET_MACHINE_TYPE = 'SET_MACHINE_TYPE';
export const SET_PROJECTS = 'SET_PROJECTS';
export const SET_ZONES = 'SET_ZONES';
export const SET_MACHINE_TYPES = 'SET_MACHINE_TYPES';
import * as types from './mutation_types';
export default {
[types.SET_PROJECT](state, selectedProject) {
Object.assign(state, { selectedProject });
},
[types.SET_IS_VALIDATING_PROJECT_BILLING](state, isValidatingProjectBilling) {
Object.assign(state, { isValidatingProjectBilling });
},
[types.SET_PROJECT_BILLING_STATUS](state, projectHasBillingEnabled) {
Object.assign(state, { projectHasBillingEnabled });
},
[types.SET_ZONE](state, selectedZone) {
Object.assign(state, { selectedZone });
},
[types.SET_MACHINE_TYPE](state, selectedMachineType) {
Object.assign(state, { selectedMachineType });
},
[types.SET_PROJECTS](state, projects) {
Object.assign(state, { projects });
},
[types.SET_ZONES](state, zones) {
Object.assign(state, { zones });
},
[types.SET_MACHINE_TYPES](state, machineTypes) {
Object.assign(state, { machineTypes });
},
};
export default () => ({
selectedProject: {
projectId: '',
name: '',
},
selectedZone: '',
selectedMachineType: '',
isValidatingProjectBilling: null,
projectHasBillingEnabled: null,
projects: [],
zones: [],
machineTypes: [],
});
<script>
import { __ } from '~/locale';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
export default {
components: {
LoadingIcon,
},
props: {
isDisabled: {
type: Boolean,
required: false,
default: false,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
toggleText: {
type: String,
required: false,
default: __('Select'),
},
},
};
</script>
<template>
<button
class="dropdown-menu-toggle dropdown-menu-full-width"
type="button"
data-toggle="dropdown"
aria-expanded="false"
:disabled="isDisabled || isLoading"
>
<loading-icon
v-show="isLoading"
:inline="true"
/>
<span class="dropdown-toggle-text">
{{ toggleText }}
</span>
<span
class="dropdown-toggle-icon"
v-show="!isLoading"
>
<i
class="fa fa-chevron-down"
aria-hidden="true"
data-hidden="true"
></i>
</span>
</button>
</template>
...@@ -5,8 +5,8 @@ export default { ...@@ -5,8 +5,8 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
label: { value: {
type: Object, type: [Number, String],
required: true, required: true,
}, },
}, },
...@@ -17,6 +17,6 @@ export default { ...@@ -17,6 +17,6 @@ export default {
<input <input
type="hidden" type="hidden"
:name="name" :name="name"
:value="label.id" :value="value"
/> />
</template> </template>
<script>
import { __ } from '~/locale';
export default {
props: {
placeholderText: {
type: String,
required: true,
default: __('Search'),
},
},
data() {
return { searchQuery: this.value };
},
watch: {
searchQuery(query) {
this.$emit('input', query);
},
},
};
</script>
<template>
<div class="dropdown-input">
<input
class="dropdown-input-field"
type="search"
v-model="searchQuery"
:placeholder="placeholderText"
autocomplete="off"
/>
<i
class="fa fa-search dropdown-input-search"
aria-hidden="true"
data-hidden="true"
>
</i>
<i
class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
aria-hidden="true"
data-hidden="true"
role="button"
>
</i>
</div>
</template>
...@@ -2,13 +2,13 @@ ...@@ -2,13 +2,13 @@
import $ from 'jquery'; import $ from 'jquery';
import { __ } from '~/locale'; import { __ } from '~/locale';
import LabelsSelect from '~/labels_select'; import LabelsSelect from '~/labels_select';
import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
import LoadingIcon from '../../loading_icon.vue'; import LoadingIcon from '../../loading_icon.vue';
import DropdownTitle from './dropdown_title.vue'; import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue'; import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import DropdownButton from './dropdown_button.vue'; import DropdownButton from './dropdown_button.vue';
import DropdownHiddenInput from './dropdown_hidden_input.vue';
import DropdownHeader from './dropdown_header.vue'; import DropdownHeader from './dropdown_header.vue';
import DropdownSearchInput from './dropdown_search_input.vue'; import DropdownSearchInput from './dropdown_search_input.vue';
import DropdownFooter from './dropdown_footer.vue'; import DropdownFooter from './dropdown_footer.vue';
...@@ -140,7 +140,7 @@ export default { ...@@ -140,7 +140,7 @@ export default {
v-for="label in context.labels" v-for="label in context.labels"
:key="label.id" :key="label.id"
:name="hiddenInputName" :name="hiddenInputName"
:label="label" :value="label.id"
/> />
<div <div
class="dropdown" class="dropdown"
......
...@@ -63,6 +63,10 @@ ...@@ -63,6 +63,10 @@
border-radius: $border-radius-base; border-radius: $border-radius-base;
white-space: nowrap; white-space: nowrap;
&:disabled.read-only {
color: $gl-text-color !important;
}
&.no-outline { &.no-outline {
outline: 0; outline: 0;
} }
......
class Projects::Clusters::GcpController < Projects::ApplicationController class Projects::Clusters::GcpController < Projects::ApplicationController
before_action :authorize_read_cluster! before_action :authorize_read_cluster!
before_action :authorize_google_api, except: [:login]
before_action :authorize_google_project_billing, only: [:new, :create]
before_action :authorize_create_cluster!, only: [:new, :create] before_action :authorize_create_cluster!, only: [:new, :create]
before_action :verify_billing, only: [:create] before_action :authorize_google_api, except: :login
helper_method :token_in_session
def login def login
begin begin
...@@ -37,21 +36,6 @@ class Projects::Clusters::GcpController < Projects::ApplicationController ...@@ -37,21 +36,6 @@ class Projects::Clusters::GcpController < Projects::ApplicationController
private private
def verify_billing
case google_project_billing_status
when nil
flash.now[:alert] = _('We could not verify that one of your projects on GCP has billing enabled. Please try again.')
when false
flash.now[:alert] = _('Please <a href=%{link_to_billing} target="_blank" rel="noopener noreferrer">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again.').html_safe % { link_to_billing: "https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral" }
when true
return
end
@cluster = ::Clusters::Cluster.new(create_params)
render :new
end
def create_params def create_params
params.require(:cluster).permit( params.require(:cluster).permit(
:enabled, :enabled,
...@@ -75,18 +59,8 @@ class Projects::Clusters::GcpController < Projects::ApplicationController ...@@ -75,18 +59,8 @@ class Projects::Clusters::GcpController < Projects::ApplicationController
end end
end end
def authorize_google_project_billing
redis_token_key = CheckGcpProjectBillingWorker.store_session_token(token_in_session)
CheckGcpProjectBillingWorker.perform_async(redis_token_key)
end
def google_project_billing_status
CheckGcpProjectBillingWorker.get_billing_state(token_in_session)
end
def token_in_session def token_in_session
@token_in_session ||= session[GoogleApi::CloudPlatform::Client.session_key_for_token]
session[GoogleApi::CloudPlatform::Client.session_key_for_token]
end end
def expires_at_in_session def expires_at_in_session
......
class CheckGcpProjectBillingService
def execute(token)
client = GoogleApi::CloudPlatform::Client.new(token, nil)
client.projects_list.select do |project|
begin
client.projects_get_billing_info(project.project_id).billing_enabled
rescue
end
end
end
end
= javascript_include_tag 'https://apis.google.com/js/api.js'
%p %p
- link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page} = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page}
= form_for @cluster, html: { class: 'prepend-top-20' }, url: gcp_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| = form_for @cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: gcp_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field|
= form_errors(@cluster) = form_errors(@cluster)
.form-group .form-group
= field.label :name, s_('ClusterIntegration|Kubernetes cluster name') = field.label :name, s_('ClusterIntegration|Kubernetes cluster name')
...@@ -14,13 +16,25 @@ ...@@ -14,13 +16,25 @@
= field.fields_for :provider_gcp, @cluster.provider_gcp do |provider_gcp_field| = field.fields_for :provider_gcp, @cluster.provider_gcp do |provider_gcp_field|
.form-group .form-group
= provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID') = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID')
= link_to(s_('ClusterIntegration|See your projects'), 'https://console.cloud.google.com/home/dashboard', target: '_blank', rel: 'noopener noreferrer') .js-gcp-project-id-dropdown-entry-point{ data: { docsUrl: 'https://console.cloud.google.com/home/dashboard' } }
= provider_gcp_field.text_field :gcp_project_id, class: 'form-control', placeholder: s_('ClusterIntegration|Project ID') = provider_gcp_field.hidden_field :gcp_project_id
.dropdown
%button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true }
%span.dropdown-toggle-text
= _('Select project')
= icon('chevron-down')
%span.help-block &nbsp;
.form-group .form-group
= provider_gcp_field.label :zone, s_('ClusterIntegration|Zone') = provider_gcp_field.label :zone, s_('ClusterIntegration|Zone')
= link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer') = link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer')
= provider_gcp_field.text_field :zone, class: 'form-control', placeholder: 'us-central1-a' .js-gcp-zone-dropdown-entry-point
= provider_gcp_field.hidden_field :zone
.dropdown
%button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true }
%span.dropdown-toggle-text
= _('Select project to choose zone')
= icon('chevron-down')
.form-group .form-group
= provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes') = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes')
...@@ -28,8 +42,13 @@ ...@@ -28,8 +42,13 @@
.form-group .form-group
= provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type') = provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type')
= link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer') .js-gcp-machine-type-dropdown-entry-point
= provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-4' = provider_gcp_field.hidden_field :machine_type
.dropdown
%button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true }
%span.dropdown-toggle-text
= _('Select project and zone to choose machine type')
= icon('chevron-down')
.form-group .form-group
= field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'btn btn-success' = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true
...@@ -24,7 +24,6 @@ ...@@ -24,7 +24,6 @@
- gcp_cluster:cluster_provision - gcp_cluster:cluster_provision
- gcp_cluster:cluster_wait_for_app_installation - gcp_cluster:cluster_wait_for_app_installation
- gcp_cluster:wait_for_cluster_creation - gcp_cluster:wait_for_cluster_creation
- gcp_cluster:check_gcp_project_billing
- gcp_cluster:cluster_wait_for_ingress_ip_address - gcp_cluster:cluster_wait_for_ingress_ip_address
- github_import_advance_stage - github_import_advance_stage
......
require 'securerandom'
class CheckGcpProjectBillingWorker
include ApplicationWorker
include ClusterQueue
LEASE_TIMEOUT = 3.seconds.to_i
SESSION_KEY_TIMEOUT = 5.minutes
BILLING_TIMEOUT = 1.hour
BILLING_CHANGED_LABELS = { state_transition: nil }.freeze
def self.get_session_token(token_key)
Gitlab::Redis::SharedState.with do |redis|
redis.get(get_redis_session_key(token_key))
end
end
def self.store_session_token(token)
generate_token_key.tap do |token_key|
Gitlab::Redis::SharedState.with do |redis|
redis.set(get_redis_session_key(token_key), token, ex: SESSION_KEY_TIMEOUT)
end
end
end
def self.get_billing_state(token)
Gitlab::Redis::SharedState.with do |redis|
value = redis.get(redis_shared_state_key_for(token))
ActiveRecord::Type::Boolean.new.type_cast_from_user(value)
end
end
def perform(token_key)
return unless token_key
token = self.class.get_session_token(token_key)
return unless token
return unless try_obtain_lease_for(token)
billing_enabled_state = !CheckGcpProjectBillingService.new.execute(token).empty?
update_billing_change_counter(self.class.get_billing_state(token), billing_enabled_state)
self.class.set_billing_state(token, billing_enabled_state)
end
private
def self.generate_token_key
SecureRandom.uuid
end
def self.get_redis_session_key(token_key)
"gitlab:gcp:session:#{token_key}"
end
def self.redis_shared_state_key_for(token)
"gitlab:gcp:#{Digest::SHA1.hexdigest(token)}:billing_enabled"
end
def self.set_billing_state(token, value)
Gitlab::Redis::SharedState.with do |redis|
redis.set(redis_shared_state_key_for(token), value, ex: BILLING_TIMEOUT)
end
end
def try_obtain_lease_for(token)
Gitlab::ExclusiveLease
.new("check_gcp_project_billing_worker:#{token.hash}", timeout: LEASE_TIMEOUT)
.try_obtain
end
def billing_changed_counter
@billing_changed_counter ||= Gitlab::Metrics.counter(
:gcp_billing_change_count,
"Counts the number of times a GCP project changed billing_enabled state from false to true",
BILLING_CHANGED_LABELS
)
end
def state_transition(previous_state, current_state)
if previous_state.nil? && !current_state
'no_billing'
elsif previous_state.nil? && current_state
'with_billing'
elsif !previous_state && current_state
'billing_configured'
end
end
def update_billing_change_counter(previous_state, current_state)
billing_changed_counter.increment(state_transition: state_transition(previous_state, current_state))
end
end
---
title: Dynamically fetch GCP cluster creation parameters.
merge_request: 17806
author:
type: changed
require 'google/apis/compute_v1'
require 'google/apis/container_v1' require 'google/apis/container_v1'
require 'google/apis/cloudbilling_v1' require 'google/apis/cloudbilling_v1'
require 'google/apis/cloudresourcemanager_v1' require 'google/apis/cloudresourcemanager_v1'
...@@ -42,22 +43,6 @@ module GoogleApi ...@@ -42,22 +43,6 @@ module GoogleApi
true true
end end
def projects_list
service = Google::Apis::CloudresourcemanagerV1::CloudResourceManagerService.new
service.authorization = access_token
service.fetch_all(items: :projects) do |token|
service.list_projects(page_token: token, options: user_agent_header)
end
end
def projects_get_billing_info(project_id)
service = Google::Apis::CloudbillingV1::CloudbillingService.new
service.authorization = access_token
service.get_project_billing_info("projects/#{project_id}", options: user_agent_header)
end
def projects_zones_clusters_get(project_id, zone, cluster_id) def projects_zones_clusters_get(project_id, zone, cluster_id)
service = Google::Apis::ContainerV1::ContainerService.new service = Google::Apis::ContainerV1::ContainerService.new
service.authorization = access_token service.authorization = access_token
......
...@@ -77,8 +77,6 @@ describe Projects::Clusters::GcpController do ...@@ -77,8 +77,6 @@ describe Projects::Clusters::GcpController do
end end
it 'has new object' do it 'has new object' do
expect(controller).to receive(:authorize_google_project_billing)
go go
expect(assigns(:cluster)).to be_an_instance_of(Clusters::Cluster) expect(assigns(:cluster)).to be_an_instance_of(Clusters::Cluster)
...@@ -137,33 +135,15 @@ describe Projects::Clusters::GcpController do ...@@ -137,33 +135,15 @@ describe Projects::Clusters::GcpController do
context 'when access token is valid' do context 'when access token is valid' do
before do before do
stub_google_api_validate_token stub_google_api_validate_token
allow_any_instance_of(described_class).to receive(:authorize_google_project_billing)
end
context 'when google project billing is enabled' do
before do
redis_double = double.as_null_object
allow(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis_double)
allow(redis_double).to receive(:get).with(CheckGcpProjectBillingWorker.redis_shared_state_key_for('token')).and_return('true')
end
it 'creates a new cluster' do
expect(ClusterProvisionWorker).to receive(:perform_async)
expect { go }.to change { Clusters::Cluster.count }
.and change { Clusters::Providers::Gcp.count }
expect(response).to redirect_to(project_cluster_path(project, project.clusters.first))
expect(project.clusters.first).to be_gcp
expect(project.clusters.first).to be_kubernetes
end
end end
context 'when google project billing is not enabled' do it 'creates a new cluster' do
it 'renders the cluster form with an error' do expect(ClusterProvisionWorker).to receive(:perform_async)
go expect { go }.to change { Clusters::Cluster.count }
.and change { Clusters::Providers::Gcp.count }
expect(response).to set_flash.now[:alert] expect(response).to redirect_to(project_cluster_path(project, project.clusters.first))
expect(response).to render_template('new') expect(project.clusters.first).to be_gcp
end expect(project.clusters.first).to be_kubernetes
end end
end end
......
This diff is collapsed.
import Vue from 'vue';
import GkeMachineTypeDropdown from '~/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue';
import { createStore } from '~/projects/gke_cluster_dropdowns/store';
import {
SET_PROJECT,
SET_PROJECT_BILLING_STATUS,
SET_ZONE,
SET_MACHINE_TYPES,
} from '~/projects/gke_cluster_dropdowns/store/mutation_types';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import {
selectedZoneMock,
selectedProjectMock,
selectedMachineTypeMock,
gapiMachineTypesResponseMock,
} from '../mock_data';
const componentConfig = {
fieldId: 'cluster_provider_gcp_attributes_gcp_machine_type',
fieldName: 'cluster[provider_gcp_attributes][gcp_machine_type]',
};
const LABELS = {
LOADING: 'Fetching machine types',
DISABLED_NO_PROJECT: 'Select project and zone to choose machine type',
DISABLED_NO_ZONE: 'Select zone to choose machine type',
DEFAULT: 'Select machine type',
};
const createComponent = (store, props = componentConfig) => {
const Component = Vue.extend(GkeMachineTypeDropdown);
return mountComponentWithStore(Component, {
el: null,
props,
store,
});
};
describe('GkeMachineTypeDropdown', () => {
let vm;
let store;
beforeEach(() => {
store = createStore();
vm = createComponent(store);
});
afterEach(() => {
vm.$destroy();
});
describe('shows various toggle text depending on state', () => {
it('returns disabled state toggle text when no project and zone are selected', () => {
expect(vm.toggleText).toBe(LABELS.DISABLED_NO_PROJECT);
});
it('returns disabled state toggle text when no zone is selected', () => {
vm.$store.commit(SET_PROJECT, selectedProjectMock);
vm.$store.commit(SET_PROJECT_BILLING_STATUS, true);
expect(vm.toggleText).toBe(LABELS.DISABLED_NO_ZONE);
});
it('returns loading toggle text', () => {
vm.isLoading = true;
expect(vm.toggleText).toBe(LABELS.LOADING);
});
it('returns default toggle text', () => {
expect(vm.toggleText).toBe(LABELS.DISABLED_NO_PROJECT);
vm.$store.commit(SET_PROJECT, selectedProjectMock);
vm.$store.commit(SET_PROJECT_BILLING_STATUS, true);
vm.$store.commit(SET_ZONE, selectedZoneMock);
expect(vm.toggleText).toBe(LABELS.DEFAULT);
});
it('returns machine type name if machine type selected', () => {
vm.setItem(selectedMachineTypeMock);
expect(vm.toggleText).toBe(selectedMachineTypeMock);
});
});
describe('form input', () => {
it('reflects new value when dropdown item is clicked', done => {
expect(vm.$el.querySelector('input').value).toBe('');
vm.$store.commit(SET_MACHINE_TYPES, gapiMachineTypesResponseMock.items);
return vm.$nextTick().then(() => {
vm.$el.querySelector('.dropdown-content button').click();
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('input').value).toBe(selectedMachineTypeMock);
done();
});
});
});
});
});
import Vue from 'vue';
import GkeProjectIdDropdown from '~/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue';
import { createStore } from '~/projects/gke_cluster_dropdowns/store';
import { SET_PROJECTS } from '~/projects/gke_cluster_dropdowns/store/mutation_types';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { emptyProjectMock, selectedProjectMock } from '../mock_data';
const componentConfig = {
docsUrl: 'https://console.cloud.google.com/home/dashboard',
fieldId: 'cluster_provider_gcp_attributes_gcp_project_id',
fieldName: 'cluster[provider_gcp_attributes][gcp_project_id]',
};
const LABELS = {
LOADING: 'Fetching projects',
VALIDATING_PROJECT_BILLING: 'Validating project billing status',
DEFAULT: 'Select project',
EMPTY: 'No projects found',
};
const createComponent = (store, props = componentConfig) => {
const Component = Vue.extend(GkeProjectIdDropdown);
return mountComponentWithStore(Component, {
el: null,
props,
store,
});
};
describe('GkeProjectIdDropdown', () => {
let vm;
let store;
beforeEach(() => {
store = createStore();
vm = createComponent(store);
});
afterEach(() => {
vm.$destroy();
});
describe('toggleText', () => {
it('returns loading toggle text', () => {
expect(vm.toggleText).toBe(LABELS.LOADING);
});
it('returns project billing validation text', () => {
vm.setIsValidatingProjectBilling(true);
expect(vm.toggleText).toBe(LABELS.VALIDATING_PROJECT_BILLING);
});
it('returns default toggle text', done =>
vm.$nextTick().then(() => {
vm.setItem(emptyProjectMock);
expect(vm.toggleText).toBe(LABELS.DEFAULT);
done();
}));
it('returns project name if project selected', done =>
vm.$nextTick().then(() => {
expect(vm.toggleText).toBe(selectedProjectMock.name);
done();
}));
it('returns empty toggle text', done =>
vm.$nextTick().then(() => {
vm.$store.commit(SET_PROJECTS, null);
vm.setItem(emptyProjectMock);
expect(vm.toggleText).toBe(LABELS.EMPTY);
done();
}));
});
describe('selectItem', () => {
it('reflects new value when dropdown item is clicked', done => {
expect(vm.$el.querySelector('input').value).toBe('');
return vm.$nextTick().then(() => {
vm.$el.querySelector('.dropdown-content button').click();
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('input').value).toBe(selectedProjectMock.projectId);
done();
});
});
});
});
});
import Vue from 'vue';
import GkeZoneDropdown from '~/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue';
import { createStore } from '~/projects/gke_cluster_dropdowns/store';
import {
SET_PROJECT,
SET_ZONES,
SET_PROJECT_BILLING_STATUS,
} from '~/projects/gke_cluster_dropdowns/store/mutation_types';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { selectedZoneMock, selectedProjectMock, gapiZonesResponseMock } from '../mock_data';
const componentConfig = {
fieldId: 'cluster_provider_gcp_attributes_gcp_zone',
fieldName: 'cluster[provider_gcp_attributes][gcp_zone]',
};
const LABELS = {
LOADING: 'Fetching zones',
DISABLED: 'Select project to choose zone',
DEFAULT: 'Select zone',
};
const createComponent = (store, props = componentConfig) => {
const Component = Vue.extend(GkeZoneDropdown);
return mountComponentWithStore(Component, {
el: null,
props,
store,
});
};
describe('GkeZoneDropdown', () => {
let vm;
let store;
beforeEach(() => {
store = createStore();
vm = createComponent(store);
});
afterEach(() => {
vm.$destroy();
});
describe('toggleText', () => {
it('returns disabled state toggle text', () => {
expect(vm.toggleText).toBe(LABELS.DISABLED);
});
it('returns loading toggle text', () => {
vm.isLoading = true;
expect(vm.toggleText).toBe(LABELS.LOADING);
});
it('returns default toggle text', () => {
expect(vm.toggleText).toBe(LABELS.DISABLED);
vm.$store.commit(SET_PROJECT, selectedProjectMock);
vm.$store.commit(SET_PROJECT_BILLING_STATUS, true);
expect(vm.toggleText).toBe(LABELS.DEFAULT);
});
it('returns project name if project selected', () => {
vm.setItem(selectedZoneMock);
expect(vm.toggleText).toBe(selectedZoneMock);
});
});
describe('selectItem', () => {
it('reflects new value when dropdown item is clicked', done => {
expect(vm.$el.querySelector('input').value).toBe('');
vm.$store.commit(SET_ZONES, gapiZonesResponseMock.items);
return vm.$nextTick().then(() => {
vm.$el.querySelector('.dropdown-content button').click();
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('input').value).toBe(selectedZoneMock);
done();
});
});
});
});
});
import {
gapiProjectsResponseMock,
gapiZonesResponseMock,
gapiMachineTypesResponseMock,
} from './mock_data';
// eslint-disable-next-line import/prefer-default-export
export const gapi = () => ({
client: {
cloudbilling: {
projects: {
getBillingInfo: () =>
new Promise(resolve => {
resolve({
result: { billingEnabled: true },
});
}),
},
},
cloudresourcemanager: {
projects: {
list: () =>
new Promise(resolve => {
resolve({
result: { ...gapiProjectsResponseMock },
});
}),
},
},
compute: {
zones: {
list: () =>
new Promise(resolve => {
resolve({
result: { ...gapiZonesResponseMock },
});
}),
},
machineTypes: {
list: () =>
new Promise(resolve => {
resolve({
result: { ...gapiMachineTypesResponseMock },
});
}),
},
},
},
});
export const emptyProjectMock = {
projectId: '',
name: '',
};
export const selectedProjectMock = {
projectId: 'gcp-project-123',
name: 'gcp-project',
};
export const selectedZoneMock = 'us-central1-a';
export const selectedMachineTypeMock = 'n1-standard-2';
export const gapiProjectsResponseMock = {
projects: [
{
projectNumber: '1234',
projectId: 'gcp-project-123',
lifecycleState: 'ACTIVE',
name: 'gcp-project',
createTime: '2017-12-16T01:48:29.129Z',
parent: {
type: 'organization',
id: '12345',
},
},
],
};
export const gapiZonesResponseMock = {
kind: 'compute#zoneList',
id: 'projects/gitlab-internal-153318/zones',
items: [
{
kind: 'compute#zone',
id: '2000',
creationTimestamp: '1969-12-31T16:00:00.000-08:00',
name: 'us-central1-a',
description: 'us-central1-a',
status: 'UP',
region:
'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/regions/us-central1',
selfLink:
'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a',
availableCpuPlatforms: ['Intel Skylake', 'Intel Broadwell', 'Intel Sandy Bridge'],
},
],
selfLink: 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones',
};
export const gapiMachineTypesResponseMock = {
kind: 'compute#machineTypeList',
id: 'projects/gitlab-internal-153318/zones/us-central1-a/machineTypes',
items: [
{
kind: 'compute#machineType',
id: '3002',
creationTimestamp: '1969-12-31T16:00:00.000-08:00',
name: 'n1-standard-2',
description: '2 vCPUs, 7.5 GB RAM',
guestCpus: 2,
memoryMb: 7680,
imageSpaceGb: 10,
maximumPersistentDisks: 64,
maximumPersistentDisksSizeGb: '65536',
zone: 'us-central1-a',
selfLink:
'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a/machineTypes/n1-standard-2',
isSharedCpu: false,
},
],
selfLink:
'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a/machineTypes',
};
import testAction from 'spec/helpers/vuex_action_helper';
import * as actions from '~/projects/gke_cluster_dropdowns/store/actions';
import { createStore } from '~/projects/gke_cluster_dropdowns/store';
import { gapi } from '../helpers';
import { selectedProjectMock, selectedZoneMock, selectedMachineTypeMock } from '../mock_data';
describe('GCP Cluster Dropdown Store Actions', () => {
let store;
beforeEach(() => {
store = createStore();
});
describe('setProject', () => {
it('should set project', done => {
testAction(
actions.setProject,
selectedProjectMock,
{ selectedProject: {} },
[{ type: 'SET_PROJECT', payload: selectedProjectMock }],
[],
done,
);
});
});
describe('setZone', () => {
it('should set zone', done => {
testAction(
actions.setZone,
selectedZoneMock,
{ selectedZone: '' },
[{ type: 'SET_ZONE', payload: selectedZoneMock }],
[],
done,
);
});
});
describe('setMachineType', () => {
it('should set machine type', done => {
testAction(
actions.setMachineType,
selectedMachineTypeMock,
{ selectedMachineType: '' },
[{ type: 'SET_MACHINE_TYPE', payload: selectedMachineTypeMock }],
[],
done,
);
});
});
describe('setIsValidatingProjectBilling', () => {
it('should set machine type', done => {
testAction(
actions.setIsValidatingProjectBilling,
true,
{ isValidatingProjectBilling: null },
[{ type: 'SET_IS_VALIDATING_PROJECT_BILLING', payload: true }],
[],
done,
);
});
});
describe('async fetch methods', () => {
window.gapi = gapi();
describe('fetchProjects', () => {
it('fetches projects from Google API', done => {
store
.dispatch('fetchProjects')
.then(() => {
expect(store.state.projects[0].projectId).toEqual(selectedProjectMock.projectId);
expect(store.state.projects[0].name).toEqual(selectedProjectMock.name);
done();
})
.catch(done.fail);
});
});
describe('validateProjectBilling', () => {
it('checks project billing status from Google API', done => {
testAction(
actions.validateProjectBilling,
true,
{
selectedProject: selectedProjectMock,
selectedZone: '',
selectedMachineType: '',
projectHasBillingEnabled: null,
},
[
{ type: 'SET_ZONE', payload: '' },
{ type: 'SET_MACHINE_TYPE', payload: '' },
{ type: 'SET_PROJECT_BILLING_STATUS', payload: true },
],
[{ type: 'setIsValidatingProjectBilling', payload: false }],
done,
);
});
});
describe('fetchZones', () => {
it('fetches zones from Google API', done => {
store
.dispatch('fetchZones')
.then(() => {
expect(store.state.zones[0].name).toEqual(selectedZoneMock);
done();
})
.catch(done.fail);
});
});
describe('fetchMachineTypes', () => {
it('fetches machine types from Google API', done => {
store
.dispatch('fetchMachineTypes')
.then(() => {
expect(store.state.machineTypes[0].name).toEqual(selectedMachineTypeMock);
done();
})
.catch(done.fail);
});
});
});
});
import * as getters from '~/projects/gke_cluster_dropdowns/store/getters';
import { selectedProjectMock, selectedZoneMock, selectedMachineTypeMock } from '../mock_data';
describe('GCP Cluster Dropdown Store Getters', () => {
let state;
describe('valid states', () => {
beforeEach(() => {
state = {
selectedProject: selectedProjectMock,
selectedZone: selectedZoneMock,
selectedMachineType: selectedMachineTypeMock,
};
});
describe('hasProject', () => {
it('should return true when project is selected', () => {
expect(getters.hasProject(state)).toEqual(true);
});
});
describe('hasZone', () => {
it('should return true when zone is selected', () => {
expect(getters.hasZone(state)).toEqual(true);
});
});
describe('hasMachineType', () => {
it('should return true when machine type is selected', () => {
expect(getters.hasMachineType(state)).toEqual(true);
});
});
});
describe('invalid states', () => {
beforeEach(() => {
state = {
selectedProject: {
projectId: '',
name: '',
},
selectedZone: '',
selectedMachineType: '',
};
});
describe('hasProject', () => {
it('should return false when project is not selected', () => {
expect(getters.hasProject(state)).toEqual(false);
});
});
describe('hasZone', () => {
it('should return false when zone is not selected', () => {
expect(getters.hasZone(state)).toEqual(false);
});
});
describe('hasMachineType', () => {
it('should return false when machine type is not selected', () => {
expect(getters.hasMachineType(state)).toEqual(false);
});
});
});
});
import { createStore } from '~/projects/gke_cluster_dropdowns/store';
import * as types from '~/projects/gke_cluster_dropdowns/store/mutation_types';
import {
selectedProjectMock,
selectedZoneMock,
selectedMachineTypeMock,
gapiProjectsResponseMock,
gapiZonesResponseMock,
gapiMachineTypesResponseMock,
} from '../mock_data';
describe('GCP Cluster Dropdown Store Mutations', () => {
let store;
beforeEach(() => {
store = createStore();
});
describe('SET_PROJECT', () => {
it('should set GCP project as selectedProject', () => {
const projectToSelect = gapiProjectsResponseMock.projects[0];
store.commit(types.SET_PROJECT, projectToSelect);
expect(store.state.selectedProject.projectId).toEqual(selectedProjectMock.projectId);
expect(store.state.selectedProject.name).toEqual(selectedProjectMock.name);
});
});
describe('SET_PROJECT_BILLING_STATUS', () => {
it('should set project billing status', () => {
store.commit(types.SET_PROJECT_BILLING_STATUS, true);
expect(store.state.projectHasBillingEnabled).toBeTruthy();
});
});
describe('SET_ZONE', () => {
it('should set GCP zone as selectedZone', () => {
const zoneToSelect = gapiZonesResponseMock.items[0].name;
store.commit(types.SET_ZONE, zoneToSelect);
expect(store.state.selectedZone).toEqual(selectedZoneMock);
});
});
describe('SET_MACHINE_TYPE', () => {
it('should set GCP machine type as selectedMachineType', () => {
const machineTypeToSelect = gapiMachineTypesResponseMock.items[0].name;
store.commit(types.SET_MACHINE_TYPE, machineTypeToSelect);
expect(store.state.selectedMachineType).toEqual(selectedMachineTypeMock);
});
});
describe('SET_PROJECTS', () => {
it('should set Google API Projects response as projects', () => {
expect(store.state.projects.length).toEqual(0);
store.commit(types.SET_PROJECTS, gapiProjectsResponseMock.projects);
expect(store.state.projects.length).toEqual(gapiProjectsResponseMock.projects.length);
});
});
describe('SET_ZONES', () => {
it('should set Google API Zones response as zones', () => {
expect(store.state.zones.length).toEqual(0);
store.commit(types.SET_ZONES, gapiZonesResponseMock.items);
expect(store.state.zones.length).toEqual(gapiZonesResponseMock.items.length);
});
});
describe('SET_MACHINE_TYPES', () => {
it('should set Google API Machine Types response as machineTypes', () => {
expect(store.state.machineTypes.length).toEqual(0);
store.commit(types.SET_MACHINE_TYPES, gapiMachineTypesResponseMock.items);
expect(store.state.machineTypes.length).toEqual(gapiMachineTypesResponseMock.items.length);
});
});
});
import Vue from 'vue';
import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
const defaultLabel = 'Select';
const customLabel = 'Select project';
const createComponent = config => {
const Component = Vue.extend(dropdownButtonComponent);
return mountComponent(Component, config);
};
describe('DropdownButtonComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('dropdownToggleText', () => {
it('returns default toggle text', () => {
expect(vm.toggleText).toBe(defaultLabel);
});
it('returns custom toggle text when provided via props', () => {
const vmEmptyLabels = createComponent({ toggleText: customLabel });
expect(vmEmptyLabels.toggleText).toBe(customLabel);
vmEmptyLabels.$destroy();
});
});
});
describe('template', () => {
it('renders component container element of type `button`', () => {
expect(vm.$el.nodeName).toBe('BUTTON');
});
it('renders component container element with required data attributes', () => {
expect(vm.$el.dataset.abilityName).toBe(vm.abilityName);
expect(vm.$el.dataset.fieldName).toBe(vm.fieldName);
expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath);
expect(vm.$el.dataset.labels).toBe(vm.labelsPath);
expect(vm.$el.dataset.namespacePath).toBe(vm.namespace);
expect(vm.$el.dataset.showAny).not.toBeDefined();
});
it('renders dropdown toggle text element', () => {
const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text');
expect(dropdownToggleTextEl).not.toBeNull();
expect(dropdownToggleTextEl.innerText.trim()).toBe(defaultLabel);
});
it('renders dropdown button icon', () => {
const dropdownIconEl = vm.$el.querySelector('.dropdown-toggle-icon i.fa');
expect(dropdownIconEl).not.toBeNull();
expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true);
});
});
});
import Vue from 'vue'; import Vue from 'vue';
import dropdownHiddenInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue'; import dropdownHiddenInputComponent from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockLabels } from './mock_data'; import { mockLabels } from './mock_data';
const createComponent = (name = 'label_id[]', label = mockLabels[0]) => { const createComponent = (name = 'label_id[]', value = mockLabels[0].id) => {
const Component = Vue.extend(dropdownHiddenInputComponent); const Component = Vue.extend(dropdownHiddenInputComponent);
return mountComponent(Component, { return mountComponent(Component, {
name, name,
label, value,
}); });
}; };
...@@ -31,7 +31,7 @@ describe('DropdownHiddenInputComponent', () => { ...@@ -31,7 +31,7 @@ describe('DropdownHiddenInputComponent', () => {
expect(vm.$el.nodeName).toBe('INPUT'); expect(vm.$el.nodeName).toBe('INPUT');
expect(vm.$el.getAttribute('type')).toBe('hidden'); expect(vm.$el.getAttribute('type')).toBe('hidden');
expect(vm.$el.getAttribute('name')).toBe(vm.name); expect(vm.$el.getAttribute('name')).toBe(vm.name);
expect(vm.$el.getAttribute('value')).toBe(`${vm.label.id}`); expect(vm.$el.getAttribute('value')).toBe(`${vm.value}`);
}); });
}); });
}); });
import Vue from 'vue';
import dropdownSearchInputComponent from '~/vue_shared/components/dropdown/dropdown_search_input.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
const componentConfig = {
placeholderText: 'Search something',
};
const createComponent = (config = componentConfig) => {
const Component = Vue.extend(dropdownSearchInputComponent);
return mountComponent(Component, config);
};
describe('DropdownSearchInputComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('template', () => {
it('renders input element with type `search`', () => {
const inputEl = vm.$el.querySelector('input.dropdown-input-field');
expect(inputEl).not.toBeNull();
expect(inputEl.getAttribute('type')).toBe('search');
});
it('renders search icon element', () => {
expect(vm.$el.querySelector('.fa-search.dropdown-input-search')).not.toBeNull();
});
it('renders clear search icon element', () => {
expect(
vm.$el.querySelector('.fa-times.dropdown-input-clear.js-dropdown-input-clear'),
).not.toBeNull();
});
it('displays custom placeholder text', () => {
const inputEl = vm.$el.querySelector('input.dropdown-input-field');
expect(inputEl.getAttribute('placeholder')).toBe(componentConfig.placeholderText);
});
});
});
export const mockLabels = [
{
id: 26,
title: 'Foo Label',
description: 'Foobar',
color: '#BADA55',
text_color: '#FFFFFF',
},
];
export default mockLabels;
...@@ -50,30 +50,6 @@ describe GoogleApi::CloudPlatform::Client do ...@@ -50,30 +50,6 @@ describe GoogleApi::CloudPlatform::Client do
end end
end end
describe '#projects_list' do
subject { client.projects_list }
let(:projects) { double }
before do
allow_any_instance_of(Google::Apis::CloudresourcemanagerV1::CloudResourceManagerService)
.to receive(:fetch_all).and_return(projects)
end
it { is_expected.to eq(projects) }
end
describe '#projects_get_billing_info' do
subject { client.projects_get_billing_info('project') }
let(:billing_info) { double }
before do
allow_any_instance_of(Google::Apis::CloudbillingV1::CloudbillingService)
.to receive(:get_project_billing_info).and_return(billing_info)
end
it { is_expected.to eq(billing_info) }
end
describe '#projects_zones_clusters_get' do describe '#projects_zones_clusters_get' do
subject { client.projects_zones_clusters_get(spy, spy, spy) } subject { client.projects_zones_clusters_get(spy, spy, spy) }
let(:gke_cluster) { double } let(:gke_cluster) { double }
......
require 'spec_helper'
describe CheckGcpProjectBillingService do
include GoogleApi::CloudPlatformHelpers
let(:service) { described_class.new }
let(:project_id) { 'test-project-1234' }
describe '#execute' do
before do
stub_cloud_platform_projects_list(project_id: project_id)
end
subject { service.execute('bogustoken') }
context 'google account has a billing enabled gcp project' do
before do
stub_cloud_platform_projects_get_billing_info(project_id, true)
end
it { is_expected.to all(satisfy { |project| project.project_id == project_id }) }
end
context 'google account does not have a billing enabled gcp project' do
before do
stub_cloud_platform_projects_get_billing_info(project_id, false)
end
it { is_expected.to eq([]) }
end
end
end
require 'spec_helper'
describe CheckGcpProjectBillingWorker do
describe '.perform' do
let(:token) { 'bogustoken' }
subject { described_class.new.perform('token_key') }
before do
allow(described_class).to receive(:get_billing_state)
allow_any_instance_of(described_class).to receive(:update_billing_change_counter)
end
context 'when there is a token in redis' do
before do
allow(described_class).to receive(:get_session_token).and_return(token)
end
context 'when there is no lease' do
before do
allow_any_instance_of(described_class).to receive(:try_obtain_lease_for).and_return('randomuuid')
end
it 'calls the service' do
expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double])
subject
end
it 'stores billing status in redis' do
expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double])
expect(described_class).to receive(:set_billing_state).with(token, true)
subject
end
end
context 'when there is a lease' do
before do
allow_any_instance_of(described_class).to receive(:try_obtain_lease_for).and_return(false)
end
it 'does not call the service' do
expect(CheckGcpProjectBillingService).not_to receive(:new)
subject
end
end
end
context 'when there is no token in redis' do
before do
allow(described_class).to receive(:get_session_token).and_return(nil)
end
it 'does not call the service' do
expect(CheckGcpProjectBillingService).not_to receive(:new)
subject
end
end
end
describe 'billing change counter' do
subject { described_class.new.perform('token_key') }
before do
allow(described_class).to receive(:get_session_token).and_return('bogustoken')
allow_any_instance_of(described_class).to receive(:try_obtain_lease_for).and_return('randomuuid')
allow(described_class).to receive(:set_billing_state)
end
context 'when previous state was false' do
before do
expect(described_class).to receive(:get_billing_state).and_return(false)
end
context 'when the current state is false' do
before do
expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([])
end
it 'increments the billing change counter' do
expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment)
subject
end
end
context 'when the current state is true' do
before do
expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double])
end
it 'increments the billing change counter' do
expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment)
subject
end
end
end
context 'when previous state was true' do
before do
expect(described_class).to receive(:get_billing_state).and_return(true)
expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double])
end
it 'increment the billing change counter' do
expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment)
subject
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment