Commit cbf0218b authored by Catalin Irimie's avatar Catalin Irimie Committed by Michael Kozono

Add frontend routing to Geo GraphQL specific sites

This adds replicable endpoints for each Geo site, to
allow the primary to request this data from the tracking DB
directly on any secondary site.

This is necessary for Geo proxying with unified URLs, as
the web traffic will all be proxied to the primary, however
we don't have projects/designs implemented in GraphQL so these
don't yet work.

Changelog: added
EE: true
parent d5dfee6a
......@@ -210,6 +210,18 @@ This list of limitations only reflects the latest version of GitLab. If you are
There is a complete list of all GitLab [data types](replication/datatypes.md) and [existing support for replication and verification](replication/datatypes.md#limitations-on-replicationverification).
### View replication data on the primary site
If you try to view replication data on the primary site, you receive a warning that this may be inconsistent:
> Viewing projects and designs data from a primary site is not possible when using a unified URL. Visit the secondary site directly.
The only way to view projects replication data for a particular secondary site is to visit that secondary site directly. For example, `https://<IP of your secondary site>/admin/geo/replication/projects`.
An [epic exists](https://gitlab.com/groups/gitlab-org/-/epics/4623) to fix this limitation.
The only way to view designs replication data for a particular secondary site is to visit that secondary site directly. For example, `https://<IP of your secondary site>/admin/geo/replication/designs`.
An [epic exists](https://gitlab.com/groups/gitlab-org/-/epics/4624) to fix this limitation.
## Setup instructions
For setup instructions, see [Setting up Geo](setup/index.md).
......
......@@ -140,6 +140,8 @@ for details.
To use TLS certificates with Let's Encrypt, you can manually point the domain to one of the Geo sites, generate
the certificate, then copy it to all other sites.
- [Viewing projects and designs data from a primary site is not possible when using a unified URL](../index.md#view-replication-data-on-the-primary-site).
## Behavior of secondary sites when the primary Geo site is down
Considering that web traffic is proxied to the primary, the behavior of the secondary sites differs when the primary
......
......@@ -63,7 +63,8 @@ Example response:
"sync_object_storage": false,
"clone_protocol": "http",
"web_edit_url": "https://primary.example.com/admin/geo/sites/3/edit",
"web_geo_projects_url": "http://secondary.example.com/admin/geo/projects",
"web_geo_projects_url": "https://secondary.example.com/admin/geo/projects",
"web_geo_replication_details_url": "https://secondary.example.com/admin/geo/sites/3/replication/lfs_objects",
"_links": {
"self": "https://primary.example.com/api/v4/geo_nodes/3",
"status": "https://primary.example.com/api/v4/geo_nodes/3/status",
......@@ -72,6 +73,10 @@ Example response:
}
```
WARNING:
The `web_geo_projects_url` attribute is in its end-of-life process. It is [deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80106)
for use in GitLab 14.9.
## Retrieve configuration about all Geo nodes
```plaintext
......@@ -130,6 +135,7 @@ Example response:
"clone_protocol": "http",
"web_edit_url": "https://primary.example.com/admin/geo/sites/2/edit",
"web_geo_projects_url": "https://secondary.example.com/admin/geo/projects",
"web_geo_replication_details_url": "https://secondary.example.com/admin/geo/sites/2/replication/lfs_objects",
"_links": {
"self":"https://primary.example.com/api/v4/geo_nodes/2",
"status":"https://primary.example.com/api/v4/geo_nodes/2/status",
......@@ -139,6 +145,10 @@ Example response:
]
```
WARNING:
The `web_geo_projects_url` attribute is in its end-of-life process. It is [deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80106)
for use in GitLab 14.9.
## Retrieve configuration about a specific Geo node
```plaintext
......@@ -228,6 +238,7 @@ Example response:
"clone_protocol": "http",
"web_edit_url": "https://primary.example.com/admin/geo/sites/2/edit",
"web_geo_projects_url": "https://secondary.example.com/admin/geo/projects",
"web_geo_replication_details_url": "https://secondary.example.com/admin/geo/sites/2/replication/lfs_objects",
"_links": {
"self":"https://primary.example.com/api/v4/geo_nodes/2",
"status":"https://primary.example.com/api/v4/geo_nodes/2/status",
......@@ -236,6 +247,10 @@ Example response:
}
```
WARNING:
The `web_geo_projects_url` attribute is in its end-of-life process. It is [deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80106)
for use in GitLab 14.9.
## Delete a Geo node
Removes the Geo node.
......
......@@ -38,7 +38,7 @@ export default {
variant="confirm"
icon="external-link"
category="secondary"
:href="node.webGeoProjectsUrl"
:href="node.webGeoReplicationDetailsUrl"
target="_blank"
>{{ $options.i18n.replicationDetailsButton }}</gl-button
>
......
......@@ -12,11 +12,13 @@ export default () => {
geoTroubleshootingLink,
geoReplicableEmptySvgPath,
graphqlFieldName,
geoCurrentNodeId,
geoTargetNodeId,
} = el.dataset;
return new Vue({
el,
store: createStore({ replicableType, graphqlFieldName }),
store: createStore({ replicableType, graphqlFieldName, geoCurrentNodeId, geoTargetNodeId }),
components: {
GeoReplicableApp,
},
......
......@@ -9,7 +9,7 @@ import { __, sprintf } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import { FILTER_STATES, PREV, NEXT, DEFAULT_PAGE_SIZE } from '../constants';
import buildReplicableTypeQuery from '../graphql/replicable_type_query_builder';
import { gqClient } from '../utils';
import { getGraphqlClient } from '../utils';
import * as types from './mutation_types';
// Fetch Replicable Items
......@@ -49,7 +49,9 @@ export const fetchReplicableItemsGraphQl = ({ state, dispatch }, direction) => {
after = state.paginationData.endCursor;
}
gqClient
const client = getGraphqlClient(state.geoCurrentNodeId, state.geoTargetNodeId);
client
.query({
query: buildReplicableTypeQuery(state.graphqlFieldName),
variables: { first, last, before, after },
......
......@@ -7,11 +7,16 @@ import createState from './state';
Vue.use(Vuex);
export const getStoreConfig = ({ replicableType, graphqlFieldName }) => ({
export const getStoreConfig = ({
replicableType,
graphqlFieldName,
geoCurrentNodeId,
geoTargetNodeId,
}) => ({
actions,
getters,
mutations,
state: createState({ replicableType, graphqlFieldName }),
state: createState({ replicableType, graphqlFieldName, geoCurrentNodeId, geoTargetNodeId }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
......
import { FILTER_STATES, DEFAULT_PAGE_SIZE } from '../constants';
const createState = ({ replicableType, graphqlFieldName }) => ({
const createState = ({ replicableType, graphqlFieldName, geoCurrentNodeId, geoTargetNodeId }) => ({
replicableType,
graphqlFieldName,
geoCurrentNodeId,
geoTargetNodeId,
useGraphQl: Boolean(graphqlFieldName),
isLoading: false,
......
......@@ -13,3 +13,46 @@ export const gqClient = createGqClient(
fetchPolicy: fetchPolicies.NO_CACHE,
},
);
/**
* This is an alias for /api/v4/graphql that bypasses the Geo proxy,
* so we ensure that we always hit the current node, if on a secondary.
*/
export const gqGeoClient = createGqClient(
{},
{
fetchPolicy: fetchPolicies.NO_CACHE,
path: '/api/v4/geo/graphql',
},
);
/**
* This is a proxy of the /api/v4/geo/graphql of a specific node,
* so we're requesting data directly from a secondary's GraphQL endpoint.
*/
export const gqGeoClientForSecondaryNodeId = (secondaryNodeId) =>
createGqClient(
{},
{
fetchPolicy: fetchPolicies.NO_CACHE,
path: `/api/v4/geo/node_proxy/${secondaryNodeId}/graphql`,
},
);
/**
* Slight optimization, if we know the target node is also the current node, or
* if we don't know what's the target node, we look at the current node instead,
* by going to /api/v4/geo/graphql - an alias for /api/v4/graphql that bypasses
* the Geo proxy (so we always hit the current node here, if on a secondary).
*
* @param {string} currentNodeId - current node id
* @param {string} targetNodeId - target node id
* @returns {Object} - GraphQL client
*/
export const getGraphqlClient = (currentNodeId, targetNodeId) => {
if (targetNodeId !== undefined && currentNodeId !== targetNodeId) {
return gqGeoClientForSecondaryNodeId(targetNodeId);
}
return gqGeoClient;
};
......@@ -12,4 +12,24 @@ class Admin::Geo::ApplicationController < Admin::ApplicationController
render_403
end
end
def load_node_data
# used in replication controllers (replicables, projects, designs) and the
# navbar data, to figure out which site's data we're trying to access
@current_node = ::Gitlab::Geo.current_node
@target_node = if params[:id]
GeoNode.find(params[:id])
else
::Gitlab::Geo.current_node
end
end
def warn_viewing_primary_replication_data
if ::Gitlab::Geo.primary?
help_url = help_page_url('administration/geo/index', anchor: 'view-replication-data-on-the-primary-site')
flash[:alert] = _('Viewing projects and designs data from a primary site is not possible when using a unified URL. Visit the secondary site directly. %{geo_help_url}').html_safe % {
geo_help_url: view_context.link_to(_('Learn more.'), help_url, target: '_blank', rel: 'noopener noreferrer')
}
end
end
end
......@@ -2,6 +2,8 @@
class Admin::Geo::DesignsController < Admin::Geo::ApplicationController
before_action :check_license!
before_action :load_node_data, only: [:index]
before_action :warn_viewing_primary_replication_data, only: [:index]
def index
end
......
......@@ -4,6 +4,8 @@ class Admin::Geo::ProjectsController < Admin::Geo::ApplicationController
before_action :check_license!
before_action :load_registry, except: [:index]
before_action :limited_actions_message!
before_action :load_node_data, only: [:index]
before_action :warn_viewing_primary_replication_data, only: [:index]
def index
@registries = case params[:sync_status]
......
......@@ -3,8 +3,23 @@
class Admin::Geo::ReplicablesController < Admin::Geo::ApplicationController
before_action :check_license!
before_action :set_replicator_class, only: :index
before_action :load_node_data, only: [:index]
def index
# legacy routes always get redirected, either to the current node, or
# if a secondary, we know the ID this should use, so redirect there instead
unless params[:id].present?
redirect_path = if ::Gitlab::Geo.secondary?
site_replicables_admin_geo_node_path(
id: ::Gitlab::Geo.current_node.id,
replicable_name_plural: params[:replicable_name_plural]
)
else
admin_geo_nodes_path
end
redirect_to redirect_path
end
end
def set_replicator_class
......
......@@ -229,6 +229,17 @@ class GeoNode < ApplicationRecord
Gitlab::Routing.url_helpers.admin_geo_projects_url(url_helper_args)
end
def geo_replication_details_url
return unless self.secondary?
replicator_class = ::Gitlab::Geo.enabled_replicator_classes.first
# redirect to the replicables for the first SSF data type
Gitlab::Routing.url_helpers.site_replicables_admin_geo_node_url(
id: self.id, replicable_name_plural: replicator_class.replicable_name_plural, **url_helper_args
)
end
def missing_oauth_application?
self.primary? ? false : !oauth_application.present?
end
......
......@@ -2,4 +2,6 @@
#js-geo-replicable{ data: { "geo-replicable-empty-svg-path" => image_path("illustrations/empty-state/geo-replication-empty.svg"),
"geo-troubleshooting-link" => help_page_path("administration/geo/replication/troubleshooting.md"),
"replicable-type" => @replicator_class.replicable_name_plural,
"graphql-field-name" => @replicator_class.graphql_field_name } }
"graphql-field-name" => @replicator_class.graphql_field_name,
"geo-target-node-id" => @target_node&.id,
"geo-current-node-id" => @current_node&.id } }
......@@ -2,11 +2,11 @@
%header.py-2
%h2.page-title
= _('Geo Replication')
= _('Geo Replication - %{node_name}' % { node_name: @target_node.name })
%p
= s_('Geo|Review replication status, and resynchronize and reverify items with the primary site.')
= gl_tabs_nav({ class: 'border-top border-bottom border-secondary-100' }) do
= gl_tab_link_to _('Projects'), admin_geo_replicables_path(replicable_name_plural: 'projects'), { title: _('Projects'), class: 'gl-mr-2' }
= gl_tab_link_to _('Designs'), admin_geo_replicables_path(replicable_name_plural: 'designs'), { title: _('Designs'), class: 'gl-mr-2' }
- Gitlab::Geo.enabled_replicator_classes.each do |replicator_class|
= gl_tab_link_to replicator_class.replicable_title_plural, admin_geo_replicables_path(replicable_name_plural: replicator_class.replicable_name_plural), { title: replicator_class.replicable_title_plural, class: 'gl-mr-2' }
= gl_tab_link_to replicator_class.replicable_title_plural, site_replicables_admin_geo_node_path(id: @target_node.id, replicable_name_plural: replicator_class.replicable_name_plural), { title: replicator_class.replicable_title_plural, class: 'gl-mr-2' }
......@@ -14,11 +14,10 @@
= link_to admin_geo_nodes_path, title: _('Sites') do
%span
= _('Sites')
- if Gitlab::Geo.secondary?
= nav_link(controller: %w(admin/geo/projects admin/geo/uploads admin/geo/designs admin/geo/replicables)) do
= link_to admin_geo_projects_path, title: _('Replication') do
%span
= _('Replication')
= nav_link(controller: %w(admin/geo/projects admin/geo/uploads admin/geo/designs admin/geo/replicables)) do
= link_to admin_geo_projects_path, title: _('Replication') do
%span
= _('Replication')
= nav_link(path: 'admin/geo/settings#show') do
= link_to admin_geo_settings_path, title: _('Settings') do
%span
......
......@@ -53,7 +53,14 @@ namespace :admin do
get '/projects', to: redirect(path: 'admin/geo/replication/projects')
get '/designs', to: redirect(path: 'admin/geo/replication/designs')
resources :nodes, path: 'sites', only: [:index, :create, :new, :edit, :update]
resources :nodes, path: 'sites', only: [:index, :create, :new, :edit, :update] do
member do
scope '/replication' do
get '/', to: 'nodes#index'
get '/:replicable_name_plural', to: 'replicables#index', as: 'site_replicables'
end
end
end
# Old Route Replaced in 14.7
get '/nodes', to: redirect(path: 'admin/geo/sites')
......@@ -61,7 +68,7 @@ namespace :admin do
get '/nodes/:id/edit', to: redirect(path: 'admin/geo/sites/%{id}/edit')
scope '/replication' do
get '/', to: redirect(path: 'admin/geo/replication/projects')
get '/', to: redirect(path: 'admin/geo/sites')
resources :projects, only: [:index, :destroy] do
member do
......
......@@ -31,10 +31,15 @@ module EE
::Gitlab::Routing.url_helpers.edit_admin_geo_node_url(geo_node)
end
# @deprecated in favor of web_geo_replication_details_url
expose :web_geo_projects_url, if: ->(geo_node, _) { geo_node.secondary? } do |geo_node|
geo_node.geo_projects_url
end
expose :web_geo_replication_details_url, if: ->(geo_node, _) { geo_node.secondary? } do |geo_node|
geo_node.geo_replication_details_url
end
expose :_links do
expose :self do |geo_node|
expose_url api_v4_geo_nodes_path(id: geo_node.id)
......
......@@ -40,6 +40,36 @@ RSpec.describe Admin::Geo::ProjectsController, :geo do
expect(subject.body).to include(geo_primary.url)
end
context 'warns about viewing replication data' do
let(:expected_warning) { 'Viewing projects and designs data from a primary site is not possible when using a unified URL. Visit the secondary site directly.' }
context 'on a Geo primary' do
before do
stub_primary_node
end
it 'renders the warning on a primary' do
expect(subject.body).to match(expected_warning)
expect(subject.body).to include(
help_page_url('administration/geo/index', anchor: 'view-replication-data-on-the-primary-site')
)
end
end
context 'on a Geo secondary' do
before do
stub_secondary_node
end
it 'does not render the warning on a secondary' do
expect(subject.body).not_to match(expected_warning)
expect(subject.body).not_to include(
help_page_url('administration/geo/index', anchor: 'view-replication-data-on-the-primary-site')
)
end
end
end
context 'without sync_status specified' do
it 'renders all template when no extra get params is specified' do
expect(subject).to have_gitlab_http_status(:ok)
......
......@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'admin Geo Projects', :js, :geo do
include ::EE::GeoHelpers
let!(:geo_node) { create(:geo_node) }
let!(:synced_registry) { create(:geo_project_registry, :synced, :repository_verified) }
let!(:sync_pending_registry) { create(:geo_project_registry, :synced, :repository_dirty) }
......@@ -14,6 +16,7 @@ RSpec.describe 'admin Geo Projects', :js, :geo do
end
before do
stub_current_geo_node(geo_node)
allow(Gitlab::Geo).to receive(:license_allows?).and_return(true)
admin = create(:admin)
sign_in(admin)
......
......@@ -7,12 +7,13 @@ RSpec.describe 'admin Geo Replication Nav', :js, :geo do
include StubENV
let_it_be(:admin) { create(:admin) }
let_it_be(:secondary_node) { create(:geo_node) }
before do
stub_licensed_features(geo: true)
sign_in(admin)
gitlab_enable_admin_mode_sign_in(admin)
stub_secondary_node
stub_current_geo_node(secondary_node)
end
shared_examples 'active sidebar link' do |link_name|
......
......@@ -7,9 +7,11 @@ RSpec.describe 'admin Geo Sidebar', :js, :geo do
include StubENV
let_it_be(:admin) { create(:admin) }
let_it_be(:primary_node) { create(:geo_node, :primary) }
before do
stub_licensed_features(geo: true)
stub_current_geo_node(primary_node)
sign_in(admin)
gitlab_enable_admin_mode_sign_in(admin)
end
......
......@@ -54,7 +54,7 @@ RSpec.describe 'GEO Nodes', :geo do
wait_for_requests
expected_url = File.join(geo_secondary.url, '/admin/geo/projects')
expected_url = File.join(geo_secondary.url, "/admin/geo/sites/#{geo_secondary.id}/replication/lfs_objects")
expect(all('.geo-node-details-grid-columns').last).to have_link('Open replications', href: expected_url)
end
......
......@@ -48,7 +48,8 @@
"sync_object_storage" : { "type": "boolean" },
"clone_protocol": { "type": "string" },
"web_edit_url": { "type": "string" },
"web_geo_projects_url" : { "type": ["string", "null"] },
"web_geo_projects_url": { "type": ["string", "null"] },
"web_geo_replication_details_url" : { "type": ["string", "null"] },
"_links": {
"type": "object",
"required": ["self", "repair"],
......
......@@ -39,7 +39,7 @@ describe('GeoNodeReplicationSummary', () => {
it('renders the GlButton as a link', () => {
expect(findGlButton().exists()).toBe(true);
expect(findGlButton().attributes('href')).toBe(MOCK_NODES[1].webGeoProjectsUrl);
expect(findGlButton().attributes('href')).toBe(MOCK_NODES[1].webGeoReplicationDetailsUrl);
});
it('renders the geo node replication status', () => {
......
......@@ -175,6 +175,7 @@ export const MOCK_NODES = [
revision: 'b93c51849b',
storageShardsMatch: true,
webGeoProjectsUrl: 'http://127.0.0.1:3002/replication/projects',
webGeoReplicationDetailsUrl: 'http://127.0.0.1:3002/admin/geo/sites/2/replication/lfs_objects',
},
];
......@@ -239,6 +240,8 @@ export const MOCK_NODE_STATUSES_RES = [
revision: 'b93c51849b',
storage_shards_match: true,
web_geo_projects_url: 'http://127.0.0.1:3002/replication/projects',
web_geo_replication_details_url:
'http://127.0.0.1:3002/admin/geo/sites/2/replication/lfs_objects',
},
];
......
......@@ -4,7 +4,6 @@ import buildReplicableTypeQuery from 'ee/geo_replicable/graphql/replicable_type_
import * as actions from 'ee/geo_replicable/store/actions';
import * as types from 'ee/geo_replicable/store/mutation_types';
import createState from 'ee/geo_replicable/store/state';
import { gqClient } from 'ee/geo_replicable/utils';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
......@@ -23,11 +22,22 @@ import {
jest.mock('~/flash');
jest.mock('~/vue_shared/plugins/global_toast');
const mockGeoGqClient = { query: jest.fn() };
jest.mock('ee/geo_replicable/utils', () => ({
...jest.requireActual('ee/geo_replicable/utils'),
getGraphqlClient: jest.fn().mockImplementation(() => mockGeoGqClient),
}));
describe('GeoReplicable Store Actions', () => {
let state;
beforeEach(() => {
state = createState({ replicableType: MOCK_REPLICABLE_TYPE, graphqlFieldName: null });
state = createState({
replicableType: MOCK_REPLICABLE_TYPE,
graphqlFieldName: null,
geoCurrentNodeId: null,
geoTargetNodeId: null,
});
});
describe('requestReplicableItems', () => {
......@@ -116,57 +126,31 @@ describe('GeoReplicable Store Actions', () => {
});
describe('fetchReplicableItemsGraphQl', () => {
beforeEach(() => {
state.graphqlFieldName = MOCK_GRAPHQL_REGISTRY;
});
describe('on success with no registry data', () => {
describe.each`
geoCurrentNodeId | geoTargetNodeId
${2} | ${3}
${2} | ${2}
${undefined} | ${2}
${undefined} | ${undefined}
${2} | ${undefined}
`(`geoNodeIds`, ({ geoCurrentNodeId, geoTargetNodeId }) => {
beforeEach(() => {
jest.spyOn(gqClient, 'query').mockResolvedValue({
data: {},
});
});
const direction = null;
const data = [];
it('should not error and pass empty values to the mutations', () => {
testAction(
actions.fetchReplicableItemsGraphQl,
direction,
state,
[],
[
{
type: 'receiveReplicableItemsSuccess',
payload: { data, pagination: null },
},
],
() => {
expect(gqClient.query).toHaveBeenCalledWith({
query: buildReplicableTypeQuery(MOCK_GRAPHQL_REGISTRY),
variables: { before: '', after: '', first: DEFAULT_PAGE_SIZE, last: null },
});
},
);
state.graphqlFieldName = MOCK_GRAPHQL_REGISTRY;
state.geoCurrentNodeId = geoCurrentNodeId;
state.geoTargetNodeId = geoTargetNodeId;
});
});
describe('on success', () => {
beforeEach(() => {
jest.spyOn(gqClient, 'query').mockResolvedValue({
data: MOCK_BASIC_GRAPHQL_QUERY_RESPONSE,
describe('on success with no registry data', () => {
beforeEach(() => {
jest.spyOn(mockGeoGqClient, 'query').mockResolvedValue({
data: {},
});
});
state.paginationData = MOCK_GRAPHQL_PAGINATION_DATA;
state.paginationData.page = 1;
});
describe('with no direction set', () => {
const direction = null;
const registries = MOCK_BASIC_GRAPHQL_QUERY_RESPONSE.geoNode[MOCK_GRAPHQL_REGISTRY];
const data = registries.nodes;
const data = [];
it('should call gqClient with no before/after variables as well as a first variable but no last variable', () => {
it('should not error and pass empty values to the mutations', () => {
testAction(
actions.fetchReplicableItemsGraphQl,
direction,
......@@ -175,11 +159,11 @@ describe('GeoReplicable Store Actions', () => {
[
{
type: 'receiveReplicableItemsSuccess',
payload: { data, pagination: registries.pageInfo },
payload: { data, pagination: null },
},
],
() => {
expect(gqClient.query).toHaveBeenCalledWith({
expect(mockGeoGqClient.query).toHaveBeenCalledWith({
query: buildReplicableTypeQuery(MOCK_GRAPHQL_REGISTRY),
variables: { before: '', after: '', first: DEFAULT_PAGE_SIZE, last: null },
});
......@@ -188,87 +172,124 @@ describe('GeoReplicable Store Actions', () => {
});
});
describe('with direction set to "next"', () => {
const direction = NEXT;
const registries = MOCK_BASIC_GRAPHQL_QUERY_RESPONSE.geoNode[MOCK_GRAPHQL_REGISTRY];
const data = registries.nodes;
describe('on success', () => {
beforeEach(() => {
jest.spyOn(mockGeoGqClient, 'query').mockResolvedValue({
data: MOCK_BASIC_GRAPHQL_QUERY_RESPONSE,
});
state.paginationData = MOCK_GRAPHQL_PAGINATION_DATA;
state.paginationData.page = 1;
});
it('should call gqClient with after variable but no before variable as well as a first variable but no last variable', () => {
testAction(
actions.fetchReplicableItemsGraphQl,
direction,
state,
[],
[
{
type: 'receiveReplicableItemsSuccess',
payload: { data, pagination: registries.pageInfo },
describe('with no direction set', () => {
const direction = null;
const registries = MOCK_BASIC_GRAPHQL_QUERY_RESPONSE.geoNode[MOCK_GRAPHQL_REGISTRY];
const data = registries.nodes;
it('should call mockGeoGqClient with no before/after variables as well as a first variable but no last variable', () => {
testAction(
actions.fetchReplicableItemsGraphQl,
direction,
state,
[],
[
{
type: 'receiveReplicableItemsSuccess',
payload: { data, pagination: registries.pageInfo },
},
],
() => {
expect(mockGeoGqClient.query).toHaveBeenCalledWith({
query: buildReplicableTypeQuery(MOCK_GRAPHQL_REGISTRY),
variables: { before: '', after: '', first: DEFAULT_PAGE_SIZE, last: null },
});
},
],
() => {
expect(gqClient.query).toHaveBeenCalledWith({
query: buildReplicableTypeQuery(MOCK_GRAPHQL_REGISTRY),
variables: {
before: '',
after: MOCK_GRAPHQL_PAGINATION_DATA.endCursor,
first: DEFAULT_PAGE_SIZE,
last: null,
);
});
});
describe('with direction set to "next"', () => {
const direction = NEXT;
const registries = MOCK_BASIC_GRAPHQL_QUERY_RESPONSE.geoNode[MOCK_GRAPHQL_REGISTRY];
const data = registries.nodes;
it('should call mockGeoGqClient with after variable but no before variable as well as a first variable but no last variable', () => {
testAction(
actions.fetchReplicableItemsGraphQl,
direction,
state,
[],
[
{
type: 'receiveReplicableItemsSuccess',
payload: { data, pagination: registries.pageInfo },
},
});
},
);
],
() => {
expect(mockGeoGqClient.query).toHaveBeenCalledWith({
query: buildReplicableTypeQuery(MOCK_GRAPHQL_REGISTRY),
variables: {
before: '',
after: MOCK_GRAPHQL_PAGINATION_DATA.endCursor,
first: DEFAULT_PAGE_SIZE,
last: null,
},
});
},
);
});
});
describe('with direction set to "prev"', () => {
const direction = PREV;
const registries = MOCK_BASIC_GRAPHQL_QUERY_RESPONSE.geoNode[MOCK_GRAPHQL_REGISTRY];
const data = registries.nodes;
it('should call mockGeoGqClient with before variable but no after variable as well as a last variable but no first variable', () => {
testAction(
actions.fetchReplicableItemsGraphQl,
direction,
state,
[],
[
{
type: 'receiveReplicableItemsSuccess',
payload: { data, pagination: registries.pageInfo },
},
],
() => {
expect(mockGeoGqClient.query).toHaveBeenCalledWith({
query: buildReplicableTypeQuery(MOCK_GRAPHQL_REGISTRY),
variables: {
before: MOCK_GRAPHQL_PAGINATION_DATA.startCursor,
after: '',
first: null,
last: DEFAULT_PAGE_SIZE,
},
});
},
);
});
});
});
describe('with direction set to "prev"', () => {
const direction = PREV;
const registries = MOCK_BASIC_GRAPHQL_QUERY_RESPONSE.geoNode[MOCK_GRAPHQL_REGISTRY];
const data = registries.nodes;
describe('on error', () => {
beforeEach(() => {
jest.spyOn(mockGeoGqClient, 'query').mockRejectedValue();
});
it('should call gqClient with before variable but no after variable as well as a last variable but no first variable', () => {
it('should dispatch the request and error actions', (done) => {
testAction(
actions.fetchReplicableItemsGraphQl,
direction,
null,
state,
[],
[
{
type: 'receiveReplicableItemsSuccess',
payload: { data, pagination: registries.pageInfo },
},
],
() => {
expect(gqClient.query).toHaveBeenCalledWith({
query: buildReplicableTypeQuery(MOCK_GRAPHQL_REGISTRY),
variables: {
before: MOCK_GRAPHQL_PAGINATION_DATA.startCursor,
after: '',
first: null,
last: DEFAULT_PAGE_SIZE,
},
});
},
[{ type: 'receiveReplicableItemsError' }],
done,
);
});
});
});
describe('on error', () => {
beforeEach(() => {
jest.spyOn(gqClient, 'query').mockRejectedValue();
});
it('should dispatch the request and error actions', (done) => {
testAction(
actions.fetchReplicableItemsGraphQl,
null,
state,
[],
[{ type: 'receiveReplicableItemsError' }],
done,
);
});
});
});
describe('fetchReplicableItemsRestful', () => {
......
import { getGraphqlClient } from 'ee/geo_replicable/utils';
jest.mock('~/lib/graphql', () => ({
...jest.requireActual('~/lib/graphql'),
__esModule: true,
default: jest.fn().mockImplementation((_, { path }) => path),
}));
describe('GeoReplicable utils', () => {
describe('getGraphqlClient', () => {
describe.each`
currentNodeId | targetNodeId | shouldReturnSpecificClient
${2} | ${3} | ${true}
${2} | ${2} | ${false}
${undefined} | ${2} | ${true}
${undefined} | ${undefined} | ${false}
${2} | ${undefined} | ${false}
`(`geoNodeIds`, ({ currentNodeId, targetNodeId, shouldReturnSpecificClient }) => {
it('returns the expected client', () => {
const expectedGqClientPath = shouldReturnSpecificClient
? `/api/v4/geo/node_proxy/${targetNodeId}/graphql`
: '/api/v4/geo/graphql';
expect(getGraphqlClient(currentNodeId, targetNodeId)).toBe(expectedGqClientPath);
});
});
});
});
......@@ -647,6 +647,24 @@ RSpec.describe GeoNode, :request_store, :geo, type: :model do
end
end
describe '#geo_replication_details_url' do
before do
allow(Gitlab::Geo).to receive(:enabled_replicator_classes).and_return(
instance_double("Array", first: class_double("Gitlab::Geo::Replicator", replicable_name_plural: 'replicables'))
)
end
it 'returns the Geo Replicables url for the specific node' do
expected_url = "https://localhost:3000/gitlab/admin/geo/sites/#{new_node.id}/replication/replicables"
expect(new_node.geo_replication_details_url).to eq(expected_url)
end
it 'returns nil when node is a primary one' do
expect(primary_node.geo_replication_details_url).to be_nil
end
end
describe '#missing_oauth_application?' do
context 'on a primary node' do
it 'returns false' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Admin::Geo::ReplicablesController, :geo do
include AdminModeHelper
include EE::GeoHelpers
let_it_be(:admin) { create(:admin) }
let_it_be(:primary_node) { create(:geo_node) }
let_it_be(:secondary_node) { create(:geo_node, :secondary) }
let(:replicable_name) { 'replicable' }
let(:replicable_class) { class_double("Gitlab::Geo::Replicator", replicable_name_plural: 'replicables', graphql_field_name: 'graphql') }
before do
enable_admin_mode!(admin)
login_as(admin)
end
subject do
get url
response
end
shared_examples 'license required' do
context 'without a valid license' do
it { is_expected.to have_gitlab_http_status(:forbidden) }
end
end
describe 'GET /admin/geo/replicables/:replicable_name_plural' do
let(:url) { "/admin/geo/replication/#{replicable_name}" }
it_behaves_like 'license required'
context 'with a valid license' do
before do
stub_licensed_features(geo: true)
allow(::Gitlab::Geo::Replicator).to receive(:for_replicable_name)
.with(replicable_name).and_return(replicable_class)
get url
end
context 'when Geo is not enabled' do
it { is_expected.to redirect_to(admin_geo_nodes_path) }
end
context 'when on a Geo primary' do
before do
stub_primary_node
end
it { is_expected.to redirect_to(admin_geo_nodes_path) }
end
context 'when on a Geo secondary' do
before do
stub_current_geo_node(secondary_node)
end
it do
is_expected.to redirect_to(
site_replicables_admin_geo_node_path(id: secondary_node.id, replicable_name_plural: replicable_name)
)
end
end
end
end
describe 'GET /admin/geo/sites/:id/replicables/:replicable_name_plural' do
let(:url) { "/admin/geo/sites/#{secondary_node.id}/replication/#{replicable_name}" }
it_behaves_like 'license required'
context 'with a valid license' do
before do
stub_licensed_features(geo: true)
allow(::Gitlab::Geo::Replicator).to receive(:for_replicable_name)
.with(replicable_name).and_return(replicable_class)
end
where(:current_node) { [nil, lazy { primary_node }, lazy { secondary_node }] }
with_them do
context 'loads node data' do
before do
stub_current_geo_node(current_node) if current_node.present?
end
it { is_expected.not_to be_redirect }
it 'includes expected current and target ids' do
get url
expect(response.body).to include("geo-target-node-id=\"#{secondary_node.id}\"")
if current_node.present?
expect(response.body).to include("geo-current-node-id=\"#{current_node&.id}\"")
else
expect(response.body).not_to include("geo-current-node-id")
end
end
end
end
end
end
end
......@@ -40642,6 +40642,9 @@ msgstr ""
msgid "Viewing commit"
msgstr ""
msgid "Viewing projects and designs data from a primary site is not possible when using a unified URL. Visit the secondary site directly. %{geo_help_url}"
msgstr ""
msgid "Violation"
msgstr ""
......
......@@ -385,11 +385,10 @@ func configureRoutes(u *upstream) {
u.route("", "^/oauth/geo/(auth|callback|logout)$", defaultUpstream),
// Admin Area > Geo routes
u.route("", "^/admin/geo$", defaultUpstream),
u.route("", "^/admin/geo/", defaultUpstream),
u.route("", "^/admin/geo/replication/projects", defaultUpstream),
u.route("", "^/admin/geo/replication/designs", defaultUpstream),
// Geo API routes
u.route("", "^/api/v4/geo_nodes", defaultUpstream),
u.route("", "^/api/v4/geo_replication", defaultUpstream),
u.route("", "^/api/v4/geo/proxy_git_ssh", defaultUpstream),
u.route("", "^/api/v4/geo/graphql", defaultUpstream),
......
......@@ -4,6 +4,22 @@ import (
"testing"
)
func TestAdminGeoPathsWithGeoProxy(t *testing.T) {
testCases := []testCase{
{"Regular admin/geo", "/admin/geo", "Geo primary received request to path /admin/geo"},
{"Specific object replication", "/admin/geo/replication/object_type", "Geo primary received request to path /admin/geo/replication/object_type"},
{"Specific object replication per-site", "/admin/geo/sites/2/replication/object_type", "Geo primary received request to path /admin/geo/sites/2/replication/object_type"},
{"Projects replication per-site", "/admin/geo/sites/2/replication/projects", "Geo primary received request to path /admin/geo/sites/2/replication/projects"},
{"Designs replication per-site", "/admin/geo/sites/2/replication/designs", "Geo primary received request to path /admin/geo/sites/2/replication/designs"},
{"Projects replication", "/admin/geo/replication/projects", "Local Rails server received request to path /admin/geo/replication/projects"},
{"Projects replication subpaths", "/admin/geo/replication/projects/2", "Local Rails server received request to path /admin/geo/replication/projects/2"},
{"Designs replication", "/admin/geo/replication/designs", "Local Rails server received request to path /admin/geo/replication/designs"},
{"Designs replication subpaths", "/admin/geo/replication/designs/3", "Local Rails server received request to path /admin/geo/replication/designs/3"},
}
runTestCasesWithGeoProxyEnabled(t, testCases)
}
func TestProjectNotExistingGitHttpPullWithGeoProxy(t *testing.T) {
testCases := []testCase{
{"secondary info/refs", "/group/project.git/info/refs", "Local Rails server received request to path /group/project.git/info/refs"},
......
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