2018-10-06 19:10:08 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2018-04-19 12:53:07 -04:00
|
|
|
require 'flipper/adapters/active_record'
|
|
|
|
require 'flipper/adapters/active_support_cache_store'
|
|
|
|
|
2017-05-31 17:06:01 -04:00
|
|
|
class Feature
|
|
|
|
# Classes to override flipper table names
|
|
|
|
class FlipperFeature < Flipper::Adapters::ActiveRecord::Feature
|
2021-11-15 10:10:57 -05:00
|
|
|
include DatabaseReflection
|
|
|
|
|
2017-05-31 17:06:01 -04:00
|
|
|
# Using `self.table_name` won't work. ActiveRecord bug?
|
|
|
|
superclass.table_name = 'features'
|
2017-10-30 11:10:31 -04:00
|
|
|
|
|
|
|
def self.feature_names
|
|
|
|
pluck(:key)
|
|
|
|
end
|
2017-05-31 17:06:01 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
class FlipperGate < Flipper::Adapters::ActiveRecord::Gate
|
|
|
|
superclass.table_name = 'feature_gates'
|
|
|
|
end
|
|
|
|
|
2021-06-07 08:10:00 -04:00
|
|
|
# To enable EE overrides
|
|
|
|
class ActiveSupportCacheStoreAdapter < Flipper::Adapters::ActiveSupportCacheStore
|
|
|
|
end
|
|
|
|
|
2020-05-29 14:08:26 -04:00
|
|
|
InvalidFeatureFlagError = Class.new(Exception) # rubocop:disable Lint/InheritException
|
|
|
|
|
2017-05-31 17:06:01 -04:00
|
|
|
class << self
|
2017-06-21 10:49:51 -04:00
|
|
|
delegate :group, to: :flipper
|
|
|
|
|
2022-01-12 04:15:13 -05:00
|
|
|
def feature_flags_available?
|
|
|
|
# When the DBMS is not available, an exception (e.g. PG::ConnectionBad) is raised
|
|
|
|
active_db_connection = ActiveRecord::Base.connection.active? rescue false # rubocop:disable Database/MultipleDatabases
|
|
|
|
|
|
|
|
active_db_connection && Feature::FlipperFeature.table_exists?
|
|
|
|
rescue ActiveRecord::NoDatabaseError
|
|
|
|
false
|
|
|
|
end
|
|
|
|
|
2017-05-31 17:06:01 -04:00
|
|
|
def all
|
|
|
|
flipper.features.to_a
|
|
|
|
end
|
|
|
|
|
2022-05-03 20:09:14 -04:00
|
|
|
RecursionError = Class.new(RuntimeError)
|
|
|
|
|
2017-05-31 17:06:01 -04:00
|
|
|
def get(key)
|
2022-05-03 20:09:14 -04:00
|
|
|
with_feature(key, &:itself)
|
2017-05-31 17:06:01 -04:00
|
|
|
end
|
|
|
|
|
2017-10-30 11:10:31 -04:00
|
|
|
def persisted_names
|
2021-11-15 10:10:57 -05:00
|
|
|
return [] unless ApplicationRecord.database.exists?
|
2020-02-12 07:09:01 -05:00
|
|
|
|
2020-06-30 08:08:57 -04:00
|
|
|
# This loads names of all stored feature flags
|
|
|
|
# and returns a stable Set in the following order:
|
|
|
|
# - Memoized: using Gitlab::SafeRequestStore or @flipper
|
|
|
|
# - L1: using Process cache
|
|
|
|
# - L2: using Redis cache
|
|
|
|
# - DB: using a single SQL query
|
|
|
|
flipper.adapter.features
|
2017-10-30 11:10:31 -04:00
|
|
|
end
|
|
|
|
|
2020-05-29 14:08:26 -04:00
|
|
|
def persisted_name?(feature_name)
|
2017-05-31 17:06:01 -04:00
|
|
|
# Flipper creates on-memory features when asked for a not-yet-created one.
|
|
|
|
# If we want to check if a feature has been actually set, we look for it
|
|
|
|
# on the persisted features list.
|
2020-05-29 14:08:26 -04:00
|
|
|
persisted_names.include?(feature_name.to_s)
|
2017-05-31 17:06:01 -04:00
|
|
|
end
|
|
|
|
|
2022-05-10 20:08:02 -04:00
|
|
|
# The default state of feature flag is read from `YAML`:
|
|
|
|
# 1. If feature flag does not have YAML it will fallback to `default_enabled: false`
|
|
|
|
# in production environment, but raise exception in development or tests.
|
|
|
|
# 2. The `default_enabled_if_undefined:` is tech debt related to Gitaly flags
|
|
|
|
# and should not be used outside of Gitaly's `lib/feature/gitaly.rb`
|
|
|
|
def enabled?(key, thing = nil, type: :development, default_enabled_if_undefined: nil)
|
2020-05-29 14:08:26 -04:00
|
|
|
if check_feature_flags_definition?
|
|
|
|
if thing && !thing.respond_to?(:flipper_id)
|
|
|
|
raise InvalidFeatureFlagError,
|
|
|
|
"The thing '#{thing.class.name}' for feature flag '#{key}' needs to include `FeatureGate` or implement `flipper_id`"
|
|
|
|
end
|
2020-07-02 05:09:00 -04:00
|
|
|
|
2022-05-10 20:08:02 -04:00
|
|
|
Feature::Definition.valid_usage!(key, type: type)
|
2020-05-29 14:08:26 -04:00
|
|
|
end
|
|
|
|
|
2022-05-10 20:08:02 -04:00
|
|
|
default_enabled = Feature::Definition.default_enabled?(key, default_enabled_if_undefined: default_enabled_if_undefined)
|
2020-12-14 10:09:40 -05:00
|
|
|
|
2022-05-03 20:09:14 -04:00
|
|
|
feature_value = with_feature(key) do |feature|
|
|
|
|
feature_value = current_feature_value(feature, thing, default_enabled: default_enabled)
|
|
|
|
end
|
2018-09-04 14:34:37 -04:00
|
|
|
|
2022-05-03 20:09:14 -04:00
|
|
|
# If not yielded, then either recursion is happening, or the database does not exist yet, so use default_enabled.
|
|
|
|
feature_value = default_enabled if feature_value.nil?
|
2021-11-22 10:14:13 -05:00
|
|
|
|
|
|
|
# If we don't filter out this flag here we will enter an infinite loop
|
|
|
|
log_feature_flag_state(key, feature_value) if log_feature_flag_states?(key)
|
|
|
|
|
|
|
|
feature_value
|
2017-06-21 10:49:51 -04:00
|
|
|
end
|
|
|
|
|
2022-05-10 20:08:02 -04:00
|
|
|
def disabled?(key, thing = nil, type: :development, default_enabled_if_undefined: nil)
|
2018-08-20 14:52:56 -04:00
|
|
|
# we need to make different method calls to make it easy to mock / define expectations in test mode
|
2022-05-10 20:08:02 -04:00
|
|
|
thing.nil? ? !enabled?(key, type: type, default_enabled_if_undefined: default_enabled_if_undefined) : !enabled?(key, thing, type: type, default_enabled_if_undefined: default_enabled_if_undefined)
|
Add repository languages for projects
Our friends at GitHub show the programming languages for a long time,
and inspired by that this commit means to create about the same
functionality.
Language detection is done through Linguist, as before, where the
difference is that we cache the result in the database. Also, Gitaly can
incrementaly scan a repository. This is done through a shell out, which
creates overhead of about 3s each run. For now this won't be improved.
Scans are triggered by pushed to the default branch, usually `master`.
However, one exception to this rule the charts page. If we're requesting
this expensive data anyway, we just cache it in the database.
Edge cases where there is no repository, or its empty are caught in the
Repository model. This makes use of Redis caching, which is probably
already loaded.
The added model is called RepositoryLanguage, which will make it harder
if/when GitLab supports multiple repositories per project. However, for
now I think this shouldn't be a concern. Also, Language could be
confused with the i18n languages and felt like the current name was
suiteable too.
Design of the Project#Show page is done with help from @dimitrieh. This
change is not visible to the end user unless detections are done.
2018-06-06 07:10:59 -04:00
|
|
|
end
|
|
|
|
|
2017-06-21 10:49:51 -04:00
|
|
|
def enable(key, thing = true)
|
2020-12-01 22:09:38 -05:00
|
|
|
log(key: key, action: __method__, thing: thing)
|
2022-05-03 20:09:14 -04:00
|
|
|
with_feature(key) { _1.enable(thing) }
|
2017-06-21 10:49:51 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def disable(key, thing = false)
|
2020-12-01 22:09:38 -05:00
|
|
|
log(key: key, action: __method__, thing: thing)
|
2022-05-03 20:09:14 -04:00
|
|
|
with_feature(key) { _1.disable(thing) }
|
2017-05-12 12:44:03 -04:00
|
|
|
end
|
|
|
|
|
2020-06-02 08:08:33 -04:00
|
|
|
def enable_percentage_of_time(key, percentage)
|
2020-12-01 22:09:38 -05:00
|
|
|
log(key: key, action: __method__, percentage: percentage)
|
2022-05-03 20:09:14 -04:00
|
|
|
with_feature(key) { _1.enable_percentage_of_time(percentage) }
|
2020-06-02 08:08:33 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def disable_percentage_of_time(key)
|
2020-12-01 22:09:38 -05:00
|
|
|
log(key: key, action: __method__)
|
2022-05-03 20:09:14 -04:00
|
|
|
with_feature(key, &:disable_percentage_of_time)
|
2020-06-02 08:08:33 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def enable_percentage_of_actors(key, percentage)
|
2020-12-01 22:09:38 -05:00
|
|
|
log(key: key, action: __method__, percentage: percentage)
|
2022-05-03 20:09:14 -04:00
|
|
|
with_feature(key) { _1.enable_percentage_of_actors(percentage) }
|
2020-06-02 08:08:33 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def disable_percentage_of_actors(key)
|
2020-12-01 22:09:38 -05:00
|
|
|
log(key: key, action: __method__)
|
2022-05-03 20:09:14 -04:00
|
|
|
with_feature(key, &:disable_percentage_of_actors)
|
2020-06-02 08:08:33 -04:00
|
|
|
end
|
|
|
|
|
2019-07-30 20:41:11 -04:00
|
|
|
def remove(key)
|
2020-05-29 14:08:26 -04:00
|
|
|
return unless persisted_name?(key)
|
2019-07-30 20:41:11 -04:00
|
|
|
|
2020-12-01 22:09:38 -05:00
|
|
|
log(key: key, action: __method__)
|
2022-05-03 20:09:14 -04:00
|
|
|
with_feature(key, &:remove)
|
2019-07-30 20:41:11 -04:00
|
|
|
end
|
|
|
|
|
2020-06-02 08:08:33 -04:00
|
|
|
def reset
|
|
|
|
Gitlab::SafeRequestStore.delete(:flipper) if Gitlab::SafeRequestStore.active?
|
|
|
|
@flipper = nil
|
|
|
|
end
|
|
|
|
|
|
|
|
# This method is called from config/initializers/flipper.rb and can be used
|
|
|
|
# to register Flipper groups.
|
2021-02-10 10:11:19 -05:00
|
|
|
# See https://docs.gitlab.com/ee/development/feature_flags/index.html
|
2020-06-02 08:08:33 -04:00
|
|
|
def register_feature_groups
|
|
|
|
end
|
|
|
|
|
2020-07-02 05:09:00 -04:00
|
|
|
def register_definitions
|
2020-09-21 17:09:27 -04:00
|
|
|
Feature::Definition.reload!
|
2020-07-02 05:09:00 -04:00
|
|
|
end
|
|
|
|
|
2020-09-01 08:11:01 -04:00
|
|
|
def register_hot_reloader
|
|
|
|
return unless check_feature_flags_definition?
|
|
|
|
|
|
|
|
Feature::Definition.register_hot_reloader!
|
|
|
|
end
|
|
|
|
|
2020-12-01 22:09:38 -05:00
|
|
|
def logger
|
|
|
|
@logger ||= Feature::Logger.build
|
|
|
|
end
|
|
|
|
|
2021-11-22 10:14:13 -05:00
|
|
|
def log_feature_flag_states?(key)
|
2021-12-14 16:13:08 -05:00
|
|
|
Feature::Definition.log_states?(key)
|
2021-11-22 10:14:13 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def log_feature_flag_state(key, feature_value)
|
|
|
|
logged_states[key] ||= feature_value
|
|
|
|
end
|
|
|
|
|
|
|
|
def logged_states
|
|
|
|
RequestStore.fetch(:feature_flag_events) { {} }
|
|
|
|
end
|
|
|
|
|
2020-06-02 08:08:33 -04:00
|
|
|
private
|
|
|
|
|
2022-04-22 14:09:22 -04:00
|
|
|
# Evaluate if `default enabled: false` or the feature has been persisted.
|
|
|
|
# `persisted_name?` can potentially generate DB queries and also checks for inclusion
|
|
|
|
# in an array of feature names (177 at last count), possibly reducing performance by half.
|
|
|
|
# So we only perform the `persisted` check if `default_enabled: true`
|
|
|
|
def current_feature_value(feature, thing, default_enabled:)
|
|
|
|
return true if default_enabled && !Feature.persisted_name?(feature.name)
|
|
|
|
|
|
|
|
feature.enabled?(thing)
|
|
|
|
end
|
|
|
|
|
2022-05-03 20:09:14 -04:00
|
|
|
# NOTE: it is not safe to call `Flipper::Feature#enabled?` outside the block
|
|
|
|
def with_feature(key)
|
|
|
|
feature = unsafe_get(key)
|
|
|
|
yield feature if feature.present?
|
|
|
|
ensure
|
|
|
|
pop_recursion_stack
|
|
|
|
end
|
|
|
|
|
|
|
|
def unsafe_get(key)
|
|
|
|
# During setup the database does not exist yet. So we haven't stored a value
|
|
|
|
# for the feature yet and return the default.
|
|
|
|
return unless ApplicationRecord.database.exists?
|
|
|
|
|
|
|
|
flag_stack = ::Thread.current[:feature_flag_recursion_check] || []
|
|
|
|
Thread.current[:feature_flag_recursion_check] = flag_stack
|
|
|
|
|
|
|
|
# Prevent more than 10 levels of recursion. This limit was chosen as a fairly
|
|
|
|
# low limit while allowing some nesting of flag evaluation. We have not seen
|
|
|
|
# this limit hit in production.
|
|
|
|
if flag_stack.size > 10
|
|
|
|
Gitlab::ErrorTracking.track_exception(RecursionError.new('deep recursion'), stack: flag_stack)
|
|
|
|
return
|
|
|
|
elsif flag_stack.include?(key)
|
|
|
|
Gitlab::ErrorTracking.track_exception(RecursionError.new('self recursion'), stack: flag_stack)
|
|
|
|
return
|
|
|
|
end
|
|
|
|
|
|
|
|
flag_stack.push(key)
|
|
|
|
flipper.feature(key)
|
|
|
|
end
|
|
|
|
|
|
|
|
def pop_recursion_stack
|
|
|
|
flag_stack = Thread.current[:feature_flag_recursion_check]
|
|
|
|
flag_stack.pop if flag_stack
|
|
|
|
end
|
|
|
|
|
2017-05-31 17:06:01 -04:00
|
|
|
def flipper
|
2018-10-05 10:20:19 -04:00
|
|
|
if Gitlab::SafeRequestStore.active?
|
2021-10-21 11:12:54 -04:00
|
|
|
Gitlab::SafeRequestStore[:flipper] ||= build_flipper_instance(memoize: true)
|
2018-10-05 10:20:19 -04:00
|
|
|
else
|
|
|
|
@flipper ||= build_flipper_instance
|
|
|
|
end
|
2018-05-31 12:12:48 -04:00
|
|
|
end
|
|
|
|
|
2021-10-21 11:12:54 -04:00
|
|
|
def build_flipper_instance(memoize: false)
|
2018-04-19 12:53:07 -04:00
|
|
|
active_record_adapter = Flipper::Adapters::ActiveRecord.new(
|
|
|
|
feature_class: FlipperFeature,
|
|
|
|
gate_class: FlipperGate)
|
|
|
|
|
2019-07-02 14:19:30 -04:00
|
|
|
# Redis L2 cache
|
|
|
|
redis_cache_adapter =
|
2020-09-14 14:09:48 -04:00
|
|
|
ActiveSupportCacheStoreAdapter.new(
|
2019-07-02 14:19:30 -04:00
|
|
|
active_record_adapter,
|
|
|
|
l2_cache_backend,
|
2021-06-07 08:10:00 -04:00
|
|
|
expires_in: 1.hour,
|
|
|
|
write_through: true)
|
2019-07-02 14:19:30 -04:00
|
|
|
|
|
|
|
# Thread-local L1 cache: use a short timeout since we don't have a
|
|
|
|
# way to expire this cache all at once
|
2020-05-29 14:08:26 -04:00
|
|
|
flipper_adapter = Flipper::Adapters::ActiveSupportCacheStore.new(
|
2019-07-02 14:19:30 -04:00
|
|
|
redis_cache_adapter,
|
|
|
|
l1_cache_backend,
|
|
|
|
expires_in: 1.minute)
|
2020-05-29 14:08:26 -04:00
|
|
|
|
|
|
|
Flipper.new(flipper_adapter).tap do |flip|
|
2021-10-21 11:12:54 -04:00
|
|
|
flip.memoize = memoize
|
2020-05-29 14:08:26 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def check_feature_flags_definition?
|
|
|
|
# We want to check feature flags usage only when
|
|
|
|
# running in development or test environment
|
|
|
|
Gitlab.dev_or_test_env?
|
|
|
|
end
|
|
|
|
|
2019-07-02 14:19:30 -04:00
|
|
|
def l1_cache_backend
|
2020-05-06 17:10:00 -04:00
|
|
|
Gitlab::ProcessMemoryCache.cache_backend
|
2019-07-02 14:19:30 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def l2_cache_backend
|
|
|
|
Rails.cache
|
2018-04-19 12:53:07 -04:00
|
|
|
end
|
2020-12-01 22:09:38 -05:00
|
|
|
|
|
|
|
def log(key:, action:, **extra)
|
|
|
|
extra ||= {}
|
|
|
|
extra = extra.transform_keys { |k| "extra.#{k}" }
|
|
|
|
extra = extra.transform_values { |v| v.respond_to?(:flipper_id) ? v.flipper_id : v }
|
|
|
|
extra = extra.transform_values(&:to_s)
|
|
|
|
logger.info(key: key, action: action, **extra)
|
|
|
|
end
|
2017-05-31 17:06:01 -04:00
|
|
|
end
|
2019-01-07 05:07:14 -05:00
|
|
|
|
|
|
|
class Target
|
2022-05-19 05:09:08 -04:00
|
|
|
UnknowTargetError = Class.new(StandardError)
|
|
|
|
|
2019-01-07 05:07:14 -05:00
|
|
|
attr_reader :params
|
|
|
|
|
|
|
|
def initialize(params)
|
|
|
|
@params = params
|
|
|
|
end
|
|
|
|
|
|
|
|
def gate_specified?
|
2022-02-17 07:12:30 -05:00
|
|
|
%i(user project group feature_group namespace).any? { |key| params.key?(key) }
|
2019-01-07 05:07:14 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def targets
|
2022-05-19 05:09:08 -04:00
|
|
|
[feature_group, users, projects, groups, namespaces].flatten.compact
|
2019-01-07 05:07:14 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
# rubocop: disable CodeReuse/ActiveRecord
|
|
|
|
def feature_group
|
|
|
|
return unless params.key?(:feature_group)
|
|
|
|
|
|
|
|
Feature.group(params[:feature_group])
|
|
|
|
end
|
|
|
|
# rubocop: enable CodeReuse/ActiveRecord
|
|
|
|
|
2022-05-19 05:09:08 -04:00
|
|
|
def users
|
2019-01-07 05:07:14 -05:00
|
|
|
return unless params.key?(:user)
|
|
|
|
|
2022-05-19 05:09:08 -04:00
|
|
|
params[:user].split(',').map do |arg|
|
|
|
|
UserFinder.new(arg).find_by_username || (raise UnknowTargetError, "#{arg} is not found!")
|
|
|
|
end
|
2019-01-07 05:07:14 -05:00
|
|
|
end
|
|
|
|
|
2022-05-19 05:09:08 -04:00
|
|
|
def projects
|
2019-01-07 05:07:14 -05:00
|
|
|
return unless params.key?(:project)
|
|
|
|
|
2022-05-19 05:09:08 -04:00
|
|
|
params[:project].split(',').map do |arg|
|
|
|
|
Project.find_by_full_path(arg) || (raise UnknowTargetError, "#{arg} is not found!")
|
|
|
|
end
|
2019-01-07 05:07:14 -05:00
|
|
|
end
|
2019-02-07 15:27:03 -05:00
|
|
|
|
2022-05-19 05:09:08 -04:00
|
|
|
def groups
|
2019-02-07 15:27:03 -05:00
|
|
|
return unless params.key?(:group)
|
|
|
|
|
2022-05-19 05:09:08 -04:00
|
|
|
params[:group].split(',').map do |arg|
|
|
|
|
Group.find_by_full_path(arg) || (raise UnknowTargetError, "#{arg} is not found!")
|
|
|
|
end
|
2019-02-07 15:27:03 -05:00
|
|
|
end
|
2022-02-17 07:12:30 -05:00
|
|
|
|
2022-05-19 05:09:08 -04:00
|
|
|
def namespaces
|
2022-02-17 07:12:30 -05:00
|
|
|
return unless params.key?(:namespace)
|
|
|
|
|
2022-05-19 05:09:08 -04:00
|
|
|
params[:namespace].split(',').map do |arg|
|
|
|
|
# We are interested in Group or UserNamespace
|
|
|
|
Namespace.without_project_namespaces.find_by_full_path(arg) || (raise UnknowTargetError, "#{arg} is not found!")
|
|
|
|
end
|
2022-02-17 07:12:30 -05:00
|
|
|
end
|
2019-01-07 05:07:14 -05:00
|
|
|
end
|
2017-05-31 17:06:01 -04:00
|
|
|
end
|
2020-05-24 08:08:20 -04:00
|
|
|
|
2021-05-11 17:10:21 -04:00
|
|
|
Feature::ActiveSupportCacheStoreAdapter.prepend_mod_with('Feature::ActiveSupportCacheStoreAdapter')
|