diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 3e15f9b535ee2b02113ab848f312e3db9d5f624f..8b83625f10b1cd2ab838b03f02da1042f18142c7 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -22,6 +22,7 @@ def execute all_runners end + items = by_ids(items) items = search(items) items = by_active(items) items = by_status(items) @@ -45,6 +46,12 @@ def sort_key attr_reader :group, :project + def by_ids(items) + return items unless @params[:id_in] + + items.id_in(@params[:id_in]) + end + def runner_type @params[:type_type]&.to_sym end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index ae8e09f2135b75bb974fb50e6c32f2255f009500..ef15569c16c7b42e1fe3730596083b5dcdb9c5ee 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -13,6 +13,11 @@ class BasePolicy < DeclarativePolicy::Base end end + desc "The current instance is a GitLab Dedicated instance" + condition :gitlab_dedicated do + Gitlab::CurrentSettings.gitlab_dedicated_instance? + end + desc "User is blocked" with_options scope: :user, score: 0 condition(:blocked) { @user&.blocked? } @@ -92,6 +97,10 @@ class BasePolicy < DeclarativePolicy::Base enable :change_repository_storage end + rule { gitlab_dedicated & admin }.policy do + enable :read_dedicated_hosted_runner_usage + end + rule { default }.enable :read_cross_project condition(:is_gitlab_com, score: 0, scope: :global) { ::Gitlab.com? } diff --git a/db/migrate/20250225045649_add_billing_month_year_index.rb b/db/migrate/20250225045649_add_billing_month_year_index.rb new file mode 100644 index 0000000000000000000000000000000000000000..210b4af153071a735e2f45eceaae480e2b9a5b88 --- /dev/null +++ b/db/migrate/20250225045649_add_billing_month_year_index.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddBillingMonthYearIndex < Gitlab::Database::Migration[2.2] + disable_ddl_transaction! + milestone '17.10' + + INDEX_NAME = 'idx_gitlab_hosted_runner_monthly_usages_on_billing_month_year' + + def up + add_concurrent_index :ci_gitlab_hosted_runner_monthly_usages, "EXTRACT(YEAR FROM billing_month)", name: INDEX_NAME, + using: :btree + end + + def down + remove_concurrent_index_by_name :ci_gitlab_hosted_runner_monthly_usages, name: INDEX_NAME + end +end diff --git a/db/schema_migrations/20250225045649 b/db/schema_migrations/20250225045649 new file mode 100644 index 0000000000000000000000000000000000000000..a1edbda4d63fb35caca64c59f2f1dbd91ab74e66 --- /dev/null +++ b/db/schema_migrations/20250225045649 @@ -0,0 +1 @@ +80718abeb6da1c012b1b339f5ad3e57746cffb0473185f95d37edb4b76fb494a \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 1d9cfad8d85e310fd98135a1ca8c45881385a4a9..c72b6c1645b95b77821134b05b4012b86ec6778d 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -31123,6 +31123,8 @@ CREATE UNIQUE INDEX idx_external_audit_event_destination_id_key_uniq ON audit_ev CREATE INDEX idx_external_status_checks_on_id_and_project_id ON external_status_checks USING btree (id, project_id); +CREATE INDEX idx_gitlab_hosted_runner_monthly_usages_on_billing_month_year ON ci_gitlab_hosted_runner_monthly_usages USING btree (EXTRACT(year FROM billing_month)); + CREATE INDEX idx_gpg_keys_on_user_externally_verified ON gpg_keys USING btree (user_id) WHERE (externally_verified = true); CREATE INDEX idx_group_audit_events_on_author_id_created_at_id ON ONLY group_audit_events USING btree (author_id, created_at, id); diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index b33088e712c160f8cd66f8e97e740b86b29f7fff..46bc35fe7e690b027123d6b8ce4410f353366940 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -409,6 +409,12 @@ Returns [`CiConfig`](#ciconfig). | <a id="queryciconfigsha"></a>`sha` | [`String`](#string) | Sha for the pipeline. | | <a id="queryciconfigskipverifyprojectsha"></a>`skipVerifyProjectSha` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 16.5. **Status**: Experiment. If the provided `sha` is found in the project's repository but is not associated with a Git reference (a detached commit), the verification fails and a validation error is returned. Otherwise, verification passes, even if the `sha` is invalid. Set to `true` to skip this verification process. | +### `Query.ciDedicatedHostedRunnerFilters` + +Returns available filters for GitLab Dedicated runner usage data. + +Returns [`CiDedicatedHostedRunnerFilters`](#cidedicatedhostedrunnerfilters). + ### `Query.ciDedicatedHostedRunnerUsage` Compute usage data for runners across namespaces on GitLab Dedicated. Defaults to the current year if no year or billing month is specified. Ultimate only. @@ -21722,6 +21728,17 @@ CI/CD config variables. | <a id="ciconfigvariablevalue"></a>`value` | [`String`](#string) | Value of the variable. | | <a id="ciconfigvariablevalueoptions"></a>`valueOptions` | [`[String!]`](#string) | Value options for the variable. | +### `CiDedicatedHostedRunnerFilters` + +Filter options available for GitLab Dedicated runner usage data. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cidedicatedhostedrunnerfiltersrunners"></a>`runners` | [`CiRunnerConnection`](#cirunnerconnection) | List of unique runners with usage data. (see [Connections](#connections)) | +| <a id="cidedicatedhostedrunnerfiltersyears"></a>`years` | [`[Int!]`](#int) | List of years with available usage data. | + ### `CiDedicatedHostedRunnerUsage` Compute usage data for hosted runners on GitLab Dedicated. diff --git a/ee/app/graphql/ee/types/ci/minutes/dedicated_runner_filters_type.rb b/ee/app/graphql/ee/types/ci/minutes/dedicated_runner_filters_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..daec8e5dea7968ba1f7ee3180a88973084ccefb3 --- /dev/null +++ b/ee/app/graphql/ee/types/ci/minutes/dedicated_runner_filters_type.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# rubocop:disable Gitlab/EeOnlyClass -- This is only used in GitLab dedicated that comes under ultimate tier only. +module EE + module Types + module Ci + module Minutes + class DedicatedRunnerFiltersType < ::Types::BaseObject + graphql_name 'CiDedicatedHostedRunnerFilters' + description 'Filter options available for GitLab Dedicated runner usage data.' + + include ::Gitlab::Graphql::Authorize::AuthorizeResource + + field :runners, ::Types::Ci::RunnerType.connection_type, null: true, + description: 'List of unique runners with usage data.' + field :years, [GraphQL::Types::Int], null: true, + description: 'List of years with available usage data.' + + def runners + raise_resource_not_available_error! unless allowed? + + runner_ids = ::Ci::Minutes::GitlabHostedRunnerMonthlyUsage.distinct_runner_ids + + ::Ci::RunnersFinder + .new(current_user: context[:current_user], params: { id_in: runner_ids }) + .execute + end + + def years + raise_resource_not_available_error! unless allowed? + + ::Ci::Minutes::GitlabHostedRunnerMonthlyUsage.distinct_years + end + + def allowed? + current_user.can?(:read_dedicated_hosted_runner_usage) + end + end + end + end + end +end +# rubocop:enable Gitlab/EeOnlyClass diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb index 9b496bb1a1b5744c13683b2549997eed5fe0383f..c28c6a58ca43d3069e30645a96605df902d14edb 100644 --- a/ee/app/graphql/ee/types/query_type.rb +++ b/ee/app/graphql/ee/types/query_type.rb @@ -33,6 +33,10 @@ module QueryType required: false, description: 'Date for which to retrieve the usage data, should be the first day of a month.' end + field :ci_dedicated_hosted_runner_filters, Types::Ci::Minutes::DedicatedRunnerFiltersType, + null: true, + fallback_value: {}, + description: 'Returns available filters for GitLab Dedicated runner usage data.' field :ci_dedicated_hosted_runner_usage, Types::Ci::Minutes::DedicatedMonthlyUsageType.connection_type, null: true, resolver: EE::Resolvers::Ci::Minutes::DedicatedMonthlyUsageResolver, diff --git a/ee/app/models/ci/minutes/gitlab_hosted_runner_monthly_usage.rb b/ee/app/models/ci/minutes/gitlab_hosted_runner_monthly_usage.rb index 85f99347273211d50dc701baf47d942ea1c7cdd5..9749b715d93fde8a3b83feae6cfd7c19701caf9e 100644 --- a/ee/app/models/ci/minutes/gitlab_hosted_runner_monthly_usage.rb +++ b/ee/app/models/ci/minutes/gitlab_hosted_runner_monthly_usage.rb @@ -6,6 +6,7 @@ module Minutes # For gitlab dedicated hosted runners only class GitlabHostedRunnerMonthlyUsage < Ci::ApplicationRecord include Ci::NamespacedModelName + include LooseIndexScan belongs_to :project, inverse_of: :hosted_runner_monthly_usages belongs_to :root_namespace, class_name: 'Namespace', inverse_of: :hosted_runner_monthly_usages @@ -56,6 +57,22 @@ class GitlabHostedRunnerMonthlyUsage < Ci::ApplicationRecord query end + # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- This will be done on GitLab dedicated only. Result set would be small. + scope :distinct_runner_ids, -> do + loose_index_scan(column: :runner_id) + .joins(:runner) + .pluck(:runner_id) + end + # rubocop:enable Database/AvoidUsingPluckWithoutLimit + + # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- Result set is small. + scope :distinct_years, -> do + distinct + .pluck(Arel.sql('EXTRACT(YEAR FROM billing_month)::integer')) + .sort + end + # rubocop:enable Database/AvoidUsingPluckWithoutLimit + private def self.billing_month_range(billing_month, year) diff --git a/ee/spec/graphql/ee/types/ci/minutes/dedicated_runner_filters_type_spec.rb b/ee/spec/graphql/ee/types/ci/minutes/dedicated_runner_filters_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e7f1ec0864ad9a0ed046b328c0f9a89f655c4b85 --- /dev/null +++ b/ee/spec/graphql/ee/types/ci/minutes/dedicated_runner_filters_type_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CiDedicatedHostedRunnerFilters'], feature_category: :hosted_runners do + include GraphqlHelpers + + let_it_be(:admin) { create(:admin) } + let_it_be(:runner1) { create(:ci_runner) } + let_it_be(:runner2) { create(:ci_runner) } + let_it_be(:usage1) { create(:ci_hosted_runner_monthly_usage, runner: runner1, billing_month: Date.new(2023, 1, 1)) } + let_it_be(:usage2) { create(:ci_hosted_runner_monthly_usage, runner: runner2, billing_month: Date.new(2024, 1, 1)) } + + let(:filters) { graphql_response.dig('data', 'ciDedicatedHostedRunnerFilters') } + let(:query) do + %( + query { + ciDedicatedHostedRunnerFilters { + #{query_field} + } + } + ) + end + + let_it_be(:fields) { %i[runners years] } + + subject(:graphql_response) { GitlabSchema.execute(query, context: { current_user: admin }).as_json } + + before do + stub_application_setting(gitlab_dedicated_instance: true) + end + + it { expect(described_class).to have_graphql_fields(fields) } + + describe 'fields' do + let(:type_fields) { described_class.fields } + + it { expect(type_fields['runners'].type).to be_present } + it { expect(type_fields['years'].type).to be_present } + it { expect(type_fields['years'].type.to_type_signature).to eq('[Int!]') } + end + + describe 'runners field' do + let(:query_field) { 'runners { nodes { id } }' } + + context 'when user is not authorized' do + let(:unauthorized_user) { create(:user) } + + subject(:graphql_response) { GitlabSchema.execute(query, context: { current_user: unauthorized_user }).as_json } + + it 'returns nil' do + expect(filters['runners']).to be_nil + end + end + + context 'when user is authorized', :enable_admin_mode do + it 'returns distinct runners' do + runner_ids = filters.dig('runners', 'nodes').pluck('id') + expect(runner_ids).to contain_exactly( + runner1.to_global_id.to_s, + runner2.to_global_id.to_s + ) + end + end + end + + describe 'years field' do + let(:query_field) { 'years' } + + context 'when user is not authorized' do + let(:unauthorized_user) { create(:user) } + + subject(:graphql_response) { GitlabSchema.execute(query, context: { current_user: unauthorized_user }).as_json } + + it 'returns nil' do + expect(filters['years']).to be_nil + end + end + + context 'when user is authorized', :enable_admin_mode do + it 'returns all distinct years' do + expect(filters['years']).to contain_exactly(2023, 2024) + end + end + end +end diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb index 3608cdccc519a0aae00d3d7786563e641b970968..aad41d0f30957afdbea0900ca167806911dc3584 100644 --- a/ee/spec/graphql/types/query_type_spec.rb +++ b/ee/spec/graphql/types/query_type_spec.rb @@ -15,6 +15,7 @@ :ci_catalog_resources, :ci_catalog_resource, :ci_minutes_usage, + :ci_dedicated_hosted_runner_filters, :ci_dedicated_hosted_runner_usage, :ci_queueing_history, :current_license, diff --git a/ee/spec/models/ci/minutes/gitlab_hosted_runner_monthly_usage_spec.rb b/ee/spec/models/ci/minutes/gitlab_hosted_runner_monthly_usage_spec.rb index 743b3bcc5c93a1ae8865ec08041a2c434b1b0029..d28beb651bad5e5841c58d01bbf9817e04697a56 100644 --- a/ee/spec/models/ci/minutes/gitlab_hosted_runner_monthly_usage_spec.rb +++ b/ee/spec/models/ci/minutes/gitlab_hosted_runner_monthly_usage_spec.rb @@ -142,4 +142,35 @@ end end end + + describe '.distinct_runner_ids' do + let_it_be(:runner1) { create(:ci_runner) } + let_it_be(:runner2) { create(:ci_runner) } + + before do + create(:ci_hosted_runner_monthly_usage, runner: runner1) + create(:ci_hosted_runner_monthly_usage, runner: runner1) # Duplicate usage for same runner + create(:ci_hosted_runner_monthly_usage, runner: runner2) + end + + it 'returns distinct runner IDs' do + expect(described_class.distinct_runner_ids).to contain_exactly(runner1.id, runner2.id) + end + end + + describe '.distinct_years' do + before do + create(:ci_hosted_runner_monthly_usage, billing_month: Date.new(2023, 1, 1)) + create(:ci_hosted_runner_monthly_usage, billing_month: Date.new(2023, 2, 1)) # Same year + create(:ci_hosted_runner_monthly_usage, billing_month: Date.new(2024, 1, 1)) + end + + it 'returns distinct years' do + expect(described_class.distinct_years).to contain_exactly(2023, 2024) + end + + it 'returns years in ascending order' do + expect(described_class.distinct_years).to eq([2023, 2024]) + end + end end diff --git a/spec/finders/ci/runners_finder_spec.rb b/spec/finders/ci/runners_finder_spec.rb index a2ff9b06c0f8d4fbded501b50f24130dc199acb2..f15f49cc287a5c477714053b5e9532efba30dcde 100644 --- a/spec/finders/ci/runners_finder_spec.rb +++ b/spec/finders/ci/runners_finder_spec.rb @@ -55,6 +55,36 @@ end context 'filtering' do + context 'by ids' do + let_it_be(:runner1) { create(:ci_runner) } + let_it_be(:runner2) { create(:ci_runner) } + let_it_be(:runner3) { create(:ci_runner) } + + context 'when id_in param is provided' do + let(:params) { { id_in: [runner1.id, runner2.id] } } + + it 'returns runners with matching ids' do + expect(execute).to contain_exactly(runner1, runner2) + end + end + + context 'when id_in param is empty' do + let(:params) { { id_in: [] } } + + it 'returns no runners' do + expect(execute).to be_empty + end + end + + context 'when id_in param contains non-existing ids' do + let(:params) { { id_in: [non_existing_record_id] } } + + it 'returns no runners' do + expect(execute).to be_empty + end + end + end + context 'by search term' do let(:params) { { search: 'term' } } @@ -523,6 +553,14 @@ end context 'filtering' do + context 'by ids' do + let(:extra_params) { { id_in: [runner_group.id, runner_sub_group_1.id] } } + + it 'returns correct runners' do + expect(subject).to contain_exactly(runner_group, runner_sub_group_1) + end + end + context 'by search term' do let(:extra_params) { { search: 'runner_project_search' } } @@ -725,6 +763,14 @@ let_it_be(:runner_other_project_paused) { create(:ci_runner, :project, :paused, :online, projects: [other_project]) } let_it_be(:runner_manager) { create(:ci_runner_machine, runner: runner_instance_paused, version: '15.10.0') } + context 'by ids' do + let(:extra_params) { { id_in: [runner_project_active.id, runner_project_paused.id] } } + + it 'returns correct runners' do + expect(subject).to contain_exactly(runner_project_active, runner_project_paused) + end + end + context 'by search term' do let_it_be(:runner_project_1) { create(:ci_runner, :project, :online, description: 'runner_project_search', projects: [project]) } let_it_be(:runner_project_2) { create(:ci_runner, :project, :online, description: 'runner_project', projects: [project]) } diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb index cb3de5c72a0cff365076cfabf9659586663d07f5..5512484d4c335f06c2bb7f6b933e559895e3e6ec 100644 --- a/spec/policies/base_policy_spec.rb +++ b/spec/policies/base_policy_spec.rb @@ -81,6 +81,60 @@ def policy end end + describe 'read_dedicated_hosted_runner_usage' do + let(:current_user) { build_stubbed(:user) } + + subject { described_class.new(current_user, nil) } + + context 'for a regular user' do + it { is_expected.not_to be_allowed(:read_dedicated_hosted_runner_usage) } + end + + context 'with an admin' do + let(:current_user) { build_stubbed(:admin) } + + before do + enable_admin_mode!(current_user) + end + + context 'on a non-dedicated instance' do + before do + allow(Gitlab::CurrentSettings).to receive(:gitlab_dedicated_instance?).and_return(false) + end + + it { is_expected.not_to be_allowed(:read_dedicated_hosted_runner_usage) } + end + + context 'on a dedicated instance' do + before do + allow(Gitlab::CurrentSettings).to receive(:gitlab_dedicated_instance?).and_return(true) + end + + it { is_expected.to be_allowed(:read_dedicated_hosted_runner_usage) } + end + end + + context 'with an admin not in admin mode' do + let(:current_user) { build_stubbed(:admin) } + + before do + allow(Gitlab::CurrentSettings).to receive(:gitlab_dedicated_instance?).and_return(true) + end + + it { is_expected.not_to be_allowed(:read_dedicated_hosted_runner_usage) } + end + + context 'with anonymous' do + let(:current_user) { nil } + + before do + allow(Gitlab::CurrentSettings).to receive(:gitlab_dedicated_instance?).and_return(true) + end + + it { is_expected.not_to be_allowed(:read_dedicated_hosted_runner_usage) } + end + end + describe 'read cross project' do let(:current_user) { build_stubbed(:user) } let(:user) { build_stubbed(:user) }