Commit 4008b1e5 authored by Hordur Freyr Yngvason's avatar Hordur Freyr Yngvason Committed by Fabio Pitino

Add limits for deployments per pipeline

See https://gitlab.com/gitlab-org/gitlab/-/issues/24087
parent 5c7db792
......@@ -9,7 +9,8 @@ module Enums
{
unknown_failure: 0,
config_error: 1,
external_validation_failure: 2
external_validation_failure: 2,
deployments_limit_exceeded: 23
}
end
......
......@@ -10,7 +10,8 @@ module Ci
def self.failure_reasons
{ unknown_failure: 'Unknown pipeline failure!',
config_error: 'CI/CD YAML configuration error!',
external_validation_failure: 'External pipeline validation failed!' }
external_validation_failure: 'External pipeline validation failed!',
deployments_limit_exceeded: 'Pipeline deployments limit exceeded!' }
end
presents :pipeline
......
......@@ -18,6 +18,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules,
Gitlab::Ci::Pipeline::Chain::Seed,
Gitlab::Ci::Pipeline::Chain::Limit::Size,
Gitlab::Ci::Pipeline::Chain::Limit::Deployments,
Gitlab::Ci::Pipeline::Chain::Validate::External,
Gitlab::Ci::Pipeline::Chain::Populate,
Gitlab::Ci::Pipeline::Chain::StopDryRun,
......
---
title: Limit maximum deployments per pipeline to 500
merge_request: 46931
author:
type: added
# frozen_string_literal: true
class AddCiPipelineDeploymentsToPlanLimits < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :plan_limits, :ci_pipeline_deployments, :integer, default: 500, null: false
end
end
a3aa783f2648a95e3ff8b503ef15b8153759c74ac85b30bf94e39710824e57b0
\ No newline at end of file
......@@ -14797,7 +14797,8 @@ CREATE TABLE plan_limits (
golang_max_file_size bigint DEFAULT 104857600 NOT NULL,
debian_max_file_size bigint DEFAULT '3221225472'::bigint NOT NULL,
project_feature_flags integer DEFAULT 200 NOT NULL,
ci_max_artifact_size_api_fuzzing integer DEFAULT 0 NOT NULL
ci_max_artifact_size_api_fuzzing integer DEFAULT 0 NOT NULL,
ci_pipeline_deployments integer DEFAULT 500 NOT NULL
);
CREATE SEQUENCE plan_limits_id_seq
......
......@@ -250,6 +250,29 @@ Plan.default.actual_limits.update!(ci_active_jobs: 500)
Set the limit to `0` to disable it.
### Maximum number of deployment jobs in a pipeline
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46931) in GitLab 13.7.
You can limit the maximum number of deployment jobs in a pipeline. A deployment is
any job with an [`environment`](../ci/environments/index.md) specified. The number
of deployments in a pipeline is checked at pipeline creation. Pipelines that have
too many deployments fail with a `deployments_limit_exceeded` error.
The default limit is 500 for all [self-managed and GitLab.com plans](https://about.gitlab.com/pricing/).
To change the limit on a self-managed installation, change the `default` plan limit with the following
[GitLab Rails console](operations/rails_console.md#starting-a-rails-console-session) command:
```ruby
# If limits don't exist for the default plan, you can create one with:
# Plan.default.create_limits!
Plan.default.actual_limits.update!(ci_pipeline_deployments: 500)
```
Set the limit to `0` to disable it.
### Number of CI/CD subscriptions to a project
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9045) in GitLab 12.9.
......
......@@ -6,11 +6,6 @@ module EE
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
EE_FAILURE_REASONS = {
activity_limit_exceeded: 20,
size_limit_exceeded: 21
}.freeze
prepended do
include UsageStatistics
......
# frozen_string_literal: true
module EE
module Gitlab
module Ci
##
# Abstract base class for CI/CD Quotas
#
class Limit
LimitExceededError = Class.new(StandardError)
def initialize(_context, _resource)
end
def enabled?
raise NotImplementedError
end
def exceeded?
raise NotImplementedError
end
def message
raise NotImplementedError
end
def log_error!(extra_context = {})
error = LimitExceededError.new(message)
# TODO: change this to Gitlab::ErrorTracking.log_exception(error, extra_context)
# https://gitlab.com/gitlab-org/gitlab/issues/32906
::Gitlab::ErrorTracking.track_exception(error, extra_context)
end
end
end
end
end
......@@ -5,7 +5,7 @@ module EE
module Ci
module Pipeline
module Quota
class Activity < Ci::Limit
class Activity < ::Gitlab::Ci::Limit
include ::Gitlab::Utils::StrongMemoize
include ActionView::Helpers::TextHelper
......
......@@ -5,7 +5,7 @@ module EE
module Ci
module Pipeline
module Quota
class JobActivity < Ci::Limit
class JobActivity < ::Gitlab::Ci::Limit
include ::Gitlab::Utils::StrongMemoize
include ActionView::Helpers::TextHelper
......
......@@ -5,7 +5,7 @@ module EE
module Ci
module Pipeline
module Quota
class Size < Ci::Limit
class Size < ::Gitlab::Ci::Limit
include ::Gitlab::Utils::StrongMemoize
include ActionView::Helpers::TextHelper
......
......@@ -55,7 +55,7 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::Activity do
it 'logs the error' do
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
instance_of(EE::Gitlab::Ci::Limit::LimitExceededError),
instance_of(Gitlab::Ci::Limit::LimitExceededError),
project_id: project.id, plan: namespace.actual_plan_name
)
......
......@@ -57,7 +57,7 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::JobActivity do
it 'logs the error' do
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
instance_of(EE::Gitlab::Ci::Limit::LimitExceededError),
instance_of(Gitlab::Ci::Limit::LimitExceededError),
project_id: project.id, plan: namespace.actual_plan_name
)
......
......@@ -69,7 +69,7 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::Size do
it 'logs the error' do
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
instance_of(EE::Gitlab::Ci::Limit::LimitExceededError),
instance_of(Gitlab::Ci::Limit::LimitExceededError),
project_id: project.id, plan: namespace.actual_plan_name
)
......
# frozen_string_literal: true
module Gitlab
module Ci
##
# Abstract base class for CI/CD Quotas
#
class Limit
LimitExceededError = Class.new(StandardError)
def initialize(_context, _resource)
end
def enabled?
raise NotImplementedError
end
def exceeded?
raise NotImplementedError
end
def message
raise NotImplementedError
end
def log_error!(extra_context = {})
error = LimitExceededError.new(message)
# TODO: change this to Gitlab::ErrorTracking.log_exception(error, extra_context)
# https://gitlab.com/gitlab-org/gitlab/issues/32906
::Gitlab::ErrorTracking.track_exception(error, extra_context)
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
module Pipeline
module Chain
module Limit
class Deployments < Chain::Base
extend ::Gitlab::Utils::Override
include ::Gitlab::Ci::Pipeline::Chain::Helpers
attr_reader :limit
private :limit
def initialize(*)
super
@limit = ::Gitlab::Ci::Pipeline::Quota::Deployments
.new(project.namespace, pipeline, command)
end
override :perform!
def perform!
return unless limit.exceeded?
limit.log_error!(project_id: project.id, plan: project.actual_plan_name)
error(limit.message, drop_reason: :deployments_limit_exceeded)
end
override :break?
def break?
limit.exceeded?
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
module Pipeline
module Quota
class Deployments < ::Gitlab::Ci::Limit
include ::Gitlab::Utils::StrongMemoize
include ActionView::Helpers::TextHelper
def initialize(namespace, pipeline, command)
@namespace = namespace
@pipeline = pipeline
@command = command
end
def enabled?
limit > 0
end
def exceeded?
return false unless enabled?
pipeline_deployment_count > limit
end
def message
return unless exceeded?
"Pipeline has too many deployments! Requested #{pipeline_deployment_count}, but the limit is #{limit}."
end
private
def pipeline_deployment_count
strong_memoize(:pipeline_deployment_count) do
@command.stage_seeds.sum do |stage_seed|
stage_seed.seeds.count do |build_seed|
build_seed.attributes[:environment].present?
end
end
end
end
def limit
strong_memoize(:limit) do
@namespace.actual_limits.ci_pipeline_deployments
end
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::Deployments do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:project, reload: true) { create(:project, namespace: namespace) }
let_it_be(:plan_limits, reload: true) { create(:plan_limits, :default_plan) }
let(:stage_seeds) do
[
double(:test, seeds: [
double(:test, attributes: {})
]),
double(:staging, seeds: [
double(:staging, attributes: { environment: 'staging' })
]),
double(:production, seeds: [
double(:production, attributes: { environment: 'production' })
])
]
end
let(:save_incompleted) { false }
let(:command) do
double(:command,
project: project,
stage_seeds: stage_seeds,
save_incompleted: save_incompleted
)
end
let(:pipeline) { build(:ci_pipeline, project: project) }
let(:step) { described_class.new(pipeline, command) }
subject(:perform) { step.perform! }
context 'when pipeline deployments limit is exceeded' do
before do
plan_limits.update!(ci_pipeline_deployments: 1)
end
context 'when saving incompleted pipelines' do
let(:save_incompleted) { true }
it 'drops the pipeline' do
perform
expect(pipeline).to be_persisted
expect(pipeline.reload).to be_failed
end
it 'breaks the chain' do
perform
expect(step.break?).to be true
end
it 'sets a valid failure reason' do
perform
expect(pipeline.deployments_limit_exceeded?).to be true
end
end
context 'when not saving incomplete pipelines' do
let(:save_incompleted) { false }
it 'does not persist the pipeline' do
perform
expect(pipeline).not_to be_persisted
end
it 'breaks the chain' do
perform
expect(step.break?).to be true
end
it 'adds an informative error to the pipeline' do
perform
expect(pipeline.errors.messages).to include(base: ['Pipeline has too many deployments! Requested 2, but the limit is 1.'])
end
end
it 'logs the error' do
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
instance_of(Gitlab::Ci::Limit::LimitExceededError),
project_id: project.id, plan: namespace.actual_plan_name
)
perform
end
end
context 'when pipeline deployments limit is not exceeded' do
before do
plan_limits.update!(ci_pipeline_deployments: 100)
end
it 'does not break the chain' do
perform
expect(step.break?).to be false
end
it 'does not invalidate the pipeline' do
perform
expect(pipeline.errors).to be_empty
end
it 'does not log any error' do
expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
perform
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Ci::Pipeline::Quota::Deployments do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:default_plan, reload: true) { create(:default_plan) }
let_it_be(:project, reload: true) { create(:project, :repository, namespace: namespace) }
let_it_be(:plan_limits) { create(:plan_limits, plan: default_plan) }
let(:pipeline) { build_stubbed(:ci_pipeline, project: project) }
let(:stage_seeds) do
[
double(:test, seeds: [
double(:test, attributes: {})
]),
double(:staging, seeds: [
double(:staging, attributes: { environment: 'staging' })
]),
double(:production, seeds: [
double(:production, attributes: { environment: 'production' })
])
]
end
let(:command) do
double(:command,
project: project,
stage_seeds: stage_seeds,
save_incompleted: true
)
end
let(:ci_pipeline_deployments_limit) { 0 }
before do
plan_limits.update!(ci_pipeline_deployments: ci_pipeline_deployments_limit)
end
subject(:quota) { described_class.new(namespace, pipeline, command) }
shared_context 'limit exceeded' do
let(:ci_pipeline_deployments_limit) { 1 }
end
shared_context 'limit not exceeded' do
let(:ci_pipeline_deployments_limit) { 2 }
end
describe '#enabled?' do
context 'when limit is enabled in plan' do
let(:ci_pipeline_deployments_limit) { 10 }
it 'is enabled' do
expect(quota).to be_enabled
end
end
context 'when limit is not enabled' do
let(:ci_pipeline_deployments_limit) { 0 }
it 'is not enabled' do
expect(quota).not_to be_enabled
end
end
context 'when limit does not exist' do
before do
allow(namespace).to receive(:actual_plan) { create(:default_plan) }
end
it 'is enabled by default' do
expect(quota).to be_enabled
end
end
end
describe '#exceeded?' do
context 'when limit is exceeded' do
include_context 'limit exceeded'
it 'is exceeded' do
expect(quota).to be_exceeded
end
end
context 'when limit is not exceeded' do
include_context 'limit not exceeded'
it 'is not exceeded' do
expect(quota).not_to be_exceeded
end
end
end
describe '#message' do
context 'when limit is exceeded' do
include_context 'limit exceeded'
it 'returns info about pipeline deployment limit exceeded' do
expect(quota.message)
.to eq "Pipeline has too many deployments! Requested 2, but the limit is 1."
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