From 606a1d2d31aff69ddabe7e3794f61f3e778da3e8 Mon Sep 17 00:00:00 2001 From: Alessio Caiazza Date: Thu, 22 Aug 2019 22:08:28 +0000 Subject: [PATCH] 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 } } } --- app/graphql/types/namespace_type.rb | 5 ++ .../types/root_storage_statistics_type.rb | 16 ++++ .../namespace/root_storage_statistics.rb | 2 + app/policies/group_policy.rb | 2 + .../root_storage_statistics_policy.rb | 5 ++ app/policies/namespace_policy.rb | 1 + .../ac-graphql-root-namespace-stats.yml | 5 ++ doc/api/graphql/reference/index.md | 12 +++ .../batch_root_storage_statistics_loader.rb | 23 ++++++ spec/graphql/types/namespace_type_spec.rb | 2 +- .../root_storage_statistics_type_spec.rb | 14 ++++ ...tch_root_storage_statistics_loader_spec.rb | 18 +++++ .../namespace/root_storage_statistics_spec.rb | 13 +++ .../root_storage_statistics_policy_spec.rb | 80 +++++++++++++++++++ spec/policies/namespace_policy_spec.rb | 2 +- .../namespace/root_storage_statistics_spec.rb | 55 +++++++++++++ .../project/project_statistics_spec.rb | 2 +- .../policies/group_policy_shared_context.rb | 3 +- 18 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 app/graphql/types/root_storage_statistics_type.rb create mode 100644 app/policies/namespace/root_storage_statistics_policy.rb create mode 100644 changelogs/unreleased/ac-graphql-root-namespace-stats.yml create mode 100644 lib/gitlab/graphql/loaders/batch_root_storage_statistics_loader.rb create mode 100644 spec/graphql/types/root_storage_statistics_type_spec.rb create mode 100644 spec/lib/gitlab/graphql/loaders/batch_root_storage_statistics_loader_spec.rb create mode 100644 spec/policies/namespace/root_storage_statistics_policy_spec.rb create mode 100644 spec/requests/api/graphql/namespace/root_storage_statistics_spec.rb diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index f105e9e6e28..35a97b5ace0 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -19,6 +19,11 @@ module Types field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled? 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, Types::ProjectType.connection_type, null: false, diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb new file mode 100644 index 00000000000..a7498ee0a2e --- /dev/null +++ b/app/graphql/types/root_storage_statistics_type.rb @@ -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 diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb index 56c430013ee..ae9b2f14343 100644 --- a/app/models/namespace/root_storage_statistics.rb +++ b/app/models/namespace/root_storage_statistics.rb @@ -8,6 +8,8 @@ class Namespace::RootStorageStatistics < ApplicationRecord belongs_to :namespace has_one :route, through: :namespace + scope :for_namespace_ids, ->(namespace_ids) { where(namespace_id: namespace_ids) } + delegate :all_projects, to: :namespace def recalculate! diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index c686e7763bb..5d2b74b17a2 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -124,6 +124,8 @@ class GroupPolicy < BasePolicy rule { developer & developer_maintainer_access }.enable :create_projects rule { create_projects_disabled }.prevent :create_projects + rule { owner | admin }.enable :read_statistics + def access_level return GroupMember::NO_ACCESS if @user.nil? diff --git a/app/policies/namespace/root_storage_statistics_policy.rb b/app/policies/namespace/root_storage_statistics_policy.rb new file mode 100644 index 00000000000..63fcaf20dfe --- /dev/null +++ b/app/policies/namespace/root_storage_statistics_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Namespace::RootStorageStatisticsPolicy < BasePolicy + delegate { @subject.namespace } +end diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb index 2babcb0a2d9..937666c7e54 100644 --- a/app/policies/namespace_policy.rb +++ b/app/policies/namespace_policy.rb @@ -11,6 +11,7 @@ class NamespacePolicy < BasePolicy enable :create_projects enable :admin_namespace enable :read_namespace + enable :read_statistics end rule { personal_project & ~can_create_personal_project }.prevent :create_projects diff --git a/changelogs/unreleased/ac-graphql-root-namespace-stats.yml b/changelogs/unreleased/ac-graphql-root-namespace-stats.yml new file mode 100644 index 00000000000..9784605ab2e --- /dev/null +++ b/changelogs/unreleased/ac-graphql-root-namespace-stats.yml @@ -0,0 +1,5 @@ +--- +title: Expose namespace storage statistics with GraphQL +merge_request: 32012 +author: +type: added diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 2d3bec4ff67..d99a4c37d72 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -109,6 +109,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `visibility` | String | | | `lfsEnabled` | 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 | | `webUrl` | String! | | | `avatarUrl` | String | | @@ -453,6 +454,17 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `exists` | Boolean! | | | `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 | Name | Type | Description | diff --git a/lib/gitlab/graphql/loaders/batch_root_storage_statistics_loader.rb b/lib/gitlab/graphql/loaders/batch_root_storage_statistics_loader.rb new file mode 100644 index 00000000000..a0312366d66 --- /dev/null +++ b/lib/gitlab/graphql/loaders/batch_root_storage_statistics_loader.rb @@ -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 diff --git a/spec/graphql/types/namespace_type_spec.rb b/spec/graphql/types/namespace_type_spec.rb index e1153832cc9..f476dd7286f 100644 --- a/spec/graphql/types/namespace_type_spec.rb +++ b/spec/graphql/types/namespace_type_spec.rb @@ -8,7 +8,7 @@ describe GitlabSchema.types['Namespace'] do it 'has the expected fields' do expected_fields = %w[ 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) diff --git a/spec/graphql/types/root_storage_statistics_type_spec.rb b/spec/graphql/types/root_storage_statistics_type_spec.rb new file mode 100644 index 00000000000..8c69c13aa73 --- /dev/null +++ b/spec/graphql/types/root_storage_statistics_type_spec.rb @@ -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 diff --git a/spec/lib/gitlab/graphql/loaders/batch_root_storage_statistics_loader_spec.rb b/spec/lib/gitlab/graphql/loaders/batch_root_storage_statistics_loader_spec.rb new file mode 100644 index 00000000000..38931f7ab5e --- /dev/null +++ b/spec/lib/gitlab/graphql/loaders/batch_root_storage_statistics_loader_spec.rb @@ -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 diff --git a/spec/models/namespace/root_storage_statistics_spec.rb b/spec/models/namespace/root_storage_statistics_spec.rb index 5341278db7c..9e12831a704 100644 --- a/spec/models/namespace/root_storage_statistics_spec.rb +++ b/spec/models/namespace/root_storage_statistics_spec.rb @@ -8,6 +8,19 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do 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 let(:namespace) { create(:group) } let(:root_storage_statistics) { create(:namespace_root_storage_statistics, namespace: namespace) } diff --git a/spec/policies/namespace/root_storage_statistics_policy_spec.rb b/spec/policies/namespace/root_storage_statistics_policy_spec.rb new file mode 100644 index 00000000000..8d53050fffb --- /dev/null +++ b/spec/policies/namespace/root_storage_statistics_policy_spec.rb @@ -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 diff --git a/spec/policies/namespace_policy_spec.rb b/spec/policies/namespace_policy_spec.rb index 99fa8b1fe44..216aaae70ee 100644 --- a/spec/policies/namespace_policy_spec.rb +++ b/spec/policies/namespace_policy_spec.rb @@ -6,7 +6,7 @@ describe NamespacePolicy do let(:admin) { create(:admin) } 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) } diff --git a/spec/requests/api/graphql/namespace/root_storage_statistics_spec.rb b/spec/requests/api/graphql/namespace/root_storage_statistics_spec.rb new file mode 100644 index 00000000000..ac76d991bd4 --- /dev/null +++ b/spec/requests/api/graphql/namespace/root_storage_statistics_spec.rb @@ -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 diff --git a/spec/requests/api/graphql/project/project_statistics_spec.rb b/spec/requests/api/graphql/project/project_statistics_spec.rb index 14a3f37b779..ddee8537454 100644 --- a/spec/requests/api/graphql/project/project_statistics_spec.rb +++ b/spec/requests/api/graphql/project/project_statistics_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe 'rendering namespace statistics' do +describe 'rendering project statistics' do include GraphqlHelpers let(:project) { create(:project) } diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb index fd24c443288..b89723b1e1a 100644 --- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb @@ -31,7 +31,8 @@ RSpec.shared_context 'GroupPolicy context' do :admin_group_member, :change_visibility_level, :set_note_created_at, - :create_subgroup + :create_subgroup, + :read_statistics ].compact end