Commit 494dfe89 authored by Douwe Maan's avatar Douwe Maan

Merge branch 'feature/gb/pipeline-bridge-transitions' into 'master'

Implement CI/CD bridge transitions

See merge request gitlab-org/gitlab-ee!8813
parents 73310aec 18aa2fb6
...@@ -32,3 +32,5 @@ module Ci ...@@ -32,3 +32,5 @@ module Ci
end end
end end
end end
::Ci::Bridge.prepend(::EE::Ci::Bridge)
...@@ -6,10 +6,11 @@ module Ci ...@@ -6,10 +6,11 @@ module Ci
self.table_name = "ci_sources_pipelines" self.table_name = "ci_sources_pipelines"
belongs_to :project, class_name: Project belongs_to :project, class_name: Project
belongs_to :pipeline, class_name: Ci::Pipeline belongs_to :pipeline, class_name: Ci::Pipeline, inverse_of: :source_pipeline
belongs_to :source_project, class_name: Project, foreign_key: :source_project_id belongs_to :source_project, class_name: Project, foreign_key: :source_project_id
belongs_to :source_job, class_name: Ci::Build, foreign_key: :source_job_id belongs_to :source_job, class_name: CommitStatus, foreign_key: :source_job_id
belongs_to :source_bridge, class_name: Ci::Bridge, foreign_key: :source_job_id
belongs_to :source_pipeline, class_name: Ci::Pipeline, foreign_key: :source_pipeline_id belongs_to :source_pipeline, class_name: Ci::Pipeline, foreign_key: :source_pipeline_id
validates :project, presence: true validates :project, presence: true
......
# frozen_string_literal: true
module EE
module Ci
module Bridge
extend ActiveSupport::Concern
prepended do
serialize :options # rubocop:disable Cop/ActiveRecordSerialize
has_many :sourced_pipelines, class_name: ::Ci::Sources::Pipeline,
foreign_key: :source_job_id
state_machine :status do
after_transition created: :pending do |bridge|
bridge.run_after_commit do
bridge.schedule_downstream_pipeline!
end
end
end
end
def schedule_downstream_pipeline!
::Ci::CreateCrossProjectPipelineWorker.perform_async(self.id)
end
def target_user
self.user
end
def target_project_path
options&.dig(:trigger, :project)
end
def target_ref
options&.dig(:trigger, :branch)
end
end
end
end
...@@ -5,6 +5,8 @@ module EE ...@@ -5,6 +5,8 @@ module EE
module Pipeline module Pipeline
extend ActiveSupport::Concern extend ActiveSupport::Concern
BridgeStatusError = Class.new(StandardError)
EE_FAILURE_REASONS = { EE_FAILURE_REASONS = {
activity_limit_exceeded: 20, activity_limit_exceeded: 20,
size_limit_exceeded: 21 size_limit_exceeded: 21
...@@ -17,10 +19,12 @@ module EE ...@@ -17,10 +19,12 @@ module EE
has_many :vulnerabilities_occurrence_pipelines, class_name: 'Vulnerabilities::OccurrencePipeline' has_many :vulnerabilities_occurrence_pipelines, class_name: 'Vulnerabilities::OccurrencePipeline'
has_many :vulnerabilities, source: :occurrence, through: :vulnerabilities_occurrence_pipelines, class_name: 'Vulnerabilities::Occurrence' has_many :vulnerabilities, source: :occurrence, through: :vulnerabilities_occurrence_pipelines, class_name: 'Vulnerabilities::Occurrence'
has_one :source_pipeline, class_name: ::Ci::Sources::Pipeline has_one :source_pipeline, class_name: ::Ci::Sources::Pipeline, inverse_of: :pipeline
has_many :sourced_pipelines, class_name: ::Ci::Sources::Pipeline, foreign_key: :source_pipeline_id has_many :sourced_pipelines, class_name: ::Ci::Sources::Pipeline, foreign_key: :source_pipeline_id
has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline
has_one :source_job, through: :source_pipeline, source: :source_job
has_one :source_bridge, through: :source_pipeline, source: :source_bridge
has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline
has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id' has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
...@@ -95,7 +99,24 @@ module EE ...@@ -95,7 +99,24 @@ module EE
StoreSecurityReportsWorker.perform_async(pipeline.id) StoreSecurityReportsWorker.perform_async(pipeline.id)
end end
end end
after_transition created: :pending do |pipeline|
next unless pipeline.bridge_triggered?
pipeline.update_bridge_status!
end
end
end end
def bridge_triggered?
source_bridge.present?
end
def update_bridge_status!
raise ArgumentError unless bridge_triggered?
raise BridgeStatusError unless source_bridge.active?
source_bridge.success!
end end
def any_report_artifact_for_type(file_type) def any_report_artifact_for_type(file_type)
......
...@@ -9,7 +9,9 @@ module EE ...@@ -9,7 +9,9 @@ module EE
override :failure_reasons override :failure_reasons
def failure_reasons def failure_reasons
super.merge(protected_environment_failure: 1_000) super.merge(protected_environment_failure: 1_000,
insufficient_bridge_permissions: 1_001,
invalid_bridge_trigger: 1_002)
end end
end end
end end
......
...@@ -5,7 +5,9 @@ module EE ...@@ -5,7 +5,9 @@ module EE
prepended do prepended do
EE_CALLOUT_FAILURE_MESSAGES = const_get(:CALLOUT_FAILURE_MESSAGES).merge( EE_CALLOUT_FAILURE_MESSAGES = const_get(:CALLOUT_FAILURE_MESSAGES).merge(
protected_environment_failure: 'The environment this job is deploying to is protected. Only users with permission may successfully run this job' protected_environment_failure: 'The environment this job is deploying to is protected. Only users with permission may successfully run this job.',
insufficient_bridge_permissions: 'This job could not be executed because of insufficient permissions to create a downstream pipeline.',
invalid_bridge_trigger: 'This job could not be executed because downstream pipeline trigger definition is invalid.'
).freeze ).freeze
EE::CommitStatusPresenter.private_constant :EE_CALLOUT_FAILURE_MESSAGES EE::CommitStatusPresenter.private_constant :EE_CALLOUT_FAILURE_MESSAGES
......
# frozen_string_literal: true
module Ci
class CreateCrossProjectPipelineService < ::BaseService
include Gitlab::Utils::StrongMemoize
def execute(bridge)
@bridge = bridge
unless target_project_exists?
return bridge.drop!(:invalid_bridge_trigger)
end
if target_project == project
return bridge.drop!(:invalid_bridge_trigger)
end
unless can_create_cross_pipeline?
return bridge.drop!(:insufficient_bridge_permissions)
end
create_pipeline!
end
private
def target_project_exists?
target_project.present? &&
can?(current_user, :read_project, target_project)
end
def can_create_cross_pipeline?
can?(current_user, :update_pipeline, project) &&
can?(target_user, :create_pipeline, target_project)
end
def create_pipeline!
::Ci::CreatePipelineService
.new(target_project, target_user, ref: target_ref)
.execute(:pipeline, ignore_skip_ci: true) do |pipeline|
@bridge.sourced_pipelines.build(
source_pipeline: @bridge.pipeline,
source_project: @bridge.project,
project: target_project,
pipeline: pipeline)
end
end
def target_user
strong_memoize(:target_user) { @bridge.target_user }
end
def target_ref
strong_memoize(:target_ref) do
@bridge.target_ref || target_project.default_branch
end
end
def target_project
strong_memoize(:target_project) do
Project.find_by_full_path(@bridge.target_project_path)
end
end
end
end
...@@ -42,6 +42,7 @@ ...@@ -42,6 +42,7 @@
- geo:geo_scheduler_secondary_scheduler - geo:geo_scheduler_secondary_scheduler
- pipeline_default:store_security_reports - pipeline_default:store_security_reports
- pipeline_default:ci_create_cross_project_pipeline
- admin_emails - admin_emails
- chat_notification - chat_notification
......
# frozen_string_literal: true
module Ci
class CreateCrossProjectPipelineWorker
include ::ApplicationWorker
include ::PipelineQueue
def perform(bridge_id)
::Ci::Bridge.find_by_id(bridge_id).try do |bridge|
::Ci::CreateCrossProjectPipelineService
.new(bridge.project, bridge.user)
.execute(bridge)
end
end
end
end
# frozen_string_literal: true # frozen_string_literal: true
module EE module EE
module Gitlab module Gitlab
module Ci module Ci
...@@ -9,7 +10,9 @@ module EE ...@@ -9,7 +10,9 @@ module EE
prepended do prepended do
EE_REASONS = const_get(:REASONS).merge( EE_REASONS = const_get(:REASONS).merge(
protected_environment_failure: 'protected environment failure' protected_environment_failure: 'protected environment failure',
invalid_bridge_trigger: 'downstream pipeline trigger definition is invalid',
insufficient_bridge_permissions: 'no permissions to trigger downstream pipeline'
).freeze ).freeze
EE::Gitlab::Ci::Status::Build::Failed.private_constant :EE_REASONS EE::Gitlab::Ci::Status::Build::Failed.private_constant :EE_REASONS
......
...@@ -13,6 +13,8 @@ merge_requests: ...@@ -13,6 +13,8 @@ merge_requests:
- draft_notes - draft_notes
ci_pipelines: ci_pipelines:
- source_pipeline - source_pipeline
- source_bridge
- source_job
- sourced_pipelines - sourced_pipelines
- triggered_by_pipeline - triggered_by_pipeline
- triggered_pipelines - triggered_pipelines
......
require 'spec_helper'
describe Ci::Bridge do
set(:project) { create(:project) }
set(:pipeline) { create(:ci_pipeline, project: project) }
let(:bridge) do
create(:ci_bridge, status: :created, options: options, pipeline: pipeline)
end
let(:options) do
{ trigger: { project: 'my/project', branch: 'master' } }
end
it 'has many sourced pipelines' do
expect(bridge).to have_many(:sourced_pipelines)
end
describe 'state machine transitions' do
context 'when it changes status from created to pending' do
it 'schedules downstream pipeline creation' do
expect(bridge).to receive(:schedule_downstream_pipeline!)
bridge.enqueue!
end
end
end
describe '#target_user' do
it 'is the same as a user who created a pipeline' do
expect(bridge.target_user).to eq bridge.user
end
end
describe '#target_project_path' do
context 'when trigger is defined' do
it 'returns a full path of a project' do
expect(bridge.target_project_path).to eq 'my/project'
end
end
context 'when trigger does not have project defined' do
let(:options) { { trigger: {} } }
it 'returns nil' do
expect(bridge.target_project_path).to be_nil
end
end
end
describe '#target_ref' do
context 'when trigger is defined' do
it 'returns a ref name' do
expect(bridge.target_ref).to eq 'master'
end
end
context 'when trigger does not have project defined' do
let(:options) { nil }
it 'returns nil' do
expect(bridge.target_ref).to be_nil
end
end
end
end
...@@ -393,4 +393,82 @@ describe Ci::Pipeline do ...@@ -393,4 +393,82 @@ describe Ci::Pipeline do
end end
end end
end end
describe 'upstream status interactions' do
context 'when a pipeline has an upstream status' do
context 'when an upstream status is a bridge' do
let(:bridge) { create(:ci_bridge, status: :pending) }
before do
create(:ci_sources_pipeline, pipeline: pipeline, source_job: bridge)
end
describe '#bridge_triggered?' do
it 'is a pipeline triggered by a bridge' do
expect(pipeline).to be_bridge_triggered
end
end
describe '#source_job' do
it 'has a correct source job' do
expect(pipeline.source_job).to eq bridge
end
end
describe '#source_bridge' do
it 'has a correct bridge source' do
expect(pipeline.source_bridge).to eq bridge
end
end
describe '#update_bridge_status!' do
it 'can update bridge status if it is running' do
pipeline.update_bridge_status!
expect(bridge.reload).to be_success
end
it 'can not update bridge status if is not active' do
bridge.success!
expect { pipeline.update_bridge_status! }
.to raise_error EE::Ci::Pipeline::BridgeStatusError
end
end
end
context 'when an upstream status is a build' do
let(:build) { create(:ci_build) }
before do
create(:ci_sources_pipeline, pipeline: pipeline, source_job: build)
end
describe '#bridge_triggered?' do
it 'is a pipeline that has not been triggered by a bridge' do
expect(pipeline).not_to be_bridge_triggered
end
end
describe '#source_job' do
it 'has a correct source job' do
expect(pipeline.source_job).to eq build
end
end
describe '#source_bridge' do
it 'does not have a bridge source' do
expect(pipeline.source_bridge).to be_nil
end
end
describe '#update_bridge_status!' do
it 'can not update upstream job status' do
expect { pipeline.update_bridge_status! }
.to raise_error ArgumentError
end
end
end
end
end
end end
...@@ -9,7 +9,9 @@ describe Ci::BuildPresenter do ...@@ -9,7 +9,9 @@ describe Ci::BuildPresenter do
it 'returns a verbose failure reason' do it 'returns a verbose failure reason' do
description = presenter.callout_failure_message description = presenter.callout_failure_message
expect(description).to eq('The environment this job is deploying to is protected. Only users with permission may successfully run this job')
expect(description).to eq 'The environment this job is deploying to is protected. ' \
'Only users with permission may successfully run this job.'
end end
end end
end end
require 'spec_helper'
describe Ci::CreateCrossProjectPipelineService, '#execute' do
set(:user) { create(:user) }
set(:upstream_project) { create(:project, :repository) }
set(:downstream_project) { create(:project, :repository) }
set(:upstream_pipeline) do
create(:ci_pipeline, :running, project: upstream_project)
end
let(:trigger) do
{
trigger: {
project: downstream_project.full_path,
branch: 'feature'
}
}
end
let(:bridge) do
create(:ci_bridge, status: :pending,
user: user,
options: trigger,
pipeline: upstream_pipeline)
end
let(:service) { described_class.new(upstream_project, user) }
before do
stub_ci_pipeline_to_return_yaml_file
upstream_project.add_developer(user)
end
context 'when downstream project has not been found' do
let(:trigger) do
{ trigger: { project: 'unknown/project' } }
end
it 'does not create a pipeline' do
expect { service.execute(bridge) }
.not_to change { Ci::Pipeline.count }
end
it 'changes pipeline bridge job status to failed' do
service.execute(bridge)
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq 'invalid_bridge_trigger'
end
end
context 'when user can not access downstream project' do
it 'does not create a new pipeline' do
expect { service.execute(bridge) }
.not_to change { Ci::Pipeline.count }
end
it 'changes status of the bridge build' do
service.execute(bridge)
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq 'invalid_bridge_trigger'
end
end
context 'when user does not have access to create pipeline' do
before do
downstream_project.add_guest(user)
end
it 'does not create a new pipeline' do
expect { service.execute(bridge) }
.not_to change { Ci::Pipeline.count }
end
it 'changes status of the bridge build' do
service.execute(bridge)
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq 'insufficient_bridge_permissions'
end
end
context 'when user can create pipeline in a downstream project' do
before do
downstream_project.add_developer(user)
end
it 'creates only one new pipeline' do
expect { service.execute(bridge) }
.to change { Ci::Pipeline.count }.by(1)
end
it 'creates a new pipeline in a downstream project' do
pipeline = service.execute(bridge)
expect(pipeline.user).to eq bridge.user
expect(pipeline.project).to eq downstream_project
expect(bridge.sourced_pipelines.first.pipeline).to eq pipeline
expect(pipeline.triggered_by_pipeline).to eq upstream_pipeline
expect(pipeline.source_bridge).to eq bridge
expect(pipeline.source_bridge).to be_a ::Ci::Bridge
end
it 'updates bridge status when downstream pipeline gets proceesed' do
pipeline = service.execute(bridge)
expect(pipeline.reload).to be_pending
expect(bridge.reload).to be_success
end
context 'when target ref is not specified' do
let(:trigger) do
{ trigger: { project: downstream_project.full_path } }
end
it 'is using default branch name' do
pipeline = service.execute(bridge)
expect(pipeline.ref).to eq 'master'
end
end
context 'when circular dependency is defined' do
let(:trigger) do
{ trigger: { project: upstream_project.full_path } }
end
it 'does not create a new pipeline' do
expect { service.execute(bridge) }
.not_to change { Ci::Pipeline.count }
end
it 'changes status of the bridge build' do
service.execute(bridge)
expect(bridge.reload).to be_failed
expect(bridge.failure_reason).to eq 'invalid_bridge_trigger'
end
end
end
end
require 'spec_helper'
describe Ci::CreateCrossProjectPipelineWorker do
set(:user) { create(:user) }
set(:project) { create(:project) }
set(:pipeline) { create(:ci_pipeline, project: project) }
set(:bridge) { create(:ci_bridge, user: user, pipeline: pipeline) }
let(:service) { double('pipeline creation service') }
describe '#perform' do
context 'when bridge exists' do
it 'calls cross project pipeline creation service' do
expect(Ci::CreateCrossProjectPipelineService)
.to receive(:new)
.with(project, user)
.and_return(service)
expect(service).to receive(:execute).with(bridge)
described_class.new.perform(bridge.id)
end
end
context 'when bridge does not exist' do
it 'does nothing' do
expect(Ci::CreateCrossProjectPipelineService)
.not_to receive(:new)
described_class.new.perform(123)
end
end
end
end
...@@ -6,7 +6,7 @@ FactoryBot.define do ...@@ -6,7 +6,7 @@ FactoryBot.define do
ref 'master' ref 'master'
tag false tag false
created_at 'Di 29. Okt 09:50:00 CET 2013' created_at 'Di 29. Okt 09:50:00 CET 2013'
status :success status :created
pipeline factory: :ci_pipeline pipeline factory: :ci_pipeline
......
...@@ -5,7 +5,7 @@ describe Ci::Bridge do ...@@ -5,7 +5,7 @@ describe Ci::Bridge do
set(:pipeline) { create(:ci_pipeline, project: project) } set(:pipeline) { create(:ci_pipeline, project: project) }
let(:bridge) do let(:bridge) do
create(:ci_bridge, pipeline: pipeline) create(:ci_bridge, status: :success, pipeline: pipeline)
end end
describe '#tags' do describe '#tags' do
......
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