Skip to content
Snippets Groups Projects
Commit 647312f4 authored by Adam Hegyi's avatar Adam Hegyi :coffee:
Browse files

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
No related branches found
No related tags found
No related merge requests found
Showing
with 359 additions and 209 deletions
Loading
Loading
@@ -59,7 +59,7 @@ def pause
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
Loading
Loading
Loading
Loading
@@ -17,7 +17,7 @@ class CiCdController < Groups::ApplicationController
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)
Loading
Loading
Loading
Loading
@@ -7,9 +7,9 @@ class RunnersFinder < UnionFinder
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
 
Loading
Loading
@@ -48,10 +48,16 @@ def all_runners
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!
Loading
Loading
# 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
Loading
Loading
@@ -34,7 +34,7 @@ def resolve_with_lookahead(**args)
.execute)
end
 
private
protected
 
def runners_finder_params(params)
{
Loading
Loading
@@ -47,6 +47,19 @@ def runners_finder_params(params)
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
Loading
Loading
# 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
Loading
Loading
@@ -155,6 +155,12 @@ def label(title:)
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
Loading
Loading
Loading
Loading
@@ -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.
Loading
Loading
@@ -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.
Loading
Loading
Loading
Loading
@@ -18,6 +18,13 @@
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
Loading
Loading
@@ -158,6 +165,7 @@
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) }
Loading
Loading
@@ -171,7 +179,10 @@
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
Loading
Loading
@@ -182,65 +193,104 @@
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
Loading
Loading
@@ -278,7 +328,7 @@
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
Loading
Loading
@@ -287,7 +337,7 @@
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')
Loading
Loading
# 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
Loading
Loading
@@ -5,185 +5,70 @@
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
Loading
Loading
# 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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment