Commit 45272e9e authored by Kamil Trzciński's avatar Kamil Trzciński

Backport Plan/PlanLimits to CE

This moves all Plan, PlanLimits, Limitable
into CE.

This also makes the `Plan.default` or `Plan.free`
to always return a valid object.

This adds `Limitable` for `ProjectHook`
and `PipelineSchedules` on CE.
parent 3e5a1f48
......@@ -6,6 +6,10 @@ module Ci
include Importable
include StripAttribute
include Schedulable
include Limitable
self.limit_name = 'ci_pipeline_schedules'
self.limit_scope = :project
belongs_to :project
belongs_to :owner, class_name: 'User'
......
......@@ -3,6 +3,9 @@
class ProjectHook < WebHook
include TriggerableHooks
include Presentable
include Limitable
self.limit_scope = :project
triggerable_hooks [
:push_hooks,
......
......@@ -346,6 +346,21 @@ class Namespace < ApplicationRecord
.try(name)
end
def actual_plan
Plan.default
end
def actual_limits
# We default to PlanLimits.new otherwise a lot of specs would fail
# On production each plan should already have associated limits record
# https://gitlab.com/gitlab-org/gitlab/issues/36037
actual_plan.limits || PlanLimits.new
end
def actual_plan_name
actual_plan.name
end
private
def all_projects_with_pages
......
# frozen_string_literal: true
class Plan < ApplicationRecord
DEFAULT = 'default'.freeze
has_one :limits, class_name: 'PlanLimits'
ALL_PLANS = [DEFAULT].freeze
DEFAULT_PLANS = [DEFAULT].freeze
private_constant :ALL_PLANS, :DEFAULT_PLANS
# This always returns an object
def self.default
Gitlab::SafeRequestStore.fetch(:plan_default) do
# find_by allows us to find object (cheaply) against replica DB
# safe_find_or_create_by does stick to primary DB
find_by(name: DEFAULT) || safe_find_or_create_by(name: DEFAULT)
end
end
def self.all_plans
ALL_PLANS
end
def self.default_plans
DEFAULT_PLANS
end
def default?
self.class.default_plans.include?(name)
end
def paid?
false
end
end
Plan.prepend_if_ee('EE::Plan')
......@@ -355,6 +355,7 @@ class Project < ApplicationRecord
delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true
delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci
delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings
delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true
# Validations
validates :creator, presence: true, on: :create
......
# frozen_string_literal: true
class AddUniqueIndexOnPlanName < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
remove_concurrent_index :plans, :name
add_concurrent_index :plans, :name, unique: true
end
def down
remove_concurrent_index :plans, :name, unique: true
add_concurrent_index :plans, :name
end
end
......@@ -9825,7 +9825,7 @@ CREATE INDEX index_personal_access_tokens_on_user_id ON public.personal_access_t
CREATE UNIQUE INDEX index_plan_limits_on_plan_id ON public.plan_limits USING btree (plan_id);
CREATE INDEX index_plans_on_name ON public.plans USING btree (name);
CREATE UNIQUE INDEX index_plans_on_name ON public.plans USING btree (name);
CREATE UNIQUE INDEX index_pool_repositories_on_disk_path ON public.pool_repositories USING btree (disk_path);
......@@ -13199,6 +13199,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200409211607
20200410232012
20200414144547
20200415153154
20200415160722
20200415161021
20200415161206
......
......@@ -90,7 +90,7 @@ project.actual_limits.exceeded?(:project_hooks, 10)
#### `Limitable` concern
The [`Limitable` concern](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/app/models/concerns/limitable.rb)
The [`Limitable` concern](https://gitlab.com/gitlab-org/gitlab/blob/master/app/models/concerns/limitable.rb)
can be used to validate that a model does not exceed the limits. It ensures
that the count of the records for the current model does not exceed the defined
limit.
......
......@@ -7,10 +7,6 @@ module EE
prepended do
include UsageStatistics
include Limitable
self.limit_name = 'ci_pipeline_schedules'
self.limit_scope = :project
end
end
end
......
......@@ -274,7 +274,7 @@ module EE
# We are plucking the user_ids from the "Members" table in an array and
# converting the array of user_ids to a Set which will have unique user_ids.
def billed_user_ids(requested_hosted_plan = nil)
if [actual_plan_name, requested_hosted_plan].include?(Plan::GOLD)
if [actual_plan_name, requested_hosted_plan].include?(::Plan::GOLD)
strong_memoize(:gold_billed_user_ids) do
(billed_group_members.non_guests.distinct.pluck(:user_id) +
billed_project_members.non_guests.distinct.pluck(:user_id) +
......
......@@ -11,10 +11,10 @@ module EE
include ::Gitlab::Utils::StrongMemoize
NAMESPACE_PLANS_TO_LICENSE_PLANS = {
Plan::BRONZE => License::STARTER_PLAN,
Plan::SILVER => License::PREMIUM_PLAN,
Plan::GOLD => License::ULTIMATE_PLAN,
Plan::EARLY_ADOPTER => License::EARLY_ADOPTER_PLAN
::Plan::BRONZE => License::STARTER_PLAN,
::Plan::SILVER => License::PREMIUM_PLAN,
::Plan::GOLD => License::ULTIMATE_PLAN,
::Plan::EARLY_ADOPTER => License::EARLY_ADOPTER_PLAN
}.freeze
LICENSE_PLANS_TO_NAMESPACE_PLANS = NAMESPACE_PLANS_TO_LICENSE_PLANS.invert.freeze
......@@ -47,7 +47,7 @@ module EE
scope :with_feature_available_in_plan, -> (feature) do
plans = plans_with_feature(feature)
matcher = Plan.where(name: plans)
matcher = ::Plan.where(name: plans)
.joins(:hosted_subscriptions)
.where("gitlab_subscriptions.namespace_id = namespaces.id")
.select('1')
......@@ -210,30 +210,20 @@ module EE
available_features[feature]
end
override :actual_plan
def actual_plan
strong_memoize(:actual_plan) do
if parent_id
root_ancestor.actual_plan
else
subscription = find_or_create_subscription
subscription&.hosted_plan || Plan.free || Plan.default
subscription&.hosted_plan
end
end
end
def actual_limits
# We default to PlanLimits.new otherwise a lot of specs would fail
# On production each plan should already have associated limits record
# https://gitlab.com/gitlab-org/gitlab/issues/36037
actual_plan&.limits || PlanLimits.new
end
def actual_plan_name
actual_plan&.name || Plan::FREE
end || super
end
def plan_name_for_upgrading
return Plan::FREE if trial_active?
return ::Plan::FREE if trial_active?
actual_plan_name
end
......@@ -302,9 +292,9 @@ module EE
def plans
@plans ||=
if parent_id
Plan.hosted_plans_for_namespaces(self_and_ancestors.select(:id))
::Plan.hosted_plans_for_namespaces(self_and_ancestors.select(:id))
else
Plan.hosted_plans_for_namespaces(self)
::Plan.hosted_plans_for_namespaces(self)
end
end
......@@ -328,7 +318,7 @@ module EE
::Gitlab.com? &&
parent_id.nil? &&
trial_ends_on.blank? &&
[Plan::EARLY_ADOPTER, Plan::FREE].include?(actual_plan_name)
[::Plan::EARLY_ADOPTER, ::Plan::FREE].include?(actual_plan_name)
end
def trial_active?
......@@ -342,7 +332,7 @@ module EE
def trial_expired?
trial_ends_on.present? &&
trial_ends_on < Date.today &&
actual_plan_name == Plan::FREE
actual_plan_name == ::Plan::FREE
end
# A namespace may not have a file template project
......@@ -362,23 +352,23 @@ module EE
end
def free_plan?
actual_plan_name == Plan::FREE
actual_plan_name == ::Plan::FREE
end
def early_adopter_plan?
actual_plan_name == Plan::EARLY_ADOPTER
actual_plan_name == ::Plan::EARLY_ADOPTER
end
def bronze_plan?
actual_plan_name == Plan::BRONZE
actual_plan_name == ::Plan::BRONZE
end
def silver_plan?
actual_plan_name == Plan::SILVER
actual_plan_name == ::Plan::SILVER
end
def gold_plan?
actual_plan_name == Plan::GOLD
actual_plan_name == ::Plan::GOLD
end
def use_elasticsearch?
......
# frozen_string_literal: true
module EE
module Plan
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
prepended do
FREE = 'free'.freeze
BRONZE = 'bronze'.freeze
SILVER = 'silver'.freeze
GOLD = 'gold'.freeze
EARLY_ADOPTER = 'early_adopter'.freeze
EE_DEFAULT_PLANS = (const_get(:DEFAULT_PLANS, false) + [FREE]).freeze
PAID_HOSTED_PLANS = [BRONZE, SILVER, GOLD].freeze
FREE_HOSTED_PLANS = [EARLY_ADOPTER].freeze
EE_ALL_PLANS = (EE_DEFAULT_PLANS + PAID_HOSTED_PLANS + FREE_HOSTED_PLANS).freeze
# This constant must keep ordered by tier.
ALL_HOSTED_PLANS = (PAID_HOSTED_PLANS + FREE_HOSTED_PLANS).freeze
has_many :hosted_subscriptions, class_name: 'GitlabSubscription', foreign_key: 'hosted_plan_id'
EE::Plan.private_constant :EE_ALL_PLANS, :EE_DEFAULT_PLANS
end
class_methods do
extend ::Gitlab::Utils::Override
override :all_plans
def all_plans
EE_ALL_PLANS
end
override :default_plans
def default_plans
EE_DEFAULT_PLANS
end
override :default
def default
# GitLab.com default plan is `free`
if ::Gitlab.com?
free
else
super
end
end
# This always returns an object if running on GitLab.com
def free
return unless ::Gitlab.com?
::Gitlab::SafeRequestStore.fetch(:plan_free) do
# find_by allows us to find object (cheaply) against replica DB
# safe_find_or_create_by does stick to primary DB
find_by(name: FREE) || safe_find_or_create_by(name: FREE)
end
end
def hosted_plans_for_namespaces(namespaces)
namespaces = Array(namespaces)
::Plan
.joins(:hosted_subscriptions)
.where(name: ALL_HOSTED_PLANS)
.where(gitlab_subscriptions: { namespace_id: namespaces })
.distinct
end
end
override :paid?
def paid?
PAID_HOSTED_PLANS.include?(name)
end
end
end
......@@ -166,7 +166,6 @@ module EE
delegate :merge_pipelines_enabled, :merge_pipelines_enabled=, :merge_pipelines_enabled?, :merge_pipelines_were_disabled?, to: :ci_cd_settings
delegate :merge_trains_enabled?, to: :ci_cd_settings
delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true
delegate :gitlab_subscription, to: :namespace
validates :repository_size_limit,
......
......@@ -6,9 +6,7 @@ module EE
prepended do
include CustomModelNaming
include Limitable
self.limit_scope = :project
self.singular_route_key = :hook
end
end
......
......@@ -235,7 +235,7 @@ module EE
::Namespace
.from("(#{namespace_union_for_reporter_developer_maintainer_owned}) #{::Namespace.table_name}")
.include_gitlab_subscription
.where(gitlab_subscriptions: { hosted_plan: Plan.where(name: Plan::PAID_HOSTED_PLANS) })
.where(gitlab_subscriptions: { hosted_plan: ::Plan.where(name: Plan::PAID_HOSTED_PLANS) })
.any?
end
......@@ -243,14 +243,14 @@ module EE
::Namespace
.from("(#{namespace_union_for_owned}) #{::Namespace.table_name}")
.include_gitlab_subscription
.where(gitlab_subscriptions: { hosted_plan: Plan.where(name: Plan::PAID_HOSTED_PLANS) })
.where(gitlab_subscriptions: { hosted_plan: ::Plan.where(name: Plan::PAID_HOSTED_PLANS) })
.any?
end
def managed_free_namespaces
manageable_groups
.left_joins(:gitlab_subscription)
.merge(GitlabSubscription.left_joins(:hosted_plan).where(plans: { name: [nil, *Plan::DEFAULT_PLANS] }))
.merge(GitlabSubscription.left_joins(:hosted_plan).where(plans: { name: [nil, *::Plan.default_plans] }))
.order(:name)
end
......
# frozen_string_literal: true
class Plan < ApplicationRecord
DEFAULT = 'default'.freeze
FREE = 'free'.freeze
BRONZE = 'bronze'.freeze
SILVER = 'silver'.freeze
GOLD = 'gold'.freeze
EARLY_ADOPTER = 'early_adopter'.freeze
# This constant must keep ordered by tier.
PAID_HOSTED_PLANS = [BRONZE, SILVER, GOLD].freeze
DEFAULT_PLANS = [DEFAULT, FREE].freeze
ALL_HOSTED_PLANS = (PAID_HOSTED_PLANS + [EARLY_ADOPTER]).freeze
has_many :hosted_subscriptions, class_name: 'GitlabSubscription', foreign_key: 'hosted_plan_id'
has_one :limits, class_name: 'PlanLimits'
def self.default
Gitlab::SafeRequestStore[:plan_default] ||= find_by(name: DEFAULT)
end
def self.free
return unless Gitlab.com?
Gitlab::SafeRequestStore[:plan_free] ||= find_by(name: FREE)
end
def self.hosted_plans_for_namespaces(namespaces)
namespaces = Array(namespaces)
Plan
.joins(:hosted_subscriptions)
.where(name: ALL_HOSTED_PLANS)
.where(gitlab_subscriptions: { namespace_id: namespaces })
.distinct
end
def default?
DEFAULT_PLANS.include?(name)
end
def paid?
!default?
end
end
......@@ -17,6 +17,8 @@ describe 'User notification dot', :aggregate_failures do
context 'when ci minutes are below threshold' do
before do
allow(Gitlab).to receive(:com?) { true }
group.update(last_ci_minutes_usage_notification_level: 30, shared_runners_minutes_limit: 10)
allow_any_instance_of(EE::Namespace).to receive(:shared_runners_remaining_minutes).and_return(2)
end
......
......@@ -21,6 +21,7 @@ describe 'Groups > Billing', :js do
stub_full_request("https://customers.gitlab.com/gitlab_plans?plan=#{plan}")
.to_return(status: 200, body: File.new(Rails.root.join('ee/spec/fixtures/gitlab_com_plans.json')))
allow(Gitlab).to receive(:com?).and_return(true)
stub_application_setting(check_namespace_plan: true)
group.add_owner(user)
......
......@@ -9,6 +9,10 @@ describe Gitlab::ApplicationContext do
let(:namespace) { create(:group) }
let(:subgroup) { create(:group, parent: namespace) }
before do
allow(Gitlab).to receive(:com?).and_return(true)
end
def result(context)
context.to_lazy_hash.transform_values { |v| v.respond_to?(:call) ? v.call : v }
end
......
# frozen_string_literal: true
require 'spec_helper'
describe Ci::PipelineSchedule do
it_behaves_like 'includes Limitable concern' do
subject { build(:ci_pipeline_schedule) }
end
end
......@@ -201,6 +201,10 @@ describe Namespace do
describe '#actual_plan_name' do
let(:namespace) { create(:namespace) }
before do
allow(Gitlab).to receive(:com?).and_return(true)
end
subject { namespace.actual_plan_name }
context 'when DB is read-only' do
......@@ -872,6 +876,20 @@ describe Namespace do
end
describe '#actual_plan' do
context 'when namespace does not have a subscription associated' do
it 'generates a subscription and returns default plan' do
expect(namespace.actual_plan).to eq(Plan.default)
# This should be revisited after https://gitlab.com/gitlab-org/gitlab/-/issues/214434
expect(namespace.gitlab_subscription).to be_present
end
end
context 'when running on Gitlab.com' do
before do
allow(Gitlab).to receive(:com?).and_return(true)
end
context 'when namespace has a subscription associated' do
before do
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
......@@ -884,8 +902,8 @@ describe Namespace do
end
context 'when namespace does not have a subscription associated' do
it 'generates a subscription without a plan' do
expect(namespace.actual_plan).to be_nil
it 'generates a subscription and returns free plan' do
expect(namespace.actual_plan).to eq(Plan.free)
expect(namespace.gitlab_subscription).to be_present
end
......@@ -900,17 +918,6 @@ describe Namespace do
end
end
context 'when default plan does exist' do
before do
default_plan
end
it 'generates a subscription' do
expect(namespace.actual_plan).to eq(default_plan)
expect(namespace.gitlab_subscription).to be_present
end
end
context 'when namespace is a subgroup with a parent' do
let(:subgroup) { create(:namespace, parent: namespace) }
......@@ -938,8 +945,20 @@ describe Namespace do
end
end
end
end
describe '#actual_plan_name' do
context 'when namespace does not have a subscription associated' do
it 'returns default plan' do
expect(namespace.actual_plan_name).to eq('default')
end
end
context 'when running on Gitlab.com' do
before do
allow(Gitlab).to receive(:com?).and_return(true)
end
context 'when namespace has a subscription associated' do
before do
create(:gitlab_subscription, namespace: namespace, hosted_plan: gold_plan)
......@@ -976,6 +995,7 @@ describe Namespace do
end
end
end
end
describe '#billed_user_ids' do
context 'with a user namespace' do
......
# frozen_string_literal: true
require 'spec_helper'
describe ProjectHook do
it_behaves_like 'includes Limitable concern' do
subject { build(:project_hook, project: create(:project)) }
end
end
......@@ -3,30 +3,10 @@
require 'spec_helper'
describe Plan do
describe '#default?' do
subject { plan.default? }
Plan::DEFAULT_PLANS.each do |plan|
context "when '#{plan}'" do
let(:plan) { build("#{plan}_plan".to_sym) }
it { is_expected.to be_truthy }
end
end
Plan::PAID_HOSTED_PLANS.each do |plan|
context "when '#{plan}'" do
let(:plan) { build("#{plan}_plan".to_sym) }
it { is_expected.to be_falsey }
end
end
end
describe '#paid?' do
subject { plan.paid? }
Plan::DEFAULT_PLANS.each do |plan|
Plan.default_plans.each do |plan|
context "when '#{plan}'" do
let(:plan) { build("#{plan}_plan".to_sym) }
......
......@@ -19,6 +19,10 @@ describe 'layouts/application' do
context 'when we show the notification dot' do
let(:show_notification_dot) { true }
before do
allow(Gitlab).to receive(:com?) { true }
end
it 'has the notification dot' do
expect(view).to receive(:track_event).with('show_buy_ci_minutes_notification', label: 'free', property: 'user_dropdown')
......
# frozen_string_literal: true
# EE-only
FactoryBot.define do
factory :plan_limits do
plan
......
# frozen_string_literal: true
# EE-only
FactoryBot.define do
factory :plan do
Plan::DEFAULT_PLANS.each do |plan|
Plan.all_plans.each do |plan|
factory :"#{plan}_plan" do
name { plan }
title { name.titleize }
initialize_with { Plan.find_or_create_by(name: plan) }
end
end
Plan::ALL_HOSTED_PLANS.each do |plan|
factory :"#{plan}_plan" do
name { plan }
title { name.titleize }
end
end
end
end
......@@ -17,6 +17,10 @@ describe Ci::PipelineSchedule do
it { is_expected.to respond_to(:description) }
it { is_expected.to respond_to(:next_run_at) }
it_behaves_like 'includes Limitable concern' do
subject { build(:ci_pipeline_schedule) }
end
describe 'validations' do
it 'does not allow invalid cron patters' do
pipeline_schedule = build(:ci_pipeline_schedule, cron: '0 0 0 * *')
......
......@@ -11,6 +11,10 @@ describe ProjectHook do
it { is_expected.to validate_presence_of(:project) }
end
it_behaves_like 'includes Limitable concern' do
subject { build(:project_hook, project: create(:project)) }
end
describe '.push_hooks' do
it 'returns hooks for push events only' do
hook = create(:project_hook, push_events: true)
......
# frozen_string_literal: true
require 'spec_helper'
describe Plan do
describe '#default?' do
subject { plan.default? }
Plan.default_plans.each do |plan|
context "when '#{plan}'" do
let(:plan) { build("#{plan}_plan".to_sym) }
it { is_expected.to be_truthy }
end
end
end
end
......@@ -3,6 +3,7 @@
RSpec.shared_examples 'a pages cronjob scheduling jobs with context' do |scheduled_worker_class|
let(:worker) { described_class.new }
context 'with RequestStore enabled', :request_store do
it 'does not cause extra queries for multiple domains' do
control = ActiveRecord::QueryRecorder.new { worker.perform }
......@@ -10,6 +11,7 @@ RSpec.shared_examples 'a pages cronjob scheduling jobs with context' do |schedul
expect { worker.perform }.not_to exceed_query_limit(control)
end
end
it 'schedules the renewal with a context' do
extra_domain
......
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