# frozen_string_literal: true require 'spec_helper' RSpec.describe 'Query.runner(id)' do include GraphqlHelpers let_it_be(:user) { create(:user, :admin) } let_it_be(:group) { create(:group) } let_it_be(:active_instance_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, executor_type: :custom, maintenance_note: '**Test maintenance note**') end let_it_be(:inactive_instance_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 let_it_be(:active_group_runner) do create(:ci_runner, :group, groups: [group], description: 'Group 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, executor_type: :shell) end let_it_be(:project1) { create(:project) } let_it_be(:active_project_runner) { create(:ci_runner, :project, projects: [project1]) } shared_examples 'runner details fetch' do let(:query) do wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner'))) end let(:query_path) do [ [:runner, { id: runner.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 expect(runner_data).to match a_hash_including( 'id' => runner.to_global_id.to_s, 'description' => runner.description, 'createdAt' => runner.created_at&.iso8601, 'contactedAt' => runner.contacted_at&.iso8601, 'version' => runner.version, 'shortSha' => runner.short_sha, 'revision' => runner.revision, 'locked' => false, 'active' => runner.active, 'paused' => !runner.active, 'status' => runner.status('14.5').to_s.upcase, 'maximumTimeout' => runner.maximum_timeout, 'accessLevel' => runner.access_level.to_s.upcase, 'runUntagged' => runner.run_untagged, 'ipAddress' => runner.ip_address, 'runnerType' => runner.instance_type? ? 'INSTANCE_TYPE' : 'PROJECT_TYPE', 'executorName' => runner.executor_type&.dasherize, 'architectureName' => runner.architecture, 'platformName' => runner.platform, 'maintenanceNote' => runner.maintenance_note, 'maintenanceNoteHtml' => runner.maintainer_note.present? ? a_string_including('Test maintenance note') : '', 'jobCount' => 0, 'jobs' => a_hash_including("count" => 0, "nodes" => [], "pageInfo" => anything), 'projectCount' => nil, 'adminUrl' => "http://localhost/admin/runners/#{runner.id}", 'userPermissions' => { 'readRunner' => true, 'updateRunner' => true, 'deleteRunner' => true } ) expect(runner_data['tagList']).to match_array runner.tag_list end end shared_examples 'retrieval with no admin url' do let(:query) do wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner'))) end let(:query_path) do [ [:runner, { id: runner.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 expect(runner_data).to match a_hash_including( 'id' => runner.to_global_id.to_s, 'adminUrl' => nil ) expect(runner_data['tagList']).to match_array runner.tag_list end end shared_examples 'retrieval by unauthorized user' do let(:query) do wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner'))) end let(:query_path) do [ [:runner, { id: runner.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 let(:runner) { active_instance_runner } it_behaves_like 'runner details fetch' context 'when tagList is not requested' do let(:query) do wrap_fields(query_graphql_path(query_path, 'id')) end let(:query_path) do [ [:runner, { id: runner.to_global_id.to_s }] ] end it 'does not retrieve tagList' do post_graphql(query, current_user: user) runner_data = graphql_data_at(:runner) expect(runner_data).not_to be_nil expect(runner_data).not_to include('tagList') end end end describe 'for project runner' do describe 'locked' do using RSpec::Parameterized::TableSyntax where(is_locked: [true, false]) with_them do let(:project_runner) do create(:ci_runner, :project, description: 'Runner 3', contacted_at: 1.day.ago, active: false, locked: is_locked, version: 'adfe157', revision: 'b', ip_address: '10.10.10.10', access_level: 1, run_untagged: true) end let(:query) do wrap_fields(query_graphql_path(query_path, 'id locked')) end let(:query_path) do [ [:runner, { id: project_runner.to_global_id.to_s }] ] end it 'retrieves correct locked value' do post_graphql(query, current_user: user) runner_data = graphql_data_at(:runner) expect(runner_data).to match a_hash_including( 'id' => project_runner.to_global_id.to_s, 'locked' => is_locked ) end end end describe 'ownerProject' do let_it_be(:project2) { create(:project) } let_it_be(:runner1) { create(:ci_runner, :project, projects: [project2, project1]) } let_it_be(:runner2) { create(:ci_runner, :project, projects: [project1, project2]) } let(:runner_query_fragment) { 'id ownerProject { id }' } let(:query) do %( query { runner1: runner(id: "#{runner1.to_global_id}") { #{runner_query_fragment} } runner2: runner(id: "#{runner2.to_global_id}") { #{runner_query_fragment} } } ) end it 'retrieves correct ownerProject.id values' do post_graphql(query, current_user: user) expect(graphql_data).to match a_hash_including( 'runner1' => { 'id' => runner1.to_global_id.to_s, 'ownerProject' => { 'id' => project2.to_global_id.to_s } }, 'runner2' => { 'id' => runner2.to_global_id.to_s, 'ownerProject' => { 'id' => project1.to_global_id.to_s } } ) end end end describe 'for inactive runner' do let(:runner) { inactive_instance_runner } it_behaves_like 'runner details fetch' end describe 'for group runner request' do let(:query) do %( query { runner(id: "#{active_group_runner.to_global_id}") { groups { nodes { id } } } } ) end it 'retrieves groups field with expected value' do post_graphql(query, current_user: user) runner_data = graphql_data_at(:runner, :groups) expect(runner_data).to eq 'nodes' => [{ 'id' => group.to_global_id.to_s }] end end describe 'for runner with status' do let_it_be(:stale_runner) { create(:ci_runner, description: 'Stale runner 1', created_at: 3.months.ago) } let_it_be(:never_contacted_instance_runner) { create(:ci_runner, description: 'Missing runner 1', created_at: 1.month.ago, contacted_at: nil) } let(:status_fragment) do %( status legacyStatusWithExplicitVersion: status(legacyMode: "14.5") newStatus: status(legacyMode: null) ) end let(:query) do %( query { staleRunner: runner(id: "#{stale_runner.to_global_id}") { #{status_fragment} } pausedRunner: runner(id: "#{inactive_instance_runner.to_global_id}") { #{status_fragment} } neverContactedInstanceRunner: runner(id: "#{never_contacted_instance_runner.to_global_id}") { #{status_fragment} } } ) end it 'retrieves status fields with expected values' do post_graphql(query, current_user: user) stale_runner_data = graphql_data_at(:stale_runner) expect(stale_runner_data).to match a_hash_including( 'status' => 'STALE', 'legacyStatusWithExplicitVersion' => 'STALE', 'newStatus' => 'STALE' ) paused_runner_data = graphql_data_at(:paused_runner) expect(paused_runner_data).to match a_hash_including( 'status' => 'PAUSED', 'legacyStatusWithExplicitVersion' => 'PAUSED', 'newStatus' => 'OFFLINE' ) never_contacted_instance_runner_data = graphql_data_at(:never_contacted_instance_runner) expect(never_contacted_instance_runner_data).to match a_hash_including( 'status' => 'NEVER_CONTACTED', 'legacyStatusWithExplicitVersion' => 'NEVER_CONTACTED', 'newStatus' => 'NEVER_CONTACTED' ) end end describe 'for multiple runners' do let_it_be(:project2) { create(:project, :test_repo) } let_it_be(:project_runner1) { create(:ci_runner, :project, projects: [project1, project2], description: 'Runner 1') } let_it_be(:project_runner2) { create(:ci_runner, :project, projects: [], description: 'Runner 2') } let!(:job) { create(:ci_build, runner: project_runner1) } context 'requesting projects and counts for projects and jobs' do let(:jobs_fragment) do %( jobs { count nodes { id status } } ) end let(:query) do %( query { projectRunner1: runner(id: "#{project_runner1.to_global_id}") { projectCount jobCount #{jobs_fragment} projects { nodes { id } } } projectRunner2: runner(id: "#{project_runner2.to_global_id}") { projectCount jobCount #{jobs_fragment} projects { nodes { id } } } activeInstanceRunner: runner(id: "#{active_instance_runner.to_global_id}") { projectCount jobCount #{jobs_fragment} projects { nodes { id } } } } ) end before do project_runner2.runner_projects.clear post_graphql(query, current_user: user) end it 'retrieves expected fields' do runner1_data = graphql_data_at(:project_runner1) runner2_data = graphql_data_at(:project_runner2) runner3_data = graphql_data_at(:active_instance_runner) expect(runner1_data).to match a_hash_including( 'jobCount' => 1, 'jobs' => a_hash_including( "count" => 1, "nodes" => [{ "id" => job.to_global_id.to_s, "status" => job.status.upcase }] ), 'projectCount' => 2, 'projects' => { 'nodes' => [ { 'id' => project1.to_global_id.to_s }, { 'id' => project2.to_global_id.to_s } ] }) expect(runner2_data).to match a_hash_including( 'jobCount' => 0, 'jobs' => nil, # returning jobs not allowed for more than 1 runner (see RunnerJobsResolver) 'projectCount' => 0, 'projects' => { 'nodes' => [] }) expect(runner3_data).to match a_hash_including( 'jobCount' => 0, 'jobs' => nil, # returning jobs not allowed for more than 1 runner (see RunnerJobsResolver) 'projectCount' => nil, 'projects' => nil) expect_graphql_errors_to_include [/"jobs" field can be requested only for 1 CiRunner\(s\) at a time./] end end end describe 'by regular user' do let(:user) { create(:user) } context 'on instance runner' do let(:runner) { active_instance_runner } it_behaves_like 'retrieval by unauthorized user' end context 'on group runner' do let(:runner) { active_group_runner } it_behaves_like 'retrieval by unauthorized user' end context 'on project runner' do let(:runner) { active_project_runner } it_behaves_like 'retrieval by unauthorized user' end end describe 'by non-admin user' do let(:user) { create(:user) } before do group.add_member(user, Gitlab::Access::OWNER) end it_behaves_like 'retrieval with no admin url' do let(:runner) { active_group_runner } end end describe 'by unauthenticated user' do let(:user) { nil } it_behaves_like 'retrieval by unauthorized user' do let(:runner) { active_instance_runner } end end describe 'Query limits' do def runner_query(runner) <<~SINGLE runner(id: "#{runner.to_global_id}") { #{all_graphql_fields_for('CiRunner', excluded: excluded_fields)} groups { nodes { id } } projects { nodes { id } } ownerProject { id } } SINGLE end let(:active_project_runner2) { create(:ci_runner, :project) } let(:active_group_runner2) { create(:ci_runner, :group) } # Currently excluding known N+1 issues, see https://gitlab.com/gitlab-org/gitlab/-/issues/334759 let(:excluded_fields) { %w[jobCount groups projects ownerProject] } let(:single_query) do <<~QUERY { instance_runner1: #{runner_query(active_instance_runner)} group_runner1: #{runner_query(active_group_runner)} project_runner1: #{runner_query(active_project_runner)} } QUERY end let(:double_query) do <<~QUERY { instance_runner1: #{runner_query(active_instance_runner)} instance_runner2: #{runner_query(inactive_instance_runner)} group_runner1: #{runner_query(active_group_runner)} group_runner2: #{runner_query(active_group_runner2)} project_runner1: #{runner_query(active_project_runner)} project_runner2: #{runner_query(active_project_runner2)} } QUERY end it 'does not execute more queries per runner', :aggregate_failures do # warm-up license cache and so on: personal_access_token = create(:personal_access_token, user: user) args = { current_user: user, token: { personal_access_token: personal_access_token } } post_graphql(double_query, **args) control = ActiveRecord::QueryRecorder.new { post_graphql(single_query, **args) } expect { post_graphql(double_query, **args) }.not_to exceed_query_limit(control) expect(graphql_data.count).to eq 6 expect(graphql_data).to match( a_hash_including( 'instance_runner1' => a_hash_including('id' => active_instance_runner.to_global_id.to_s), 'instance_runner2' => a_hash_including('id' => inactive_instance_runner.to_global_id.to_s), 'group_runner1' => a_hash_including( 'id' => active_group_runner.to_global_id.to_s, 'groups' => { 'nodes' => [a_hash_including('id' => group.to_global_id.to_s)] } ), 'group_runner2' => a_hash_including( 'id' => active_group_runner2.to_global_id.to_s, 'groups' => { 'nodes' => [a_hash_including('id' => active_group_runner2.groups[0].to_global_id.to_s)] } ), 'project_runner1' => a_hash_including( 'id' => active_project_runner.to_global_id.to_s, 'projects' => { 'nodes' => [a_hash_including('id' => active_project_runner.projects[0].to_global_id.to_s)] }, 'ownerProject' => a_hash_including('id' => active_project_runner.projects[0].to_global_id.to_s) ), 'project_runner2' => a_hash_including( 'id' => active_project_runner2.to_global_id.to_s, 'projects' => { 'nodes' => [a_hash_including('id' => active_project_runner2.projects[0].to_global_id.to_s)] }, 'ownerProject' => a_hash_including('id' => active_project_runner2.projects[0].to_global_id.to_s) ) )) end end describe 'sorting and pagination' do let(:query) do <<~GQL query($id: CiRunnerID!, $projectSearchTerm: String, $n: Int, $cursor: String) { runner(id: $id) { #{fields} } } GQL end before do post_graphql(query, current_user: user, variables: variables) end context 'with project search term' do let_it_be(:project1) { create(:project, description: 'abc') } let_it_be(:project2) { create(:project, description: 'def') } let_it_be(:project_runner) do create(:ci_runner, :project, projects: [project1, project2]) end let(:variables) { { id: project_runner.to_global_id.to_s, n: n, project_search_term: search_term } } let(:fields) do <<~QUERY projects(search: $projectSearchTerm, first: $n, after: $cursor) { count nodes { id } pageInfo { hasPreviousPage startCursor endCursor hasNextPage } } QUERY end let(:projects_data) { graphql_data_at('runner', 'projects') } context 'set to empty string' do let(:search_term) { '' } context 'with n = 1' do let(:n) { 1 } it_behaves_like 'a working graphql query' it 'returns paged result' do expect(projects_data).not_to be_nil expect(projects_data['count']).to eq 2 expect(projects_data['pageInfo']['hasNextPage']).to eq true end end context 'with n = 2' do let(:n) { 2 } it 'returns non-paged result' do expect(projects_data).not_to be_nil expect(projects_data['count']).to eq 2 expect(projects_data['pageInfo']['hasNextPage']).to eq false end end end context 'set to partial match' do let(:search_term) { 'def' } context 'with n = 1' do let(:n) { 1 } it_behaves_like 'a working graphql query' it 'returns paged result with no additional pages' do expect(projects_data).not_to be_nil expect(projects_data['count']).to eq 1 expect(projects_data['pageInfo']['hasNextPage']).to eq false end end end end end end