Commit 885611a2 authored by Kamil Trzciński's avatar Kamil Trzciński

Introduce Feature Flag Definition

This adds a YAML-based definition of feature flags
that are stored in `(ee/)configs/feature-flags/`.

Currently none of feature flag types are required
to have a present YAML definition.

This definition contains information like:

- what MR introduced FF
- helps to create and has a issue that tracks
  the rollout and removal of FF
- intentionally adds `default_enabled` to YAML
- force checks the consistency of `default_enabled`
  between what is in code and what is in YAML
parent fe81a170
# This needs to be loaded after
# config/initializers/0_inject_enterprise_edition_module.rb
Feature.register_feature_groups
Feature.register_definitions
Feature.register_feature_groups
# frozen_string_literal: true
module EE
module Feature
module Definition
module ClassMethods
extend ::Gitlab::Utils::Override
override :paths
def paths
@ee_paths ||= [Rails.root.join('ee', 'config', 'feature_flags', '**', '*.yml')] + super
end
end
def self.prepended(base)
base.singleton_class.prepend ClassMethods
end
end
end
end
...@@ -54,12 +54,14 @@ class Feature ...@@ -54,12 +54,14 @@ class Feature
# unless set explicitly. The default is `disabled` # unless set explicitly. The default is `disabled`
# TODO: remove the `default_enabled:` and read it from the `defintion_yaml` # TODO: remove the `default_enabled:` and read it from the `defintion_yaml`
# check: https://gitlab.com/gitlab-org/gitlab/-/issues/30228 # check: https://gitlab.com/gitlab-org/gitlab/-/issues/30228
def enabled?(key, thing = nil, default_enabled: false) def enabled?(key, thing = nil, type: :development, default_enabled: false)
if check_feature_flags_definition? if check_feature_flags_definition?
if thing && !thing.respond_to?(:flipper_id) if thing && !thing.respond_to?(:flipper_id)
raise InvalidFeatureFlagError, raise InvalidFeatureFlagError,
"The thing '#{thing.class.name}' for feature flag '#{key}' needs to include `FeatureGate` or implement `flipper_id`" "The thing '#{thing.class.name}' for feature flag '#{key}' needs to include `FeatureGate` or implement `flipper_id`"
end end
Feature::Definition.valid_usage!(key, type: type, default_enabled: default_enabled)
end end
# During setup the database does not exist yet. So we haven't stored a value # During setup the database does not exist yet. So we haven't stored a value
...@@ -75,9 +77,9 @@ class Feature ...@@ -75,9 +77,9 @@ class Feature
!default_enabled || Feature.persisted_name?(feature.name) ? feature.enabled?(thing) : true !default_enabled || Feature.persisted_name?(feature.name) ? feature.enabled?(thing) : true
end end
def disabled?(key, thing = nil, default_enabled: false) def disabled?(key, thing = nil, type: :development, default_enabled: false)
# we need to make different method calls to make it easy to mock / define expectations in test mode # we need to make different method calls to make it easy to mock / define expectations in test mode
thing.nil? ? !enabled?(key, default_enabled: default_enabled) : !enabled?(key, thing, default_enabled: default_enabled) thing.nil? ? !enabled?(key, type: type, default_enabled: default_enabled) : !enabled?(key, thing, type: type, default_enabled: default_enabled)
end end
def enable(key, thing = true) def enable(key, thing = true)
...@@ -129,6 +131,12 @@ class Feature ...@@ -129,6 +131,12 @@ class Feature
def register_feature_groups def register_feature_groups
end end
def register_definitions
return unless check_feature_flags_definition?
Feature::Definition.load_all!
end
private private
def flipper def flipper
......
# frozen_string_literal: true
class Feature
class Definition
include ::Feature::Shared
attr_reader :path
attr_reader :attributes
PARAMS.each do |param|
define_method(param) do
attributes[param]
end
end
def initialize(path, opts = {})
@path = path
@attributes = {}
# assign nil, for all unknown opts
PARAMS.each do |param|
@attributes[param] = opts[param]
end
end
def key
name.to_sym
end
def validate!
unless name.present?
raise Feature::InvalidFeatureFlagError, "Feature flag is missing name"
end
unless path.present?
raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' is missing path"
end
unless type.present?
raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' is missing type. Ensure to update #{path}"
end
unless Definition::TYPES.include?(type.to_sym)
raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' type '#{type}' is invalid. Ensure to update #{path}"
end
unless File.basename(path, ".yml") == name
raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' has an invalid path: '#{path}'. Ensure to update #{path}"
end
unless File.basename(File.dirname(path)) == type
raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' has an invalid type: '#{path}'. Ensure to update #{path}"
end
if default_enabled.nil?
raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' is missing default_enabled. Ensure to update #{path}"
end
end
def valid_usage!(type_in_code:, default_enabled_in_code:)
unless Array(type).include?(type_in_code.to_s)
# Raise exception in test and dev
raise Feature::InvalidFeatureFlagError, "The `type:` of `#{key}` is not equal to config: " \
"#{type_in_code} vs #{type}. Ensure to use valid type in #{path} or ensure that you use " \
"a valid syntax: #{TYPES.dig(type, :example)}"
end
# We accept an array of defaults as some features are undefined
# and have `default_enabled: true/false`
unless Array(default_enabled).include?(default_enabled_in_code)
# Raise exception in test and dev
raise Feature::InvalidFeatureFlagError, "The `default_enabled:` of `#{key}` is not equal to config: " \
"#{default_enabled_in_code} vs #{default_enabled}. Ensure to update #{path}"
end
end
def to_h
attributes
end
class << self
def paths
@paths ||= [Rails.root.join('config', 'feature_flags', '**', '*.yml')]
end
def definitions
@definitions ||= {}
end
def load_all!
definitions.clear
paths.each do |glob_path|
load_all_from_path!(glob_path)
end
definitions
end
def valid_usage!(key, type:, default_enabled:)
if definition = definitions[key.to_sym]
definition.valid_usage!(type_in_code: type, default_enabled_in_code: default_enabled)
elsif type_definition = self::TYPES[type]
raise InvalidFeatureFlagError, "Missing feature definition for `#{key}`" unless type_definition[:optional]
else
raise InvalidFeatureFlagError, "Unknown feature flag type used: `#{type}`"
end
end
private
def load_from_file(path)
definition = File.read(path)
definition = YAML.safe_load(definition)
definition.deep_symbolize_keys!
self.new(path, definition).tap(&:validate!)
rescue => e
raise Feature::InvalidFeatureFlagError, "Invalid definition for `#{path}`: #{e.message}"
end
def load_all_from_path!(glob_path)
Dir.glob(glob_path).each do |path|
definition = load_from_file(path)
if previous = definitions[definition.key]
raise InvalidFeatureFlagError, "Feature flag '#{definition.key}' is already defined in '#{previous.path}'"
end
definitions[definition.key] = definition
end
end
end
end
end
Feature::Definition.prepend_if_ee('EE::Feature::Definition')
# frozen_string_literal: true
# This file can contain only simple constructs as it is shared between:
# 1. `Pure Ruby`: `bin/feature-flag`
# 2. `GitLab Rails`: `lib/feature/definition.rb`
class Feature
module Shared
# optional: defines if a on-disk definition is required for this feature flag type
# rollout_issue: defines if `bin/feature-flag` asks for rollout issue
# example: usage being shown when exception is raised
TYPES = {
development: {
description: 'Short lived, used to enable unfinished code to be deployed',
optional: true,
rollout_issue: true,
example: <<-EOS
Feature.enabled?(:my_feature_flag)
Feature.enabled?(:my_feature_flag, type: :development)
EOS
}
}.freeze
PARAMS = %i[
name
default_enabled
type
introduced_by_url
rollout_issue_url
group
].freeze
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Feature::Definition do
let(:attributes) do
{ name: 'feature_flag',
type: 'development',
default_enabled: true }
end
let(:path) { File.join('development', 'feature_flag.yml') }
let(:definition) { described_class.new(path, attributes) }
let(:yaml_content) { attributes.deep_stringify_keys.to_yaml }
describe '#key' do
subject { definition.key }
it 'returns a symbol from name' do
is_expected.to eq(:feature_flag)
end
end
describe '#validate!' do
using RSpec::Parameterized::TableSyntax
where(:param, :value, :result) do
:name | nil | /Feature flag is missing name/
:path | nil | /Feature flag 'feature_flag' is missing path/
:type | nil | /Feature flag 'feature_flag' is missing type/
:type | 'invalid' | /Feature flag 'feature_flag' type 'invalid' is invalid/
:path | 'development/invalid.yml' | /Feature flag 'feature_flag' has an invalid path/
:path | 'invalid/feature_flag.yml' | /Feature flag 'feature_flag' has an invalid type/
:default_enabled | nil | /Feature flag 'feature_flag' is missing default_enabled/
end
with_them do
let(:params) { attributes.merge(path: path) }
before do
params[param] = value
end
it do
expect do
described_class.new(
params[:path], params.except(:path)
).validate!
end.to raise_error(result)
end
end
end
describe '#valid_usage!' do
context 'validates type' do
it 'raises exception for invalid type' do
expect { definition.valid_usage!(type_in_code: :invalid, default_enabled_in_code: false) }
.to raise_error(/The `type:` of `feature_flag` is not equal to config/)
end
end
context 'validates default enabled' do
it 'raises exception for different value' do
expect { definition.valid_usage!(type_in_code: :development, default_enabled_in_code: false) }
.to raise_error(/The `default_enabled:` of `feature_flag` is not equal to config/)
end
end
end
describe '.paths' do
it 'returns at least one path' do
expect(described_class.paths).not_to be_empty
end
end
describe '.load_from_file' do
it 'properly loads a definition from file' do
expect(File).to receive(:read).with(path) { yaml_content }
expect(described_class.send(:load_from_file, path).attributes)
.to eq(definition.attributes)
end
context 'for missing file' do
let(:path) { 'missing/feature-flag/file.yml' }
it 'raises exception' do
expect do
described_class.send(:load_from_file, path)
end.to raise_error(/Invalid definition for/)
end
end
context 'for invalid definition' do
it 'raises exception' do
expect(File).to receive(:read).with(path) { '{}' }
expect do
described_class.send(:load_from_file, path)
end.to raise_error(/Feature flag is missing name/)
end
end
end
describe '.load_all!' do
let(:store1) { Dir.mktmpdir('path1') }
let(:store2) { Dir.mktmpdir('path2') }
before do
allow(described_class).to receive(:paths).and_return(
[
File.join(store1, '**', '*.yml'),
File.join(store2, '**', '*.yml')
]
)
end
it "when there's no feature flags a list of definitions is empty" do
expect(described_class.load_all!).to be_empty
end
it "when there's a single feature flag it properly loads them" do
write_feature_flag(store1, path, yaml_content)
expect(described_class.load_all!).to be_one
end
it "when the same feature flag is stored multiple times raises exception" do
write_feature_flag(store1, path, yaml_content)
write_feature_flag(store2, path, yaml_content)
expect { described_class.load_all! }
.to raise_error(/Feature flag 'feature_flag' is already defined/)
end
it "when one of the YAMLs is invalid it does raise exception" do
write_feature_flag(store1, path, '{}')
expect { described_class.load_all! }
.to raise_error(/Feature flag is missing name/)
end
after do
FileUtils.rm_rf(store1)
FileUtils.rm_rf(store2)
end
def write_feature_flag(store, path, content)
path = File.join(store, path)
dir = File.dirname(path)
FileUtils.mkdir_p(dir)
File.write(path, content)
end
end
describe '.valid_usage!' do
before do
allow(described_class).to receive(:definitions) do
{ definition.key => definition }
end
end
context 'when a known feature flag is used' do
it 'validates it usage' do
expect(definition).to receive(:valid_usage!)
described_class.valid_usage!(:feature_flag, type: :development, default_enabled: false)
end
end
context 'when an unknown feature flag is used' do
context 'for a type that is required to have all feature flags registered' do
before do
stub_const('Feature::Shared::TYPES', {
development: { optional: false }
})
end
it 'raises exception' do
expect do
described_class.valid_usage!(:unknown_feature_flag, type: :development, default_enabled: false)
end.to raise_error(/Missing feature definition for `unknown_feature_flag`/)
end
end
context 'for a type that is optional' do
before do
stub_const('Feature::Shared::TYPES', {
development: { optional: true }
})
end
it 'does not raise exception' do
expect do
described_class.valid_usage!(:unknown_feature_flag, type: :development, default_enabled: false)
end.not_to raise_error
end
end
context 'for an unknown type' do
it 'raises exception' do
expect do
described_class.valid_usage!(:unknown_feature_flag, type: :unknown_type, default_enabled: false)
end.to raise_error(/Unknown feature flag type used: `unknown_type`/)
end
end
end
end
end
...@@ -242,6 +242,36 @@ RSpec.describe Feature, stub_feature_flags: false do ...@@ -242,6 +242,36 @@ RSpec.describe Feature, stub_feature_flags: false do
end end
end end
end end
context 'validates usage of feature flag with YAML definition' do
let(:definition) do
Feature::Definition.new('development/my_feature_flag.yml',
name: 'my_feature_flag',
type: 'development',
default_enabled: false
).tap(&:validate!)
end
before do
allow(Feature::Definition).to receive(:definitions) do
{ definition.key => definition }
end
end
it 'when usage is correct' do
expect { described_class.enabled?(:my_feature_flag) }.not_to raise_error
end
it 'when invalid type is used' do
expect { described_class.enabled?(:my_feature_flag, type: :licensed) }
.to raise_error(/The `type:` of/)
end
it 'when invalid default_enabled is used' do
expect { described_class.enabled?(:my_feature_flag, default_enabled: true) }
.to raise_error(/The `default_enabled:` of/)
end
end
end end
describe '.disable?' do describe '.disable?' do
......
...@@ -155,6 +155,9 @@ RSpec.configure do |config| ...@@ -155,6 +155,9 @@ RSpec.configure do |config|
config.before(:suite) do config.before(:suite) do
Timecop.safe_mode = true Timecop.safe_mode = true
TestEnv.init TestEnv.init
# Reload all feature flags definitions
Feature.register_definitions
end end
config.after(:all) do config.after(:all) do
......
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