Commit cbd2bada authored by Mike Kozono's avatar Mike Kozono

Add geoNode at root of GraphQL API

A GeoNode name can be specified. If it is not, then the current node is
returned.
parent d268d4c0
......@@ -2972,6 +2972,103 @@ type EpicTreeReorderPayload {
errors: [String!]!
}
type GeoNode {
"""
The maximum concurrency of container repository sync for this secondary node
"""
containerRepositoriesMaxCapacity: Int
"""
Indicates whether this Geo node is enabled
"""
enabled: Boolean
"""
The maximum concurrency of LFS/attachment backfill for this secondary node
"""
filesMaxCapacity: Int
"""
ID of this GeoNode
"""
id: ID!
"""
The URL defined on the primary node that secondary nodes should use to contact it
"""
internalUrl: String
"""
The interval (in days) in which the repository verification is valid. Once expired, it will be reverified
"""
minimumReverificationInterval: Int
"""
The unique identifier for this Geo node
"""
name: String
"""
Indicates whether this Geo node is the primary
"""
primary: Boolean
"""
The maximum concurrency of repository backfill for this secondary node
"""
reposMaxCapacity: Int
"""
The namespaces that should be synced, if `selective_sync_type` == `namespaces`
"""
selectiveSyncNamespaces(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): NamespaceConnection
"""
The repository storages whose projects should be synced, if `selective_sync_type` == `shards`
"""
selectiveSyncShards: [String!]
"""
Indicates if syncing is limited to only specific groups, or shards
"""
selectiveSyncType: String
"""
Indicates if this secondary node will replicate blobs in Object Storage
"""
syncObjectStorage: Boolean
"""
The user-facing URL for this Geo node
"""
url: String
"""
The maximum concurrency of repository verification for this secondary node
"""
verificationMaxCapacity: Int
}
type GrafanaIntegration {
"""
Timestamp of the issue's creation
......@@ -5411,6 +5508,41 @@ type Namespace {
visibility: String
}
"""
The connection type for Namespace.
"""
type NamespaceConnection {
"""
A list of edges.
"""
edges: [NamespaceEdge]
"""
A list of nodes.
"""
nodes: [Namespace]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type NamespaceEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: Namespace
}
type Note {
"""
User who wrote this note
......@@ -6852,6 +6984,16 @@ type Query {
text: String!
): String!
"""
Find a Geo node
"""
geoNode(
"""
The name of the Geo node. Defaults to the current Geo node name.
"""
name: String
): GeoNode
"""
Find a group
"""
......
......@@ -476,6 +476,25 @@ Autogenerated return type of EpicTreeReorder
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Reasons why the mutation failed. |
## GeoNode
| Name | Type | Description |
| --- | ---- | ---------- |
| `containerRepositoriesMaxCapacity` | Int | The maximum concurrency of container repository sync for this secondary node |
| `enabled` | Boolean | Indicates whether this Geo node is enabled |
| `filesMaxCapacity` | Int | The maximum concurrency of LFS/attachment backfill for this secondary node |
| `id` | ID! | ID of this GeoNode |
| `internalUrl` | String | The URL defined on the primary node that secondary nodes should use to contact it |
| `minimumReverificationInterval` | Int | The interval (in days) in which the repository verification is valid. Once expired, it will be reverified |
| `name` | String | The unique identifier for this Geo node |
| `primary` | Boolean | Indicates whether this Geo node is the primary |
| `reposMaxCapacity` | Int | The maximum concurrency of repository backfill for this secondary node |
| `selectiveSyncShards` | String! => Array | The repository storages whose projects should be synced, if `selective_sync_type` == `shards` |
| `selectiveSyncType` | String | Indicates if syncing is limited to only specific groups, or shards |
| `syncObjectStorage` | Boolean | Indicates if this secondary node will replicate blobs in Object Storage |
| `url` | String | The user-facing URL for this Geo node |
| `verificationMaxCapacity` | Int | The maximum concurrency of repository verification for this secondary node |
## GrafanaIntegration
| Name | Type | Description |
......
# frozen_string_literal: true
class GeoNodeFinder
include Gitlab::Allowable
def initialize(current_user, params = {})
@current_user = current_user
@params = params
end
def execute
return GeoNode.none unless can?(current_user, :read_all_geo)
geo_nodes = init_collection
geo_nodes = by_id(geo_nodes)
geo_nodes = by_name(geo_nodes)
geo_nodes.ordered
end
private
attr_reader :current_user, :params
def init_collection
GeoNode.all
end
def by_id(geo_nodes)
return geo_nodes unless params[:ids]
geo_nodes.id_in(params[:ids])
end
def by_name(geo_nodes)
return geo_nodes unless params[:names]
geo_nodes.name_in(params[:names])
end
end
......@@ -13,6 +13,11 @@ module EE
null: false,
description: 'Fields related to design management'
field :geo_node, ::Types::Geo::GeoNodeType,
null: true,
resolver: Resolvers::Geo::GeoNodeResolver,
description: 'Find a Geo node'
def design_management
DesignManagementObject.new(nil)
end
......
# frozen_string_literal: true
module Resolvers
module Geo
class GeoNodeResolver < BaseResolver
type Types::Geo::GeoNodeType, null: true
argument :name, GraphQL::STRING_TYPE,
required: false,
description: 'The name of the Geo node. Defaults to the current Geo node name.'
def resolve(name: GeoNode.current_node_name)
GeoNodeFinder.new(context[:current_user], names: [name]).execute.first
end
end
end
end
# frozen_string_literal: true
module Types
module Geo
class GeoNodeType < BaseObject
graphql_name 'GeoNode'
authorize :read_geo_node
field :id, GraphQL::ID_TYPE, null: false, description: 'ID of this GeoNode'
field :primary, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates whether this Geo node is the primary'
field :enabled, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates whether this Geo node is enabled'
field :name, GraphQL::STRING_TYPE, null: true, description: 'The unique identifier for this Geo node'
field :url, GraphQL::STRING_TYPE, null: true, description: 'The user-facing URL for this Geo node'
field :internal_url, GraphQL::STRING_TYPE, null: true, description: 'The URL defined on the primary node that secondary nodes should use to contact it'
field :files_max_capacity, GraphQL::INT_TYPE, null: true, description: 'The maximum concurrency of LFS/attachment backfill for this secondary node'
field :repos_max_capacity, GraphQL::INT_TYPE, null: true, description: 'The maximum concurrency of repository backfill for this secondary node'
field :verification_max_capacity, GraphQL::INT_TYPE, null: true, description: 'The maximum concurrency of repository verification for this secondary node'
field :container_repositories_max_capacity, GraphQL::INT_TYPE, null: true, description: 'The maximum concurrency of container repository sync for this secondary node'
field :sync_object_storage, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates if this secondary node will replicate blobs in Object Storage'
field :selective_sync_type, GraphQL::STRING_TYPE, null: true, description: 'Indicates if syncing is limited to only specific groups, or shards'
field :selective_sync_shards, type: [GraphQL::STRING_TYPE], null: true, description: 'The repository storages whose projects should be synced, if `selective_sync_type` == `shards`'
field :selective_sync_namespaces, ::Types::NamespaceType.connection_type, null: true, method: :namespaces, description: 'The namespaces that should be synced, if `selective_sync_type` == `namespaces`'
field :minimum_reverification_interval, GraphQL::INT_TYPE, null: true, description: 'The interval (in days) in which the repository verification is valid. Once expired, it will be reverified'
end
end
end
......@@ -51,6 +51,8 @@ class GeoNode < ApplicationRecord
scope :with_url_prefix, ->(prefix) { where('url LIKE ?', "#{prefix}%") }
scope :secondary_nodes, -> { where(primary: false) }
scope :name_in, -> (names) { where(name: names) }
scope :ordered, -> { order(:id) }
attr_encrypted :secret_access_key,
key: Settings.attr_encrypted_db_key_base_truncated,
......
......@@ -18,6 +18,7 @@ module EE
rule { admin }.policy do
enable :read_licenses
enable :destroy_licenses
enable :read_all_geo
end
rule { admin & pages_size_limit_available }.enable :update_max_pages_size
......
# frozen_string_literal: true
class GeoNodePolicy < ::BasePolicy
condition(:can_read_all_geo, scope: :user) { can?(:read_all_geo, :global) }
rule { can_read_all_geo }.enable :read_geo_node
end
---
title: 'Geo GraphQL API: Add geoNode at root'
merge_request: 28454
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
describe GeoNodeFinder do
include ::EE::GeoHelpers
let_it_be(:geo_node1) { create(:geo_node) }
let_it_be(:geo_node2) { create(:geo_node) }
let_it_be(:geo_node3) { create(:geo_node) }
let(:params) { {} }
subject(:geo_nodes) { described_class.new(user, params).execute }
describe '#execute' do
context 'when user cannot read all Geo' do
let_it_be(:user) { create(:user) }
it { is_expected.to be_empty }
end
context 'when user can read all Geo', :enable_admin_mode do
let_it_be(:user) { create(:user, :admin) }
context 'filtered by ID' do
context 'when multiple IDs are given' do
let(:params) { { ids: [geo_node3.id, geo_node1.id] } }
it 'returns specified Geo nodes' do
expect(geo_nodes.to_a).to eq([geo_node1, geo_node3])
end
end
context 'when a single ID is given' do
let(:params) { { ids: [geo_node2.id] } }
it 'returns specified Geo nodes' do
expect(geo_nodes.to_a).to eq([geo_node2])
end
end
context 'when an empty array is given' do
let(:params) { { ids: [] } }
it 'returns none' do
expect(geo_nodes).to be_empty
end
end
end
context 'filtered by name' do
context 'when multiple names are given' do
let(:params) { { names: [geo_node3.name, geo_node1.name] } }
it 'returns specified Geo nodes' do
expect(geo_nodes.to_a).to eq([geo_node1, geo_node3])
end
end
context 'when a single name is given' do
let(:params) { { names: [geo_node2.name] } }
it 'returns specified Geo nodes' do
expect(geo_nodes.to_a).to eq([geo_node2])
end
end
context 'when an empty array is given' do
let(:params) { { names: [] } }
it 'returns none' do
expect(geo_nodes).to be_empty
end
end
end
context 'not filtered by ID or name' do
it 'returns all Geo nodes' do
expect(geo_nodes.to_a).to eq([geo_node1, geo_node2, geo_node3])
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::Geo::GeoNodeResolver do
include GraphqlHelpers
include EE::GeoHelpers
describe '#resolve' do
let_it_be(:primary) { create(:geo_node, :primary) }
let_it_be(:secondary) { create(:geo_node) }
let_it_be(:user) { create(:user, :admin) }
let(:gql_context) { { current_user: user } }
context 'when the user has permission to view Geo data', :enable_admin_mode do
context 'with a name' do
context 'when the given name matches a node' do
it 'returns the GeoNode' do
expect(resolve_geo_node(name: primary.name)).to eq(primary)
expect(resolve_geo_node(name: secondary.name)).to eq(secondary)
end
end
context 'when the given name does not match any node' do
it 'returns nil' do
expect(resolve_geo_node(name: 'a node by this name does not exist')).to be_nil
end
end
end
context 'without a name' do
context 'when the GitLab instance has a current Geo node' do
before do
stub_current_geo_node(secondary)
stub_current_node_name(secondary.name)
end
it 'returns the GeoNode' do
expect(resolve_geo_node).to eq(secondary)
end
end
context 'when the GitLab instance does not have a current Geo node' do
it 'returns nil' do
expect(resolve_geo_node).to be_nil
end
end
end
end
context 'when the user does not have permission to view Geo data' do
it 'returns nil' do
expect(resolve_geo_node).to be_nil
end
end
end
def resolve_geo_node(args = {})
resolve(described_class, obj: nil, args: args, ctx: gql_context)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['GeoNode'] do
it { expect(described_class).to require_graphql_authorizations(:read_geo_node) }
it 'has the expected fields' do
expected_fields = %i[
id primary enabled name url internal_url files_max_capacity
repos_max_capacity verification_max_capacity
container_repositories_max_capacity sync_object_storage
selective_sync_type selective_sync_shards selective_sync_namespaces
minimum_reverification_interval
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
......@@ -4,6 +4,6 @@ require 'spec_helper'
describe GitlabSchema.types['Query'] do
it do
expect(described_class).to have_graphql_fields(:design_management).at_least
expect(described_class).to have_graphql_fields(:design_management, :geo_node).at_least
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GeoNodePolicy do
let_it_be(:geo_node) { create(:geo_node) }
subject(:policy) { described_class.new(current_user, geo_node) }
context 'when the user is an admin' do
let(:current_user) { create(:user, :admin) }
it 'allows read_geo_node for any GeoNode' do
expect(policy).to be_allowed(:read_geo_node)
end
end
context 'when the user is not an admin' do
let(:current_user) { create(:user) }
it 'disallows read_geo_node for any GeoNode' do
expect(policy).to be_disallowed(:read_geo_node)
end
end
end
......@@ -36,9 +36,11 @@ describe GlobalPolicy do
it { is_expected.to be_disallowed(:read_licenses) }
it { is_expected.to be_disallowed(:destroy_licenses) }
it { is_expected.to be_disallowed(:read_all_geo) }
it { expect(described_class.new(create(:admin), [user])).to be_allowed(:read_licenses) }
it { expect(described_class.new(create(:admin), [user])).to be_allowed(:destroy_licenses) }
it { expect(described_class.new(create(:admin), [user])).to be_allowed(:read_all_geo) }
shared_examples 'analytics policy' do |action|
context 'anonymous user' do
......
......@@ -199,7 +199,7 @@ describe API::GeoNodes, :request_store, :geo_fdw, :prometheus, api: true do
end
it 'returns 200 for the primary node' do
set_current_geo_node!(primary)
stub_current_geo_node(primary)
create(:geo_node_status, :healthy, geo_node: primary)
post api("/geo_nodes/#{primary.id}/repair", admin)
......
......@@ -16,7 +16,7 @@ describe API::GeoReplication, :request_store, :geo, :geo_fdw, api: true do
let(:user) { create(:user) }
before do
set_current_geo_node!(secondary)
stub_current_geo_node(secondary)
end
describe 'GET /geo_replication/designs' do
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Getting the current Geo node' do
include GraphqlHelpers
include EE::GeoHelpers
let_it_be(:secondary) { create(:geo_node) }
let(:query) do
<<~QUERY
{
geoNode {
id
primary
enabled
name
url
internalUrl
filesMaxCapacity
reposMaxCapacity
verificationMaxCapacity
containerRepositoriesMaxCapacity
syncObjectStorage
selectiveSyncType
selectiveSyncShards
minimumReverificationInterval
}
}
QUERY
end
let(:current_user) { create(:user, :admin) }
before do
stub_current_geo_node(secondary)
stub_current_node_name(secondary.name)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
it 'returns the current GeoNode' do
expected = {
'id' => secondary.to_global_id.to_s,
'primary' => secondary.primary,
'enabled' => secondary.enabled,
'name' => secondary.name,
'url' => secondary.url,
'internalUrl' => secondary.internal_url,
'filesMaxCapacity' => secondary.files_max_capacity,
'reposMaxCapacity' => secondary.repos_max_capacity,
'verificationMaxCapacity' => secondary.verification_max_capacity,
'containerRepositoriesMaxCapacity' => secondary.container_repositories_max_capacity,
'syncObjectStorage' => secondary.sync_object_storage,
'selectiveSyncType' => secondary.selective_sync_type,
'selectiveSyncShards' => secondary.selective_sync_shards,
'minimumReverificationInterval' => secondary.minimum_reverification_interval
}
post_graphql(query, current_user: current_user)
expect(graphql_data_at(:geo_node)).to eq(expected)
end
context 'connection fields' do
context 'when selectiveSyncNamespaces is queried' do
let_it_be(:namespace_link) { create(:geo_node_namespace_link, geo_node: secondary) }
it 'returns selective sync namespaces' do
query =
<<~QUERY
{
geoNode {
selectiveSyncNamespaces {
nodes {
id
name
}
}
}
}
QUERY
expected = [
{
'id' => secondary.namespaces.last.to_global_id.to_s,
'name' => secondary.namespaces.last.name
}
]
post_graphql(query, current_user: current_user)
actual = graphql_data_at(:geo_node, :selective_sync_namespaces, :nodes)
expect(actual).to eq(expected)
end
it 'supports cursor-based pagination' do
create(:geo_node_namespace_link, geo_node: secondary)
create(:geo_node_namespace_link, geo_node: secondary)
query =
<<~QUERY
{
geoNode {
selectiveSyncNamespaces(first: 2) {
edges {
node {
id
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
QUERY
post_graphql(query, current_user: current_user)
edges = graphql_data_at(:geo_node, :selective_sync_namespaces, :edges)
page_info = graphql_data_at(:geo_node, :selective_sync_namespaces, :page_info)
expect(edges.size).to eq(2)
expect(page_info).to be_present
end
end
end
end
......@@ -2,18 +2,15 @@
module EE
module GeoHelpers
# Actually sets the specified node to be the current one, so it works on new
# instances of GeoNode, unlike stub_current_geo_node. But this is slower.
def set_current_geo_node!(node)
node.name = GeoNode.current_node_name
node.save!(validate: false)
end
def stub_current_geo_node(node)
allow(::Gitlab::Geo).to receive(:current_node).and_return(node)
allow(node).to receive(:current?).and_return(true) unless node.nil?
end
def stub_current_node_name(name)
allow(GeoNode).to receive(:current_node_name).and_return(name)
end
def stub_primary_node
allow(::Gitlab::Geo).to receive(:primary?).and_return(true)
allow(::Gitlab::Geo).to receive(:secondary?).and_return(false)
......
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