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 {
type: String,
required: true,
},
label: {
type: Object,
value: {
type: [Number, String],
required: true,
},
},
......@@ -17,6 +17,6 @@ export default {
<input
type="hidden"
:name="name"
:value="label.id"
:value="value"
/>
</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 @@
import $ from 'jquery';
import { __ } from '~/locale';
import LabelsSelect from '~/labels_select';
import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
import LoadingIcon from '../../loading_icon.vue';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import DropdownButton from './dropdown_button.vue';
import DropdownHiddenInput from './dropdown_hidden_input.vue';
import DropdownHeader from './dropdown_header.vue';
import DropdownSearchInput from './dropdown_search_input.vue';
import DropdownFooter from './dropdown_footer.vue';
......@@ -140,7 +140,7 @@ export default {
v-for="label in context.labels"
:key="label.id"
:name="hiddenInputName"
:label="label"
:value="label.id"
/>
<div
class="dropdown"
......
......@@ -63,6 +63,10 @@
border-radius: $border-radius-base;
white-space: nowrap;
&:disabled.read-only {
color: $gl-text-color !important;
}
&.no-outline {
outline: 0;
}
......
class Projects::Clusters::GcpController < Projects::ApplicationController
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 :verify_billing, only: [:create]
before_action :authorize_google_api, except: :login
helper_method :token_in_session
def login
begin
......@@ -37,21 +36,6 @@ class Projects::Clusters::GcpController < Projects::ApplicationController
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
params.require(:cluster).permit(
:enabled,
......@@ -75,17 +59,7 @@ class Projects::Clusters::GcpController < Projects::ApplicationController
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
@token_in_session ||=
session[GoogleApi::CloudPlatform::Client.session_key_for_token]
end
......
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
- 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}
= 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-group
= field.label :name, s_('ClusterIntegration|Kubernetes cluster name')
......@@ -14,13 +16,25 @@
= field.fields_for :provider_gcp, @cluster.provider_gcp do |provider_gcp_field|
.form-group
= 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')
= provider_gcp_field.text_field :gcp_project_id, class: 'form-control', placeholder: s_('ClusterIntegration|Project ID')
.js-gcp-project-id-dropdown-entry-point{ data: { docsUrl: 'https://console.cloud.google.com/home/dashboard' } }
= 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
= 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')
= 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
= provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes')
......@@ -28,8 +42,13 @@
.form-group
= 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')
= provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-4'
.js-gcp-machine-type-dropdown-entry-point
= 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
= 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 @@
- gcp_cluster:cluster_provision
- gcp_cluster:cluster_wait_for_app_installation
- gcp_cluster:wait_for_cluster_creation
- gcp_cluster:check_gcp_project_billing
- gcp_cluster:cluster_wait_for_ingress_ip_address
- 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/cloudbilling_v1'
require 'google/apis/cloudresourcemanager_v1'
......@@ -42,22 +43,6 @@ module GoogleApi
true
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)
service = Google::Apis::ContainerV1::ContainerService.new
service.authorization = access_token
......
......@@ -77,8 +77,6 @@ describe Projects::Clusters::GcpController do
end
it 'has new object' do
expect(controller).to receive(:authorize_google_project_billing)
go
expect(assigns(:cluster)).to be_an_instance_of(Clusters::Cluster)
......@@ -137,14 +135,6 @@ describe Projects::Clusters::GcpController do
context 'when access token is valid' do
before do
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
......@@ -157,16 +147,6 @@ describe Projects::Clusters::GcpController do
end
end
context 'when google project billing is not enabled' do
it 'renders the cluster form with an error' do
go
expect(response).to set_flash.now[:alert]
expect(response).to render_template('new')
end
end
end
context 'when access token is expired' do
before do
stub_google_api_expired_token
......
......@@ -22,12 +22,6 @@ feature 'Gcp Cluster', :js do
.to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s)
end
context 'when user has a GCP project with billing enabled' do
before do
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing)
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(true)
end
context 'when user does not have a cluster and visits cluster index page' do
before do
visit project_clusters_path(project)
......@@ -37,6 +31,8 @@ feature 'Gcp Cluster', :js do
end
context 'when user filled form with valid parameters' do
subject { click_button 'Create Kubernetes cluster' }
before do
allow_any_instance_of(GoogleApi::CloudPlatform::Client)
.to receive(:projects_zones_clusters_create) do
......@@ -48,12 +44,25 @@ feature 'Gcp Cluster', :js do
allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil)
fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123'
fill_in 'cluster_name', with: 'dev-cluster'
click_button 'Create Kubernetes cluster'
execute_script('document.querySelector(".js-gke-cluster-creation-submit").removeAttribute("disabled")')
sleep 2 # wait for ajax
execute_script('document.querySelector(".js-gcp-project-id-dropdown input").setAttribute("type", "text")')
execute_script('document.querySelector(".js-gcp-zone-dropdown input").setAttribute("type", "text")')
execute_script('document.querySelector(".js-gcp-machine-type-dropdown input").setAttribute("type", "text")')
fill_in 'cluster[name]', with: 'dev-cluster'
fill_in 'cluster[provider_gcp_attributes][gcp_project_id]', with: 'gcp-project-123'
fill_in 'cluster[provider_gcp_attributes][zone]', with: 'us-central1-a'
fill_in 'cluster[provider_gcp_attributes][machine_type]', with: 'n1-standard-2'
end
it 'users sees a form with the GCP token' do
expect(page).to have_selector(:css, 'form[data-token="token"]')
end
it 'user sees a cluster details page and creation status' do
subject
expect(page).to have_content('Kubernetes cluster is being created on Google Kubernetes Engine...')
Clusters::Cluster.last.provider.make_created!
......@@ -61,7 +70,9 @@ feature 'Gcp Cluster', :js do
expect(page).to have_content('Kubernetes cluster was successfully created on Google Kubernetes Engine')
end
it 'user sees a error if something worng during creation' do
it 'user sees a error if something wrong during creation' do
subject
expect(page).to have_content('Kubernetes cluster is being created on Google Kubernetes Engine...')
Clusters::Cluster.last.provider.make_errored!('Something wrong!')
......@@ -72,6 +83,7 @@ feature 'Gcp Cluster', :js do
context 'when user filled form with invalid parameters' do
before do
execute_script('document.querySelector(".js-gke-cluster-creation-submit").removeAttribute("disabled")')
click_button 'Create Kubernetes cluster'
end
......@@ -131,47 +143,6 @@ feature 'Gcp Cluster', :js do
end
end
context 'when user does not have a GCP project with billing enabled' do
before do
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing)
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(false)
visit project_clusters_path(project)
click_link 'Add Kubernetes cluster'
click_link 'Create on Google Kubernetes Engine'
fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123'
fill_in 'cluster_name', with: 'dev-cluster'
click_button 'Create Kubernetes cluster'
end
it 'user sees form with error' do
expect(page).to have_content('Please enable billing for one of your projects to be able to create a Kubernetes cluster, then try again.')
end
end
context 'when gcp billing status is not in redis' do
before do
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing)
allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(nil)
visit project_clusters_path(project)
click_link 'Add Kubernetes cluster'
click_link 'Create on Google Kubernetes Engine'
fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123'
fill_in 'cluster_name', with: 'dev-cluster'
click_button 'Create Kubernetes cluster'
end
it 'user sees form with error' do
expect(page).to have_content('We could not verify that one of your projects on GCP has billing enabled. Please try again.')
end
end
end
context 'when user has not signed with Google' do
before do
visit project_clusters_path(project)
......
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 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 { 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);
return mountComponent(Component, {
name,
label,
value,
});
};
......@@ -31,7 +31,7 @@ describe('DropdownHiddenInputComponent', () => {
expect(vm.$el.nodeName).toBe('INPUT');
expect(vm.$el.getAttribute('type')).toBe('hidden');
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
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
subject { client.projects_zones_clusters_get(spy, spy, spy) }
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