diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index cf79be3792c930e3ca1c565de8c56a0999ebc43c..5097f2dddaa92f678987472127d5c6b6ea79dbd4 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -911,7 +911,8 @@ Returns [`[CiRunnerUsageByProject!]`](#cirunnerusagebyproject). | Name | Type | Description | | ---- | ---- | ----------- | | <a id="queryrunnerusagebyprojectfromdate"></a>`fromDate` | [`Date`](#date) | Start of the requested date frame. Defaults to the start of the previous calendar month. | -| <a id="queryrunnerusagebyprojectprojectslimit"></a>`projectsLimit` | [`Int`](#int) | Maximum number of projects to return.Other projects will be aggregated to a `project: null` entry. Defaults to 5 if unspecified. Maximum of 500. | +| <a id="queryrunnerusagebyprojectfullpath"></a>`fullPath` | [`String`](#string) | Filter jobs based on the full path of the group or project they belong to. For example, `gitlab-org` or `gitlab-org/gitlab`. Available only to admins, group maintainers (when a group is specified), or project maintainers (when a project is specified). Limited to runners from 5000 child projects. | +| <a id="queryrunnerusagebyprojectprojectslimit"></a>`projectsLimit` | [`Int`](#int) | Maximum number of projects to return. Other projects will be aggregated to a `project: null` entry. Defaults to 5 if unspecified. Maximum of 500. | | <a id="queryrunnerusagebyprojectrunnertype"></a>`runnerType` | [`CiRunnerType`](#cirunnertype) | Filter jobs by the type of runner that executed them. | | <a id="queryrunnerusagebyprojecttodate"></a>`toDate` | [`Date`](#date) | End of the requested date frame. Defaults to the end of the previous calendar month. | diff --git a/ee/app/graphql/resolvers/ci/runner_usage_by_project_resolver.rb b/ee/app/graphql/resolvers/ci/runner_usage_by_project_resolver.rb index c1ff1fac19c8318d6603e133e42c8004fc5ab3f6..dff03c590d89a87753cceb59b5dcfd15786470be 100644 --- a/ee/app/graphql/resolvers/ci/runner_usage_by_project_resolver.rb +++ b/ee/app/graphql/resolvers/ci/runner_usage_by_project_resolver.rb @@ -3,6 +3,7 @@ module Resolvers module Ci class RunnerUsageByProjectResolver < BaseResolver + include Gitlab::Utils::StrongMemoize include Gitlab::Graphql::Authorize::AuthorizeResource MAX_PROJECTS_LIMIT = 500 @@ -12,13 +13,23 @@ class RunnerUsageByProjectResolver < BaseResolver type [Types::Ci::RunnerUsageByProjectType], null: true description <<~MD - Runner usage in minutes by project. Available only to admins. + Runner usage in minutes by project. + Available only to admins, group maintainers (when a group is specified), + or project maintainers (when a project is specified). MD argument :runner_type, ::Types::Ci::RunnerTypeEnum, required: false, description: 'Filter jobs by the type of runner that executed them.' + argument :full_path, GraphQL::Types::String, + required: false, + description: 'Filter jobs based on the full path of the group or project they belong to. ' \ + 'For example, `gitlab-org` or `gitlab-org/gitlab`. ' \ + 'Available only to admins, group maintainers (when a group is specified), ' \ + 'or project maintainers (when a project is specified). ' \ + "Limited to runners from #{::Ci::Runners::GetUsageByProjectService::MAX_PROJECTS_IN_GROUP} child projects." + argument :from_date, Types::DateType, required: false, description: 'Start of the requested date frame. Defaults to the start of the previous calendar month.' @@ -29,12 +40,13 @@ class RunnerUsageByProjectResolver < BaseResolver argument :projects_limit, GraphQL::Types::Int, required: false, - description: 'Maximum number of projects to return.' \ - 'Other projects will be aggregated to a `project: null` entry. ' \ - "Defaults to #{DEFAULT_PROJECTS_LIMIT} if unspecified. Maximum of #{MAX_PROJECTS_LIMIT}." + description: + 'Maximum number of projects to return. ' \ + 'Other projects will be aggregated to a `project: null` entry. ' \ + "Defaults to #{DEFAULT_PROJECTS_LIMIT} if unspecified. Maximum of #{MAX_PROJECTS_LIMIT}." - def resolve(from_date: nil, to_date: nil, runner_type: nil, projects_limit: nil) - authorize! :global + def resolve(from_date: nil, to_date: nil, runner_type: nil, full_path: nil, projects_limit: nil) + find_and_authorize_scope!(full_path) from_date ||= 1.month.ago.beginning_of_month.to_date to_date ||= 1.month.ago.end_of_month.to_date @@ -47,6 +59,7 @@ def resolve(from_date: nil, to_date: nil, runner_type: nil, projects_limit: nil) result = ::Ci::Runners::GetUsageByProjectService.new( current_user, runner_type: runner_type, + scope: @group_scope || @project_scope, from_date: from_date, to_date: to_date, max_item_count: [MAX_PROJECTS_LIMIT, projects_limit || DEFAULT_PROJECTS_LIMIT].min @@ -59,6 +72,18 @@ def resolve(from_date: nil, to_date: nil, runner_type: nil, projects_limit: nil) private + def find_and_authorize_scope!(full_path) + return authorize! :global if full_path.nil? + + strong_memoize_with(:find_and_authorize_scope, full_path) do + @group_scope = Group.find_by_full_path(full_path) + @project_scope = Project.find_by_full_path(full_path) if @group_scope.nil? + + raise_resource_not_available_error! if @group_scope.nil? && @project_scope.nil? + authorize!(@group_scope || @project_scope) + end + end + def prepare_result(payload) payload.map do |project_usage| { diff --git a/ee/app/graphql/resolvers/ci/runner_usage_resolver.rb b/ee/app/graphql/resolvers/ci/runner_usage_resolver.rb index 83c4867be27004a33a7ee71d3adc45f9259a112c..0c1e783dd0cbc543bd463e5e21572a7cd645a69f 100644 --- a/ee/app/graphql/resolvers/ci/runner_usage_resolver.rb +++ b/ee/app/graphql/resolvers/ci/runner_usage_resolver.rb @@ -30,9 +30,10 @@ class RunnerUsageResolver < BaseResolver argument :runners_limit, GraphQL::Types::Int, required: false, default_value: DEFAULT_RUNNERS_LIMIT, - description: 'Maximum number of runners to return. ' \ - 'Other runners will be aggregated to a `runner: null` entry. ' \ - "Defaults to #{DEFAULT_RUNNERS_LIMIT} if unspecified. Maximum of #{MAX_RUNNERS_LIMIT}." + description: + 'Maximum number of runners to return. ' \ + 'Other runners will be aggregated to a `runner: null` entry. ' \ + "Defaults to #{DEFAULT_RUNNERS_LIMIT} if unspecified. Maximum of #{MAX_RUNNERS_LIMIT}." def resolve(from_date: nil, to_date: nil, runner_type: nil, runners_limit: nil) authorize! :global diff --git a/ee/app/graphql/types/ci/runner_usage_by_project_type.rb b/ee/app/graphql/types/ci/runner_usage_by_project_type.rb index f4a6fabca37bf48ea0fecfa7608b807a9d8350c5..b8629a3047a7752f7af3390bfc9104c0e260e85c 100644 --- a/ee/app/graphql/types/ci/runner_usage_by_project_type.rb +++ b/ee/app/graphql/types/ci/runner_usage_by_project_type.rb @@ -2,12 +2,11 @@ module Types module Ci + # rubocop: disable Graphql/AuthorizeTypes -- the read_runner_usage permission is already checked by the resolver class RunnerUsageByProjectType < BaseObject graphql_name 'CiRunnerUsageByProject' description 'Runner usage in minutes by project.' - authorize :read_runner_usage - field :project, ::Types::ProjectType, null: true, description: 'Project that the usage refers to. Null means "Other projects".' @@ -27,5 +26,6 @@ def project end end end + # rubocop: enable Graphql/AuthorizeTypes end end diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb index f68aea3688e5c75eef6879cd9692945eaf529037..f7d13dabaceb7168421056ebfd6e8563d1b9b3f7 100644 --- a/ee/app/policies/ee/group_policy.rb +++ b/ee/app/policies/ee/group_policy.rb @@ -242,6 +242,10 @@ module GroupPolicy @subject.licensed_feature_available?(:runner_performance_insights_for_namespace) end + condition(:clickhouse_main_database_available, scope: :global) do + ::Gitlab::ClickHouse.configured? + end + rule { user_banned_from_namespace }.prevent_all rule { public_group | logged_in_viewable }.policy do @@ -270,6 +274,8 @@ module GroupPolicy enable :admin_wiki enable :modify_product_analytics_settings enable :read_jobs_statistics + enable :read_runner_usage + enable :admin_push_rules end rule { (admin | maintainer) & group_analytics_dashboards_available & ~has_parent }.policy do @@ -716,9 +722,12 @@ module GroupPolicy end rule { ~runner_performance_insights_available }.policy do + prevent :read_runner_usage prevent :read_jobs_statistics end + rule { ~clickhouse_main_database_available }.prevent :read_runner_usage + condition(:pre_receive_secret_detection_available) do ::Gitlab::CurrentSettings.gitlab_dedicated_instance? || ::Feature.enabled?(:pre_receive_secret_detection_push_check, @subject) end diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index fd263748cec2a34718a5ad24d232ee20998e6331..f258c9c42ade854a3abf6207154f87ad42bae8a7 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -525,8 +525,13 @@ module ProjectPolicy enable :modify_product_analytics_settings enable :admin_push_rules enable :manage_deploy_tokens + enable :read_runner_usage end + rule { ~runner_performance_insights_available }.prevent :read_runner_usage + + rule { ~clickhouse_main_database_available }.prevent :read_runner_usage + rule { license_scanning_enabled & can?(:maintainer_access) }.enable :admin_software_license_policy rule { oncall_schedules_available & can?(:maintainer_access) }.enable :admin_incident_management_oncall_schedule @@ -916,6 +921,16 @@ module ProjectPolicy enable :read_web_hook enable :admin_web_hook end + + with_scope :subject + condition(:runner_performance_insights_available) do + @subject.group&.licensed_feature_available?(:runner_performance_insights_for_namespace) + end + + with_scope :global + condition(:clickhouse_main_database_available) do + ::Gitlab::ClickHouse.configured? + end end override :lookup_access_level! diff --git a/ee/app/services/ci/runners/get_usage_service_base.rb b/ee/app/services/ci/runners/get_usage_service_base.rb index 29182e52e7152f46d0e9ee237964e7806760e158..fc603628d174abc56d3d8c7ebe0ef8580418f358 100644 --- a/ee/app/services/ci/runners/get_usage_service_base.rb +++ b/ee/app/services/ci/runners/get_usage_service_base.rb @@ -5,8 +5,29 @@ module Runners class GetUsageServiceBase include Gitlab::Utils::StrongMemoize - def initialize(current_user, runner_type:, from_date:, to_date:, max_item_count:, additional_group_by_columns: []) + MAX_PROJECTS_IN_GROUP = 5_000 + + # Instantiates a new service + # + # @param [User, token String] current_user The current user for whom the report is generated + # @param [DateTime] from_date The start date for the report data (inclusive) + # @param [DateTime] to_date The end date for the report data (exclusive) + # @param [Integer] max_item_count The maximum number of items to include in the report + # @param [Project, Group, nil] scope The top-level object that owns the jobs. + # - Project: filter jobs from the specified project. current_user needs to be at least a maintainer + # - Group: filter jobs from projects under the specified group. current_user needs to be at least a maintainer + # - nil: use all jobs. current_user must be an admin + # @param [String] runner_type The type of CI runner to include data for + # Valid options are defined in `Ci::Runner.runner_types` + # @param [Array<String>] additional_group_by_columns An array of additional columns to group the report data by. + # + # @return [GetUsageServiceBase] + def initialize( + current_user, from_date:, to_date:, max_item_count:, + scope: nil, runner_type: nil, additional_group_by_columns: [] + ) @current_user = current_user + @scope = scope @runner_type = Ci::Runner.runner_types[runner_type] @from_date = from_date @to_date = to_date @@ -20,7 +41,7 @@ def execute reason: :db_not_configured) end - unless Ability.allowed?(@current_user, :read_runner_usage) + unless Ability.allowed?(@current_user, :read_runner_usage, scope || :global) return ServiceResponse.error(message: 'Insufficient permissions', reason: :insufficient_permissions) end @@ -31,7 +52,7 @@ def execute private - attr_reader :runner_type, :from_date, :to_date, :max_item_count, :additional_group_by_columns + attr_reader :scope, :runner_type, :from_date, :to_date, :max_item_count, :additional_group_by_columns def clickhouse_query grouping_columns = ["#{bucket_column}_bucket", *additional_group_by_columns].join(', ') @@ -66,6 +87,18 @@ def bucket_column raise NotImplementedError end + def project_ids + case scope + when ::Project + [scope.id] + when ::Group + # rubocop: disable CodeReuse/ActiveRecord -- the number of returned IDs is limited and the logic is specific + scope.all_projects.limit(MAX_PROJECTS_IN_GROUP).ids + # rubocop: enable CodeReuse/ActiveRecord + end + end + strong_memoize_attr :project_ids + def select_list [ *additional_group_by_columns, @@ -87,6 +120,7 @@ def order_list def where_conditions <<~SQL #{'runner_type = {runner_type: UInt8} AND' if runner_type} + #{'project_id IN {project_ids: Array(UInt64)} AND' if project_ids} finished_at_bucket >= {from_date: DateTime('UTC', 6)} AND finished_at_bucket < {to_date: DateTime('UTC', 6)} SQL @@ -96,6 +130,7 @@ def where_conditions def placeholders { runner_type: runner_type, + project_ids: project_ids&.to_json, from_date: format_date(from_date), to_date: format_date(to_date + 1), # Include jobs until the end of the day max_item_count: max_item_count diff --git a/ee/spec/policies/group_policy_spec.rb b/ee/spec/policies/group_policy_spec.rb index 13607845cb98da224d67781783592becc3b1a30e..d9f5d993beecca05bab05c1a3a85b1f245a5314d 100644 --- a/ee/spec/policies/group_policy_spec.rb +++ b/ee/spec/policies/group_policy_spec.rb @@ -3794,6 +3794,35 @@ def create_member_role(member, abilities = member_role_abilities) end end + describe 'read_runner_usage' do + where(:licensed, :current_user, :enable_admin_mode, :clickhouse_configured, :expected) do + true | ref(:admin) | true | true | true + false | ref(:maintainer) | false | true | false + true | ref(:maintainer) | false | false | false + true | ref(:maintainer) | false | true | true + true | ref(:auditor) | false | true | false + true | ref(:developer) | false | true | false + end + + with_them do + before do + stub_licensed_features(runner_performance_insights_for_namespace: licensed) + + enable_admin_mode!(admin) if enable_admin_mode + + allow(::Gitlab::ClickHouse).to receive(:configured?).and_return(clickhouse_configured) + end + + it 'matches expectation' do + if expected + is_expected.to be_allowed(:read_runner_usage) + else + is_expected.to be_disallowed(:read_runner_usage) + end + end + end + end + describe 'web_hooks' do let(:current_user) { maintainer } diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb index 82791962f287b9aceb4b9a4d89eb264444f79ab6..180af5e5673a327378ca8ebba44732e93e5e93d1 100644 --- a/ee/spec/policies/project_policy_spec.rb +++ b/ee/spec/policies/project_policy_spec.rb @@ -7,6 +7,8 @@ include AdminModeHelper include_context 'ProjectPolicy context' + using RSpec::Parameterized::TableSyntax + let(:project) { public_project } let_it_be(:auditor) { create(:user, :auditor) } @@ -131,8 +133,6 @@ # permissions unless the feature is disabled. project_features.each do |feature, permissions| context "with project feature #{feature}" do - using RSpec::Parameterized::TableSyntax - where(:project_visibility, :access_level, :allowed) do :public | ProjectFeature::ENABLED | true :public | ProjectFeature::PRIVATE | true @@ -193,8 +193,6 @@ end context 'in a group project' do - using RSpec::Parameterized::TableSyntax - let(:project) { public_project_in_group } let(:current_user) { maintainer } @@ -413,8 +411,6 @@ end context 'when SAML SSO is enabled for resource' do - using RSpec::Parameterized::TableSyntax - let(:saml_provider) { create(:saml_provider, enabled: true, enforced_sso: false) } let(:identity) { create(:group_saml_identity, saml_provider: saml_provider) } let(:root_group) { saml_provider.group } @@ -1314,8 +1310,6 @@ let(:policy) { :publish_status_page } context 'when feature is available' do - using RSpec::Parameterized::TableSyntax - where(:role, :admin_mode, :allowed) do :anonymous | nil | false :guest | nil | false @@ -1607,8 +1601,6 @@ describe ':read_code_review_analytics' do let(:project) { private_project } - using RSpec::Parameterized::TableSyntax - where(:role, :admin_mode, :allowed) do :guest | nil | false :reporter | nil | true @@ -1644,8 +1636,6 @@ shared_examples 'merge request approval settings' do |admin_override_allowed = false| let(:project) { private_project } - using RSpec::Parameterized::TableSyntax - context 'with merge request approvers rules available in license' do where(:role, :setting, :admin_mode, :allowed) do :guest | true | nil | false @@ -1706,8 +1696,6 @@ describe ':admin_merge_request_approval_settings' do let(:project) { private_project } - using RSpec::Parameterized::TableSyntax - where(:role, :licensed, :allowed) do :guest | true | false :reporter | true | false @@ -1758,8 +1746,6 @@ end describe 'Quality Management test case' do - using RSpec::Parameterized::TableSyntax - let(:policy) { :create_test_case } where(:role, :admin_mode, :allowed) do @@ -1793,8 +1779,6 @@ end shared_examples_for 'prevents CI cancellation ability' do - using RSpec::Parameterized::TableSyntax - context 'when feature is enabled' do where(:restricted_role, :actual_role, :allowed) do :developer | :guest | false @@ -1840,8 +1824,6 @@ end describe ':compliance_framework_available' do - using RSpec::Parameterized::TableSyntax - let(:policy) { :admin_compliance_framework } where(:role, :feature_enabled, :admin_mode, :allowed) do @@ -1873,8 +1855,6 @@ end describe 'Incident Management on-call schedules' do - using RSpec::Parameterized::TableSyntax - let(:current_user) { public_send(role) } let(:admin_mode) { false } @@ -1943,8 +1923,6 @@ end describe 'Escalation Policies' do - using RSpec::Parameterized::TableSyntax - let(:current_user) { public_send(role) } let(:admin_mode) { false } @@ -2309,8 +2287,6 @@ end describe ':build_read_project' do - using RSpec::Parameterized::TableSyntax - let(:policy) { :build_read_project } where(:role, :project_visibility, :allowed) do @@ -2346,8 +2322,6 @@ end describe 'pending member permissions' do - using RSpec::Parameterized::TableSyntax - let_it_be(:current_user) { create(:user) } let_it_be(:group) { create(:group, :public) } @@ -2435,8 +2409,6 @@ def expect_private_project_permissions_as_if_non_member end describe ':read_approvers' do - using RSpec::Parameterized::TableSyntax - let(:policy) { :read_approvers } where(:role, :allowed) do @@ -2601,8 +2573,6 @@ def expect_private_project_permissions_as_if_non_member end describe 'create_objective' do - using RSpec::Parameterized::TableSyntax - let(:okr_policies) { [:create_objective, :create_key_result] } where(:role, :allowed) do @@ -2646,8 +2616,6 @@ def expect_private_project_permissions_as_if_non_member end describe 'read_member_role' do - using RSpec::Parameterized::TableSyntax - let_it_be_with_reload(:project) { private_project_in_group } let_it_be_with_reload(:current_user) { create(:user) } @@ -3017,8 +2985,6 @@ def create_member_role(member, abilities = member_role_abilities) end describe 'permissions for suggested reviewers bot', :saas do - using RSpec::Parameterized::TableSyntax - let(:permissions) { [:admin_project_member, :create_resource_access_tokens] } let(:namespace) { build_stubbed(:namespace) } let(:project) { build_stubbed(:project, namespace: namespace) } @@ -3077,6 +3043,37 @@ def create_member_role(member, abilities = member_role_abilities) end end + describe 'read_runner_usage' do + where(:licensed, :current_user, :project, :enable_admin_mode, :clickhouse_configured, :expected) do + true | ref(:admin) | ref(:public_project_in_group) | true | true | true + false | ref(:maintainer) | ref(:public_project_in_group) | false | true | false + true | ref(:maintainer) | ref(:public_project_in_group) | false | false | false + true | ref(:maintainer) | ref(:public_project_in_group) | false | true | true + true | ref(:auditor) | ref(:public_project_in_group) | false | true | false + true | ref(:developer) | ref(:public_project_in_group) | false | true | false + true | ref(:admin) | ref(:public_project) | true | true | false + true | ref(:maintainer) | ref(:public_project) | false | true | false + end + + with_them do + before do + stub_licensed_features(runner_performance_insights_for_namespace: licensed) + + enable_admin_mode!(admin) if enable_admin_mode + + allow(::Gitlab::ClickHouse).to receive(:configured?).and_return(clickhouse_configured) + end + + it 'matches expectation' do + if expected + is_expected.to be_allowed(:read_runner_usage) + else + is_expected.to be_disallowed(:read_runner_usage) + end + end + end + end + describe 'workspace creation' do context 'with no user' do let(:current_user) { nil } @@ -3553,8 +3550,6 @@ def create_member_role(member, abilities = member_role_abilities) end describe 'generate_cube_query policy' do - using RSpec::Parameterized::TableSyntax - let(:current_user) { owner } where(:ai_global_switch, :flag_enabled, :licensed, :allowed) do @@ -3586,8 +3581,6 @@ def create_member_role(member, abilities = member_role_abilities) end describe 'read_ai_agents' do - using RSpec::Parameterized::TableSyntax - where(:feature_flag_enabled, :licensed_feature, :current_user, :allowed) do true | true | ref(:owner) | true true | true | ref(:reporter) | true @@ -3621,8 +3614,6 @@ def create_member_role(member, abilities = member_role_abilities) end describe 'write_ai_agents' do - using RSpec::Parameterized::TableSyntax - where(:feature_flag_enabled, :licensed_feature, :current_user, :allowed) do true | true | ref(:owner) | true true | true | ref(:reporter) | true @@ -3723,8 +3714,6 @@ def create_member_role(member, abilities = member_role_abilities) end context 'for self-managed', :with_cloud_connector do - using RSpec::Parameterized::TableSyntax - let_it_be_with_reload(:group) { create(:group) } let(:policy) { :access_duo_chat } @@ -3754,8 +3743,6 @@ def create_member_role(member, abilities = member_role_abilities) end context 'access_duo_features' do - using RSpec::Parameterized::TableSyntax - let(:project) { private_project } where(:current_user, :duo_features_enabled, :cs_matcher) do @@ -3777,7 +3764,6 @@ def create_member_role(member, abilities = member_role_abilities) end describe 'on_demand_scans_enabled policy' do - using RSpec::Parameterized::TableSyntax let(:current_user) { owner } let(:permissions) { [:read_on_demand_dast_scan, :create_on_demand_dast_scan, :edit_on_demand_dast_scan] } @@ -3868,8 +3854,6 @@ def create_member_role(member, abilities = member_role_abilities) end describe 'read_google_cloud_artifact_registry' do - using RSpec::Parameterized::TableSyntax - where(:saas_feature_enabled, :current_user, :match_expected_result) do true | ref(:owner) | be_allowed(:read_google_cloud_artifact_registry) true | ref(:reporter) | be_allowed(:read_google_cloud_artifact_registry) @@ -3891,8 +3875,6 @@ def create_member_role(member, abilities = member_role_abilities) end describe 'admin_google_cloud_artifact_registry' do - using RSpec::Parameterized::TableSyntax - where(:saas_feature_enabled, :current_user, :match_expected_result) do true | ref(:owner) | be_allowed(:admin_google_cloud_artifact_registry) true | ref(:maintainer) | be_allowed(:admin_google_cloud_artifact_registry) @@ -3954,8 +3936,6 @@ def create_member_role(member, abilities = member_role_abilities) end describe 'enable_pre_receive_secret_detection' do - using RSpec::Parameterized::TableSyntax - where(:dedicated_instance, :pre_receive_secret_detection_actor, :current_user, :match_expected_result) do false | project | ref(:owner) | be_allowed(:enable_pre_receive_secret_detection) false | project | ref(:maintainer) | be_allowed(:enable_pre_receive_secret_detection) @@ -3982,8 +3962,6 @@ def create_member_role(member, abilities = member_role_abilities) end describe 'enable_container_scanning_for_registry' do - using RSpec::Parameterized::TableSyntax - where(:container_scanning_for_registry, :current_user, :match_expected_result) do true | ref(:owner) | be_allowed(:enable_container_scanning_for_registry) true | ref(:maintainer) | be_allowed(:enable_container_scanning_for_registry) diff --git a/ee/spec/requests/api/graphql/ci/runner_usage_by_project_spec.rb b/ee/spec/requests/api/graphql/ci/runner_usage_by_project_spec.rb index 8326dbe060fc994d182e0f46c8e9624d0e2b65f9..50eb453920c393a32665bff52fe1d75f9f12dbca 100644 --- a/ee/spec/requests/api/graphql/ci/runner_usage_by_project_spec.rb +++ b/ee/spec/requests/api/graphql/ci/runner_usage_by_project_spec.rb @@ -5,21 +5,28 @@ RSpec.describe 'Query.ciRunnerUsageByProject', :click_house, feature_category: :fleet_visibility do include GraphqlHelpers - let_it_be(:projects) { create_list(:project, 7) } + let_it_be(:group1) { create(:group) } + let_it_be(:projects) { create_list(:project, 7, group: group1) } let_it_be(:project) { projects.first } - let_it_be(:instance_runner) { create(:ci_runner, :instance, :with_runner_manager) } - let_it_be(:project_runner) { create(:ci_runner, :project, :with_runner_manager) } + let_it_be(:instance_runner) { create(:ci_runner, :instance) } + let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project]) } let_it_be(:admin) { create(:user, :admin) } + let_it_be(:group_maintainer) { create(:user, maintainer_of: group1) } + let_it_be(:group_developer) { create(:user, developer_of: group1) } let_it_be(:starting_date) { Date.new(2023) } + let(:full_path) { nil } let(:runner_type) { nil } let(:from_date) { starting_date } let(:to_date) { starting_date + 1.day } let(:projects_limit) { nil } let(:params) do - { runner_type: runner_type, from_date: from_date, to_date: to_date, projects_limit: projects_limit }.compact + { + full_path: full_path, runner_type: runner_type, from_date: from_date, to_date: to_date, + projects_limit: projects_limit + }.compact end let(:query_path) do @@ -78,12 +85,6 @@ include_examples "returns unauthorized or unavailable error" end - context "when runner_performance_insights feature is disabled" do - let(:licensed_feature_available) { false } - - include_examples "returns unauthorized or unavailable error" - end - context "when user is nil" do let(:current_user) { nil } @@ -91,7 +92,7 @@ end context "when user is not admin" do - let(:current_user) { create(:user) } + let(:current_user) { group_developer } include_examples "returns unauthorized or unavailable error" end @@ -119,140 +120,290 @@ let(:other_projects) { projects - top_projects } it "returns #{n} projects consuming most of the runner minutes and one line for the 'rest'" do - builds = top_projects.each_with_index.flat_map do |project, index| - Array.new(index + 1) do - stubbed_build(starting_date, 20.minutes, project: project) + builds = top_projects.flat_map.with_index(1) do |project, index| + Array.new(index) do + stubbed_build(starting_date, 20.minutes, project: project, runner: default_runner) end end builds += other_projects.flat_map do |project| Array.new(3) do - stubbed_build(starting_date, 2.minutes, project: project) + stubbed_build(starting_date, 2.minutes, project: project, runner: default_runner) end end insert_ci_builds_to_click_house(builds) - expected_result = top_projects.each_with_index.flat_map do |project, index| + expected_result = top_projects.flat_map.with_index(1) do |project, index| { - "project" => a_graphql_entity_for(project, :name, :full_path), - "ciMinutesUsed" => (20 * (index + 1)).to_s, - "ciBuildCount" => (index + 1).to_s + 'project' => a_graphql_entity_for(project, :name, :full_path), + 'ciMinutesUsed' => (20 * index).to_s, + 'ciBuildCount' => index.to_s } end.reverse + [{ - "project" => nil, - "ciMinutesUsed" => (other_projects.count * 3 * 2).to_s, - "ciBuildCount" => (other_projects.count * 3).to_s + 'project' => nil, + 'ciMinutesUsed' => (other_projects.count * 3 * 2).to_s, + 'ciBuildCount' => (other_projects.count * 3).to_s }] expect(runner_usage_by_project).to match(expected_result) end end - include_examples 'returns top N projects', 5 - - context 'when projects_limit = 2' do - let(:projects_limit) { 2 } + shared_examples 'a working ciRunnerUsageByProject query' do + context "when runner_performance_insights feature is disabled" do + let(:licensed_feature_available) { false } - include_examples 'returns top N projects', 2 - end + include_examples "returns unauthorized or unavailable error" + end - context 'when projects_limit > MAX_PROJECTS_LIMIT' do - let(:projects_limit) { 5 } + it 'only counts builds from from_date to to_date' do + builds = [from_date - 1.minute, from_date, to_date + 1.day - 1.minute, to_date + 1.day] + .map.with_index(1) do |finished_at, index| + stubbed_build(finished_at, index.minutes, runner: default_runner) + end + insert_ci_builds_to_click_house(builds) - before do - stub_const('Resolvers::Ci::RunnerUsageByProjectResolver::MAX_PROJECTS_LIMIT', 3) + expect(runner_usage_by_project).to contain_exactly({ + 'project' => a_graphql_entity_for(project, :name, :full_path), + 'ciMinutesUsed' => '5', + 'ciBuildCount' => '2' + }) end - include_examples 'returns top N projects', 3 - end + context 'when from_date and to_date are not specified' do + let(:from_date) { nil } + let(:to_date) { nil } - it 'only counts builds from from_date to to_date' do - builds = [from_date - 1.minute, - from_date, - to_date + 1.day - 1.minute, - to_date + 1.day].each_with_index.map do |finished_at, index| - stubbed_build(finished_at, (index + 1).minutes) + around do |example| + travel_to(Date.new(2024, 2, 1)) do + example.run + end + end + + it 'defaults time frame to the last calendar month' do + from_date_default = Date.new(2024, 1, 1) + to_date_default = Date.new(2024, 1, 31) + + builds = [ + from_date_default - 1.minute, + from_date_default, + to_date_default + 1.day - 1.minute, + to_date_default + 1.day + ].map.with_index(1) do |finished_at, index| + stubbed_build(finished_at, index.minutes, runner: default_runner) + end + insert_ci_builds_to_click_house(builds) + + execute_query + expect_graphql_errors_to_be_empty + + expect(runner_usage_by_project).to contain_exactly({ + 'project' => a_graphql_entity_for(project, :name, :full_path), + 'ciMinutesUsed' => '5', + 'ciBuildCount' => '2' + }) + end end - insert_ci_builds_to_click_house(builds) - expect(runner_usage_by_project).to contain_exactly({ - 'project' => a_graphql_entity_for(project, :name, :full_path), - 'ciMinutesUsed' => '5', - 'ciBuildCount' => '2' - }) - end + context 'when runner_type is specified' do + let(:runner_type) { :PROJECT_TYPE } + + it 'filters data by runner type' do + builds = [ + stubbed_build(starting_date, 21.minutes, runner: default_runner), + stubbed_build(starting_date, 33.minutes, runner: project_runner) + ] - context 'when from_date and to_date are not specified' do - let(:from_date) { nil } - let(:to_date) { nil } + insert_ci_builds_to_click_house(builds) - around do |example| - travel_to(Date.new(2024, 2, 1)) do - example.run + expect(runner_usage_by_project).to contain_exactly({ + 'project' => a_graphql_entity_for(project, :name, :full_path), + 'ciMinutesUsed' => '33', + 'ciBuildCount' => '1' + }) end end - it 'defaults time frame to the last calendar month' do - from_date_default = Date.new(2024, 1, 1) - to_date_default = Date.new(2024, 1, 31) + context 'when requesting more than 1 year' do + let(:to_date) { from_date + 13.months } + + it 'returns error' do + execute_query - builds = [from_date_default - 1.minute, - from_date_default, - to_date_default + 1.day - 1.minute, - to_date_default + 1.day].each_with_index.map do |finished_at, index| - stubbed_build(finished_at, (index + 1).minutes) + expect_graphql_errors_to_include("'to_date' must be greater than 'from_date' and be within 1 year") end - insert_ci_builds_to_click_house(builds) + end - expect(runner_usage_by_project).to contain_exactly({ - 'project' => a_graphql_entity_for(project, :name, :full_path), - 'ciMinutesUsed' => '5', - 'ciBuildCount' => '2' - }) + context 'when to_date is before from_date' do + let(:to_date) { from_date - 1.day } + + it 'returns error' do + execute_query + + expect_graphql_errors_to_include("'to_date' must be greater than 'from_date' and be within 1 year") + end end end - context 'when runner_type is specified' do - let(:runner_type) { :PROJECT_TYPE } + context 'when fullPath is not specified' do + let(:full_path) { nil } + let(:default_runner) { instance_runner } - it 'filters data by runner type' do - builds = [ - stubbed_build(starting_date, 21.minutes), - stubbed_build(starting_date, 33.minutes, runner: project_runner) - ] + it_behaves_like 'a working ciRunnerUsageByProject query' - insert_ci_builds_to_click_house(builds) + include_examples 'returns top N projects', 5 - expect(runner_usage_by_project).to contain_exactly({ - 'project' => a_graphql_entity_for(project, :name, :full_path), - 'ciMinutesUsed' => '33', - 'ciBuildCount' => '1' - }) + context 'when projects_limit = 2' do + let(:projects_limit) { 2 } + + include_examples 'returns top N projects', 2 end - end - context 'when requesting more than 1 year' do - let(:to_date) { from_date + 13.months } + context 'when projects_limit > MAX_PROJECTS_LIMIT' do + let(:projects_limit) { 5 } - it 'returns error' do - execute_query + before do + stub_const('Resolvers::Ci::RunnerUsageByProjectResolver::MAX_PROJECTS_LIMIT', 3) + end - expect_graphql_errors_to_include("'to_date' must be greater than 'from_date' and be within 1 year") + include_examples 'returns top N projects', 3 end end - context 'when to_date is before from_date' do - let(:to_date) { from_date - 1.day } + context 'when fullPath is specified' do + let(:full_path) { group1.full_path } + let(:current_user) { group_maintainer } - it 'returns error' do - execute_query + before do + stub_licensed_features(runner_performance_insights_for_namespace: licensed_feature_available) + end + + context 'and fullPath refers to a group' do + let_it_be(:group1_runner) { create(:ci_runner, :group, groups: [group1]) } + + let(:full_path) { group1.full_path } + let(:default_runner) { group1_runner } + + it_behaves_like 'a working ciRunnerUsageByProject query' + + include_examples 'returns top N projects', 5 + + context 'when projects_limit = 2' do + let(:projects_limit) { 2 } + + include_examples 'returns top N projects', 2 + end + + context 'when projects_limit > MAX_PROJECTS_LIMIT' do + let(:projects_limit) { 5 } + + before do + stub_const('Resolvers::Ci::RunnerUsageByProjectResolver::MAX_PROJECTS_LIMIT', 3) + end + + include_examples 'returns top N projects', 3 + end + + context 'when multiple groups exist' do + let_it_be(:group2) { create(:group, maintainers: group_maintainer) } + let_it_be(:project2) { create(:project, group: group2) } + + before do + group2_runner = create(:ci_runner, :group, groups: [group2]) + builds = [ + stubbed_build(1.hour.after(starting_date), 21.minutes, runner: group1_runner, project: project), + stubbed_build(10.hours.after(starting_date), 33.minutes, runner: group2_runner, project: project2) + ] + + insert_ci_builds_to_click_house(builds) + end + + context 'when full_path refers to group1' do + let(:full_path) { group1.full_path } + + it "returns only group's projects" do + expect(runner_usage_by_project).to contain_exactly({ + 'project' => a_graphql_entity_for(project, :name, :full_path), + 'ciMinutesUsed' => '21', + 'ciBuildCount' => '1' + }) + end + end + + context 'when full_path refers to group2' do + let(:full_path) { group2.full_path } - expect_graphql_errors_to_include("'to_date' must be greater than 'from_date' and be within 1 year") + it "returns only group2's projects" do + expect(runner_usage_by_project).to contain_exactly({ + 'project' => a_graphql_entity_for(project2, :name, :full_path), + 'ciMinutesUsed' => '33', + 'ciBuildCount' => '1' + }) + end + end + end + + context 'when specified group is not accessible' do + let(:current_user) { group_developer } + + include_examples 'returns unauthorized or unavailable error' + end + end + + context 'and fullPath refers to a project' do + let(:full_path) { project.full_path } + let(:default_runner) { instance_runner } + + it_behaves_like 'a working ciRunnerUsageByProject query' + + context 'when multiple projects exist' do + let_it_be(:project2) { projects.second } + let_it_be(:project2_runner) { create(:ci_runner, :project, projects: [project2]) } + + before do + builds = [ + stubbed_build(1.hour.after(starting_date), 21.minutes, runner: project_runner, project: project), + stubbed_build(10.hours.after(starting_date), 33.minutes, runner: project2_runner, project: project2) + ] + + insert_ci_builds_to_click_house(builds) + end + + context 'when full_path refers to project' do + let(:full_path) { project.full_path } + + it "returns only stats referring to project" do + expect(runner_usage_by_project).to contain_exactly({ + 'project' => a_graphql_entity_for(project, :name, :full_path), + 'ciMinutesUsed' => '21', + 'ciBuildCount' => '1' + }) + end + end + + context 'when full_path refers to project2' do + let(:full_path) { project2.full_path } + + it "returns only stats referring to project2" do + expect(runner_usage_by_project).to contain_exactly({ + 'project' => a_graphql_entity_for(project2, :name, :full_path), + 'ciMinutesUsed' => '33', + 'ciBuildCount' => '1' + }) + end + end + end + + context 'when specified project is not accessible' do + let(:current_user) { group_developer } + + include_examples 'returns unauthorized or unavailable error' + end end end - def stubbed_build(finished_at, duration, project: projects.first, runner: instance_runner) + def stubbed_build(finished_at, duration, runner:, project: projects.first) created_at = finished_at - duration build_stubbed(:ci_build, @@ -262,7 +413,6 @@ def stubbed_build(finished_at, duration, project: projects.first, runner: instan queued_at: created_at, started_at: created_at, finished_at: finished_at, - runner: runner, - runner_manager: runner.runner_managers.first) + runner: runner) end end diff --git a/ee/spec/services/ci/runners/get_usage_by_project_service_spec.rb b/ee/spec/services/ci/runners/get_usage_by_project_service_spec.rb index ba3fe6e15662713ccd0aef1e629b05b5b4ec28bf..9c8c429f64c4ea63155d5facd9ed37ffe2e17045 100644 --- a/ee/spec/services/ci/runners/get_usage_by_project_service_spec.rb +++ b/ee/spec/services/ci/runners/get_usage_by_project_service_spec.rb @@ -2,38 +2,38 @@ require 'spec_helper' -RSpec.describe Ci::Runners::GetUsageByProjectService, :click_house, :enable_admin_mode, - feature_category: :fleet_visibility do - let_it_be(:user) { create(:admin) } - let_it_be(:instance_runner) { create(:ci_runner, :instance, :with_runner_manager) } +RSpec.describe Ci::Runners::GetUsageByProjectService, :click_house, feature_category: :fleet_visibility do + let_it_be(:instance_runner) { create(:ci_runner, :instance) } let_it_be(:group) { create(:group) } let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group]) } + let_it_be(:projects) { create_list(:project, 21, group: group) } + let_it_be(:project1) { projects.first } + let_it_be(:starting_time) { DateTime.new(2023, 12, 31, 21, 0, 0) } + let_it_be(:developer) { create(:user, developer_of: group) } let_it_be(:builds) do - starting_time = DateTime.new(2023, 12, 31, 21, 0, 0) - - builds = Array.new(20) do |i| - project = create(:project, group: group) + builds = projects.reject { |p| p == project1 }.map.with_index do |project, i| create_build(instance_runner, project, starting_time + (50.minutes * i), (14 + i).minutes, Ci::HasStatus::COMPLETED_STATUSES[i % Ci::HasStatus::COMPLETED_STATUSES.size]) end - project = create(:project, group: group) - builds << create_build(group_runner, project, starting_time, 2.hours, :failed) - builds << create_build(instance_runner, project, starting_time, 10.minutes, :failed) - builds << create_build(instance_runner, project, starting_time, 7.minutes) - builds << create_build(group_runner, project, starting_time, 3.minutes, :canceled) + builds << create_build(group_runner, project1, starting_time, 2.hours, :failed) + builds << create_build(instance_runner, project1, starting_time, 10.minutes, :failed) + builds << create_build(instance_runner, project1, starting_time, 7.minutes) + builds << create_build(group_runner, project1, starting_time, 3.minutes, :canceled) builds end + let(:scope) { nil } let(:runner_type) { nil } let(:from_date) { Date.new(2023, 12, 1) } let(:to_date) { Date.new(2023, 12, 31) } let(:max_item_count) { 50 } - let(:additional_group_by_columns) { [] } + let(:additional_group_by_columns) { nil } let(:service) do - described_class.new(user, runner_type: runner_type, from_date: from_date, to_date: to_date, - additional_group_by_columns: additional_group_by_columns, max_item_count: max_item_count) + described_class.new(user, + **{ scope: scope, runner_type: runner_type, from_date: from_date, to_date: to_date, + additional_group_by_columns: additional_group_by_columns, max_item_count: max_item_count }.compact) end let(:result) { service.execute } @@ -46,9 +46,7 @@ insert_ci_builds_to_click_house(builds) end - context 'when user has not enough permissions' do - let_it_be(:user) { create(:user) } - + shared_examples 'a user without required permissions' do it 'returns error' do expect(result).to be_error expect(result.message).to eq('Insufficient permissions') @@ -56,105 +54,192 @@ end end - context 'when ClickHouse database is not configured' do - before do - allow(::Gitlab::ClickHouse).to receive(:configured?).and_return(false) - end - - it 'returns error' do - expect(result).to be_error - expect(result.message).to eq('ClickHouse database is not configured') - expect(result.reason).to eq(:db_not_configured) - end - end - - it 'contains 24 builds in source ci_finished_builds table' do - expect(ClickHouse::Client.select('SELECT count() FROM ci_finished_builds FINAL', :main)) - .to contain_exactly({ 'count()' => 24 }) + it_behaves_like 'a user without required permissions' do + let(:user) { developer } end - it 'exports usage data' do - is_expected.to eq([ - { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 4, 'total_duration_in_mins' => 140 }, - { 'project_id_bucket' => builds[3].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 17 }, - { 'project_id_bucket' => builds[2].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 16 }, - { 'project_id_bucket' => builds[1].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 15 }, - { 'project_id_bucket' => builds[0].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 14 } - ]) - end + context 'when user is admin', :enable_admin_mode do + let_it_be(:user) { create(:admin) } - context 'when additional_group_by_columns specified' do - let(:additional_group_by_columns) { [:status, :runner_type] } + context 'when ClickHouse database is not configured' do + before do + allow(::Gitlab::ClickHouse).to receive(:configured?).and_return(false) + end - it 'exports usage data grouped by status and runner_type' do - is_expected.to eq([ - { 'project_id_bucket' => builds.last.project.id, 'status' => 'failed', 'runner_type' => 2, - 'count_builds' => 1, 'total_duration_in_mins' => 120 }, - { 'project_id_bucket' => builds[3].project.id, 'status' => 'skipped', 'runner_type' => 1, - 'count_builds' => 1, 'total_duration_in_mins' => 17 }, - { 'project_id_bucket' => builds[2].project.id, 'status' => 'canceled', 'runner_type' => 1, - 'count_builds' => 1, 'total_duration_in_mins' => 16 }, - { 'project_id_bucket' => builds[1].project.id, 'status' => 'failed', 'runner_type' => 1, 'count_builds' => 1, - 'total_duration_in_mins' => 15 }, - { 'project_id_bucket' => builds[0].project.id, 'status' => 'success', 'runner_type' => 1, - 'count_builds' => 1, 'total_duration_in_mins' => 14 }, - { 'project_id_bucket' => builds.last.project.id, 'status' => 'failed', 'runner_type' => 1, - 'count_builds' => 1, 'total_duration_in_mins' => 10 }, - { 'project_id_bucket' => builds.last.project.id, 'status' => 'success', 'runner_type' => 1, - 'count_builds' => 1, 'total_duration_in_mins' => 7 }, - { 'project_id_bucket' => builds.last.project.id, 'status' => 'canceled', 'runner_type' => 2, - 'count_builds' => 1, 'total_duration_in_mins' => 3 } - ]) + it 'returns error' do + expect(result).to be_error + expect(result.message).to eq('ClickHouse database is not configured') + expect(result.reason).to eq(:db_not_configured) + end end - end - context 'when the number of projects exceeds max_item_count' do - let(:max_item_count) { 2 } + it 'contains 24 builds in source ci_finished_builds table' do + expect(ClickHouse::Client.select('SELECT count() FROM ci_finished_builds FINAL', :main)) + .to contain_exactly({ 'count()' => 24 }) + end - it 'exports usage data for the 2 top projects plus aggregate for other projects' do + it 'exports usage data' do is_expected.to eq([ { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 4, 'total_duration_in_mins' => 140 }, { 'project_id_bucket' => builds[3].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 17 }, - { 'project_id_bucket' => nil, 'count_builds' => 3, 'total_duration_in_mins' => 45 } + { 'project_id_bucket' => builds[2].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 16 }, + { 'project_id_bucket' => builds[1].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 15 }, + { 'project_id_bucket' => builds[0].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 14 } ]) end - end - context 'with group_type runner_type argument specified' do - let(:runner_type) { :group_type } + context 'when additional_group_by_columns specified' do + let(:additional_group_by_columns) { %i[status runner_type] } - it 'exports usage data for runners of specified type' do - is_expected.to contain_exactly( - { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 2, 'total_duration_in_mins' => 123 } - ) + it 'exports usage data grouped by status and runner_type' do + is_expected.to eq([ + { 'project_id_bucket' => builds.last.project.id, 'status' => 'failed', 'runner_type' => 2, + 'count_builds' => 1, 'total_duration_in_mins' => 120 }, + { 'project_id_bucket' => builds[3].project.id, 'status' => 'skipped', 'runner_type' => 1, + 'count_builds' => 1, 'total_duration_in_mins' => 17 }, + { 'project_id_bucket' => builds[2].project.id, 'status' => 'canceled', 'runner_type' => 1, + 'count_builds' => 1, 'total_duration_in_mins' => 16 }, + { 'project_id_bucket' => builds[1].project.id, 'status' => 'failed', 'runner_type' => 1, 'count_builds' => 1, + 'total_duration_in_mins' => 15 }, + { 'project_id_bucket' => builds[0].project.id, 'status' => 'success', 'runner_type' => 1, + 'count_builds' => 1, 'total_duration_in_mins' => 14 }, + { 'project_id_bucket' => builds.last.project.id, 'status' => 'failed', 'runner_type' => 1, + 'count_builds' => 1, 'total_duration_in_mins' => 10 }, + { 'project_id_bucket' => builds.last.project.id, 'status' => 'success', 'runner_type' => 1, + 'count_builds' => 1, 'total_duration_in_mins' => 7 }, + { 'project_id_bucket' => builds.last.project.id, 'status' => 'canceled', 'runner_type' => 2, + 'count_builds' => 1, 'total_duration_in_mins' => 3 } + ]) + end end - end - context 'with project_type runner_type argument specified' do - let(:runner_type) { :project_type } + context 'when the number of projects exceeds max_item_count' do + let(:max_item_count) { 2 } + + it 'exports usage data for the 2 top projects plus aggregate for other projects' do + is_expected.to eq([ + { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 4, 'total_duration_in_mins' => 140 }, + { 'project_id_bucket' => builds[3].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 17 }, + { 'project_id_bucket' => nil, 'count_builds' => 3, 'total_duration_in_mins' => 45 } + ]) + end + end + + context 'with group_type runner_type argument specified' do + let(:runner_type) { :group_type } + + it 'exports usage data for runners of specified type' do + is_expected.to contain_exactly( + { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 2, 'total_duration_in_mins' => 123 } + ) + end + end + + context 'with project_type runner_type argument specified' do + let(:runner_type) { :project_type } + + it 'exports usage data for runners of specified type' do + is_expected.to eq([]) + end + end + + context 'when dates are set' do + let_it_be(:project) { create(:project) } + + let(:from_date) { Date.new(2024, 1, 2) } + let(:to_date) { Date.new(2024, 1, 2) } - it 'exports usage data for runners of specified type' do - is_expected.to eq([]) + let(:build_before) { create_build(instance_runner, project, Date.new(2024, 1, 1)) } + let(:build_in_range) { create_build(instance_runner, project, Date.new(2024, 1, 2), 111.minutes) } + let(:build_overflowing_the_range) { create_build(instance_runner, project, Date.new(2024, 1, 2, 23), 61.minutes) } + let(:build_after) { create_build(instance_runner, project, Date.new(2024, 1, 3)) } + + let(:builds) { [build_before, build_in_range, build_overflowing_the_range, build_after] } + + it 'only exports usage data for builds created in the date range' do + is_expected.to contain_exactly( + { 'project_id_bucket' => project.id, 'count_builds' => 2, 'total_duration_in_mins' => 172 } + ) + end end end - context 'when dates are set' do - let(:from_date) { Date.new(2024, 1, 2) } - let(:to_date) { Date.new(2024, 1, 2) } + context 'when scope is specified' do + let_it_be(:group_maintainer) { create(:user, maintainer_of: group) } + let_it_be(:group2) { create(:group, maintainers: [group_maintainer]) } + let_it_be(:group2_project) { create(:project, group: group2) } + + let(:user) { group_maintainer } + let(:project2) { projects.second } + + before do + stub_licensed_features(runner_performance_insights_for_namespace: true) + end + + context 'with scope set to group' do + let(:scope) { group } + + it_behaves_like 'a user without required permissions' do + let(:user) { developer } + end + + it 'exports usage data' do + expect(result.errors).to be_empty - let(:project) { create(:project) } + expect(data).to eq([ + { 'project_id_bucket' => builds.last.project.id, 'count_builds' => 4, 'total_duration_in_mins' => 140 }, + { 'project_id_bucket' => builds[3].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 17 }, + { 'project_id_bucket' => builds[2].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 16 }, + { 'project_id_bucket' => builds[1].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 15 }, + { 'project_id_bucket' => builds[0].project.id, 'count_builds' => 1, 'total_duration_in_mins' => 14 } + ]) + end + end + + context 'with scope set to a different group' do + let(:scope) { group2 } + + before do + build = create_build(group_runner, group2_project, starting_time, 2.hours, :failed) + + insert_ci_builds_to_click_house([build]) + end + + it 'exports usage data' do + expect(result.errors).to be_empty + + expect(data).to contain_exactly( + { 'project_id_bucket' => group2_project.id, 'count_builds' => 1, 'total_duration_in_mins' => 120 } + ) + end + end + + context 'with scope set to project1' do + let(:scope) { project1 } + + it_behaves_like 'a user without required permissions' do + let(:user) { developer } + end + + it 'exports usage data' do + expect(result.errors).to be_empty + + expect(data).to contain_exactly( + { 'project_id_bucket' => scope.id, 'count_builds' => 4, 'total_duration_in_mins' => 140 } + ) + end + end - let(:build_before) { create_build(instance_runner, project, Date.new(2024, 1, 1)) } - let(:build_in_range) { create_build(instance_runner, project, Date.new(2024, 1, 2), 111.minutes) } - let(:build_overflowing_the_range) { create_build(instance_runner, project, Date.new(2024, 1, 2, 23), 61.minutes) } - let(:build_after) { create_build(instance_runner, project, Date.new(2024, 1, 3)) } + context 'with scope set to project2' do + let(:scope) { project2 } - let(:builds) { [build_before, build_in_range, build_overflowing_the_range, build_after] } + it 'exports usage data' do + expect(result.errors).to be_empty - it 'only exports usage data for builds created in the date range' do - is_expected.to contain_exactly( - { 'project_id_bucket' => project.id, 'count_builds' => 2, 'total_duration_in_mins' => 172 } - ) + expect(data).to contain_exactly( + { 'project_id_bucket' => scope.id, 'count_builds' => 1, 'total_duration_in_mins' => 14 } + ) + end end end @@ -168,7 +253,6 @@ def create_build(runner, project, created_at, duration = 14.minutes, status = :s started_at: started_at, finished_at: started_at + duration, project: project, - runner: runner, - runner_manager: runner.runner_managers.first) + runner: runner) end end