Expose namespace storage statistics with GraphQL

Root namespaces have storage statistics.
This commit allows namespace owners to get those stats via GraphQL
queries like the following one

{
  namespace(fullPath: "a_namespace_path") {
    rootStorageStatistics {
      storageSize
      repositorySize
      lfsObjectsSize
      buildArtifactsSize
      packagesSize
      wikiSize
    }
  }
}
This commit is contained in:
Alessio Caiazza 2019-08-22 22:08:28 +00:00 committed by Mayra Cabrera
parent c65ea080ba
commit 606a1d2d31
18 changed files with 256 additions and 4 deletions

View file

@ -19,6 +19,11 @@ module Types
field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled? field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?
field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :root_storage_statistics, Types::RootStorageStatisticsType,
null: true,
description: 'The aggregated storage statistics. Only available for root namespaces',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(obj.id).find }
field :projects, field :projects,
Types::ProjectType.connection_type, Types::ProjectType.connection_type,
null: false, null: false,

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Types
class RootStorageStatisticsType < BaseObject
graphql_name 'RootStorageStatistics'
authorize :read_statistics
field :storage_size, GraphQL::INT_TYPE, null: false, description: 'The total storage in bytes'
field :repository_size, GraphQL::INT_TYPE, null: false, description: 'The git repository size in bytes'
field :lfs_objects_size, GraphQL::INT_TYPE, null: false, description: 'The LFS objects size in bytes'
field :build_artifacts_size, GraphQL::INT_TYPE, null: false, description: 'The CI artifacts size in bytes'
field :packages_size, GraphQL::INT_TYPE, null: false, description: 'The packages size in bytes'
field :wiki_size, GraphQL::INT_TYPE, null: false, description: 'The wiki size in bytes'
end
end

View file

@ -8,6 +8,8 @@ class Namespace::RootStorageStatistics < ApplicationRecord
belongs_to :namespace belongs_to :namespace
has_one :route, through: :namespace has_one :route, through: :namespace
scope :for_namespace_ids, ->(namespace_ids) { where(namespace_id: namespace_ids) }
delegate :all_projects, to: :namespace delegate :all_projects, to: :namespace
def recalculate! def recalculate!

View file

@ -124,6 +124,8 @@ class GroupPolicy < BasePolicy
rule { developer & developer_maintainer_access }.enable :create_projects rule { developer & developer_maintainer_access }.enable :create_projects
rule { create_projects_disabled }.prevent :create_projects rule { create_projects_disabled }.prevent :create_projects
rule { owner | admin }.enable :read_statistics
def access_level def access_level
return GroupMember::NO_ACCESS if @user.nil? return GroupMember::NO_ACCESS if @user.nil?

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
class Namespace::RootStorageStatisticsPolicy < BasePolicy
delegate { @subject.namespace }
end

View file

@ -11,6 +11,7 @@ class NamespacePolicy < BasePolicy
enable :create_projects enable :create_projects
enable :admin_namespace enable :admin_namespace
enable :read_namespace enable :read_namespace
enable :read_statistics
end end
rule { personal_project & ~can_create_personal_project }.prevent :create_projects rule { personal_project & ~can_create_personal_project }.prevent :create_projects

View file

@ -0,0 +1,5 @@
---
title: Expose namespace storage statistics with GraphQL
merge_request: 32012
author:
type: added

View file

@ -109,6 +109,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `visibility` | String | | | `visibility` | String | |
| `lfsEnabled` | Boolean | | | `lfsEnabled` | Boolean | |
| `requestAccessEnabled` | Boolean | | | `requestAccessEnabled` | Boolean | |
| `rootStorageStatistics` | RootStorageStatistics | The aggregated storage statistics. Only available if the namespace has no parent |
| `userPermissions` | GroupPermissions! | Permissions for the current user on the resource | | `userPermissions` | GroupPermissions! | Permissions for the current user on the resource |
| `webUrl` | String! | | | `webUrl` | String! | |
| `avatarUrl` | String | | | `avatarUrl` | String | |
@ -453,6 +454,17 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `exists` | Boolean! | | | `exists` | Boolean! | |
| `tree` | Tree | | | `tree` | Tree | |
### RootStorageStatistics
| Name | Type | Description |
| --- | ---- | ---------- |
| `storageSize` | Int! | The total storage in Bytes |
| `repositorySize` | Int! | The git repository size in Bytes |
| `lfsObjectsSize` | Int! | The LFS objects size in Bytes |
| `buildArtifactsSize` | Int! | The CI artifacts size in Bytes |
| `packagesSize` | Int! | The packages size in Bytes |
| `wikiSize` | Int! | The wiki size in Bytes |
### Submodule ### Submodule
| Name | Type | Description | | Name | Type | Description |

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Gitlab
module Graphql
module Loaders
class BatchRootStorageStatisticsLoader
attr_reader :namespace_id
def initialize(namespace_id)
@namespace_id = namespace_id
end
def find
BatchLoader.for(namespace_id).batch do |namespace_ids, loader|
Namespace::RootStorageStatistics.for_namespace_ids(namespace_ids).each do |statistics|
loader.call(statistics.namespace_id, statistics)
end
end
end
end
end
end
end

View file

@ -8,7 +8,7 @@ describe GitlabSchema.types['Namespace'] do
it 'has the expected fields' do it 'has the expected fields' do
expected_fields = %w[ expected_fields = %w[
id name path full_name full_path description description_html visibility id name path full_name full_path description description_html visibility
lfs_enabled request_access_enabled projects lfs_enabled request_access_enabled projects root_storage_statistics
] ]
is_expected.to have_graphql_fields(*expected_fields) is_expected.to have_graphql_fields(*expected_fields)

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['RootStorageStatistics'] do
it { expect(described_class.graphql_name).to eq('RootStorageStatistics') }
it 'has all the required fields' do
is_expected.to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size,
:build_artifacts_size, :packages_size, :wiki_size)
end
it { is_expected.to require_graphql_authorizations(:read_statistics) }
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader do
describe '#find' do
it 'only queries once for project statistics' do
stats = create_list(:namespace_root_storage_statistics, 2)
namespace1 = stats.first.namespace
namespace2 = stats.last.namespace
expect do
described_class.new(namespace1.id).find
described_class.new(namespace2.id).find
end.not_to exceed_query_limit(1)
end
end
end

View file

@ -8,6 +8,19 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
it { is_expected.to delegate_method(:all_projects).to(:namespace) } it { is_expected.to delegate_method(:all_projects).to(:namespace) }
context 'scopes' do
describe '.for_namespace_ids' do
it 'returns only requested namespaces' do
stats = create_list(:namespace_root_storage_statistics, 3)
namespace_ids = stats[0..1].map { |s| s.namespace_id }
requested_stats = described_class.for_namespace_ids(namespace_ids).pluck(:namespace_id)
expect(requested_stats).to eq(namespace_ids)
end
end
end
describe '#recalculate!' do describe '#recalculate!' do
let(:namespace) { create(:group) } let(:namespace) { create(:group) }
let(:root_storage_statistics) { create(:namespace_root_storage_statistics, namespace: namespace) } let(:root_storage_statistics) { create(:namespace_root_storage_statistics, namespace: namespace) }

View file

@ -0,0 +1,80 @@
# frozen_string_literal: true
require 'spec_helper'
describe Namespace::RootStorageStatisticsPolicy do
using RSpec::Parameterized::TableSyntax
describe '#rules' do
let(:statistics) { create(:namespace_root_storage_statistics, namespace: namespace) }
let(:user) { create(:user) }
subject { Ability.allowed?(user, :read_statistics, statistics) }
shared_examples 'deny anonymous users' do
context 'when the users is anonymous' do
let(:user) { nil }
it { is_expected.to be_falsey }
end
end
context 'when the namespace is a personal namespace' do
let(:owner) { create(:user) }
let(:namespace) { owner.namespace }
include_examples 'deny anonymous users'
context 'when the user is not the owner' do
it { is_expected.to be_falsey }
end
context 'when the user is the owner' do
let(:user) { owner }
it { is_expected.to be_truthy }
end
end
context 'when the namespace is a group' do
let(:user) { create(:user) }
let(:external) { create(:user, :external) }
shared_examples 'allows only owners' do |group_type|
let(:group) { create(:group, visibility_level: Gitlab::VisibilityLevel.level_value(group_type.to_s)) }
let(:namespace) { group }
include_examples 'deny anonymous users'
where(:user_type, :outcome) do
[
[:non_member, false],
[:guest, false],
[:reporter, false],
[:developer, false],
[:maintainer, false],
[:owner, true]
]
end
with_them do
before do
group.add_user(user, user_type) unless user_type == :non_member
end
it { is_expected.to eq(outcome) }
context 'when the user is external' do
let(:user) { external }
it { is_expected.to eq(outcome) }
end
end
end
include_examples 'allows only owners', :public
include_examples 'allows only owners', :private
include_examples 'allows only owners', :internal
end
end
end

View file

@ -6,7 +6,7 @@ describe NamespacePolicy do
let(:admin) { create(:admin) } let(:admin) { create(:admin) }
let(:namespace) { create(:namespace, owner: owner) } let(:namespace) { create(:namespace, owner: owner) }
let(:owner_permissions) { [:create_projects, :admin_namespace, :read_namespace] } let(:owner_permissions) { [:create_projects, :admin_namespace, :read_namespace, :read_statistics] }
subject { described_class.new(current_user, namespace) } subject { described_class.new(current_user, namespace) }

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
require 'spec_helper'
describe 'rendering namespace statistics' do
include GraphqlHelpers
let(:namespace) { user.namespace }
let!(:statistics) { create(:namespace_root_storage_statistics, namespace: namespace, packages_size: 5.megabytes) }
let(:user) { create(:user) }
let(:query) do
graphql_query_for('namespace',
{ 'fullPath' => namespace.full_path },
"rootStorageStatistics { #{all_graphql_fields_for('RootStorageStatistics')} }")
end
shared_examples 'a working namespace with storage statistics query' do
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: user)
end
end
it 'includes the packages size if the user can read the statistics' do
post_graphql(query, current_user: user)
expect(graphql_data['namespace']['rootStorageStatistics']).not_to be_blank
expect(graphql_data['namespace']['rootStorageStatistics']['packagesSize']).to eq(5.megabytes)
end
end
it_behaves_like 'a working namespace with storage statistics query'
context 'when the namespace is a group' do
let(:group) { create(:group) }
let(:namespace) { group }
before do
group.add_owner(user)
end
it_behaves_like 'a working namespace with storage statistics query'
context 'when the namespace is public' do
let(:group) { create(:group, :public)}
it 'hides statistics for unauthenticated requests' do
post_graphql(query, current_user: nil)
expect(graphql_data['namespace']).to be_blank
end
end
end
end

View file

@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
describe 'rendering namespace statistics' do describe 'rendering project statistics' do
include GraphqlHelpers include GraphqlHelpers
let(:project) { create(:project) } let(:project) { create(:project) }

View file

@ -31,7 +31,8 @@ RSpec.shared_context 'GroupPolicy context' do
:admin_group_member, :admin_group_member,
:change_visibility_level, :change_visibility_level,
:set_note_created_at, :set_note_created_at,
:create_subgroup :create_subgroup,
:read_statistics
].compact ].compact
end end