diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..710706325cc91c42ddb1d2ea16f9c2f7aab2e093 --- /dev/null +++ b/app/graphql/resolvers/ci/runners_resolver.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class RunnersResolver < BaseResolver + type Types::Ci::RunnerType.connection_type, null: true + + argument :status, ::Types::Ci::RunnerStatusEnum, + required: false, + description: 'Filter runners by status.' + + argument :type, ::Types::Ci::RunnerTypeEnum, + required: false, + description: 'Filter runners by type.' + + argument :tag_list, [GraphQL::STRING_TYPE], + required: false, + description: 'Filter by tags associated with the runner (comma-separated or array).' + + argument :sort, ::Types::Ci::RunnerSortEnum, + required: false, + description: 'Sort order of results.' + + def resolve(**args) + ::Ci::RunnersFinder + .new(current_user: current_user, params: runners_finder_params(args)) + .execute + end + + private + + def runners_finder_params(params) + { + status_status: params[:status]&.to_s, + type_type: params[:type], + tag_name: params[:tag_list], + search: params[:search], + sort: params[:sort]&.to_s + }.compact + end + end + end +end diff --git a/app/graphql/types/ci/runner_sort_enum.rb b/app/graphql/types/ci/runner_sort_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..550e870316a756c53efcae51b01b9fcf0b4e4490 --- /dev/null +++ b/app/graphql/types/ci/runner_sort_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Ci + class RunnerSortEnum < BaseEnum + graphql_name 'CiRunnerSort' + description 'Values for sorting runners' + + value 'CONTACTED_ASC', 'Ordered by contacted_at in ascending order.', value: :contacted_asc + value 'CREATED_DESC', 'Ordered by created_date in descending order.', value: :created_date + end + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 57e5df51aa8df160e24f3790fbd493499c588499..8b7b9f0107be6d0a01a219b44033e48f970efc00 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -119,6 +119,12 @@ class QueryType < ::Types::BaseObject description: "Find a runner.", feature_flag: :runner_graphql_query + field :runners, Types::Ci::RunnerType.connection_type, + null: true, + resolver: Resolvers::Ci::RunnersResolver, + description: "Find runners visible to the current user.", + feature_flag: :runner_graphql_query + field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1 def design_management diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index a3f6c45ba7588ebd4ff8db33b3da847de52808df..d7727217dac9f03dcaefcc41ad7769d00c0d84b6 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -322,6 +322,25 @@ Returns [`RunnerSetup`](#runnersetup). | <a id="queryrunnersetupplatform"></a>`platform` | [`String!`](#string) | Platform to generate the instructions for. | | <a id="queryrunnersetupprojectid"></a>`projectId` **{warning-solid}** | [`ProjectID`](#projectid) | **Deprecated** in 13.11. No longer used. | +### `Query.runners` + +Find runners visible to the current user. Available only when feature flag `runner_graphql_query` is enabled. + +Returns [`CiRunnerConnection`](#cirunnerconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#connection-pagination-arguments): +`before: String`, `after: String`, `first: Int`, `last: Int`. + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="queryrunnerssort"></a>`sort` | [`CiRunnerSort`](#cirunnersort) | Sort order of results. | +| <a id="queryrunnersstatus"></a>`status` | [`CiRunnerStatus`](#cirunnerstatus) | Filter runners by status. | +| <a id="queryrunnerstaglist"></a>`tagList` | [`[String!]`](#string) | Filter by tags associated with the runner (comma-separated or array). | +| <a id="queryrunnerstype"></a>`type` | [`CiRunnerType`](#cirunnertype) | Filter runners by type. | + ### `Query.snippets` Find Snippets visible to the current user. @@ -4600,6 +4619,29 @@ The edge type for [`CiJob`](#cijob). | <a id="cijobedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="cijobedgenode"></a>`node` | [`CiJob`](#cijob) | The item at the end of the edge. | +#### `CiRunnerConnection` + +The connection type for [`CiRunner`](#cirunner). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cirunnerconnectionedges"></a>`edges` | [`[CiRunnerEdge]`](#cirunneredge) | A list of edges. | +| <a id="cirunnerconnectionnodes"></a>`nodes` | [`[CiRunner]`](#cirunner) | A list of nodes. | +| <a id="cirunnerconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `CiRunnerEdge` + +The edge type for [`CiRunner`](#cirunner). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cirunneredgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="cirunneredgenode"></a>`node` | [`CiRunner`](#cirunner) | The item at the end of the edge. | + #### `CiStageConnection` The connection type for [`CiStage`](#cistage). @@ -13647,6 +13689,15 @@ Values for YAML processor result. | <a id="cirunneraccesslevelnot_protected"></a>`NOT_PROTECTED` | A runner that is not protected. | | <a id="cirunneraccesslevelref_protected"></a>`REF_PROTECTED` | A runner that is ref protected. | +### `CiRunnerSort` + +Values for sorting runners. + +| Value | Description | +| ----- | ----------- | +| <a id="cirunnersortcontacted_asc"></a>`CONTACTED_ASC` | Ordered by contacted_at in ascending order. | +| <a id="cirunnersortcreated_desc"></a>`CREATED_DESC` | Ordered by created_date in descending order. | + ### `CiRunnerStatus` | Value | Description | diff --git a/spec/graphql/resolvers/ci/runners_resolver_spec.rb b/spec/graphql/resolvers/ci/runners_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..006d6785506d701c39159d15461c00f1b7f5fb6b --- /dev/null +++ b/spec/graphql/resolvers/ci/runners_resolver_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Ci::RunnersResolver do + include GraphqlHelpers + + let_it_be(:user) { create_default(:user, :admin) } + let_it_be(:group) { create(:group, :public) } + let_it_be(:project) { create(:project, :repository, :public) } + + let_it_be(:inactive_project_runner) do + create(:ci_runner, :project, projects: [project], active: false, contacted_at: 1.minute.ago, tag_list: %w(project_runner)) + end + + let_it_be(:offline_project_runner) do + create(:ci_runner, :project, projects: [project], contacted_at: 1.day.ago, tag_list: %w(project_runner active_runner)) + end + + let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], contacted_at: 1.second.ago) } + let_it_be(:instance_runner) { create(:ci_runner, :instance, contacted_at: 2.minutes.ago, tag_list: %w(instance_runner active_runner)) } + + describe '#resolve' do + subject { resolve(described_class, ctx: { current_user: user }, args: args).items.to_a } + + let(:args) do + {} + end + + context 'without sort' do + it 'returns all the runners' do + is_expected.to contain_exactly(inactive_project_runner, offline_project_runner, group_runner, instance_runner) + end + end + + context 'with a sort argument' do + context "set to :contacted_asc" do + let(:args) do + { sort: :contacted_asc } + end + + it { is_expected.to eq([offline_project_runner, instance_runner, inactive_project_runner, group_runner]) } + end + + context "set to :created_date" do + let(:args) do + { sort: :created_date } + end + + it { is_expected.to eq([instance_runner, group_runner, offline_project_runner, inactive_project_runner]) } + end + end + + context 'when type is filtered' do + let(:args) do + { type: runner_type.to_s } + end + + context 'to instance runners' do + let(:runner_type) { :instance_type } + + it 'returns the instance runner' do + is_expected.to contain_exactly(instance_runner) + end + end + + context 'to group runners' do + let(:runner_type) { :group_type } + + it 'returns the group runner' do + is_expected.to contain_exactly(group_runner) + end + end + + context 'to project runners' do + let(:runner_type) { :project_type } + + it 'returns the project runner' do + is_expected.to contain_exactly(inactive_project_runner, offline_project_runner) + end + end + end + + context 'when status is filtered' do + let(:args) do + { status: runner_status.to_s } + end + + context 'to active runners' do + let(:runner_status) { :active } + + it 'returns the instance and group runners' do + is_expected.to contain_exactly(offline_project_runner, group_runner, instance_runner) + end + end + + context 'to offline runners' do + let(:runner_status) { :offline } + + it 'returns the offline project runner' do + is_expected.to contain_exactly(offline_project_runner) + end + end + end + + context 'when tag list is filtered' do + let(:args) do + { tag_list: tag_list } + end + + context 'with "project_runner" tag' do + let(:tag_list) { ['project_runner'] } + + it 'returns the project_runner runners' do + is_expected.to contain_exactly(offline_project_runner, inactive_project_runner) + end + end + + context 'with "project_runner" and "active_runner" tags as comma-separated string' do + let(:tag_list) { ['project_runner,active_runner'] } + + it 'returns the offline_project_runner runner' do + is_expected.to contain_exactly(offline_project_runner) + end + end + + context 'with "active_runner" and "instance_runner" tags as array' do + let(:tag_list) { %w[instance_runner active_runner] } + + it 'returns the offline_project_runner runner' do + is_expected.to contain_exactly(instance_runner) + end + end + end + end +end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index f0e80fa8f147a3546f0f485cf881f8f0902200fa..9a8f2090cc1725da1db940e28378efbe9c07c741 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -25,6 +25,7 @@ usage_trends_measurements runner_platforms runner + runners ] expect(described_class).to have_graphql_fields(*expected_fields).at_least @@ -91,6 +92,12 @@ it { is_expected.to have_graphql_type(Types::Ci::RunnerType) } end + describe 'runners field' do + subject { described_class.fields['runners'] } + + it { is_expected.to have_graphql_type(Types::Ci::RunnerType.connection_type) } + end + describe 'runner_platforms field' do subject { described_class.fields['runnerPlatforms'] } diff --git a/spec/requests/api/graphql/ci/runners_spec.rb b/spec/requests/api/graphql/ci/runners_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..778fe5b129e598e197ccbd2570bf18235483d2ff --- /dev/null +++ b/spec/requests/api/graphql/ci/runners_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe 'Query.runners' do + include GraphqlHelpers + + let_it_be(:current_user) { create_default(:user, :admin) } + + describe 'Query.runners' do + let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:instance_runner) { create(:ci_runner, :instance, version: 'abc', revision: '123', description: 'Instance runner', ip_address: '127.0.0.1') } + let_it_be(:project_runner) { create(:ci_runner, :project, active: false, version: 'def', revision: '456', description: 'Project runner', projects: [project], ip_address: '127.0.0.1') } + + let(:runners_graphql_data) { graphql_data['runners'] } + + let(:params) { {} } + + let(:fields) do + <<~QUERY + nodes { + #{all_graphql_fields_for('CiRunner')} + } + QUERY + end + + let(:query) do + %( + query { + runners(type:#{runner_type},status:#{status}) { + #{fields} + } + } + ) + end + + before do + post_graphql(query, current_user: current_user) + end + + shared_examples 'a working graphql query returning expected runner' do + it_behaves_like 'a working graphql query' + + it 'returns expected runner' do + expect(runners_graphql_data['nodes'].map { |n| n['id'] }).to contain_exactly(expected_runner.to_global_id.to_s) + end + end + + context 'runner_type is INSTANCE_TYPE and status is ACTIVE' do + let(:runner_type) { 'INSTANCE_TYPE' } + let(:status) { 'ACTIVE' } + + let!(:expected_runner) { instance_runner } + + it_behaves_like 'a working graphql query returning expected runner' + end + + context 'runner_type is PROJECT_TYPE and status is NOT_CONNECTED' do + let(:runner_type) { 'PROJECT_TYPE' } + let(:status) { 'NOT_CONNECTED' } + + let!(:expected_runner) { project_runner } + + it_behaves_like 'a working graphql query returning expected runner' + end + end + + describe 'pagination' do + let(:data_path) { [:runners] } + + def pagination_query(params) + graphql_query_for(:runners, params, "#{page_info} nodes { id }") + end + + def pagination_results_data(runners) + runners.map { |runner| GitlabSchema.parse_gid(runner['id'], expected_type: ::Ci::Runner).model_id.to_i } + end + + let_it_be(:runners) do + common_args = { + version: 'abc', + revision: '123', + ip_address: '127.0.0.1' + } + + [ + create(:ci_runner, :instance, created_at: 4.days.ago, contacted_at: 3.days.ago, **common_args), + create(:ci_runner, :instance, created_at: 30.hours.ago, contacted_at: 1.day.ago, **common_args), + create(:ci_runner, :instance, created_at: 1.day.ago, contacted_at: 1.hour.ago, **common_args), + create(:ci_runner, :instance, created_at: 2.days.ago, contacted_at: 2.days.ago, **common_args), + create(:ci_runner, :instance, created_at: 3.days.ago, contacted_at: 1.second.ago, **common_args) + ] + end + + context 'when sorted by contacted_at ascending' do + let(:ordered_runners) { runners.sort_by(&:contacted_at) } + + it_behaves_like 'sorted paginated query' do + let(:sort_param) { :CONTACTED_ASC } + let(:first_param) { 2 } + let(:expected_results) { ordered_runners.map(&:id) } + end + end + + context 'when sorted by created_at' do + let(:ordered_runners) { runners.sort_by(&:created_at).reverse } + + it_behaves_like 'sorted paginated query' do + let(:sort_param) { :CREATED_DESC } + let(:first_param) { 2 } + let(:expected_results) { ordered_runners.map(&:id) } + end + end + end +end