Commit 7a0c1c1c authored by Sri's avatar Sri

Configure Regions from `Projects :: Infra :: Google Cloud`

The commit allows the maintainer / owner to configure
their preferred GCP region per environment from the
`Project :: Infra :: Google Cloud` section

Change list:
* Backend:
    * Update the `infrastructure_menu` to recognize `:gcp_regions`as an active route for `:google_cloud`
    * `routes/google_cloud` includes the `gcp_regions_controller` routers
    * `gcp_regions_controller` with implementations for showing form and processing form submit
    * `gcp_regions_service` to list out GCP region vars for project
    * `gcp_regions_finder` lists regions for a given project
* Frontend
    * `gcp_regions_list` component to list out regions configured for project
    * `home` includes `gcp_regions_list`
    * `gcp_regions_form` can be used to configure a new region / env mapping
    * template for `gcp_regions_form`
    * `app` recognizes `GCP_REGIONS_FORM` screen
parent a9eee14b
......@@ -4,6 +4,7 @@ import { __ } from '~/locale';
import Home from './home.vue';
import IncubationBanner from './incubation_banner.vue';
import ServiceAccountsForm from './service_accounts_form.vue';
import GcpRegionsForm from './gcp_regions_form.vue';
import NoGcpProjects from './errors/no_gcp_projects.vue';
import GcpError from './errors/gcp_error.vue';
......@@ -11,6 +12,7 @@ const SCREEN_GCP_ERROR = 'gcp_error';
const SCREEN_HOME = 'home';
const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects';
const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form';
const SCREEN_GCP_REGIONS_FORM = 'gcp_regions_form';
export default {
components: {
......@@ -34,6 +36,8 @@ export default {
return NoGcpProjects;
case SCREEN_SERVICE_ACCOUNTS_FORM:
return ServiceAccountsForm;
case SCREEN_GCP_REGIONS_FORM:
return GcpRegionsForm;
default:
throw new Error(__('Unknown screen'));
}
......
<script>
import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: { GlButton, GlFormGroup, GlFormSelect },
props: {
availableRegions: { required: true, type: Array },
environments: { required: true, type: Array },
cancelPath: { required: true, type: String },
},
i18n: {
title: __('Configure region for environment'),
gcpRegionLabel: __('Region'),
gcpRegionDescription: __('List of suitable GCP locations'),
environmentLabel: __('Environment'),
environmentDescription: __('List of environments for this project'),
submitLabel: __('Configure region'),
cancelLabel: __('Cancel'),
},
};
</script>
<template>
<div>
<header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid">
<h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
</header>
<gl-form-group
label-for="environment"
:label="$options.i18n.environmentLabel"
:description="$options.i18n.environmentDescription"
>
<gl-form-select id="environment" name="environment" required>
<option value="*">{{ __('All') }}</option>
<option v-for="environment in environments" :key="environment.id" :value="environment.name">
{{ environment.name }}
</option>
</gl-form-select>
</gl-form-group>
<gl-form-group
label-for="gcp_region"
:label="$options.i18n.gcpRegionLabel"
:description="$options.i18n.gcpRegionDescription"
>
<gl-form-select id="gcp_region" name="gcp_region" required :list="availableRegions">
<option v-for="(region, index) in availableRegions" :key="index" :value="region">
{{ region }}
</option>
</gl-form-select>
</gl-form-group>
<div class="form-actions row">
<gl-button type="submit" category="primary" variant="confirm">
{{ $options.i18n.submitLabel }}
</gl-button>
<gl-button class="gl-ml-1" :href="cancelPath">{{ $options.i18n.cancelLabel }}</gl-button>
</div>
</div>
</template>
<script>
import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: { GlButton, GlEmptyState, GlTable },
props: {
list: {
type: Array,
required: true,
},
createUrl: {
type: String,
required: true,
},
emptyIllustrationUrl: {
type: String,
required: true,
},
},
tableFields: [
{ key: 'environment', label: __('Environment'), sortable: true },
{ key: 'gcp_region', label: __('Region'), sortable: true },
],
i18n: {
emptyStateTitle: __('No regions configured'),
description: __('Configure your environments to be deployed to specific geographical regions'),
emptyStateAction: __('Add a GCP region'),
configureRegions: __('Configure regions'),
listTitle: __('Regions'),
},
};
</script>
<template>
<div>
<gl-empty-state
v-if="list.length === 0"
:title="$options.i18n.emptyStateTitle"
:description="$options.i18n.description"
:primary-button-link="createUrl"
:primary-button-text="$options.i18n.configureRegions"
/>
<div v-else>
<h2 class="gl-font-size-h2">{{ $options.i18n.listTitle }}</h2>
<p>{{ $options.i18n.description }}</p>
<gl-table :items="list" :fields="$options.tableFields" />
<gl-button :href="createUrl" category="primary" variant="info">
{{ $options.i18n.configureRegions }}
</gl-button>
</div>
</div>
</template>
......@@ -2,6 +2,7 @@
import { GlTabs, GlTab } from '@gitlab/ui';
import DeploymentsServiceTable from './deployments_service_table.vue';
import ServiceAccountsList from './service_accounts_list.vue';
import GcpRegionsList from './gcp_regions_list.vue';
export default {
components: {
......@@ -9,6 +10,7 @@ export default {
GlTab,
DeploymentsServiceTable,
ServiceAccountsList,
GcpRegionsList,
},
props: {
serviceAccounts: {
......@@ -19,6 +21,10 @@ export default {
type: String,
required: true,
},
configureGcpRegionsUrl: {
type: String,
required: true,
},
emptyIllustrationUrl: {
type: String,
required: true,
......@@ -31,6 +37,10 @@ export default {
type: String,
required: true,
},
gcpRegions: {
type: Array,
required: true,
},
},
};
</script>
......@@ -44,6 +54,13 @@ export default {
:create-url="createServiceAccountUrl"
:empty-illustration-url="emptyIllustrationUrl"
/>
<hr />
<gcp-regions-list
class="gl-mx-4"
:empty-illustration-url="emptyIllustrationUrl"
:create-url="configureGcpRegionsUrl"
:list="gcpRegions"
/>
</gl-tab>
<gl-tab :title="__('Deployments')">
<deployments-service-table
......
# frozen_string_literal: true
class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseController
# filtered list of GCP cloud run locations...
# ...that have domain mapping available
# Source https://cloud.google.com/run/docs/locations 2022-01-30
AVAILABLE_REGIONS = %w[asia-east1 asia-northeast1 asia-southeast1 europe-north1 europe-west1 europe-west4 us-central1 us-east1 us-east4 us-west1].freeze
def index
@google_cloud_path = project_google_cloud_index_path(project)
@js_data = {
screen: 'gcp_regions_form',
availableRegions: AVAILABLE_REGIONS,
environments: project.environments,
cancelPath: project_google_cloud_index_path(project)
}.to_json
end
def create
permitted_params = params.permit(:environment, :gcp_region)
GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:environment], permitted_params[:gcp_region])
redirect_to project_google_cloud_index_path(project), notice: _('GCP region configured')
end
end
# frozen_string_literal: true
class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController
GCP_REGION_CI_VAR_KEY = 'GCP_REGION'
def index
@js_data = {
screen: 'home',
......@@ -8,7 +10,16 @@ class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController
createServiceAccountUrl: project_google_cloud_service_accounts_path(project),
enableCloudRunUrl: project_google_cloud_deployments_cloud_run_path(project),
enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project),
emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg')
emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'),
configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project),
gcpRegions: gcp_regions
}.to_json
end
private
def gcp_regions
list = ::Ci::VariablesFinder.new(project, { key: GCP_REGION_CI_VAR_KEY }).execute
list.map { |variable| { gcp_region: variable.value, environment: variable.environment_scope } }
end
end
# frozen_string_literal: true
module GoogleCloud
class GcpRegionAddOrReplaceService < ::BaseService
def execute(environment, region)
gcp_region_key = Projects::GoogleCloudController::GCP_REGION_CI_VAR_KEY
change_params = { variable_params: { key: gcp_region_key, value: region, environment_scope: environment } }
filter_params = { key: gcp_region_key, filter: { environment_scope: environment } }
existing_variable = ::Ci::VariablesFinder.new(project, filter_params).execute.first
if existing_variable
change_params[:action] = :update
change_params[:variable] = existing_variable
else
change_params[:action] = :create
end
::Ci::ChangeVariableService.new(container: project, current_user: current_user, params: change_params).execute
end
end
end
- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
- breadcrumb_title _('Regions')
- page_title _('Regions')
- @content_class = "limit-container-width" unless fluid_layout
= form_tag project_google_cloud_gcp_regions_path(@project), method: 'post' do
#js-google-cloud{ data: @js_data }
......@@ -320,6 +320,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
namespace :google_cloud do
resources :service_accounts, only: [:index, :create]
resources :gcp_regions, only: [:index, :create]
get '/deployments/cloud_run', to: 'deployments#cloud_run'
get '/deployments/cloud_storage', to: 'deployments#cloud_storage'
......
......@@ -100,7 +100,7 @@ module Sidebars
::Sidebars::MenuItem.new(
title: _('Google Cloud'),
link: project_google_cloud_index_path(context.project),
active_routes: { controller: [:google_cloud, :service_accounts, :deployments] },
active_routes: { controller: [:google_cloud, :service_accounts, :deployments, :gcp_regions] },
item_id: :google_cloud
)
end
......
......@@ -2055,6 +2055,9 @@ msgstr ""
msgid "Add a %{type}"
msgstr ""
msgid "Add a GCP region"
msgstr ""
msgid "Add a GPG key"
msgstr ""
......@@ -9194,6 +9197,15 @@ msgstr ""
msgid "Configure pipelines to deploy web apps, backend services, APIs and static resources to Google Cloud"
msgstr ""
msgid "Configure region"
msgstr ""
msgid "Configure region for environment"
msgstr ""
msgid "Configure regions"
msgstr ""
msgid "Configure repository mirroring."
msgstr ""
......@@ -9227,6 +9239,9 @@ msgstr ""
msgid "Configure which lists are shown for anyone who visits this board"
msgstr ""
msgid "Configure your environments to be deployed to specific geographical regions"
msgstr ""
msgid "Confirm"
msgstr ""
......@@ -15895,6 +15910,9 @@ msgstr ""
msgid "Full name"
msgstr ""
msgid "GCP region configured"
msgstr ""
msgid "GPG Key ID:"
msgstr ""
......@@ -21984,6 +22002,12 @@ msgstr ""
msgid "List of all merge commits"
msgstr ""
msgid "List of environments for this project"
msgstr ""
msgid "List of suitable GCP locations"
msgstr ""
msgid "List of users allowed to exceed the rate limit."
msgstr ""
......@@ -24747,6 +24771,9 @@ msgstr ""
msgid "No ref selected"
msgstr ""
msgid "No regions configured"
msgstr ""
msgid "No related merge requests found."
msgstr ""
......@@ -30079,6 +30106,12 @@ msgstr ""
msgid "Regex pattern"
msgstr ""
msgid "Region"
msgstr ""
msgid "Regions"
msgstr ""
msgid "Register"
msgstr ""
......
......@@ -22,7 +22,9 @@ const SERVICE_ACCOUNTS_FORM_PROPS = {
};
const HOME_PROPS = {
serviceAccounts: [{}, {}],
gcpRegions: [{}, {}],
createServiceAccountUrl: '#url-create-service-account',
configureGcpRegionsUrl: '#url-configure-gcp-regions',
emptyIllustrationUrl: '#url-empty-illustration',
enableCloudRunUrl: '#url-enable-cloud-run',
enableCloudStorageUrl: '#enableCloudStorageUrl',
......
import { shallowMount } from '@vue/test-utils';
import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
import GcpRegionsForm from '~/google_cloud/components/gcp_regions_form.vue';
describe('GcpRegionsForm component', () => {
let wrapper;
const findHeader = () => wrapper.find('header');
const findAllFormGroups = () => wrapper.findAllComponents(GlFormGroup);
const findAllFormSelects = () => wrapper.findAllComponents(GlFormSelect);
const findAllButtons = () => wrapper.findAllComponents(GlButton);
const propsData = { availableRegions: [], environments: [], cancelPath: '#cancel-url' };
beforeEach(() => {
wrapper = shallowMount(GcpRegionsForm, { propsData });
});
afterEach(() => {
wrapper.destroy();
});
it('contains header', () => {
expect(findHeader().exists()).toBe(true);
});
it('contains Regions form group', () => {
const formGroup = findAllFormGroups().at(0);
expect(formGroup.exists()).toBe(true);
});
it('contains Regions dropdown', () => {
const select = findAllFormSelects().at(0);
expect(select.exists()).toBe(true);
});
it('contains Environments form group', () => {
const formGroup = findAllFormGroups().at(1);
expect(formGroup.exists()).toBe(true);
});
it('contains Environments dropdown', () => {
const select = findAllFormSelects().at(1);
expect(select.exists()).toBe(true);
});
it('contains Submit button', () => {
const button = findAllButtons().at(0);
expect(button.exists()).toBe(true);
expect(button.text()).toBe(GcpRegionsForm.i18n.submitLabel);
});
it('contains Cancel button', () => {
const button = findAllButtons().at(1);
expect(button.exists()).toBe(true);
expect(button.text()).toBe(GcpRegionsForm.i18n.cancelLabel);
expect(button.attributes('href')).toBe('#cancel-url');
});
});
import { mount } from '@vue/test-utils';
import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
import GcpRegionsList from '~/google_cloud/components/gcp_regions_list.vue';
describe('GcpRegions component', () => {
describe('when the project does not have any configured regions', () => {
let wrapper;
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findButtonInEmptyState = () => findEmptyState().findComponent(GlButton);
beforeEach(() => {
const propsData = {
list: [],
createUrl: '#create-url',
emptyIllustrationUrl: '#empty-illustration-url',
};
wrapper = mount(GcpRegionsList, { propsData });
});
afterEach(() => {
wrapper.destroy();
});
it('shows the empty state component', () => {
expect(findEmptyState().exists()).toBe(true);
});
it('shows the link to create new service accounts', () => {
const button = findButtonInEmptyState();
expect(button.exists()).toBe(true);
expect(button.text()).toBe('Configure regions');
expect(button.attributes('href')).toBe('#create-url');
});
});
describe('when three gcp regions are passed via props', () => {
let wrapper;
const findTitle = () => wrapper.find('h2');
const findDescription = () => wrapper.find('p');
const findTable = () => wrapper.findComponent(GlTable);
const findRows = () => findTable().findAll('tr');
const findButton = () => wrapper.findComponent(GlButton);
beforeEach(() => {
const propsData = {
list: [{}, {}, {}],
createUrl: '#create-url',
emptyIllustrationUrl: '#empty-illustration-url',
};
wrapper = mount(GcpRegionsList, { propsData });
});
it('shows the title', () => {
expect(findTitle().text()).toBe('Regions');
});
it('shows the description', () => {
expect(findDescription().text()).toBe(
'Configure your environments to be deployed to specific geographical regions',
);
});
it('shows the table', () => {
expect(findTable().exists()).toBe(true);
});
it('table must have three rows + header row', () => {
expect(findRows()).toHaveLength(4);
});
it('shows the link to create new service accounts', () => {
const button = findButton();
expect(button.exists()).toBe(true);
expect(button.text()).toBe('Configure regions');
expect(button.attributes('href')).toBe('#create-url');
});
});
});
......@@ -18,7 +18,9 @@ describe('google_cloud Home component', () => {
const TEST_HOME_PROPS = {
serviceAccounts: [{}, {}],
gcpRegions: [{}, {}],
createServiceAccountUrl: '#url-create-service-account',
configureGcpRegionsUrl: '#url-configure-gcp-regions',
emptyIllustrationUrl: '#url-empty-illustration',
enableCloudRunUrl: '#url-enable-cloud-run',
enableCloudStorageUrl: '#enableCloudStorageUrl',
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::GoogleCloud::GcpRegionsController do
let_it_be(:project) { create(:project, :public) }
RSpec.shared_examples "should be not found" do
it 'returns not found' do
is_expected.to be(404)
end
end
RSpec.shared_examples "should be forbidden" do
it 'returns forbidden' do
is_expected.to be(403)
end
end
RSpec.shared_examples "public request should 404" do
it_behaves_like "should be not found"
end
RSpec.shared_examples "unauthorized access should 404" do
let(:user_guest) { create(:user) }
before do
project.add_guest(user_guest)
end
it_behaves_like "should be not found"
end
describe 'GET #index' do
subject { get project_google_cloud_gcp_regions_path(project) }
it_behaves_like "public request should 404"
it_behaves_like "unauthorized access should 404"
context 'when authorized members make requests' do
let(:user_maintainer) { create(:user) }
before do
project.add_maintainer(user_maintainer)
sign_in(user_maintainer)
end
it 'renders gcp_regions' do
is_expected.to render_template('projects/google_cloud/gcp_regions/index')
end
context 'but gitlab instance is not configured for google oauth2' do
before do
unconfigured_google_oauth2 = Struct.new(:app_id, :app_secret)
.new('', '')
allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for)
.with('google_oauth2')
.and_return(unconfigured_google_oauth2)
end
it_behaves_like "should be forbidden"
end
context 'but feature flag is disabled' do
before do
stub_feature_flags(incubation_5mp_google_cloud: false)
end
it_behaves_like "should be not found"
end
end
end
describe 'POST #index' do
subject { post project_google_cloud_gcp_regions_path(project), params: { gcp_region: 'region1', environment: 'env1' } }
it_behaves_like "public request should 404"
it_behaves_like "unauthorized access should 404"
context 'when authorized members make requests' do
let(:user_maintainer) { create(:user) }
before do
project.add_maintainer(user_maintainer)
sign_in(user_maintainer)
end
it 'redirects to google cloud index' do
is_expected.to redirect_to(project_google_cloud_index_path(project))
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GoogleCloud::GcpRegionAddOrReplaceService do
it 'adds and replaces GCP region vars' do
project = create(:project, :public)
service = described_class.new(project)
service.execute('env_1', 'loc_1')
service.execute('env_2', 'loc_2')
service.execute('env_1', 'loc_3')
list = project.variables.reload.filter { |variable| variable.key == Projects::GoogleCloudController::GCP_REGION_CI_VAR_KEY }
list = list.sort_by(&:environment_scope)
aggregate_failures 'testing list of gcp regions' do
expect(list.length).to eq(2)
# asserting that the first region is replaced
expect(list.first.environment_scope).to eq('env_1')
expect(list.first.value).to eq('loc_3')
expect(list.second.environment_scope).to eq('env_2')
expect(list.second.value).to eq('loc_2')
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