Commit 97eab290 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'lm-be-pipeline-mutations' into 'master'

GraphQL: Pipeline mutations for retry, cancel, and destroy

See merge request gitlab-org/gitlab!39780
parents 8e80c580 d1cbcaf1
# frozen_string_literal: true
module Mutations
module Ci
class Base < BaseMutation
argument :id, ::Types::GlobalIDType[::Ci::Pipeline],
required: true,
description: 'The id of the pipeline to mutate'
private
def find_object(id:)
GlobalID::Locator.locate(id)
end
end
end
end
# frozen_string_literal: true
module Mutations
module Ci
class PipelineCancel < BaseMutation
graphql_name 'PipelineCancel'
authorize :update_pipeline
def resolve
result = ::Ci::CancelUserPipelinesService.new.execute(current_user)
{
success: result.success?,
errors: [result&.message]
}
end
end
end
end
# frozen_string_literal: true
module Mutations
module Ci
class PipelineDestroy < Base
graphql_name 'PipelineDestroy'
authorize :destroy_pipeline
def resolve(id:)
pipeline = authorized_find!(id: id)
project = pipeline.project
result = ::Ci::DestroyPipelineService.new(project, current_user).execute(pipeline)
{
success: result.success?,
errors: result.errors
}
end
end
end
end
# frozen_string_literal: true
module Mutations
module Ci
class PipelineRetry < Base
graphql_name 'PipelineRetry'
field :pipeline,
Types::Ci::PipelineType,
null: true,
description: 'The pipeline after mutation'
authorize :update_pipeline
def resolve(id:)
pipeline = authorized_find!(id: id)
project = pipeline.project
::Ci::RetryPipelineService.new(project, current_user).execute(pipeline)
{
pipeline: pipeline,
errors: errors_on_object(pipeline)
}
end
end
end
end
......@@ -62,6 +62,9 @@ module Types
mount_mutation Mutations::DesignManagement::Delete, calls_gitaly: true
mount_mutation Mutations::DesignManagement::Move
mount_mutation Mutations::ContainerExpirationPolicies::Update
mount_mutation Mutations::Ci::PipelineCancel
mount_mutation Mutations::Ci::PipelineDestroy
mount_mutation Mutations::Ci::PipelineRetry
end
end
......
......@@ -7,6 +7,10 @@ module Ci
# https://gitlab.com/gitlab-org/gitlab/issues/32332
def execute(user)
user.pipelines.cancelable.find_each(&:cancel_running)
ServiceResponse.success(message: 'Pipeline canceled')
rescue ActiveRecord::StaleObjectError
ServiceResponse.error(message: 'Error canceling pipeline')
end
# rubocop: enable CodeReuse/ActiveRecord
end
......
......@@ -8,6 +8,10 @@ module Ci
Ci::ExpirePipelineCacheService.new.execute(pipeline, delete: true)
pipeline.destroy!
ServiceResponse.success(message: 'Pipeline not found')
rescue ActiveRecord::RecordNotFound
ServiceResponse.error(message: 'Pipeline not found')
end
end
end
---
title: 'GraphQL: Pipeline mutations for retry, cancel, and destroy'
merge_request: 39780
author:
type: added
......@@ -1596,6 +1596,11 @@ type CiJobEdge {
node: CiJob
}
"""
Identifier of Ci::Pipeline
"""
scalar CiPipelineID
type CiStage {
"""
Group of jobs for the stage
......@@ -9795,6 +9800,9 @@ type Mutation {
"""
mergeRequestUpdate(input: MergeRequestUpdateInput!): MergeRequestUpdatePayload
namespaceIncreaseStorageTemporarily(input: NamespaceIncreaseStorageTemporarilyInput!): NamespaceIncreaseStorageTemporarilyPayload
pipelineCancel(input: PipelineCancelInput!): PipelineCancelPayload
pipelineDestroy(input: PipelineDestroyInput!): PipelineDestroyPayload
pipelineRetry(input: PipelineRetryInput!): PipelineRetryPayload
removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload @deprecated(reason: "Use awardEmojiRemove. Deprecated in 13.2")
removeProjectFromSecurityDashboard(input: RemoveProjectFromSecurityDashboardInput!): RemoveProjectFromSecurityDashboardPayload
runDastScan(input: RunDASTScanInput!): RunDASTScanPayload
......@@ -10589,6 +10597,31 @@ type Pipeline {
userPermissions: PipelinePermissions!
}
"""
Autogenerated input type of PipelineCancel
"""
input PipelineCancelInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
}
"""
Autogenerated return type of PipelineCancel
"""
type PipelineCancelPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
enum PipelineConfigSourceEnum {
AUTO_DEVOPS_SOURCE
BRIDGE_SOURCE
......@@ -10625,6 +10658,36 @@ type PipelineConnection {
pageInfo: PageInfo!
}
"""
Autogenerated input type of PipelineDestroy
"""
input PipelineDestroyInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The id of the pipeline to mutate
"""
id: CiPipelineID!
}
"""
Autogenerated return type of PipelineDestroy
"""
type PipelineDestroyPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
"""
An edge in a connection.
"""
......@@ -10657,6 +10720,41 @@ type PipelinePermissions {
updatePipeline: Boolean!
}
"""
Autogenerated input type of PipelineRetry
"""
input PipelineRetryInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The id of the pipeline to mutate
"""
id: CiPipelineID!
}
"""
Autogenerated return type of PipelineRetry
"""
type PipelineRetryPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The pipeline after mutation
"""
pipeline: Pipeline
}
enum PipelineStatusEnum {
CANCELED
CREATED
......
......@@ -4352,6 +4352,16 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "CiPipelineID",
"description": "Identifier of Ci::Pipeline",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CiStage",
......@@ -28921,6 +28931,87 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipelineCancel",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "PipelineCancelInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "PipelineCancelPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipelineDestroy",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "PipelineDestroyInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "PipelineDestroyPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipelineRetry",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "PipelineRetryInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "PipelineRetryPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "removeAwardEmoji",
"description": null,
......@@ -31695,6 +31786,80 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "PipelineCancelInput",
"description": "Autogenerated input type of PipelineCancel",
"fields": null,
"inputFields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "PipelineCancelPayload",
"description": "Autogenerated return type of PipelineCancel",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "PipelineConfigSourceEnum",
......@@ -31839,6 +32004,94 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "PipelineDestroyInput",
"description": "Autogenerated input type of PipelineDestroy",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "The id of the pipeline to mutate",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "CiPipelineID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "PipelineDestroyPayload",
"description": "Autogenerated return type of PipelineDestroy",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "PipelineEdge",
......@@ -31951,6 +32204,108 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "PipelineRetryInput",
"description": "Autogenerated input type of PipelineRetry",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "The id of the pipeline to mutate",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "CiPipelineID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "PipelineRetryPayload",
"description": "Autogenerated return type of PipelineRetry",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipeline",
"description": "The pipeline after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Pipeline",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "PipelineStatusEnum",
......@@ -1635,6 +1635,24 @@ Information about pagination in a connection.
| `user` | User | Pipeline user |
| `userPermissions` | PipelinePermissions! | Permissions for the current user on the resource |
## PipelineCancelPayload
Autogenerated return type of PipelineCancel
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
## PipelineDestroyPayload
Autogenerated return type of PipelineDestroy
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
## PipelinePermissions
| Name | Type | Description |
......@@ -1643,6 +1661,16 @@ Information about pagination in a connection.
| `destroyPipeline` | Boolean! | Indicates the user can perform `destroy_pipeline` on this resource |
| `updatePipeline` | Boolean! | Indicates the user can perform `update_pipeline` on this resource |
## PipelineRetryPayload
Autogenerated return type of PipelineRetry
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `pipeline` | Pipeline | The pipeline after mutation |
## Project
| Name | Type | Description |
......
......@@ -29,7 +29,7 @@ module Mutations
dast_site_profile = find_dast_site_profile(project: project, dast_site_profile_id: dast_site_profile_id)
dast_site = dast_site_profile.dast_site
service = Ci::RunDastScanService.new(project, current_user)
service = ::Ci::RunDastScanService.new(project, current_user)
result = service.execute(branch: project.default_branch, target_url: dast_site.url)
if result.success?
......
......@@ -32,7 +32,7 @@ module Mutations
def resolve(project_path:, target_url:, branch:, scan_type:)
project = authorized_find!(full_path: project_path)
service = Ci::RunDastScanService.new(project, current_user)
service = ::Ci::RunDastScanService.new(project, current_user)
result = service.execute(branch: branch, target_url: target_url)
if result.success?
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'PipelineCancel' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, :running, project: project, user: user) }
let(:mutation) { graphql_mutation(:pipeline_cancel, {}, 'errors') }
let(:mutation_response) { graphql_mutation_response(:pipeline_cancel) }
before_all do
project.add_maintainer(user)
end
it 'reports the service-level error' do
service = double(execute: ServiceResponse.error(message: 'Error canceling pipeline'))
allow(::Ci::CancelUserPipelinesService).to receive(:new).and_return(service)
post_graphql_mutation(mutation, current_user: create(:user))
expect(mutation_response).to include('errors' => ['Error canceling pipeline'])
end
it 'does not change any pipelines not owned by the current user' do
build = create(:ci_build, :running, pipeline: pipeline)
post_graphql_mutation(mutation, current_user: create(:user))
expect(build).not_to be_canceled
end
it "cancels all of the current user's cancelable pipelines" do
build = create(:ci_build, :running, pipeline: pipeline)
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(build.reload).to be_canceled
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'PipelineDestroy' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:user) { project.owner }
let_it_be(:pipeline) { create(:ci_pipeline, :success, project: project, user: user) }
let(:mutation) do
variables = {
id: pipeline.to_global_id.to_s
}
graphql_mutation(:pipeline_destroy, variables, 'errors')
end
it 'returns an error if the user is not allowed to destroy the pipeline' do
post_graphql_mutation(mutation, current_user: create(:user))
expect(graphql_errors).not_to be_empty
end
it 'destroys a pipeline' do
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect { pipeline.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'PipelineRetry' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
let(:mutation) do
variables = {
id: pipeline.to_global_id.to_s
}
graphql_mutation(:pipeline_retry, variables,
<<-QL
errors
pipeline {
id
}
QL
)
end
let(:mutation_response) { graphql_mutation_response(:pipeline_retry) }
before_all do
project.add_maintainer(user)
end
it 'returns an error if the user is not allowed to retry the pipeline' do
post_graphql_mutation(mutation, current_user: create(:user))
expect(graphql_errors).not_to be_empty
end
it 'retries a pipeline' do
pipeline_id = ::Gitlab::GlobalId.build(pipeline, id: pipeline.id).to_s
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['pipeline']['id']).to eq(pipeline_id)
end
end
......@@ -19,5 +19,17 @@ RSpec.describe Ci::CancelUserPipelinesService do
expect(build.reload).to be_canceled
end
end
context 'when an error ocurrs' do
it 'raises a service level error' do
service = double(execute: ServiceResponse.error(message: 'Error canceling pipeline'))
allow(::Ci::CancelUserPipelinesService).to receive(:new).and_return(service)
result = subject
expect(result).to be_a(ServiceResponse)
expect(result).to be_error
end
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