Commit 1b1cf299 authored by Matija Čupić's avatar Matija Čupić

Implement subscription triggering mechanism

Implements the mechanism that triggers pipelines based on project
subscriptions.
parent 49e558e9
...@@ -227,3 +227,19 @@ Some features are not implemented yet. For example, support for environments. ...@@ -227,3 +227,19 @@ Some features are not implemented yet. For example, support for environments.
- `only` and `except` - `only` and `except`
- `when` (only with `on_success`, `on_failure`, and `always` values) - `when` (only with `on_success`, `on_failure`, and `always` values)
- `extends` - `extends`
## Trigger a pipeline when an upstream project is rebuilt
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/9045) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.8.
You can trigger a pipeline in your project whenever a pipeline finishes for a new
tag in a different project:
1. Go to the project's **Settings > CI / CD** page, and expand the **Pipeline subscriptions** section.
1. Enter the path to the project you want to subscribe to.
1. Click subscribe.
Any pipelines that complete successfully for new tags in the subscribed project
will now trigger a pipeline on the current project's default branch. The maximum
number of upstream pipeline subscriptions is 2, for both the upstream and
downstream projects.
...@@ -4,16 +4,17 @@ class Projects::SubscriptionsController < Projects::ApplicationController ...@@ -4,16 +4,17 @@ class Projects::SubscriptionsController < Projects::ApplicationController
include ::Gitlab::Utils::StrongMemoize include ::Gitlab::Utils::StrongMemoize
before_action :authorize_admin_project! before_action :authorize_admin_project!
before_action :authorize_read_upstream_project!, only: [:create]
before_action :feature_ci_project_subscriptions! before_action :feature_ci_project_subscriptions!
before_action :authorize_upstream_project!, only: [:create]
before_action :check_subscription_count!, only: [:create]
def create def create
subscription = project.upstream_project_subscriptions.create(upstream_project: upstream_project) subscription = project.upstream_project_subscriptions.create(upstream_project: upstream_project)
flash[:notice] = if subscription.persisted? if subscription.persisted?
_('Subscription successfully created.') flash[:notice] = _('Subscription successfully created.')
else else
_('This project path either does not exist or is private.') flash[:alert] = _('Subscription creation failed because the specified project is not public.')
end end
redirect_to project_settings_ci_cd_path(project) redirect_to project_settings_ci_cd_path(project)
...@@ -41,11 +42,21 @@ class Projects::SubscriptionsController < Projects::ApplicationController ...@@ -41,11 +42,21 @@ class Projects::SubscriptionsController < Projects::ApplicationController
project.upstream_project_subscriptions.find(params[:id]) project.upstream_project_subscriptions.find(params[:id])
end end
def authorize_read_upstream_project!
render_404 unless can?(current_user, :read_project, upstream_project)
end
def feature_ci_project_subscriptions! def feature_ci_project_subscriptions!
render_404 unless project.feature_available?(:ci_project_subscriptions) render_404 unless project.feature_available?(:ci_project_subscriptions)
end end
def authorize_upstream_project!
return if can?(current_user, :developer_access, upstream_project)
flash[:warning] = _('This project path either does not exist or you do not have access.')
redirect_to project_settings_ci_cd_path(project)
end
def check_subscription_count!
return if project.upstream_project_subscriptions.count < 2
flash[:warning] = _('Subscription limit reached.')
redirect_to project_settings_ci_cd_path(project)
end
end end
...@@ -65,9 +65,22 @@ module EE ...@@ -65,9 +65,22 @@ module EE
::Ci::PipelineBridgeStatusWorker.perform_async(pipeline.id) ::Ci::PipelineBridgeStatusWorker.perform_async(pipeline.id)
end end
end end
after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
next unless pipeline.triggers_subscriptions?
pipeline.run_after_commit do
::Ci::TriggerDownstreamSubscriptionsWorker.perform_async(pipeline.id)
end
end
end end
end end
def triggers_subscriptions?
# Currently we trigger subscriptions only for tags.
tag? && project_has_subscriptions?
end
def retryable? def retryable?
!merge_train_pipeline? && super !merge_train_pipeline? && super
end end
...@@ -145,6 +158,12 @@ module EE ...@@ -145,6 +158,12 @@ module EE
private private
def project_has_subscriptions?
return false unless ::Feature.enabled?(:ci_project_subscriptions, project)
project.downstream_projects.any?
end
def merge_train_ref? def merge_train_ref?
::MergeRequest.merge_train_ref?(ref) ::MergeRequest.merge_train_ref?(ref)
end end
......
# frozen_string_literal: true
module Ci
class TriggerDownstreamSubscriptionService < ::BaseService
def execute(pipeline)
pipeline.project.downstream_projects.each do |downstream_project|
::Ci::CreatePipelineService.new(downstream_project, pipeline.user, ref: downstream_project.default_branch).execute(:pipeline)
end
end
end
end
...@@ -9,10 +9,10 @@ ...@@ -9,10 +9,10 @@
%p %p
= _("Set up pipeline subscriptions for this project.") = _("Set up pipeline subscriptions for this project.")
%p %p
- default_branch_docs = link_to(_("default branch"), help_page_path('user/project/repository/branches/index.md', anchor: 'default-branch')) - default_branch_docs = link_to(_("default branch"), help_page_path('user/project/repository/branches', anchor: 'default-branch'))
= _("Subscriptions allow successfully completed pipelines on the %{default_branch_docs} of the subscribed project to trigger a new pipeline on the default branch of this project.").html_safe % { default_branch_docs: default_branch_docs } = _("A subscription will trigger a new pipeline on the default branch of this project when a pipeline successfully completes for a new tag on the %{default_branch_docs} of the subscribed project.").html_safe % { default_branch_docs: default_branch_docs }
%p %p
= _("There is a limit of 100 subscriptions from or to a project.") = _("There is a limit of 2 subscriptions from or to a project.")
.settings-content .settings-content
= render 'projects/settings/subscriptions/index' = render 'projects/settings/subscriptions/index'
...@@ -402,6 +402,13 @@ ...@@ -402,6 +402,13 @@
:resource_boundary: :unknown :resource_boundary: :unknown
:weight: 1 :weight: 1
:idempotent: :idempotent:
- :name: pipeline_default:ci_trigger_downstream_subscriptions
:feature_category: :continuous_integration
:has_external_dependencies:
:urgency: :default
:resource_boundary: :cpu
:weight: 3
:idempotent:
- :name: security_scans:store_security_reports - :name: security_scans:store_security_reports
:feature_category: :static_application_security_testing :feature_category: :static_application_security_testing
:has_external_dependencies: :has_external_dependencies:
......
# frozen_string_literal: true
module Ci
class TriggerDownstreamSubscriptionsWorker # rubocop:disable Scalability/IdempotentWorker
include ::ApplicationWorker
include ::PipelineQueue
worker_resource_boundary :cpu
def perform(pipeline_id)
::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
::Ci::TriggerDownstreamSubscriptionService
.new(pipeline.project, pipeline.user)
.execute(pipeline)
end
end
end
end
...@@ -19,7 +19,6 @@ describe Projects::SubscriptionsController do ...@@ -19,7 +19,6 @@ describe Projects::SubscriptionsController do
context 'when user is authorized' do context 'when user is authorized' do
before do before do
project.add_maintainer(user) project.add_maintainer(user)
upstream_project.add_developer(user)
end end
context 'when feature is available' do context 'when feature is available' do
...@@ -27,7 +26,13 @@ describe Projects::SubscriptionsController do ...@@ -27,7 +26,13 @@ describe Projects::SubscriptionsController do
stub_licensed_features(ci_project_subscriptions: true) stub_licensed_features(ci_project_subscriptions: true)
end end
context 'when user is developer in upstream project' do
before do
upstream_project.add_developer(user)
end
context 'when project is public' do context 'when project is public' do
context 'when subscription count is below the limit' do
it 'creates a new subscription' do it 'creates a new subscription' do
expect { post_create }.to change { project.upstream_project_subscriptions.count }.from(0).to(1) expect { post_create }.to change { project.upstream_project_subscriptions.count }.from(0).to(1)
end end
...@@ -45,6 +50,29 @@ describe Projects::SubscriptionsController do ...@@ -45,6 +50,29 @@ describe Projects::SubscriptionsController do
end end
end end
context 'when subscription count is on the limit' do
before do
create_list(:ci_subscriptions_project, 2, downstream_project: project)
end
it 'does not create a new subscription' do
expect { post_create }.not_to change { project.upstream_project_subscriptions.count }.from(2)
end
it 'sets the flash' do
post_create
expect(response).to set_flash[:warning].to('Subscription limit reached.')
end
it 'redirects to ci_cd settings' do
post_create
expect(response).to redirect_to project_settings_ci_cd_path(project)
end
end
end
context 'when project is not public' do context 'when project is not public' do
before do before do
upstream_project.update(visibility_level: Gitlab::VisibilityLevel::PRIVATE) upstream_project.update(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
...@@ -57,7 +85,26 @@ describe Projects::SubscriptionsController do ...@@ -57,7 +85,26 @@ describe Projects::SubscriptionsController do
it 'sets the flash' do it 'sets the flash' do
post_create post_create
expect(response).to set_flash[:notice].to('This project path either does not exist or is private.') expect(response).to set_flash[:alert].to('Subscription creation failed because the specified project is not public.')
end
it 'redirects to ci_cd settings' do
post_create
expect(response).to redirect_to project_settings_ci_cd_path(project)
end
end
end
context 'when user is not developer in upstream project' do
it 'does not create a new subscription' do
expect { post_create }.not_to change { project.upstream_project_subscriptions.count }.from(0)
end
it 'sets the flash' do
post_create
expect(response).to set_flash[:warning].to('This project path either does not exist or you do not have access.')
end end
it 'redirects to ci_cd settings' do it 'redirects to ci_cd settings' do
......
...@@ -385,6 +385,44 @@ describe Ci::Pipeline do ...@@ -385,6 +385,44 @@ describe Ci::Pipeline do
pipeline.cancel! pipeline.cancel!
end end
end end
context 'when pipeline project has downstream subscriptions' do
let(:pipeline) { create(:ci_empty_pipeline, project: create(:project, :public)) }
before do
pipeline.project.downstream_projects << create(:project)
end
context 'when pipeline runs on a tag' do
before do
pipeline.update(tag: true)
end
context 'when feature is not available' do
before do
stub_feature_flags(ci_project_subscriptions: false)
end
it 'does not schedule the trigger downstream subscriptions worker' do
expect(::Ci::TriggerDownstreamSubscriptionsWorker).not_to receive(:perform_async)
pipeline.succeed!
end
end
context 'when feature is available' do
before do
stub_feature_flags(ci_project_subscriptions: true)
end
it 'schedules the trigger downstream subscriptions worker' do
expect(::Ci::TriggerDownstreamSubscriptionsWorker).to receive(:perform_async)
pipeline.succeed!
end
end
end
end
end end
describe '#latest_merge_request_pipeline?' do describe '#latest_merge_request_pipeline?' do
......
# frozen_string_literal: true
require 'spec_helper'
describe Ci::TriggerDownstreamSubscriptionService do
describe '#execute' do
subject(:execute) { described_class.new(pipeline.project, pipeline.user).execute(pipeline) }
let(:upstream_project) { create(:project, :public) }
let(:pipeline) { create(:ci_pipeline, project: upstream_project, user: create(:user)) }
context 'when pipeline project has downstream projects' do
before do
create(:project, :repository, upstream_projects: [upstream_project])
end
it 'calls the create pipeline service' do
service_double = instance_double(::Ci::CreatePipelineService)
expect(service_double).to receive(:execute)
expect(::Ci::CreatePipelineService).to receive(:new).with(
an_instance_of(Project), an_instance_of(User), ref: 'master'
).and_return(service_double)
execute
end
end
context 'when pipeline project does not have downstream projects' do
it 'does not call the create pipeline service' do
expect(::Ci::CreatePipelineService).not_to receive(:new)
execute
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Ci::TriggerDownstreamSubscriptionsWorker do
describe '#perform' do
subject(:perform) { described_class.new.perform(pipeline_id) }
context 'when pipeline exists' do
let(:pipeline_id) { create(:ci_pipeline, user: create(:user)).id }
it 'calls the trigger downstream pipeline service' do
expect(::Ci::TriggerDownstreamSubscriptionService).to receive_message_chain(:new, :execute)
perform
end
end
context 'when pipeline does not exist' do
let(:pipeline_id) { 1234 }
it 'does nothing' do
expect(::Ci::TriggerDownstreamSubscriptionService).not_to receive(:new)
perform
end
end
end
end
...@@ -861,6 +861,9 @@ msgstr "" ...@@ -861,6 +861,9 @@ msgstr ""
msgid "A secure token that identifies an external storage request." msgid "A secure token that identifies an external storage request."
msgstr "" msgstr ""
msgid "A subscription will trigger a new pipeline on the default branch of this project when a pipeline successfully completes for a new tag on the %{default_branch_docs} of the subscribed project."
msgstr ""
msgid "A user with write access to the source branch selected this option" msgid "A user with write access to the source branch selected this option"
msgstr "" msgstr ""
...@@ -18807,9 +18810,15 @@ msgstr "" ...@@ -18807,9 +18810,15 @@ msgstr ""
msgid "Subscription" msgid "Subscription"
msgstr "" msgstr ""
msgid "Subscription creation failed because the specified project is not public."
msgstr ""
msgid "Subscription deletion failed." msgid "Subscription deletion failed."
msgstr "" msgstr ""
msgid "Subscription limit reached."
msgstr ""
msgid "Subscription successfully applied to \"%{group_name}\"" msgid "Subscription successfully applied to \"%{group_name}\""
msgstr "" msgstr ""
...@@ -18891,9 +18900,6 @@ msgstr "" ...@@ -18891,9 +18900,6 @@ msgstr ""
msgid "Subscriptions" msgid "Subscriptions"
msgstr "" msgstr ""
msgid "Subscriptions allow successfully completed pipelines on the %{default_branch_docs} of the subscribed project to trigger a new pipeline on the default branch of this project."
msgstr ""
msgid "Subtracted" msgid "Subtracted"
msgstr "" msgstr ""
...@@ -19732,7 +19738,7 @@ msgstr "" ...@@ -19732,7 +19738,7 @@ msgstr ""
msgid "There are no unstaged changes" msgid "There are no unstaged changes"
msgstr "" msgstr ""
msgid "There is a limit of 100 subscriptions from or to a project." msgid "There is a limit of 2 subscriptions from or to a project."
msgstr "" msgstr ""
msgid "There is already a repository with that name on disk" msgid "There is already a repository with that name on disk"
...@@ -20197,7 +20203,7 @@ msgstr "" ...@@ -20197,7 +20203,7 @@ msgstr ""
msgid "This project is archived and cannot be commented on." msgid "This project is archived and cannot be commented on."
msgstr "" msgstr ""
msgid "This project path either does not exist or is private." msgid "This project path either does not exist or you do not have access."
msgstr "" msgstr ""
msgid "This project will be removed on %{date}" msgid "This project will be removed on %{date}"
......
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