Commit 3eec34e8 authored by Pedro Pombeiro's avatar Pedro Pombeiro Committed by Bob Van Landuyt

Implement runner field in GraphQL

This allows fetching information about a single runner using GraphQL.

The field is currently behind a feature flag.
parent cc7fed7d
# frozen_string_literal: true
module Resolvers
module Ci
class RunnerResolver < BaseResolver
include LooksAhead
type Types::Ci::RunnerType, null: true
extras [:lookahead]
description 'Runner information.'
argument :id,
type: ::Types::GlobalIDType[::Ci::Runner],
required: true,
description: 'Runner ID.'
def resolve_with_lookahead(id:)
find_runner(id: id)
end
private
def find_runner(id:)
runner_id = GitlabSchema.parse_gid(id, expected_type: ::Ci::Runner).model_id.to_i
preload_tag_list = lookahead.selects?(:tag_list)
BatchLoader::GraphQL.for(runner_id).batch(key: { preload_tag_list: preload_tag_list }) do |ids, loader, batch|
results = ::Ci::Runner.id_in(ids)
results = results.with_tags if batch[:key][:preload_tag_list]
results.each { |record| loader.call(record.id, record) }
end
end
end
end
end
# frozen_string_literal: true
module Types
module Ci
class RunnerAccessLevelEnum < BaseEnum
graphql_name 'CiRunnerAccessLevel'
::Ci::Runner.access_levels.keys.each do |type|
value type.upcase,
description: "A runner that is #{type.tr('_', ' ')}.",
value: type
end
end
end
end
# frozen_string_literal: true
module Types
module Ci
class RunnerStatusEnum < BaseEnum
graphql_name 'CiRunnerStatus'
::Ci::Runner::AVAILABLE_STATUSES.each do |status|
value status.to_s.upcase,
description: "A runner that is #{status.to_s.tr('_', ' ')}.",
value: status.to_sym
end
end
end
end
# frozen_string_literal: true
module Types
module Ci
class RunnerType < BaseObject
graphql_name 'CiRunner'
authorize :read_runner
field :id, ::Types::GlobalIDType[::Ci::Runner], null: false,
description: 'ID of the runner.'
field :description, GraphQL::STRING_TYPE, null: true,
description: 'Description of the runner.'
field :contacted_at, Types::TimeType, null: true,
description: 'Last contact from the runner.',
method: :contacted_at
field :maximum_timeout, GraphQL::INT_TYPE, null: true,
description: 'Maximum timeout (in seconds) for jobs processed by the runner.'
field :access_level, ::Types::Ci::RunnerAccessLevelEnum, null: false,
description: 'Access level of the runner.'
field :active, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates the runner is allowed to receive jobs.'
field :status, ::Types::Ci::RunnerStatusEnum, null: false,
description: 'Status of the runner.'
field :version, GraphQL::STRING_TYPE, null: false,
description: 'Version of the runner.'
field :short_sha, GraphQL::STRING_TYPE, null: true,
description: %q(First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID.)
field :revision, GraphQL::STRING_TYPE, null: false,
description: 'Revision of the runner.'
field :locked, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates the runner is locked.'
field :run_untagged, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates the runner is able to run untagged jobs.'
field :ip_address, GraphQL::STRING_TYPE, null: false,
description: 'IP address of the runner.'
field :runner_type, ::Types::Ci::RunnerTypeEnum, null: false,
description: 'Type of the runner.'
field :tag_list, [GraphQL::STRING_TYPE], null: true,
description: 'Tags associated with the runner.'
end
end
end
# frozen_string_literal: true
module Types
module Ci
class RunnerTypeEnum < BaseEnum
graphql_name 'CiRunnerType'
::Ci::Runner.runner_types.keys.each do |type|
value type.upcase,
description: "A runner that is #{type.tr('_', ' ')}.",
value: type
end
end
end
end
......@@ -112,6 +112,13 @@ module Types
field :runner_platforms, resolver: Resolvers::Ci::RunnerPlatformsResolver
field :runner_setup, resolver: Resolvers::Ci::RunnerSetupResolver
field :runner, Types::Ci::RunnerType,
null: true,
resolver: Resolvers::Ci::RunnerResolver,
extras: [:lookahead],
description: "Find a runner.",
feature_flag: :runner_graphql_query
field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1
def design_management
......
---
name: runner_graphql_query
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59763
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/328700
milestone:
type: development
group: group::runner
default_enabled: false
......@@ -283,6 +283,18 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="queryprojectssearchnamespaces"></a>`searchNamespaces` | [`Boolean`](#boolean) | Include namespace in project search. |
| <a id="queryprojectssort"></a>`sort` | [`String`](#string) | Sort order of results. |
### `Query.runner`
Find a runner. Available only when feature flag `runner_graphql_query` is enabled.
Returns [`CiRunner`](#cirunner).
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="queryrunnerid"></a>`id` | [`CiRunnerID!`](#cirunnerid) | Runner ID. |
### `Query.runnerPlatforms`
Supported runner platforms.
......@@ -7253,6 +7265,28 @@ Represents the total number of issues and their weights for a particular day.
| <a id="cijobartifactdownloadpath"></a>`downloadPath` | [`String`](#string) | URL for downloading the artifact's file. |
| <a id="cijobartifactfiletype"></a>`fileType` | [`JobArtifactFileType`](#jobartifactfiletype) | File type of the artifact. |
### `CiRunner`
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="cirunneraccesslevel"></a>`accessLevel` | [`CiRunnerAccessLevel!`](#cirunneraccesslevel) | Access level of the runner. |
| <a id="cirunneractive"></a>`active` | [`Boolean!`](#boolean) | Indicates the runner is allowed to receive jobs. |
| <a id="cirunnercontactedat"></a>`contactedAt` | [`Time`](#time) | Last contact from the runner. |
| <a id="cirunnerdescription"></a>`description` | [`String`](#string) | Description of the runner. |
| <a id="cirunnerid"></a>`id` | [`CiRunnerID!`](#cirunnerid) | ID of the runner. |
| <a id="cirunneripaddress"></a>`ipAddress` | [`String!`](#string) | IP address of the runner. |
| <a id="cirunnerlocked"></a>`locked` | [`Boolean`](#boolean) | Indicates the runner is locked. |
| <a id="cirunnermaximumtimeout"></a>`maximumTimeout` | [`Int`](#int) | Maximum timeout (in seconds) for jobs processed by the runner. |
| <a id="cirunnerrevision"></a>`revision` | [`String!`](#string) | Revision of the runner. |
| <a id="cirunnerrununtagged"></a>`runUntagged` | [`Boolean!`](#boolean) | Indicates the runner is able to run untagged jobs. |
| <a id="cirunnerrunnertype"></a>`runnerType` | [`CiRunnerType!`](#cirunnertype) | Type of the runner. |
| <a id="cirunnershortsha"></a>`shortSha` | [`String`](#string) | First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID. |
| <a id="cirunnerstatus"></a>`status` | [`CiRunnerStatus!`](#cirunnerstatus) | Status of the runner. |
| <a id="cirunnertaglist"></a>`tagList` | [`[String!]`](#string) | Tags associated with the runner. |
| <a id="cirunnerversion"></a>`version` | [`String!`](#string) | Version of the runner. |
### `CiStage`
#### Fields
......@@ -13340,6 +13374,30 @@ Values for YAML processor result.
| <a id="cijobstatussuccess"></a>`SUCCESS` | A job that is success. |
| <a id="cijobstatuswaiting_for_resource"></a>`WAITING_FOR_RESOURCE` | A job that is waiting for resource. |
### `CiRunnerAccessLevel`
| Value | Description |
| ----- | ----------- |
| <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. |
### `CiRunnerStatus`
| Value | Description |
| ----- | ----------- |
| <a id="cirunnerstatusactive"></a>`ACTIVE` | A runner that is active. |
| <a id="cirunnerstatusoffline"></a>`OFFLINE` | A runner that is offline. |
| <a id="cirunnerstatusonline"></a>`ONLINE` | A runner that is online. |
| <a id="cirunnerstatuspaused"></a>`PAUSED` | A runner that is paused. |
### `CiRunnerType`
| Value | Description |
| ----- | ----------- |
| <a id="cirunnertypegroup_type"></a>`GROUP_TYPE` | A runner that is group type. |
| <a id="cirunnertypeinstance_type"></a>`INSTANCE_TYPE` | A runner that is instance type. |
| <a id="cirunnertypeproject_type"></a>`PROJECT_TYPE` | A runner that is project type. |
### `CommitActionMode`
Mode of a commit action.
......@@ -14445,6 +14503,12 @@ A `CiPipelineID` is a global ID. It is encoded as a string.
An example `CiPipelineID` is: `"gid://gitlab/Ci::Pipeline/1"`.
### `CiRunnerID`
A `CiRunnerID` is a global ID. It is encoded as a string.
An example `CiRunnerID` is: `"gid://gitlab/Ci::Runner/1"`.
### `ClustersAgentID`
A `ClustersAgentID` is a global ID. It is encoded as a string.
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::Ci::RunnerType do
specify { expect(described_class.graphql_name).to eq('CiRunner') }
it 'contains attributes related to a runner' do
expected_fields = %w[
id description contacted_at maximum_timeout access_level active status
version short_sha revision locked run_untagged ip_address runner_type tag_list
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
end
......@@ -24,6 +24,7 @@ RSpec.describe GitlabSchema.types['Query'] do
merge_request
usage_trends_measurements
runner_platforms
runner
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
......@@ -84,6 +85,12 @@ RSpec.describe GitlabSchema.types['Query'] do
end
end
describe 'runner field' do
subject { described_class.fields['runner'] }
it { is_expected.to have_graphql_type(Types::Ci::RunnerType) }
end
describe 'runner_platforms field' do
subject { described_class.fields['runnerPlatforms'] }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Query.runner(id)' do
include GraphqlHelpers
let_it_be(:user) { create_default(:user, :admin) }
let_it_be(:active_runner) do
create(:ci_runner, :instance, description: 'Runner 1', contacted_at: 2.hours.ago,
active: true, version: 'adfe156', revision: 'a', locked: true, ip_address: '127.0.0.1', maximum_timeout: 600,
access_level: 0, tag_list: %w[tag1 tag2], run_untagged: true)
end
let_it_be(:inactive_runner) do
create(:ci_runner, :instance, description: 'Runner 2', contacted_at: 1.day.ago, active: false,
version: 'adfe157', revision: 'b', ip_address: '10.10.10.10', access_level: 1, run_untagged: true)
end
def get_runner(id)
case id
when :active_runner
active_runner
when :inactive_runner
inactive_runner
end
end
shared_examples 'runner details fetch' do |runner_id|
let(:query) do
wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner')))
end
let(:query_path) do
[
[:runner, { id: get_runner(runner_id).to_global_id.to_s }]
]
end
it 'retrieves expected fields' do
post_graphql(query, current_user: user)
runner_data = graphql_data_at(:runner)
expect(runner_data).not_to be_nil
runner = get_runner(runner_id)
expect(runner_data).to match a_hash_including(
'id' => "gid://gitlab/Ci::Runner/#{runner.id}",
'description' => runner.description,
'contactedAt' => runner.contacted_at&.iso8601,
'version' => runner.version,
'shortSha' => runner.short_sha,
'revision' => runner.revision,
'locked' => runner.locked,
'active' => runner.active,
'status' => runner.status.to_s.upcase,
'maximumTimeout' => runner.maximum_timeout,
'accessLevel' => runner.access_level.to_s.upcase,
'runUntagged' => runner.run_untagged,
'ipAddress' => runner.ip_address,
'runnerType' => 'INSTANCE_TYPE'
)
expect(runner_data['tagList']).to match_array runner.tag_list
end
end
shared_examples 'retrieval by unauthorized user' do |runner_id|
let(:query) do
wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner')))
end
let(:query_path) do
[
[:runner, { id: get_runner(runner_id).to_global_id.to_s }]
]
end
it 'returns null runner' do
post_graphql(query, current_user: user)
expect(graphql_data_at(:runner)).to be_nil
end
end
describe 'for active runner' do
it_behaves_like 'runner details fetch', :active_runner
end
describe 'for inactive runner' do
it_behaves_like 'runner details fetch', :inactive_runner
end
describe 'by regular user' do
let(:user) { create_default(:user) }
it_behaves_like 'retrieval by unauthorized user', :active_runner
end
describe 'by unauthenticated user' do
let(:user) { nil }
it_behaves_like 'retrieval by unauthorized user', :active_runner
end
describe 'Query limits' do
def runner_query(runner)
<<~SINGLE
runner(id: "#{runner.to_global_id}") {
#{all_graphql_fields_for('CiRunner')}
}
SINGLE
end
let(:single_query) do
<<~QUERY
{
active: #{runner_query(active_runner)}
}
QUERY
end
let(:double_query) do
<<~QUERY
{
active: #{runner_query(active_runner)}
inactive: #{runner_query(inactive_runner)}
}
QUERY
end
it 'does not execute more queries per runner', :aggregate_failures do
# warm-up license cache and so on:
post_graphql(single_query, current_user: user)
control = ActiveRecord::QueryRecorder.new { post_graphql(single_query, current_user: user) }
expect { post_graphql(double_query, current_user: user) }
.not_to exceed_query_limit(control)
expect(graphql_data_at(:active)).not_to be_nil
expect(graphql_data_at(:inactive)).not_to be_nil
end
end
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