diff --git a/app/graphql/mutations/ci/runner/bulk_delete.rb b/app/graphql/mutations/ci/runner/bulk_delete.rb
new file mode 100644
index 0000000000000000000000000000000000000000..09aeee37317937c10f67fa84d5c432ed39bdce0b
--- /dev/null
+++ b/app/graphql/mutations/ci/runner/bulk_delete.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Ci
+ module Runner
+ class BulkDelete < BaseMutation
+ graphql_name 'BulkRunnerDelete'
+
+ RunnerID = ::Types::GlobalIDType[::Ci::Runner]
+
+ argument :ids, [RunnerID],
+ required: false,
+ description: 'IDs of the runners to delete.'
+
+ field :deleted_count,
+ ::GraphQL::Types::Int,
+ null: true,
+ description: 'Number of records effectively deleted. ' \
+ 'Only present if operation was performed synchronously.'
+
+ field :deleted_ids,
+ [RunnerID],
+ null: true,
+ description: 'IDs of records effectively deleted. ' \
+ 'Only present if operation was performed synchronously.'
+
+ def resolve(**runner_attrs)
+ raise_resource_not_available_error! unless Ability.allowed?(current_user, :delete_runners)
+
+ if ids = runner_attrs[:ids]
+ runners = find_all_runners_by_ids(model_ids_of(ids))
+
+ result = ::Ci::Runners::BulkDeleteRunnersService.new(runners: runners).execute
+ result.slice(:deleted_count, :deleted_ids).merge(errors: [])
+ else
+ { errors: [] }
+ end
+ end
+
+ private
+
+ def model_ids_of(ids)
+ ids.map do |gid|
+ gid.model_id.to_i
+ end.compact
+ end
+
+ def find_all_runners_by_ids(ids)
+ return ::Ci::Runner.none if ids.blank?
+
+ ::Ci::Runner.id_in(ids)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index dc9eb369dc8909444fb22245d2cce52facade680..ba595a1191133cb277444cd9287abfa3eb322926 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -130,6 +130,7 @@ class MutationType < BaseObject
mount_mutation Mutations::Ci::JobTokenScope::RemoveProject
mount_mutation Mutations::Ci::Runner::Update
mount_mutation Mutations::Ci::Runner::Delete
+ mount_mutation Mutations::Ci::Runner::BulkDelete, alpha: { milestone: '15.3' }
mount_mutation Mutations::Ci::RunnersRegistrationToken::Reset
mount_mutation Mutations::Namespace::PackageSettings::Update
mount_mutation Mutations::Groups::Update
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 461938b061dd3655e15635665974570b7172c74f..42749dd57744205d2c7d144f4ba1d97219fe1a08 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -951,6 +951,30 @@ Input type: `BulkEnableDevopsAdoptionNamespacesInput`
| `enabledNamespaces` | [`[DevopsAdoptionEnabledNamespace!]`](#devopsadoptionenablednamespace) | Enabled namespaces after mutation. |
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+### `Mutation.bulkRunnerDelete`
+
+WARNING:
+**Introduced** in 15.3.
+This feature is in Alpha. It can be changed or removed at any time.
+
+Input type: `BulkRunnerDeleteInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `ids` | [`[CiRunnerID!]`](#cirunnerid) | IDs of the runners to delete. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `deletedCount` | [`Int`](#int) | Number of records effectively deleted. Only present if operation was performed synchronously. |
+| `deletedIds` | [`[CiRunnerID!]`](#cirunnerid) | IDs of records effectively deleted. Only present if operation was performed synchronously. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+
### `Mutation.ciCdSettingsUpdate`
WARNING:
diff --git a/spec/graphql/mutations/ci/runner/bulk_delete_spec.rb b/spec/graphql/mutations/ci/runner/bulk_delete_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f47f1b9869e4aad5044c6821a6bd56a8c7ac81c1
--- /dev/null
+++ b/spec/graphql/mutations/ci/runner/bulk_delete_spec.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Ci::Runner::BulkDelete do
+ include GraphqlHelpers
+
+ let_it_be(:admin_user) { create(:user, :admin) }
+ let_it_be(:user) { create(:user) }
+
+ let(:current_ctx) { { current_user: user } }
+
+ let(:mutation_params) do
+ {}
+ end
+
+ describe '#resolve' do
+ subject(:response) do
+ sync(resolve(described_class, args: mutation_params, ctx: current_ctx))
+ end
+
+ context 'when the user cannot admin the runner' do
+ let(:runner) { create(:ci_runner) }
+ let(:mutation_params) do
+ { ids: [runner.to_global_id] }
+ end
+
+ it 'generates an error' do
+ expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) { response }
+ end
+ end
+
+ context 'when user can delete runners' do
+ let(:user) { admin_user }
+ let!(:runners) do
+ create_list(:ci_runner, 2, :instance)
+ end
+
+ context 'when required arguments are missing' do
+ let(:mutation_params) { {} }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'does not return an error' do
+ is_expected.to match a_hash_including(errors: [])
+ end
+ end
+ end
+
+ context 'with runners specified by id' do
+ let(:mutation_params) do
+ { ids: runners.map(&:to_global_id) }
+ end
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'deletes runners', :aggregate_failures do
+ expect_next_instance_of(
+ ::Ci::Runners::BulkDeleteRunnersService, { runners: runners }
+ ) do |service|
+ expect(service).to receive(:execute).once.and_call_original
+ end
+
+ expect { response }.to change { Ci::Runner.count }.by(-2)
+ expect(response[:errors]).to be_empty
+ end
+
+ context 'when runner list is is above limit' do
+ before do
+ stub_const('::Ci::Runners::BulkDeleteRunnersService::RUNNER_LIMIT', 1)
+ end
+
+ it 'only deletes up to the defined limit', :aggregate_failures do
+ expect { response }.to change { Ci::Runner.count }
+ .by(-::Ci::Runners::BulkDeleteRunnersService::RUNNER_LIMIT)
+ expect(response[:errors]).to be_empty
+ end
+ end
+ end
+
+ context 'when admin mode is disabled', :aggregate_failures do
+ it 'returns error', :aggregate_failures do
+ expect do
+ expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do
+ response
+ end
+ end.not_to change { Ci::Runner.count }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/ci/runner/update_spec.rb b/spec/graphql/mutations/ci/runner/update_spec.rb
index ffaa6e93d1bbed82bab8d73d62e968dc006bf089..b8efd4213fa9e52f81b691a0178285626aca2b25 100644
--- a/spec/graphql/mutations/ci/runner/update_spec.rb
+++ b/spec/graphql/mutations/ci/runner/update_spec.rb
@@ -2,12 +2,11 @@
require 'spec_helper'
-RSpec.describe 'Mutations::Ci::Runner::Update' do
+RSpec.describe Mutations::Ci::Runner::Update do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:runner) { create(:ci_runner, active: true, locked: false, run_untagged: true) }
- let_it_be(:described_class) { Mutations::Ci::Runner::Update }
let(:current_ctx) { { current_user: user } }
let(:mutated_runner) { subject[:runner] }