diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 8db80cd05dc0c6953b4d31a48ada86a5a3bd21bd..d62bdfa4c8701f93236d6e0f9b68f06a1650a7af 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -32,10 +32,8 @@ class Bridge < Ci::Processable state_machine :status do after_transition [:created, :manual, :waiting_for_resource] => :pending do |bridge| - next unless bridge.triggers_downstream_pipeline? - bridge.run_after_commit do - ::Ci::CreateDownstreamPipelineWorker.perform_async(bridge.id) + Ci::TriggerDownstreamPipelineService.new(bridge).execute # rubocop: disable CodeReuse/ServiceClass end end diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb index 9de2da5aac3cb136a3b70a73d0a02c63153c5bc8..b6eabd46de3d0758d29081286bf4cd2227ebd940 100644 --- a/app/models/concerns/enums/ci/commit_status.rb +++ b/app/models/concerns/enums/ci/commit_status.rb @@ -41,7 +41,8 @@ def self.failure_reasons secrets_provider_not_found: 1_008, reached_max_descendant_pipelines_depth: 1_009, ip_restriction_failure: 1_010, - reached_max_pipeline_hierarchy_size: 1_011 + reached_max_pipeline_hierarchy_size: 1_011, + reached_downstream_pipeline_trigger_rate_limit: 1_012 } end end diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index 38469be572ac93b489e486db05a85e00a7aec6d6..28656b0ccc4b875707b466b904baa4d4ea175ae2 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -37,7 +37,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated environment_creation_failure: 'This job could not be executed because it would create an environment with an invalid parameter.', deployment_rejected: 'This deployment job was rejected.', ip_restriction_failure: "This job could not be executed because group IP address restrictions are enabled, and the runner's IP address is not in the allowed range.", - failed_outdated_deployment_job: 'The deployment job is older than the latest deployment, and therefore failed.' + failed_outdated_deployment_job: 'The deployment job is older than the latest deployment, and therefore failed.', + reached_downstream_pipeline_trigger_rate_limit: 'Too many downstream pipelines triggered in the last minute. Try again later.' }.freeze TROUBLESHOOTING_DOC = { diff --git a/app/services/ci/trigger_downstream_pipeline_service.rb b/app/services/ci/trigger_downstream_pipeline_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..87f1c075f0ef4947de4f35f646598b0d0b31b1a6 --- /dev/null +++ b/app/services/ci/trigger_downstream_pipeline_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Ci + # Enqueues the downstream pipeline worker. + class TriggerDownstreamPipelineService + # This is a temporary constant. It may be converted into an application setting + # in the future. See https://gitlab.com/gitlab-org/gitlab/-/issues/425941. + DOWNSTREAM_PIPELINE_TRIGGER_LIMIT_PER_PROJECT_USER_SHA = 50 + + def initialize(bridge) + @bridge = bridge + @current_user = bridge.user + @project = bridge.project + @pipeline = bridge.pipeline + end + + def execute + unless bridge.triggers_downstream_pipeline? + return ServiceResponse.success(message: 'Does not trigger a downstream pipeline') + end + + if rate_limit_throttled? && enforce_rate_limit? + bridge.drop!(:reached_downstream_pipeline_trigger_rate_limit) + + return ServiceResponse.error(message: 'Reached downstream pipeline trigger rate limit') + end + + CreateDownstreamPipelineWorker.perform_async(bridge.id) + + ServiceResponse.success(message: 'Downstream pipeline enqueued') + end + + private + + attr_reader :bridge, :current_user, :project, :pipeline + + def rate_limit_throttled? + scope = [project, current_user, pipeline.sha] + + ::Gitlab::ApplicationRateLimiter.throttled?(:downstream_pipeline_trigger, scope: scope).tap do |throttled| + create_throttled_log_entry if throttled + end + end + + def create_throttled_log_entry + ::Gitlab::AppJsonLogger.info( + class: self.class.name, + project_id: project.id, + current_user_id: current_user.id, + pipeline_sha: pipeline.sha, + subscription_plan: project.actual_plan_name, + downstream_type: bridge.triggers_child_pipeline? ? 'child' : 'multi-project', + message: 'Activated downstream pipeline trigger rate limit' + ) + end + + def enforce_rate_limit? + ::Feature.enabled?(:ci_rate_limit_downstream_pipelines, project, type: :gitlab_com_derisk) + end + end +end diff --git a/config/feature_flags/gitlab_com_derisk/ci_rate_limit_downstream_pipelines.yml b/config/feature_flags/gitlab_com_derisk/ci_rate_limit_downstream_pipelines.yml new file mode 100644 index 0000000000000000000000000000000000000000..8f4fa323f7be72353495d64473dd54a5aef5039f --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/ci_rate_limit_downstream_pipelines.yml @@ -0,0 +1,9 @@ +--- +name: ci_rate_limit_downstream_pipelines +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/425941 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/142869 +rollout_issue_url: https://gitlab.com/gitlab-com/gl-infra/production/-/issues/17471 +milestone: '16.9' +group: group::pipeline authoring +type: gitlab_com_derisk +default_enabled: false diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index e961f86bc6cc66327d47aa9a3a7e47915489a8ef..d7c2a8da78286415c810dbd3d9878af4da0970d4 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -29835,6 +29835,7 @@ Values for sorting inherited variables. | <a id="cijobfailurereasonpipeline_loop_detected"></a>`PIPELINE_LOOP_DETECTED` | A job that failed due to pipeline loop detected. | | <a id="cijobfailurereasonproject_deleted"></a>`PROJECT_DELETED` | A job that failed due to project deleted. | | <a id="cijobfailurereasonprotected_environment_failure"></a>`PROTECTED_ENVIRONMENT_FAILURE` | A job that failed due to protected environment failure. | +| <a id="cijobfailurereasonreached_downstream_pipeline_trigger_rate_limit"></a>`REACHED_DOWNSTREAM_PIPELINE_TRIGGER_RATE_LIMIT` | A job that failed due to reached downstream pipeline trigger rate limit. | | <a id="cijobfailurereasonreached_max_descendant_pipelines_depth"></a>`REACHED_MAX_DESCENDANT_PIPELINES_DEPTH` | A job that failed due to reached max descendant pipelines depth. | | <a id="cijobfailurereasonreached_max_pipeline_hierarchy_size"></a>`REACHED_MAX_PIPELINE_HIERARCHY_SIZE` | A job that failed due to reached max pipeline hierarchy size. | | <a id="cijobfailurereasonrunner_system_failure"></a>`RUNNER_SYSTEM_FAILURE` | A job that failed due to runner system failure. | diff --git a/lib/gitlab/application_rate_limiter.rb b/lib/gitlab/application_rate_limiter.rb index 5a2881e6c966362301db8af38564b944afda2171..2e992e38a44c70f9896174f2415f400ee6215f31 100644 --- a/lib/gitlab/application_rate_limiter.rb +++ b/lib/gitlab/application_rate_limiter.rb @@ -65,6 +65,9 @@ def rate_limits # rubocop:disable Metrics/AbcSize bulk_import: { threshold: 6, interval: 1.minute }, projects_api_rate_limit_unauthenticated: { threshold: -> { application_settings.projects_api_rate_limit_unauthenticated }, interval: 10.minutes + }, + downstream_pipeline_trigger: { + threshold: -> { ::Ci::TriggerDownstreamPipelineService::DOWNSTREAM_PIPELINE_TRIGGER_LIMIT_PER_PROJECT_USER_SHA }, interval: 1.minute } }.freeze end diff --git a/lib/gitlab/ci/status/build/failed.rb b/lib/gitlab/ci/status/build/failed.rb index a136044c124995127deb6ca8c97f9cae6c43efb2..caaa4139f38edef9e584524c22d512079ba60a90 100644 --- a/lib/gitlab/ci/status/build/failed.rb +++ b/lib/gitlab/ci/status/build/failed.rb @@ -5,6 +5,7 @@ module Ci module Status module Build class Failed < Status::Extended + # rubocop: disable Layout/LineLength -- Long error messages REASONS = { unknown_failure: 'unknown failure', script_failure: 'script failure', @@ -41,8 +42,10 @@ class Failed < Status::Extended environment_creation_failure: 'environment creation failure', deployment_rejected: 'deployment rejected', ip_restriction_failure: 'IP address restriction failure', - failed_outdated_deployment_job: 'failed outdated deployment job' + failed_outdated_deployment_job: 'failed outdated deployment job', + reached_downstream_pipeline_trigger_rate_limit: 'Too many downstream pipelines triggered in the last minute. Try again later.' }.freeze + # rubocop: enable Layout/LineLength private_constant :REASONS diff --git a/spec/services/ci/trigger_downstream_pipeline_service_spec.rb b/spec/services/ci/trigger_downstream_pipeline_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..71d6931658925faa279856864c9bdf5c9edb03d9 --- /dev/null +++ b/spec/services/ci/trigger_downstream_pipeline_service_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::TriggerDownstreamPipelineService, feature_category: :continuous_integration do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { project.first_owner } + let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) } + + let(:bridge) do + create(:ci_bridge, status: :created, options: bridge_options, pipeline: pipeline, user: user) + end + + let(:bridge_options) { { trigger: { project: 'my/project' } } } + let(:service) { described_class.new(bridge) } + + describe '#execute' do + subject(:execute) { service.execute } + + context 'when the bridge does not trigger a downstream pipeline' do + let(:bridge_options) { { trigger: {} } } + + it 'returns a success response' do + expect(execute).to be_success + expect(execute.message).to eq('Does not trigger a downstream pipeline') + end + end + + # In these tests, we execute the service twice in succession + describe 'rate limiting', :freeze_time, :clean_gitlab_redis_rate_limiting do + shared_examples 'creates a log entry' do |downstream_type = 'multi-project'| + it do + service.execute + + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + class: described_class.name, + project_id: project.id, + current_user_id: user.id, + pipeline_sha: pipeline.sha, + subscription_plan: project.actual_plan_name, + downstream_type: downstream_type, + message: 'Activated downstream pipeline trigger rate limit' + ) + ) + + execute + end + end + + context 'when the limit is exceeded' do + before do + stub_const("#{described_class.name}::DOWNSTREAM_PIPELINE_TRIGGER_LIMIT_PER_PROJECT_USER_SHA", 1) + end + + it 'drops the bridge and does not schedule the downstream pipeline worker', :aggregate_failures do + service.execute + + expect { execute }.not_to change { ::Ci::CreateDownstreamPipelineWorker.jobs.size } + expect(bridge).to be_failed + expect(bridge.failure_reason).to eq('reached_downstream_pipeline_trigger_rate_limit') + expect(execute).to be_error + expect(execute.message).to eq('Reached downstream pipeline trigger rate limit') + end + + it_behaves_like 'creates a log entry' + + context 'with a child pipeline' do + let(:bridge_options) { { trigger: { include: 'my_child_config.yml' } } } + + it 'drops the bridge and does not schedule the downstream pipeline worker', :aggregate_failures do + service.execute + + expect { execute }.not_to change { ::Ci::CreateDownstreamPipelineWorker.jobs.size } + expect(bridge).to be_failed + expect(bridge.failure_reason).to eq('reached_downstream_pipeline_trigger_rate_limit') + expect(execute).to be_error + expect(execute.message).to eq('Reached downstream pipeline trigger rate limit') + end + + it_behaves_like 'creates a log entry', 'child' + end + + context 'when FF `ci_rate_limit_downstream_pipelines` is disabled' do + before do + stub_feature_flags(ci_rate_limit_downstream_pipelines: false) + end + + it 'schedules the downstream pipeline worker' do + service.execute + + expect { execute }.to change { ::Ci::CreateDownstreamPipelineWorker.jobs.size }.by(1) + expect(bridge).not_to be_failed + expect(execute).to be_success + expect(execute.message).to eq('Downstream pipeline enqueued') + end + + it_behaves_like 'creates a log entry' + end + end + + context 'when the limit is not exceeded' do + it 'schedules the downstream pipeline worker' do + service.execute + + expect { execute }.to change { ::Ci::CreateDownstreamPipelineWorker.jobs.size }.by(1) + expect(bridge).not_to be_failed + expect(execute).to be_success + expect(execute.message).to eq('Downstream pipeline enqueued') + end + + it 'does not create a log entry' do + service.execute + + expect(Gitlab::AppJsonLogger).not_to receive(:info) + + execute + end + end + end + end +end