Commit 647312f4 authored by Adam Hegyi's avatar Adam Hegyi

Merge branch 'pedropombeiro/334686/graphql-add-runners-to-groups' into 'master'

GraphQL: Add runners to groups query [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!65673
parents 5f9dcb15 60409ad0
......@@ -59,7 +59,7 @@ class Groups::RunnersController < Groups::ApplicationController
private
def runner
@runner ||= Ci::RunnersFinder.new(current_user: current_user, group: @group, params: {}).execute
@runner ||= Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }).execute
.except(:limit, :offset)
.find(params[:id])
end
......
......@@ -17,7 +17,7 @@ module Groups
NUMBER_OF_RUNNERS_PER_PAGE = 4
def show
runners_finder = Ci::RunnersFinder.new(current_user: current_user, group: @group, params: params)
runners_finder = Ci::RunnersFinder.new(current_user: current_user, params: params.merge({ group: @group }))
# We need all runners for count
@all_group_runners = runners_finder.execute.except(:limit, :offset)
@group_runners = runners_finder.execute.page(params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
......
......@@ -7,9 +7,9 @@ module Ci
ALLOWED_SORTS = %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date].freeze
DEFAULT_SORT = 'created_at_desc'
def initialize(current_user:, group: nil, params:)
def initialize(current_user:, params:)
@params = params
@group = group
@group = params.delete(:group)
@current_user = current_user
end
......@@ -48,10 +48,16 @@ module Ci
def group_runners
raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :admin_group, @group)
# Getting all runners from the group itself and all its descendants
descendant_projects = Project.for_group_and_its_subgroups(@group)
@runners = Ci::Runner.belonging_to_group_or_project(@group.self_and_descendants, descendant_projects)
@runners = case @params[:membership]
when :direct
Ci::Runner.belonging_to_group(@group.id)
when :descendants, nil
# Getting all runners from the group itself and all its descendant groups/projects
descendant_projects = Project.for_group_and_its_subgroups(@group)
Ci::Runner.belonging_to_group_or_project(@group.self_and_descendants, descendant_projects)
else
raise ArgumentError, 'Invalid membership filter'
end
end
def filter_by_status!
......
# frozen_string_literal: true
module Resolvers
module Ci
class GroupRunnersResolver < RunnersResolver
type Types::Ci::RunnerType.connection_type, null: true
argument :membership, ::Types::Ci::RunnerMembershipFilterEnum,
required: false,
default_value: :descendants,
description: 'Control which runners to include in the results.'
protected
def runners_finder_params(params)
super(params).merge(membership: params[:membership])
end
def parent_param
raise 'Expected group missing' unless parent.is_a?(Group)
{ group: parent }
end
end
end
end
......@@ -34,7 +34,7 @@ module Resolvers
.execute)
end
private
protected
def runners_finder_params(params)
{
......@@ -47,6 +47,19 @@ module Resolvers
tag_name: node_selection&.selects?(:tag_list)
}
}.compact
.merge(parent_param)
end
def parent_param
return {} unless parent
raise "Unexpected parent type: #{parent.class}"
end
private
def parent
object.respond_to?(:sync) ? object.sync : object
end
end
end
......
# frozen_string_literal: true
module Types
module Ci
class RunnerMembershipFilterEnum < BaseEnum
graphql_name 'RunnerMembershipFilter'
description 'Values for filtering runners in namespaces.'
value 'DIRECT',
description: "Include runners that have a direct relationship.",
value: :direct
value 'DESCENDANTS',
description: "Include runners that have either a direct relationship or a relationship with descendants. These can be project runners or group runners (in the case where group is queried).",
value: :descendants
end
end
end
......@@ -155,6 +155,12 @@ module Types
complexity: 5,
resolver: Resolvers::GroupsResolver
field :runners, Types::Ci::RunnerType.connection_type,
null: true,
resolver: Resolvers::Ci::GroupRunnersResolver,
description: "Find runners visible to the current user.",
feature_flag: :runner_graphql_query
def avatar_url
object.avatar_url(only_path: false)
end
......
......@@ -10004,6 +10004,27 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="groupprojectssearch"></a>`search` | [`String`](#string) | Search project with most similar names or paths. |
| <a id="groupprojectssort"></a>`sort` | [`NamespaceProjectSort`](#namespaceprojectsort) | Sort projects by this criteria. |
##### `Group.runners`
Find runners visible to the current user. Available only when feature flag `runner_graphql_query` is enabled. This flag is enabled by default.
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="grouprunnersmembership"></a>`membership` | [`RunnerMembershipFilter`](#runnermembershipfilter) | Control which runners to include in the results. |
| <a id="grouprunnerssearch"></a>`search` | [`String`](#string) | Filter by full token or partial text in description field. |
| <a id="grouprunnerssort"></a>`sort` | [`CiRunnerSort`](#cirunnersort) | Sort order of results. |
| <a id="grouprunnersstatus"></a>`status` | [`CiRunnerStatus`](#cirunnerstatus) | Filter runners by status. |
| <a id="grouprunnerstaglist"></a>`tagList` | [`[String!]`](#string) | Filter by tags associated with the runner (comma-separated or array). |
| <a id="grouprunnerstype"></a>`type` | [`CiRunnerType`](#cirunnertype) | Filter runners by type. |
##### `Group.timelogs`
Time logged on issues and merge requests in the group and its subgroups.
......@@ -15626,6 +15647,15 @@ Status of a requirement based on last test report.
| <a id="requirementstatusfiltermissing"></a>`MISSING` | Requirements without any test report. |
| <a id="requirementstatusfilterpassed"></a>`PASSED` | |
### `RunnerMembershipFilter`
Values for filtering runners in namespaces.
| Value | Description |
| ----- | ----------- |
| <a id="runnermembershipfilterdescendants"></a>`DESCENDANTS` | Include runners that have either a direct relationship or a relationship with descendants. These can be project runners or group runners (in the case where group is queried). |
| <a id="runnermembershipfilterdirect"></a>`DIRECT` | Include runners that have a direct relationship. |
### `SastUiComponentSize`
Size of UI component in SAST configuration page.
......
......@@ -18,6 +18,13 @@ RSpec.describe Ci::RunnersFinder do
end
end
context 'with nil group' do
it 'returns all runners' do
expect(Ci::Runner).to receive(:with_tags).and_call_original
expect(described_class.new(current_user: admin, params: { group: nil }).execute).to match_array [runner1, runner2]
end
end
context 'with preload param set to :tag_name true' do
it 'requests tags' do
expect(Ci::Runner).to receive(:with_tags).and_call_original
......@@ -158,6 +165,7 @@ RSpec.describe Ci::RunnersFinder do
let_it_be(:project_4) { create(:project, group: sub_group_2) }
let_it_be(:project_5) { create(:project, group: sub_group_3) }
let_it_be(:project_6) { create(:project, group: sub_group_4) }
let_it_be(:runner_instance) { create(:ci_runner, :instance, contacted_at: 13.minutes.ago) }
let_it_be(:runner_group) { create(:ci_runner, :group, contacted_at: 12.minutes.ago) }
let_it_be(:runner_sub_group_1) { create(:ci_runner, :group, active: false, contacted_at: 11.minutes.ago) }
let_it_be(:runner_sub_group_2) { create(:ci_runner, :group, contacted_at: 10.minutes.ago) }
......@@ -171,7 +179,10 @@ RSpec.describe Ci::RunnersFinder do
let_it_be(:runner_project_6) { create(:ci_runner, :project, contacted_at: 2.minutes.ago, projects: [project_5])}
let_it_be(:runner_project_7) { create(:ci_runner, :project, contacted_at: 1.minute.ago, projects: [project_6])}
let(:params) { {} }
let(:target_group) { nil }
let(:membership) { nil }
let(:extra_params) { {} }
let(:params) { { group: target_group, membership: membership }.merge(extra_params).reject { |_, v| v.nil? } }
before do
group.runners << runner_group
......@@ -182,65 +193,104 @@ RSpec.describe Ci::RunnersFinder do
end
describe '#execute' do
subject { described_class.new(current_user: user, group: group, params: params).execute }
subject { described_class.new(current_user: user, params: params).execute }
shared_examples 'membership equal to :descendants' do
it 'returns all descendant runners' do
expect(subject).to eq([runner_project_7, runner_project_6, runner_project_5,
runner_project_4, runner_project_3, runner_project_2,
runner_project_1, runner_sub_group_4, runner_sub_group_3,
runner_sub_group_2, runner_sub_group_1, runner_group])
end
end
context 'with user as group owner' do
before do
group.add_owner(user)
end
context 'passing no params' do
it 'returns all descendant runners' do
expect(subject).to eq([runner_project_7, runner_project_6, runner_project_5,
runner_project_4, runner_project_3, runner_project_2,
runner_project_1, runner_sub_group_4, runner_sub_group_3,
runner_sub_group_2, runner_sub_group_1, runner_group])
context 'with :group as target group' do
let(:target_group) { group }
context 'passing no params' do
it_behaves_like 'membership equal to :descendants'
end
end
context 'with sort param' do
let(:params) { { sort: 'contacted_asc' } }
context 'with :descendants membership' do
let(:membership) { :descendants }
it 'sorts by specified attribute' do
expect(subject).to eq([runner_group, runner_sub_group_1, runner_sub_group_2,
runner_sub_group_3, runner_sub_group_4, runner_project_1,
runner_project_2, runner_project_3, runner_project_4,
runner_project_5, runner_project_6, runner_project_7])
it_behaves_like 'membership equal to :descendants'
end
end
context 'filtering' do
context 'by search term' do
let(:params) { { search: 'runner_project_search' } }
context 'with :direct membership' do
let(:membership) { :direct }
it 'returns runners belonging to group' do
expect(subject).to eq([runner_group])
end
end
context 'with unknown membership' do
let(:membership) { :unsupported }
it 'returns correct runner' do
expect(subject).to eq([runner_project_3])
it 'raises an error' do
expect { subject }.to raise_error(ArgumentError, 'Invalid membership filter')
end
end
context 'by status' do
let(:params) { { status_status: 'paused' } }
context 'with nil group' do
let(:target_group) { nil }
it 'returns correct runner' do
expect(subject).to eq([runner_sub_group_1])
it 'returns no runners' do
# Query should run against all runners, however since user is not admin, query returns no results
expect(subject).to eq([])
end
end
context 'by tag_name' do
let(:params) { { tag_name: %w[runner_tag] } }
context 'with sort param' do
let(:extra_params) { { sort: 'contacted_asc' } }
it 'returns correct runner' do
expect(subject).to eq([runner_project_5])
it 'sorts by specified attribute' do
expect(subject).to eq([runner_group, runner_sub_group_1, runner_sub_group_2,
runner_sub_group_3, runner_sub_group_4, runner_project_1,
runner_project_2, runner_project_3, runner_project_4,
runner_project_5, runner_project_6, runner_project_7])
end
end
context 'by runner type' do
let(:params) { { type_type: 'project_type' } }
context 'filtering' do
context 'by search term' do
let(:extra_params) { { search: 'runner_project_search' } }
it 'returns correct runner' do
expect(subject).to eq([runner_project_3])
end
end
context 'by status' do
let(:extra_params) { { status_status: 'paused' } }
it 'returns correct runner' do
expect(subject).to eq([runner_sub_group_1])
end
end
context 'by tag_name' do
let(:extra_params) { { tag_name: %w[runner_tag] } }
it 'returns correct runner' do
expect(subject).to eq([runner_project_5])
end
end
context 'by runner type' do
let(:extra_params) { { type_type: 'project_type' } }
it 'returns correct runners' do
expect(subject).to eq([runner_project_7, runner_project_6,
runner_project_5, runner_project_4,
runner_project_3, runner_project_2, runner_project_1])
it 'returns correct runners' do
expect(subject).to eq([runner_project_7, runner_project_6,
runner_project_5, runner_project_4,
runner_project_3, runner_project_2, runner_project_1])
end
end
end
end
......@@ -278,7 +328,7 @@ RSpec.describe Ci::RunnersFinder do
end
describe '#sort_key' do
subject { described_class.new(current_user: user, group: group, params: params).sort_key }
subject { described_class.new(current_user: user, params: params.merge(group: group)).sort_key }
context 'without params' do
it 'returns created_at_desc' do
......@@ -287,7 +337,7 @@ RSpec.describe Ci::RunnersFinder do
end
context 'with params' do
let(:params) { { sort: 'contacted_asc' } }
let(:extra_params) { { sort: 'contacted_asc' } }
it 'returns contacted_asc' do
expect(subject).to eq('contacted_asc')
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Ci::GroupRunnersResolver do
include GraphqlHelpers
describe '#resolve' do
subject { resolve(described_class, obj: obj, ctx: { current_user: user }, args: args) }
include_context 'runners resolver setup'
let(:obj) { group }
let(:args) { {} }
# First, we can do a couple of basic real tests to verify common cases. That ensures that the code works.
context 'when user cannot see runners' do
it 'returns no runners' do
expect(subject.items.to_a).to eq([])
end
end
context 'with user as group owner' do
before do
group.add_owner(user)
end
it 'returns all the runners' do
expect(subject.items.to_a).to contain_exactly(inactive_project_runner, offline_project_runner, group_runner, subgroup_runner)
end
context 'with membership direct' do
let(:args) { { membership: :direct } }
it 'returns only direct runners' do
expect(subject.items.to_a).to contain_exactly(group_runner)
end
end
end
# Then, we can check specific edge cases for this resolver
context 'with obj set to nil' do
let(:obj) { nil }
it 'raises an error' do
expect { subject }.to raise_error('Expected group missing')
end
end
context 'with obj not set to group' do
let(:obj) { build(:project) }
it 'raises an error' do
expect { subject }.to raise_error('Expected group missing')
end
end
# Here we have a mocked part. We assume that all possible edge cases are covered in RunnersFinder spec. So we don't need to test them twice.
# Only thing we can do is to verify that args from the resolver is correctly transformed to params of the Finder and we return the Finder's result back.
describe 'Allowed query arguments' do
let(:finder) { instance_double(::Ci::RunnersFinder) }
let(:args) do
{
status: 'active',
type: :group_type,
tag_list: ['active_runner'],
search: 'abc',
sort: :contacted_asc,
membership: :descendants
}
end
let(:expected_params) do
{
status_status: 'active',
type_type: :group_type,
tag_name: ['active_runner'],
preload: { tag_name: nil },
search: 'abc',
sort: 'contacted_asc',
membership: :descendants,
group: group
}
end
it 'calls RunnersFinder with expected arguments' do
allow(::Ci::RunnersFinder).to receive(:new).with(current_user: user, params: expected_params).once.and_return(finder)
allow(finder).to receive(:execute).once.and_return([:execute_return_value])
expect(subject.items.to_a).to eq([:execute_return_value])
end
end
end
end
......@@ -5,185 +5,70 @@ 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], description: 'inactive project runner', token: 'abcdef', 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], description: 'offline project runner', token: 'defghi', contacted_at: 1.day.ago, tag_list: %w(project_runner active_runner))
end
let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], token: 'mnopqr', description: 'group runner', contacted_at: 1.second.ago) }
let_it_be(:instance_runner) { create(:ci_runner, :instance, description: 'shared runner', token: 'stuvxz', 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 'when the user cannot see runners' do
let(:user) { create(:user) }
it 'returns no runners' do
is_expected.to be_empty
end
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 :contacted_desc" do
let(:args) do
{ sort: :contacted_desc }
end
it { is_expected.to eq([offline_project_runner, instance_runner, inactive_project_runner, group_runner].reverse) }
end
context "set to :created_at_desc" do
let(:args) do
{ sort: :created_at_desc }
end
let(:obj) { nil }
let(:args) { {} }
it { is_expected.to eq([instance_runner, group_runner, offline_project_runner, inactive_project_runner]) }
end
context "set to :created_at_asc" do
let(:args) do
{ sort: :created_at_asc }
end
it { is_expected.to eq([instance_runner, group_runner, offline_project_runner, inactive_project_runner].reverse) }
end
end
subject { resolve(described_class, obj: obj, ctx: { current_user: user }, args: args) }
context 'when type is filtered' do
let(:args) do
{ type: runner_type.to_s }
end
include_context 'runners resolver setup'
context 'to instance runners' do
let(:runner_type) { :instance_type }
# First, we can do a couple of basic real tests to verify common cases. That ensures that the code works.
context 'when user cannot see runners' do
let(:user) { build(:user) }
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
it 'returns no runners' do
expect(subject.items.to_a).to eq([])
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 }
context 'when user can see runners' do
let(:obj) { nil }
it 'returns the offline project runner' do
is_expected.to contain_exactly(offline_project_runner)
end
it 'returns all the runners' do
expect(subject.items.to_a).to contain_exactly(inactive_project_runner, offline_project_runner, group_runner, subgroup_runner, instance_runner)
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'] }
# Then, we can check specific edge cases for this resolver
context 'with obj not set to nil' do
let(:obj) { build(:project) }
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
it 'raises an error' do
expect { subject }.to raise_error(a_string_including('Unexpected parent type'))
end
end
context 'when text is filtered' do
# Here we have a mocked part. We assume that all possible edge cases are covered in RunnersFinder spec. So we don't need to test them twice.
# Only thing we can do is to verify that args from the resolver is correctly transformed to params of the Finder and we return the Finder's result back.
describe 'Allowed query arguments' do
let(:finder) { instance_double(::Ci::RunnersFinder) }
let(:args) do
{ search: search_term }
end
context 'to "project"' do
let(:search_term) { 'project' }
it 'returns both project runners' do
is_expected.to contain_exactly(inactive_project_runner, offline_project_runner)
end
end
context 'to "group"' do
let(:search_term) { 'group' }
it 'returns group runner' do
is_expected.to contain_exactly(group_runner)
end
end
context 'to "defghi"' do
let(:search_term) { 'defghi' }
it 'returns runners containing term in token' do
is_expected.to contain_exactly(offline_project_runner)
end
{
status: 'active',
type: :instance_type,
tag_list: ['active_runner'],
search: 'abc',
sort: :contacted_asc
}
end
let(:expected_params) do
{
status_status: 'active',
type_type: :instance_type,
tag_name: ['active_runner'],
preload: { tag_name: nil },
search: 'abc',
sort: 'contacted_asc'
}
end
it 'calls RunnersFinder with expected arguments' do
allow(::Ci::RunnersFinder).to receive(:new).with(current_user: user, params: expected_params).once.and_return(finder)
allow(finder).to receive(:execute).once.and_return([:execute_return_value])
expect(subject.items.to_a).to eq([:execute_return_value])
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.shared_context 'runners resolver setup' do
let_it_be(:user) { create_default(:user, :admin) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:subgroup) { create(:group, :public, parent: group) }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:inactive_project_runner) do
create(:ci_runner, :project, projects: [project], description: 'inactive project runner', token: 'abcdef', 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], description: 'offline project runner', token: 'defghi', contacted_at: 1.day.ago, tag_list: %w(project_runner active_runner))
end
let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], token: 'mnopqr', description: 'group runner', contacted_at: 2.seconds.ago) }
let_it_be(:subgroup_runner) { create(:ci_runner, :group, groups: [subgroup], token: 'mnopqr', description: 'subgroup runner', contacted_at: 1.second.ago) }
let_it_be(:instance_runner) { create(:ci_runner, :instance, description: 'shared runner', token: 'stuvxz', contacted_at: 2.minutes.ago, tag_list: %w(instance_runner active_runner)) }
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment