From 885611a22ef40c939a46e11ced32469a93f44bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Trzci=C5=84ski?= <ayufan@ayufan.eu> Date: Wed, 10 Jun 2020 13:43:37 +0200 Subject: [PATCH] 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 --- config/initializers/0_inject_feature_flags.rb | 5 + config/initializers/flipper.rb | 1 - ee/lib/ee/feature/definition.rb | 20 ++ lib/feature.rb | 14 +- lib/feature/definition.rb | 137 ++++++++++++ lib/feature/shared.rb | 33 +++ spec/lib/feature/definition_spec.rb | 209 ++++++++++++++++++ spec/lib/feature_spec.rb | 30 +++ spec/spec_helper.rb | 3 + 9 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 config/initializers/0_inject_feature_flags.rb delete mode 100644 config/initializers/flipper.rb create mode 100644 ee/lib/ee/feature/definition.rb create mode 100644 lib/feature/definition.rb create mode 100644 lib/feature/shared.rb create mode 100644 spec/lib/feature/definition_spec.rb diff --git a/config/initializers/0_inject_feature_flags.rb b/config/initializers/0_inject_feature_flags.rb new file mode 100644 index 0000000000000..45e6546e29439 --- /dev/null +++ b/config/initializers/0_inject_feature_flags.rb @@ -0,0 +1,5 @@ +# This needs to be loaded after +# config/initializers/0_inject_enterprise_edition_module.rb + +Feature.register_feature_groups +Feature.register_definitions diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb deleted file mode 100644 index 80cab7273e5cc..0000000000000 --- a/config/initializers/flipper.rb +++ /dev/null @@ -1 +0,0 @@ -Feature.register_feature_groups diff --git a/ee/lib/ee/feature/definition.rb b/ee/lib/ee/feature/definition.rb new file mode 100644 index 0000000000000..8f0f7de93c30d --- /dev/null +++ b/ee/lib/ee/feature/definition.rb @@ -0,0 +1,20 @@ +# 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 diff --git a/lib/feature.rb b/lib/feature.rb index 3115c97658d64..7cf40b63fdff3 100644 --- a/lib/feature.rb +++ b/lib/feature.rb @@ -54,12 +54,14 @@ def persisted_name?(feature_name) # unless set explicitly. The default is `disabled` # TODO: remove the `default_enabled:` and read it from the `defintion_yaml` # 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 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 + + Feature::Definition.valid_usage!(key, type: type, default_enabled: default_enabled) end # During setup the database does not exist yet. So we haven't stored a value @@ -75,9 +77,9 @@ def enabled?(key, thing = nil, default_enabled: false) !default_enabled || Feature.persisted_name?(feature.name) ? feature.enabled?(thing) : true 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 - 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 def enable(key, thing = true) @@ -129,6 +131,12 @@ def reset def register_feature_groups end + def register_definitions + return unless check_feature_flags_definition? + + Feature::Definition.load_all! + end + private def flipper diff --git a/lib/feature/definition.rb b/lib/feature/definition.rb new file mode 100644 index 0000000000000..b0ea55c58051b --- /dev/null +++ b/lib/feature/definition.rb @@ -0,0 +1,137 @@ +# 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') diff --git a/lib/feature/shared.rb b/lib/feature/shared.rb new file mode 100644 index 0000000000000..14efbb07100b2 --- /dev/null +++ b/lib/feature/shared.rb @@ -0,0 +1,33 @@ +# 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 diff --git a/spec/lib/feature/definition_spec.rb b/spec/lib/feature/definition_spec.rb new file mode 100644 index 0000000000000..49224cf42796a --- /dev/null +++ b/spec/lib/feature/definition_spec.rb @@ -0,0 +1,209 @@ +# 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 diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb index 49443af6830fb..acd7d97ac854b 100644 --- a/spec/lib/feature_spec.rb +++ b/spec/lib/feature_spec.rb @@ -242,6 +242,36 @@ 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 describe '.disable?' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c43e96f2a1dae..ed3211a9c87a5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -155,6 +155,9 @@ config.before(:suite) do Timecop.safe_mode = true TestEnv.init + + # Reload all feature flags definitions + Feature.register_definitions end config.after(:all) do -- GitLab