Commit 0ea2d1de authored by Fabio Pitino's avatar Fabio Pitino

Trigger child pipeline via YAML syntax

Define `trigger:include` to spawn a child
pipeline inside the same project.
parent d3b35829
---
title: Introduce "trigger:include" CI YAML syntax to create a sub-pipeline
merge_request: 21041
author:
type: added
...@@ -98,29 +98,41 @@ module EE ...@@ -98,29 +98,41 @@ module EE
self.user self.user
end end
def target_project_path def target_project
downstream_project || upstream_project downstream_project || upstream_project
end end
def triggers_child_pipeline? def triggers_child_pipeline?
same_project? && yaml_for_downstream.present? yaml_for_downstream.present?
end
def downstream_pipeline_params
return child_params if triggers_child_pipeline?
return cross_project_params if downstream_project.present?
{}
end end
def downstream_project def downstream_project
strong_memoize(:downstream_project) do strong_memoize(:downstream_project) do
options&.dig(:trigger, :project) if downstream_project_path
::Project.find_by_full_path(downstream_project_path)
elsif triggers_child_pipeline?
project
end
end end
end end
def yaml_for_downstream def yaml_for_downstream
strong_memoize(:yaml_for_downstream) do strong_memoize(:yaml_for_downstream) do
options&.dig(:trigger, :yaml) includes = options&.dig(:trigger, :include)
YAML.dump('include' => includes) if includes
end end
end end
def upstream_project def upstream_project
strong_memoize(:upstream_project) do strong_memoize(:upstream_project) do
options&.dig(:bridge_needs, :pipeline) upstream_project_path && ::Project.find_by_full_path(upstream_project_path)
end end
end end
...@@ -149,10 +161,47 @@ module EE ...@@ -149,10 +161,47 @@ module EE
end end
end end
def downstream_project_path
strong_memoize(:downstream_project_path) do
options&.dig(:trigger, :project)
end
end
def upstream_project_path
strong_memoize(:upstream_project_path) do
options&.dig(:bridge_needs, :pipeline)
end
end
private private
def same_project? def cross_project_params
::Project.find_by_full_path(downstream_project) == project {
project: downstream_project,
source: :cross_project_pipeline,
target_revision: {
ref: target_ref || downstream_project.default_branch
}
}
end
def child_params
parent_pipeline = pipeline
{
project: project,
source: :parent_pipeline,
target_revision: {
ref: parent_pipeline.ref,
checkout_sha: parent_pipeline.sha,
before: parent_pipeline.before_sha,
source_sha: parent_pipeline.source_sha,
target_sha: parent_pipeline.target_sha
},
other_execute_params: {
config_content: yaml_for_downstream
}
}
end end
end end
end end
......
...@@ -8,124 +8,70 @@ module Ci ...@@ -8,124 +8,70 @@ module Ci
def execute(bridge) def execute(bridge)
@bridge = bridge @bridge = bridge
unless target_project_exists? pipeline_params = @bridge.downstream_pipeline_params
return bridge.drop!(:downstream_bridge_project_not_found) target_ref = pipeline_params.dig(:target_revision, :ref)
end
if target_project == project && !bridge.triggers_child_pipeline?
return bridge.drop!(:invalid_bridge_trigger)
end
unless can_create_cross_pipeline?
return bridge.drop!(:insufficient_bridge_permissions)
end
if bridge.triggers_child_pipeline? return unless ensure_preconditions!(target_ref)
create_child_pipeline!
else
create_cross_project_pipeline!
end
end
private service = ::Ci::CreatePipelineService.new(
pipeline_params.fetch(:project),
def target_project_exists? current_user,
target_project.present? && pipeline_params.fetch(:target_revision))
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) &&
can_update_branch?
end
def can_update_branch?
::Gitlab::UserAccess.new(target_user, project: target_project).can_update_branch?(target_ref)
end
def create_cross_project_pipeline! service.execute(
::Ci::CreatePipelineService pipeline_params.fetch(:source),
.new(target_project, target_user, ref: target_ref) { ignore_skip_ci: true }.merge(pipeline_params[:other_execute_params] || {})) do |pipeline|
.execute(:cross_project_pipeline, ignore_skip_ci: true) do |pipeline|
@bridge.sourced_pipelines.build( @bridge.sourced_pipelines.build(
source_pipeline: @bridge.pipeline, source_pipeline: @bridge.pipeline,
source_project: @bridge.project, source_project: @bridge.project,
project: target_project, project: @bridge.downstream_project,
pipeline: pipeline) pipeline: pipeline)
pipeline.variables.build(@bridge.downstream_variables) pipeline.variables.build(@bridge.downstream_variables)
end end
end end
def create_child_pipeline! private
return unless @bridge.triggers_child_pipeline?
parent_pipeline = @bridge.pipeline def ensure_preconditions!(target_ref)
unless downstream_project_accessible?
@bridge.drop!(:downstream_bridge_project_not_found)
return false
end
::Ci::CreatePipelineService # TODO: Remove this condition if favour of model validation
.new(@bridge.project, @bridge.user, # https://gitlab.com/gitlab-org/gitlab/issues/38338
ref: parent_pipeline.ref, if downstream_project == project && !@bridge.triggers_child_pipeline?
checkout_sha: parent_pipeline.sha, @bridge.drop!(:invalid_bridge_trigger)
before: parent_pipeline.before_sha, return false
source_sha: parent_pipeline.source_sha, end
target_sha: parent_pipeline.target_sha
)
.execute(:parent_pipeline, ignore_skip_ci: true, config_content: @bridge.yaml_for_downstream) do |pipeline|
@bridge.sourced_pipelines.build(
source_pipeline: @bridge.pipeline,
source_project: @bridge.project,
project: target_project,
pipeline: pipeline)
pipeline.variables.build(@bridge.downstream_variables) unless can_create_downstream_pipeline?(target_ref)
end @bridge.drop!(:insufficient_bridge_permissions)
end return false
end
def create_child_pipeline! true
parent_pipeline = @bridge.pipeline
::Ci::CreatePipelineService
.new(@bridge.project, @bridge.user,
ref: parent_pipeline.ref,
checkout_sha: parent_pipeline.sha,
before: parent_pipeline.before_sha,
source_sha: parent_pipeline.source_sha,
target_sha: parent_pipeline.target_sha
)
.execute(:pipeline,
ignore_skip_ci: true,
config_content: downstream_yaml,
schedule: parent_pipeline.pipeline_schedule) do |pipeline|
@bridge.sourced_pipelines.build(
source_pipeline: @bridge.pipeline,
source_project: @bridge.project,
project: target_project,
pipeline: pipeline)
pipeline.variables.build(@bridge.downstream_variables)
end
end end
def downstream_yaml def downstream_project_accessible?
return unless @bridge.triggers_child_pipeline? downstream_project.present? &&
can?(current_user, :read_project, downstream_project)
@bridge.downstream_yaml
end end
def target_user def can_create_downstream_pipeline?(target_ref)
strong_memoize(:target_user) { @bridge.target_user } can?(current_user, :update_pipeline, project) &&
can?(current_user, :create_pipeline, downstream_project) &&
can_update_branch?(target_ref)
end end
def target_ref def can_update_branch?(target_ref)
strong_memoize(:target_ref) do ::Gitlab::UserAccess.new(current_user, project: downstream_project).can_update_branch?(target_ref)
@bridge.target_ref || target_project.default_branch
end
end end
def target_project def downstream_project
strong_memoize(:target_project) do strong_memoize(:downstream_project) do
Project.find_by_full_path(@bridge.target_project_path) @bridge.downstream_project
end end
end end
end end
......
...@@ -5,7 +5,7 @@ module Ci ...@@ -5,7 +5,7 @@ module Ci
include ::Gitlab::Utils::StrongMemoize include ::Gitlab::Utils::StrongMemoize
def execute(bridge) def execute(bridge)
return unless bridge.upstream_project return unless bridge.upstream_project_path
@bridge = bridge @bridge = bridge
...@@ -29,7 +29,7 @@ module Ci ...@@ -29,7 +29,7 @@ module Ci
def upstream_project def upstream_project
strong_memoize(:upstream_project) do strong_memoize(:upstream_project) do
::Project.find_by_full_path(@bridge.target_project_path) @bridge.upstream_project
end end
end end
......
...@@ -22,19 +22,57 @@ module EE ...@@ -22,19 +22,57 @@ module EE
end end
end end
class ComplexTrigger < ::Gitlab::Config::Entry::Node class ComplexTrigger < ::Gitlab::Config::Entry::Simplifiable
include ::Gitlab::Config::Entry::Validatable strategy :CrossProjectTrigger, if: -> (config) { !config.key?(:include) }
include ::Gitlab::Config::Entry::Attributable
strategy :SameProjectTrigger, if: -> (config) do
::Feature.enabled?(:ci_parent_child_pipeline) &&
config.key?(:include)
end
ALLOWED_KEYS = %i[project branch strategy].freeze class CrossProjectTrigger < ::Gitlab::Config::Entry::Node
attributes :project, :branch, :strategy include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[project branch strategy].freeze
attributes :project, :branch, :strategy
validations do
validates :config, presence: true
validates :config, allowed_keys: ALLOWED_KEYS
validates :project, presence: true
validates :branch, type: String, allow_nil: true
validates :strategy, type: String, inclusion: { in: %w[depend], message: 'should be depend' }, allow_nil: true
end
end
class SameProjectTrigger < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
include ::Gitlab::Config::Entry::Configurable
ALLOWED_KEYS = %i[strategy include].freeze
attributes :strategy
validations do
validates :config, presence: true
validates :config, allowed_keys: ALLOWED_KEYS
validates :strategy, type: String, inclusion: { in: %w[depend], message: 'should be depend' }, allow_nil: true
end
entry :include, ::Gitlab::Ci::Config::Entry::Includes,
description: 'List of external YAML files to include.',
reserved: true
end
validations do class UnknownStrategy < ::Gitlab::Config::Entry::Node
validates :config, presence: true def errors
validates :config, allowed_keys: ALLOWED_KEYS if ::Feature.enabled?(:ci_parent_child_pipeline)
validates :project, presence: true ['config must specify either project or include']
validates :branch, type: String, allow_nil: true else
validates :strategy, type: String, inclusion: { in: %w[depend], message: 'should be depend' }, allow_nil: true ['config must specify project']
end
end
end end
end end
......
# frozen_string_literal: true # frozen_string_literal: true
require 'fast_spec_helper' require 'spec_helper'
require_dependency 'active_model'
describe EE::Gitlab::Ci::Config::Entry::Trigger do describe EE::Gitlab::Ci::Config::Entry::Trigger do
subject { described_class.new(config) } subject { described_class.new(config) }
...@@ -83,6 +82,53 @@ describe EE::Gitlab::Ci::Config::Entry::Trigger do ...@@ -83,6 +82,53 @@ describe EE::Gitlab::Ci::Config::Entry::Trigger do
end end
end end
context '#include' do
context 'with simple include' do
let(:config) { { include: 'path/to/config.yml' } }
it { is_expected.to be_valid }
it 'returns a trigger configuration hash' do
expect(subject.value).to eq(include: 'path/to/config.yml' )
end
end
context 'with project' do
let(:config) { { project: 'some/project', include: 'path/to/config.yml' } }
it { is_expected.not_to be_valid }
it 'is returns an error' do
expect(subject.errors.first)
.to match /config contains unknown keys: project/
end
end
context 'with branch' do
let(:config) { { branch: 'feature', include: 'path/to/config.yml' } }
it { is_expected.not_to be_valid }
it 'is returns an error' do
expect(subject.errors.first)
.to match /config contains unknown keys: branch/
end
end
context 'when feature flag is off' do
before do
stub_feature_flags(ci_parent_child_pipeline: false)
end
let(:config) { { include: 'path/to/config.yml' } }
it 'is returns an error if include is used' do
expect(subject.errors.first)
.to match /config must specify project/
end
end
end
context 'when config contains unknown keys' do context 'when config contains unknown keys' do
let(:config) { { project: 'some/project', unknown: 123 } } let(:config) { { project: 'some/project', unknown: 123 } }
......
...@@ -4,6 +4,7 @@ require 'spec_helper' ...@@ -4,6 +4,7 @@ require 'spec_helper'
describe Ci::Bridge do describe Ci::Bridge do
set(:project) { create(:project) } set(:project) { create(:project) }
set(:target_project) { create(:project, name: 'project', namespace: create(:namespace, name: 'my')) }
set(:pipeline) { create(:ci_pipeline, project: project) } set(:pipeline) { create(:ci_pipeline, project: project) }
let(:bridge) do let(:bridge) do
...@@ -157,10 +158,10 @@ describe Ci::Bridge do ...@@ -157,10 +158,10 @@ describe Ci::Bridge do
end end
end end
describe '#target_project_path' do describe '#target_project' do
context 'when trigger is defined' do context 'when trigger is defined' do
it 'returns a full path of a project' do it 'returns a full path of a project' do
expect(bridge.target_project_path).to eq 'my/project' expect(bridge.target_project).to eq target_project
end end
end end
...@@ -168,7 +169,7 @@ describe Ci::Bridge do ...@@ -168,7 +169,7 @@ describe Ci::Bridge do
let(:options) { { trigger: {} } } let(:options) { { trigger: {} } }
it 'returns nil' do it 'returns nil' do
expect(bridge.target_project_path).to be_nil expect(bridge.target_project).to be_nil
end end
end end
end end
...@@ -293,39 +294,23 @@ describe Ci::Bridge do ...@@ -293,39 +294,23 @@ describe Ci::Bridge do
describe '#triggers_child_pipeline?' do describe '#triggers_child_pipeline?' do
subject { bridge.triggers_child_pipeline? } subject { bridge.triggers_child_pipeline? }
context 'when downstream project is same as the bridge project' do context 'when bridge defines a downstream YAML' do
context 'when bridge defines a downstream YAML' do let(:options) do
let(:options) do {
{ trigger: {
trigger: { include: 'path/to/child.yml'
project: project.full_path,
yaml: YAML.dump(rspec: { script: 'rspec' })
}
} }
end }
it { is_expected.to be_truthy }
end end
context 'when bridge does not define a downstream YAML' do it { is_expected.to be_truthy }
let(:options) do
{
trigger: {
project: project.full_path
}
}
end
it { is_expected.to be_falsey }
end
end end
context 'when downstream project is different than bridge project' do context 'when bridge does not define a downstream YAML' do
let(:options) do let(:options) do
{ {
trigger: { trigger: {
project: 'my/project', project: project.full_path
yaml: YAML.dump(rspec: { script: 'rspec' })
} }
} }
end end
......
...@@ -148,7 +148,7 @@ describe Ci::CreateCrossProjectPipelineService, '#execute' do ...@@ -148,7 +148,7 @@ describe Ci::CreateCrossProjectPipelineService, '#execute' do
end end
end end
context 'when a custom YAML is provided' do context 'when "include" is provided' do
shared_examples 'creates a child pipeline' do shared_examples 'creates a child pipeline' do
it 'creates only one new pipeline' do it 'creates only one new pipeline' do
expect { service.execute(bridge) } expect { service.execute(bridge) }
...@@ -201,10 +201,7 @@ describe Ci::CreateCrossProjectPipelineService, '#execute' do ...@@ -201,10 +201,7 @@ describe Ci::CreateCrossProjectPipelineService, '#execute' do
let(:trigger) do let(:trigger) do
{ {
trigger: { trigger: { include: 'child-pipeline.yml' }
project: upstream_project.full_path,
yaml: YAML.dump({ include: 'child-pipeline.yml' })
}
} }
end end
......
...@@ -5,6 +5,7 @@ require 'spec_helper' ...@@ -5,6 +5,7 @@ require 'spec_helper'
describe Ci::CreatePipelineService do describe Ci::CreatePipelineService do
subject(:execute) { service.execute(:push) } subject(:execute) { service.execute(:push) }
set(:downstream_project) { create(:project, name: 'project', namespace: create(:namespace, name: 'some'))}
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
let(:user) { create(:admin) } let(:user) { create(:admin) }
let(:service) { described_class.new(project, user, { ref: 'refs/heads/master' }) } let(:service) { described_class.new(project, user, { ref: 'refs/heads/master' }) }
...@@ -46,6 +47,6 @@ describe Ci::CreatePipelineService do ...@@ -46,6 +47,6 @@ describe Ci::CreatePipelineService do
it 'persists bridge target project' do it 'persists bridge target project' do
bridge = execute.stages.last.bridges.first bridge = execute.stages.last.bridges.first
expect(bridge.downstream_project).to eq('some/project') expect(bridge.downstream_project).to eq downstream_project
end end
end end
...@@ -73,8 +73,6 @@ describe Ci::CreatePipelineService, '#execute' do ...@@ -73,8 +73,6 @@ describe Ci::CreatePipelineService, '#execute' do
describe 'cross-project pipeline triggers' do describe 'cross-project pipeline triggers' do
before do before do
stub_feature_flags(cross_project_pipeline_triggers: true)
stub_ci_pipeline_yaml_file <<~YAML stub_ci_pipeline_yaml_file <<~YAML
test: test:
script: rspec script: rspec
...@@ -144,6 +142,41 @@ describe Ci::CreatePipelineService, '#execute' do ...@@ -144,6 +142,41 @@ describe Ci::CreatePipelineService, '#execute' do
end end
end end
describe 'child pipeline triggers' do
before do
stub_ci_pipeline_yaml_file <<~YAML
test:
script: rspec
deploy:
variables:
CROSS: downstream
stage: deploy
trigger:
include:
- local: path/to/child.yml
YAML
end
it 'creates bridge jobs correctly' do
pipeline = create_pipeline!
test = pipeline.statuses.find_by(name: 'test')
bridge = pipeline.statuses.find_by(name: 'deploy')
expect(pipeline).to be_persisted
expect(test).to be_a Ci::Build
expect(bridge).to be_a Ci::Bridge
expect(bridge.stage).to eq 'deploy'
expect(pipeline.statuses).to match_array [test, bridge]
expect(bridge.options).to eq(
'trigger' => { 'include' => { '0' => { 'local' => 'path/to/child.yml' } } }
)
expect(bridge.yaml_variables)
.to include(key: 'CROSS', value: 'downstream', public: true)
end
end
def create_pipeline! def create_pipeline!
service.execute(:push) service.execute(:push)
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